old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions

This commit is contained in:
master
2026-01-15 18:37:59 +02:00
parent c631bacee2
commit 88a85cdd92
208 changed files with 32271 additions and 2287 deletions

View File

@@ -22,6 +22,11 @@ import {
AiJustifyResponse,
AiRateLimitInfo,
AiQueryOptions,
// Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring (REMPR-FE-001)
RemediationPrCreateRequest,
RemediationPrCreateResponse,
ScmConnectionInfo,
RemediationPrSettings,
} from './advisory-ai.models';
export interface AdvisoryAiApi {
@@ -32,6 +37,11 @@ export interface AdvisoryAiApi {
remediate(request: AiRemediateRequest, options?: AiQueryOptions): Observable<AiRemediateResponse>;
justify(request: AiJustifyRequest, options?: AiQueryOptions): Observable<AiJustifyResponse>;
getRateLimits(options?: AiQueryOptions): Observable<AiRateLimitInfo[]>;
// Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring (REMPR-FE-001)
createRemediationPr(request: RemediationPrCreateRequest, options?: AiQueryOptions): Observable<RemediationPrCreateResponse>;
getScmConnections(options?: AiQueryOptions): Observable<ScmConnectionInfo[]>;
getRemediationPrSettings(options?: AiQueryOptions): Observable<RemediationPrSettings>;
}
export const ADVISORY_AI_API = new InjectionToken<AdvisoryAiApi>('ADVISORY_AI_API');
@@ -92,6 +102,32 @@ export class AdvisoryAiApiHttpClient implements AdvisoryAiApi {
);
}
// Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring (REMPR-FE-001)
createRemediationPr(request: RemediationPrCreateRequest, options: AiQueryOptions = {}): Observable<RemediationPrCreateResponse> {
const traceId = options.traceId ?? generateTraceId();
return this.http.post<RemediationPrCreateResponse>(
`${this.baseUrl}/remediate/${request.remediationId}/pr`,
request,
{ headers: this.buildHeaders(traceId) }
).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getScmConnections(options: AiQueryOptions = {}): Observable<ScmConnectionInfo[]> {
const traceId = options.traceId ?? generateTraceId();
return this.http.get<ScmConnectionInfo[]>(`${this.baseUrl}/scm-connections`, { headers: this.buildHeaders(traceId) }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
getRemediationPrSettings(options: AiQueryOptions = {}): Observable<RemediationPrSettings> {
const traceId = options.traceId ?? generateTraceId();
return this.http.get<RemediationPrSettings>(`${this.baseUrl}/remediation-pr/settings`, { headers: this.buildHeaders(traceId) }).pipe(
catchError((err) => throwError(() => this.mapError(err, traceId)))
);
}
private buildHeaders(traceId: string): HttpHeaders {
const tenant = this.authSession.getActiveTenantId() || '';
return new HttpHeaders({
@@ -182,4 +218,65 @@ export class MockAdvisoryAiClient implements AdvisoryAiApi {
{ feature: 'justify', limit: 3, remaining: 3, resetsAt: new Date(Date.now() + 60000).toISOString() },
]).pipe(delay(50));
}
// Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring (REMPR-FE-001)
createRemediationPr(request: RemediationPrCreateRequest): Observable<RemediationPrCreateResponse> {
if (request.dryRun) {
return of({
success: true,
prInfo: {
prId: `pr-dry-run-${Date.now()}`,
prNumber: 0,
prUrl: '',
branch: `remediation/${request.remediationId}`,
status: 'draft' as const,
createdAt: new Date().toISOString(),
},
}).pipe(delay(500));
}
return of({
success: true,
prInfo: {
prId: `pr-${Date.now()}`,
prNumber: Math.floor(Math.random() * 1000) + 100,
prUrl: `https://github.com/example/repo/pull/${Math.floor(Math.random() * 1000) + 100}`,
branch: `remediation/${request.remediationId}`,
status: 'open' as const,
createdAt: new Date().toISOString(),
ciStatus: 'pending' as const,
},
evidenceCardId: request.attachEvidenceCard ? `ec-${Date.now()}` : undefined,
}).pipe(delay(2000));
}
getScmConnections(): Observable<ScmConnectionInfo[]> {
return of([
{
connectionId: 'conn-github-1',
provider: 'github' as const,
displayName: 'GitHub (Organization)',
organization: 'example-org',
isDefault: true,
capabilities: {
canCreatePr: true,
canAddReviewers: true,
canAddLabels: true,
canAddAssignees: true,
canAttachFiles: false,
supportsEvidenceCards: true,
},
},
]).pipe(delay(100));
}
getRemediationPrSettings(): Observable<RemediationPrSettings> {
return of({
enabled: true,
defaultAttachEvidenceCard: true,
defaultAddPrComment: true,
requireApproval: false,
defaultLabels: ['security', 'remediation'],
defaultReviewers: [],
}).pipe(delay(50));
}
}

View File

@@ -1,6 +1,8 @@
/**
* Advisory AI models for AI-assisted vulnerability analysis.
* Implements VEX-AI-007 through VEX-AI-010.
* Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring (REMPR-FE-001)
* Updated: Added PR creation fields to remediation response
*/
// AI Consent
@@ -80,6 +82,34 @@ export interface AiRemediateResponse {
modelVersion: string;
generatedAt: string;
traceId?: string;
// Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring (REMPR-FE-001)
// PR creation fields
/** Whether PR creation is available for this remediation */
prCreationAvailable?: boolean;
/** Active PR if one was created from this remediation */
activePr?: RemediationPrInfo;
/** Evidence card ID if attached to a PR */
evidenceCardId?: string;
}
/**
* PR info specific to remediation context.
* Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring (REMPR-FE-001)
*/
export interface RemediationPrInfo {
prId: string;
prNumber: number;
prUrl: string;
branch: string;
status: PullRequestStatus;
createdAt: string;
updatedAt?: string;
ciStatus?: CiCheckStatus;
evidenceCardId?: string;
}
export interface AiRemediationStep {
@@ -129,3 +159,177 @@ export interface AiQueryOptions {
traceId?: string;
timeout?: number;
}
// Pull Request Types - Support for remediation PR generation
export type PullRequestStatus =
| 'draft'
| 'open'
| 'review_requested'
| 'approved'
| 'changes_requested'
| 'merged'
| 'closed';
export type CiCheckStatus = 'pending' | 'running' | 'success' | 'failure' | 'skipped';
export interface CiCheck {
name: string;
status: CiCheckStatus;
conclusion?: string;
url?: string;
startedAt?: string;
completedAt?: string;
}
export interface PullRequestReviewer {
username: string;
avatarUrl?: string;
status: 'pending' | 'approved' | 'changes_requested' | 'commented';
reviewedAt?: string;
}
export interface PullRequestInfo {
prId: string;
prNumber: number;
title: string;
description?: string;
status: PullRequestStatus;
prUrl: string;
sourceBranch: string;
targetBranch: string;
createdAt: string;
updatedAt?: string;
mergedAt?: string;
closedAt?: string;
authorUsername: string;
authorAvatarUrl?: string;
ciChecks?: CiCheck[];
reviewers?: PullRequestReviewer[];
labels?: string[];
commitSha?: string;
additions?: number;
deletions?: number;
changedFiles?: number;
}
// PR Generation Request/Response types
export interface PrGenerationRequest {
planId: string;
repository: string;
organization: string;
scmType: 'github' | 'gitlab' | 'bitbucket' | 'azure';
targetBranch?: string;
title?: string;
labels?: string[];
assignees?: string[];
reviewers?: string[];
dryRun?: boolean;
}
export interface PrGenerationResponse {
prId: string;
prNumber: number;
prUrl: string;
title: string;
body: string;
status: PullRequestStatus;
sourceBranch: string;
targetBranch: string;
createdAt: string;
dryRun: boolean;
}
// PR list request
export interface PrListRequest {
repository?: string;
organization?: string;
status?: PullRequestStatus;
planId?: string;
limit?: number;
offset?: number;
}
// Sprint: SPRINT_20260112_012_FE_remediation_pr_ui_wiring (REMPR-FE-001)
// Remediation-specific PR creation models
/**
* Request to create a PR from remediation guidance.
*/
export interface RemediationPrCreateRequest {
remediationId: string;
scmConnectionId: string;
repository: string;
targetBranch?: string;
title?: string;
description?: string;
labels?: string[];
assignees?: string[];
reviewers?: string[];
attachEvidenceCard?: boolean;
addPrComment?: boolean;
dryRun?: boolean;
}
/**
* Response from remediation PR creation.
*/
export interface RemediationPrCreateResponse {
success: boolean;
prInfo?: RemediationPrInfo;
evidenceCardId?: string;
error?: string;
errorCode?: RemediationPrErrorCode;
}
/**
* Error codes for remediation PR creation.
*/
export type RemediationPrErrorCode =
| 'no_scm_connection'
| 'scm_auth_failed'
| 'repository_not_found'
| 'branch_conflict'
| 'rate_limited'
| 'remediation_expired'
| 'pr_already_exists'
| 'insufficient_permissions'
| 'internal_error';
/**
* SCM connection info for PR creation.
*/
export interface ScmConnectionInfo {
connectionId: string;
provider: 'github' | 'gitlab' | 'bitbucket' | 'azure';
displayName: string;
organization?: string;
isDefault: boolean;
lastUsedAt?: string;
capabilities: ScmCapabilities;
}
/**
* SCM provider capabilities.
*/
export interface ScmCapabilities {
canCreatePr: boolean;
canAddReviewers: boolean;
canAddLabels: boolean;
canAddAssignees: boolean;
canAttachFiles: boolean;
supportsEvidenceCards: boolean;
}
/**
* Remediation PR settings.
*/
export interface RemediationPrSettings {
enabled: boolean;
defaultAttachEvidenceCard: boolean;
defaultAddPrComment: boolean;
requireApproval: boolean;
defaultLabels: string[];
defaultReviewers: string[];
}

View File

@@ -0,0 +1,161 @@
// Sprint: SPRINT_20260112_013_FE_determinization_config_pane (FE-CONFIG-001)
// Determinization Config API Client and Models
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
// Sprint: SPRINT_20260112_013_FE_determinization_config_pane (FE-CONFIG-001)
// Determinization Configuration Models
export interface ReanalysisTriggerConfig {
epssDeltaThreshold: number;
triggerOnThresholdCrossing: boolean;
triggerOnRekorEntry: boolean;
triggerOnVexStatusChange: boolean;
triggerOnRuntimeTelemetryChange: boolean;
triggerOnPatchProofAdded: boolean;
triggerOnDsseValidationChange: boolean;
triggerOnToolVersionChange: boolean;
}
export interface ConflictHandlingPolicy {
vexReachabilityContradiction: ConflictAction;
staticRuntimeMismatch: ConflictAction;
backportStatusAmbiguity: ConflictAction;
vexStatusConflict: ConflictAction;
escalationSeverityThreshold: number;
conflictTtlHours: number;
}
export type ConflictAction =
| 'RequireManualReview'
| 'AutoAcceptLowerSeverity'
| 'AutoRejectHigherSeverity'
| 'Escalate'
| 'DeferToNextReanalysis'
| 'RequestVendorClarification';
export interface EnvironmentThreshold {
epssThreshold: number;
uncertaintyFactor: number;
exploitPressureWeight: number;
reachabilityWeight: number;
minScore: number;
maxScore: number;
}
export interface EnvironmentThresholds {
development: EnvironmentThreshold;
staging: EnvironmentThreshold;
production: EnvironmentThreshold;
}
export interface DeterminizationConfig {
triggers: ReanalysisTriggerConfig;
conflicts: ConflictHandlingPolicy;
thresholds: EnvironmentThresholds;
}
export interface EffectiveConfigResponse {
config: DeterminizationConfig;
isDefault: boolean;
tenantId?: string;
lastUpdatedAt?: string;
lastUpdatedBy?: string;
version: number;
}
export interface UpdateConfigRequest {
config: DeterminizationConfig;
reason: string;
}
export interface ValidationResponse {
isValid: boolean;
errors: string[];
warnings: string[];
}
export interface AuditEntry {
id: string;
changedAt: string;
actor: string;
reason: string;
source?: string;
summary?: string;
}
export interface AuditHistoryResponse {
entries: AuditEntry[];
}
// Default values for UI
export const DEFAULT_TRIGGER_CONFIG: ReanalysisTriggerConfig = {
epssDeltaThreshold: 0.2,
triggerOnThresholdCrossing: true,
triggerOnRekorEntry: true,
triggerOnVexStatusChange: true,
triggerOnRuntimeTelemetryChange: true,
triggerOnPatchProofAdded: true,
triggerOnDsseValidationChange: true,
triggerOnToolVersionChange: false,
};
export const CONFLICT_ACTION_LABELS: Record<ConflictAction, string> = {
RequireManualReview: 'Require Manual Review',
AutoAcceptLowerSeverity: 'Auto-Accept (Lower Severity)',
AutoRejectHigherSeverity: 'Auto-Reject (Higher Severity)',
Escalate: 'Escalate',
DeferToNextReanalysis: 'Defer to Next Reanalysis',
RequestVendorClarification: 'Request Vendor Clarification',
};
export const ENVIRONMENT_LABELS: Record<keyof EnvironmentThresholds, string> = {
development: 'Development',
staging: 'Staging',
production: 'Production',
};
@Injectable({ providedIn: 'root' })
export class DeterminizationConfigClient {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/policy/config/determinization';
/**
* Get effective determinization configuration for the current tenant.
*/
getEffectiveConfig(): Observable<EffectiveConfigResponse> {
return this.http.get<EffectiveConfigResponse>(this.baseUrl);
}
/**
* Get default determinization configuration.
*/
getDefaultConfig(): Observable<DeterminizationConfig> {
return this.http.get<DeterminizationConfig>(`${this.baseUrl}/defaults`);
}
/**
* Update determinization configuration (admin only).
*/
updateConfig(request: UpdateConfigRequest): Observable<EffectiveConfigResponse> {
return this.http.put<EffectiveConfigResponse>(this.baseUrl, request);
}
/**
* Validate configuration without saving.
*/
validateConfig(config: DeterminizationConfig): Observable<ValidationResponse> {
return this.http.post<ValidationResponse>(`${this.baseUrl}/validate`, { config });
}
/**
* Get audit history for configuration changes.
*/
getAuditHistory(limit = 50): Observable<AuditHistoryResponse> {
return this.http.get<AuditHistoryResponse>(`${this.baseUrl}/audit`, {
params: { limit: limit.toString() },
});
}
}

View File

@@ -131,7 +131,18 @@ export interface EvidencePackVerificationResult {
// ========== Export ==========
export type EvidencePackExportFormat = 'Json' | 'SignedJson' | 'Markdown' | 'Html' | 'Pdf';
/**
* Evidence pack export formats.
* Sprint: SPRINT_20260112_006_FE_evidence_card_ui (EVPCARD-FE-001)
*/
export type EvidencePackExportFormat =
| 'Json'
| 'SignedJson'
| 'Markdown'
| 'Html'
| 'Pdf'
| 'EvidenceCard'
| 'EvidenceCardCompact';
export interface EvidencePackExport {
packId: string;
@@ -141,6 +152,62 @@ export interface EvidencePackExport {
fileName: string;
}
// ========== Evidence Card ==========
/**
* Evidence card models for single-file export with SBOM excerpt and Rekor receipt.
* Sprint: SPRINT_20260112_006_FE_evidence_card_ui (EVPCARD-FE-001)
*/
export interface EvidenceCard {
cardId: string;
version: string;
packId: string;
createdAt: string;
subject: EvidenceCardSubject;
envelope?: DsseEnvelope;
sbomExcerpt?: SbomExcerpt;
rekorReceipt?: RekorReceipt;
contentDigest: string;
}
export interface EvidenceCardSubject {
type: EvidenceSubjectType;
findingId?: string;
cveId?: string;
component?: string;
imageDigest?: string;
}
export interface SbomExcerpt {
format: 'spdx-2.2' | 'spdx-2.3' | 'cyclonedx-1.5' | 'cyclonedx-1.6';
componentName?: string;
componentVersion?: string;
componentPurl?: string;
licenses?: string[];
vulnerabilities?: string[];
}
export interface RekorReceipt {
logIndex: number;
logId: string;
integratedTime: number;
inclusionProof?: InclusionProof;
inclusionPromise?: SignedEntryTimestamp;
}
export interface InclusionProof {
logIndex: number;
rootHash: string;
treeSize: number;
hashes: string[];
}
export interface SignedEntryTimestamp {
logId: string;
integratedTime: number;
signature: string;
}
// ========== API Request/Response ==========
export interface CreateEvidencePackRequest {

View File

@@ -1,6 +1,8 @@
/**
* Evidence-Weighted Score (EWS) models.
* Based on API endpoints from Sprint 8200.0012.0004.
* Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-001)
* Updated: Added reduction profile, hard-fail, and anchor fields
*/
/**
@@ -11,13 +13,39 @@ export type ScoreBucket = 'ActNow' | 'ScheduleNext' | 'Investigate' | 'Watchlist
/**
* Score flags indicating evidence characteristics.
*/
export type ScoreFlag = 'live-signal' | 'proven-path' | 'vendor-na' | 'speculative';
export type ScoreFlag = 'live-signal' | 'proven-path' | 'vendor-na' | 'speculative' | 'anchored' | 'hard-fail';
/**
* Trigger types for score changes.
*/
export type ScoreChangeTrigger = 'evidence_update' | 'policy_change' | 'scheduled';
/**
* Reduction mode for attested score reduction.
* Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-001)
*/
export type ReductionMode = 'none' | 'light' | 'standard' | 'aggressive' | 'custom';
/**
* Short-circuit reason for score calculation.
* Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-001)
*/
export type ShortCircuitReason =
| 'none'
| 'hard_fail_kev'
| 'hard_fail_exploited'
| 'hard_fail_critical_reachable'
| 'not_affected_vendor'
| 'not_affected_vex'
| 'runtime_confirmed'
| 'anchor_verified';
/**
* Hard-fail status for score outcomes.
* Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-001)
*/
export type HardFailStatus = 'none' | 'kev' | 'exploited' | 'critical_reachable' | 'policy_override';
/**
* Evidence dimension inputs (0.0 - 1.0 normalized).
*/
@@ -66,6 +94,52 @@ export interface AppliedGuardrails {
runtimeFloor: boolean;
}
/**
* Reduction profile metadata for attested score reduction.
* Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-001)
*/
export interface ReductionProfile {
/** Reduction mode applied */
mode: ReductionMode;
/** Original score before reduction */
originalScore: number;
/** Reduction amount applied */
reductionAmount: number;
/** Reduction factor (0.0-1.0) */
reductionFactor: number;
/** Evidence types that contributed to reduction */
contributingEvidence: string[];
/** Whether reduction was capped by policy */
cappedByPolicy: boolean;
/** Policy max reduction percentage */
maxReductionPercent?: number;
}
/**
* Proof anchor for score attestation.
* Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-001)
*/
export interface ScoreProofAnchor {
/** Whether the score has a valid anchor */
anchored: boolean;
/** DSSE envelope digest (sha256:...) */
dsseDigest?: string;
/** Rekor transparency log index */
rekorLogIndex?: number;
/** Rekor entry ID */
rekorEntryId?: string;
/** Rekor log ID (tree hash) */
rekorLogId?: string;
/** URI to full attestation */
attestationUri?: string;
/** Anchor verification timestamp */
verifiedAt?: string;
/** Anchor verification status */
verificationStatus?: 'verified' | 'pending' | 'failed' | 'offline';
/** Verification error if failed */
verificationError?: string;
}
/**
* Full evidence-weighted score result from API.
*/
@@ -92,6 +166,24 @@ export interface EvidenceWeightedScoreResult {
calculatedAt: string;
/** Cache expiry (optional) */
cachedUntil?: string;
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-001)
// Reduction profile, hard-fail, and anchor fields
/** Reduction profile metadata (if reduction was applied) */
reductionProfile?: ReductionProfile;
/** Short-circuit reason (if calculation was short-circuited) */
shortCircuitReason?: ShortCircuitReason;
/** Hard-fail status (if hard-fail triggered) */
hardFailStatus?: HardFailStatus;
/** Whether this is a hard-fail outcome */
isHardFail?: boolean;
/** Proof anchor for score attestation */
proofAnchor?: ScoreProofAnchor;
}
/**
@@ -398,13 +490,14 @@ export interface FlagDisplayInfo {
/**
* Default flag display configuration.
* Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-001) - Added anchored and hard-fail flags
*/
export const FLAG_DISPLAY: Record<ScoreFlag, FlagDisplayInfo> = {
'live-signal': {
flag: 'live-signal',
label: 'Live Signal',
description: 'Active runtime signals detected from deployed environments',
icon: '\u{1F7E2}', // green circle
icon: '[R]',
backgroundColor: '#059669', // emerald-600
textColor: '#FFFFFF',
},
@@ -412,7 +505,7 @@ export const FLAG_DISPLAY: Record<ScoreFlag, FlagDisplayInfo> = {
flag: 'proven-path',
label: 'Proven Path',
description: 'Verified reachability path to vulnerable code',
icon: '\u2713', // checkmark
icon: '[P]',
backgroundColor: '#2563EB', // blue-600
textColor: '#FFFFFF',
},
@@ -420,7 +513,7 @@ export const FLAG_DISPLAY: Record<ScoreFlag, FlagDisplayInfo> = {
flag: 'vendor-na',
label: 'Vendor N/A',
description: 'Vendor has marked this as not affected',
icon: '\u2298', // circled division slash
icon: '[NA]',
backgroundColor: '#6B7280', // gray-500
textColor: '#FFFFFF',
},
@@ -428,8 +521,113 @@ export const FLAG_DISPLAY: Record<ScoreFlag, FlagDisplayInfo> = {
flag: 'speculative',
label: 'Speculative',
description: 'Evidence is speculative or unconfirmed',
icon: '?',
icon: '[?]',
backgroundColor: '#F97316', // orange-500
textColor: '#000000',
},
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-001)
anchored: {
flag: 'anchored',
label: 'Anchored',
description: 'Score is anchored with DSSE attestation and/or Rekor transparency log',
icon: '[A]',
backgroundColor: '#7C3AED', // violet-600
textColor: '#FFFFFF',
},
'hard-fail': {
flag: 'hard-fail',
label: 'Hard Fail',
description: 'Policy hard-fail triggered - requires immediate remediation',
icon: '[!]',
backgroundColor: '#DC2626', // red-600
textColor: '#FFFFFF',
},
};
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-001)
// Display helpers for reduction mode and short-circuit reasons
/**
* Reduction mode display labels.
*/
export const REDUCTION_MODE_LABELS: Record<ReductionMode, string> = {
none: 'No Reduction',
light: 'Light Reduction',
standard: 'Standard Reduction',
aggressive: 'Aggressive Reduction',
custom: 'Custom Reduction',
};
/**
* Short-circuit reason display labels.
*/
export const SHORT_CIRCUIT_LABELS: Record<ShortCircuitReason, string> = {
none: 'No Short-Circuit',
hard_fail_kev: 'KEV Hard Fail',
hard_fail_exploited: 'Exploited Hard Fail',
hard_fail_critical_reachable: 'Critical Reachable Hard Fail',
not_affected_vendor: 'Vendor Not Affected',
not_affected_vex: 'VEX Not Affected',
runtime_confirmed: 'Runtime Confirmed',
anchor_verified: 'Anchor Verified',
};
/**
* Hard-fail status display labels.
*/
export const HARD_FAIL_LABELS: Record<HardFailStatus, string> = {
none: 'None',
kev: 'Known Exploited Vulnerability',
exploited: 'Actively Exploited',
critical_reachable: 'Critical and Reachable',
policy_override: 'Policy Override',
};
/**
* Anchor verification status display labels.
*/
export const ANCHOR_VERIFICATION_LABELS: Record<string, string> = {
verified: 'Verified',
pending: 'Pending Verification',
failed: 'Verification Failed',
offline: 'Offline (Cannot Verify)',
};
/**
* Helper to check if score has anchored evidence.
*/
export function isAnchored(score: EvidenceWeightedScoreResult): boolean {
return score.proofAnchor?.anchored === true;
}
/**
* Helper to check if score is a hard-fail outcome.
*/
export function isHardFail(score: EvidenceWeightedScoreResult): boolean {
return score.isHardFail === true || (score.hardFailStatus !== undefined && score.hardFailStatus !== 'none');
}
/**
* Helper to check if score was short-circuited.
*/
export function wasShortCircuited(score: EvidenceWeightedScoreResult): boolean {
return score.shortCircuitReason !== undefined && score.shortCircuitReason !== 'none';
}
/**
* Helper to check if score has reduction applied.
*/
export function hasReduction(score: EvidenceWeightedScoreResult): boolean {
return score.reductionProfile !== undefined && score.reductionProfile.mode !== 'none';
}
/**
* Helper to get reduction percentage.
*/
export function getReductionPercent(score: EvidenceWeightedScoreResult): number {
if (!score.reductionProfile || score.reductionProfile.originalScore === 0) {
return 0;
}
return Math.round((score.reductionProfile.reductionAmount / score.reductionProfile.originalScore) * 100);
}

View File

@@ -1,4 +1,5 @@
// Sprint: SPRINT_20251229_033_FE - Unknowns Tracking UI
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-001)
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
@@ -10,12 +11,27 @@ import {
UnknownFilter,
IdentifyRequest,
IdentifyResponse,
PolicyUnknown,
PolicyUnknownsSummary,
TriageRequest,
UnknownBand,
} from './unknowns.models';
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-001)
export interface PolicyUnknownsListResponse {
items: PolicyUnknown[];
totalCount: number;
}
export interface PolicyUnknownDetailResponse {
unknown: PolicyUnknown;
}
@Injectable({ providedIn: 'root' })
export class UnknownsClient {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/scanner/unknowns';
private readonly policyBaseUrl = '/api/v1/policy/unknowns';
list(filter?: UnknownFilter, limit = 50, cursor?: string): Observable<UnknownListResponse> {
let params = new HttpParams().set('limit', limit.toString());
@@ -50,4 +66,60 @@ export class UnknownsClient {
if (filter?.status) params = params.set('status', filter.status);
return this.http.get(`${this.baseUrl}/export`, { params, responseType: 'blob' });
}
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-001)
// Policy unknowns grey queue methods
/**
* List policy unknowns with grey queue fields.
*/
listPolicyUnknowns(
band?: UnknownBand,
limit = 50,
offset = 0
): Observable<PolicyUnknownsListResponse> {
let params = new HttpParams()
.set('limit', limit.toString())
.set('offset', offset.toString());
if (band) params = params.set('band', band);
return this.http.get<PolicyUnknownsListResponse>(this.policyBaseUrl, { params });
}
/**
* Get policy unknown detail with fingerprint, triggers, and conflict info.
*/
getPolicyUnknownDetail(id: string): Observable<PolicyUnknownDetailResponse> {
return this.http.get<PolicyUnknownDetailResponse>(`${this.policyBaseUrl}/${id}`);
}
/**
* Get unknowns summary by band.
*/
getPolicyUnknownsSummary(): Observable<PolicyUnknownsSummary> {
return this.http.get<PolicyUnknownsSummary>(`${this.policyBaseUrl}/summary`);
}
/**
* Apply triage action to an unknown (grey queue adjudication).
*/
triageUnknown(id: string, request: TriageRequest): Observable<PolicyUnknown> {
return this.http.post<PolicyUnknown>(`${this.policyBaseUrl}/${id}/triage`, request);
}
/**
* Escalate an unknown for immediate attention.
*/
escalateUnknown(id: string, reason?: string): Observable<PolicyUnknown> {
return this.http.post<PolicyUnknown>(`${this.policyBaseUrl}/${id}/escalate`, { reason });
}
/**
* Resolve an unknown.
*/
resolveUnknown(id: string, resolution: string, note?: string): Observable<PolicyUnknown> {
return this.http.post<PolicyUnknown>(`${this.policyBaseUrl}/${id}/resolve`, {
resolution,
note,
});
}
}

View File

@@ -1,9 +1,21 @@
// Sprint: SPRINT_20251229_033_FE - Unknowns Tracking UI
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-001)
export type UnknownType = 'binary' | 'symbol' | 'package' | 'file' | 'license';
export type UnknownStatus = 'open' | 'pending' | 'resolved' | 'unresolvable';
export type ConfidenceLevel = 'very_low' | 'low' | 'medium' | 'high';
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-001)
// Grey queue types for determinization
export type UnknownBand = 'hot' | 'warm' | 'cold';
export type ObservationState =
| 'PendingDeterminization'
| 'DeterminedPass'
| 'DeterminedFail'
| 'Disputed'
| 'ManualReviewRequired';
export type TriageAction = 'accept-risk' | 'require-fix' | 'defer' | 'escalate' | 'dispute';
export interface Unknown {
id: string;
type: UnknownType;
@@ -22,6 +34,83 @@ export interface Unknown {
resolution?: UnknownResolution;
}
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-001)
// Extended unknown for grey queue with determinization fields
export interface PolicyUnknown {
id: string;
packageId: string;
packageVersion: string;
band: UnknownBand;
score: number;
uncertaintyFactor: number;
exploitPressure: number;
firstSeenAt: string;
lastEvaluatedAt: string;
resolutionReason?: string;
resolvedAt?: string;
reasonCode: string;
reasonCodeShort: string;
remediationHint?: string;
detailedHint?: string;
automationCommand?: string;
evidenceRefs?: EvidenceRef[];
// Grey queue determinization fields
fingerprintId?: string;
triggers?: ReanalysisTrigger[];
nextActions?: string[];
conflictInfo?: ConflictInfo;
observationState?: ObservationState;
}
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-001)
export interface EvidenceRef {
type: string;
uri: string;
digest?: string;
}
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-001)
export interface ReanalysisTrigger {
eventType: string;
eventVersion: number;
source?: string;
receivedAt: string;
correlationId?: string;
}
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-001)
export interface ConflictInfo {
hasConflict: boolean;
severity: number;
suggestedPath: string;
conflicts: ConflictDetail[];
}
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-001)
export interface ConflictDetail {
signal1: string;
signal2: string;
type: string;
description: string;
severity: number;
}
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-001)
export interface PolicyUnknownsSummary {
hot: number;
warm: number;
cold: number;
resolved: number;
total: number;
}
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-001)
export interface TriageRequest {
action: TriageAction;
reason: string;
durationDays?: number;
}
export interface UnknownResolution {
purl?: string;
cpe?: string;
@@ -134,3 +223,69 @@ export function getConfidenceColor(confidence: number): string {
if (confidence >= 50) return 'text-yellow-600';
return 'text-red-600';
}
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-001)
// Grey queue UI helpers
export const BAND_COLORS: Record<UnknownBand, string> = {
hot: 'bg-red-100 text-red-800 border-red-300',
warm: 'bg-orange-100 text-orange-800 border-orange-300',
cold: 'bg-blue-100 text-blue-800 border-blue-300',
};
export const BAND_LABELS: Record<UnknownBand, string> = {
hot: 'HOT',
warm: 'WARM',
cold: 'COLD',
};
export const OBSERVATION_STATE_COLORS: Record<ObservationState, string> = {
PendingDeterminization: 'bg-yellow-100 text-yellow-800',
DeterminedPass: 'bg-green-100 text-green-800',
DeterminedFail: 'bg-red-100 text-red-800',
Disputed: 'bg-purple-100 text-purple-800',
ManualReviewRequired: 'bg-orange-100 text-orange-800',
};
export const OBSERVATION_STATE_LABELS: Record<ObservationState, string> = {
PendingDeterminization: 'Pending',
DeterminedPass: 'Pass',
DeterminedFail: 'Fail',
Disputed: 'Disputed',
ManualReviewRequired: 'Review Required',
};
export const TRIAGE_ACTION_LABELS: Record<TriageAction, string> = {
'accept-risk': 'Accept Risk',
'require-fix': 'Require Fix',
defer: 'Defer',
escalate: 'Escalate',
dispute: 'Dispute',
};
export function getBandPriority(band: UnknownBand): number {
switch (band) {
case 'hot':
return 0;
case 'warm':
return 1;
case 'cold':
return 2;
default:
return 3;
}
}
export function isGreyQueueState(state?: ObservationState): boolean {
return state === 'Disputed' || state === 'ManualReviewRequired';
}
export function hasConflicts(unknown: PolicyUnknown): boolean {
return unknown.conflictInfo?.hasConflict === true;
}
export function getConflictSeverityColor(severity: number): string {
if (severity >= 0.8) return 'text-red-600';
if (severity >= 0.5) return 'text-orange-600';
return 'text-yellow-600';
}

View File

@@ -1,6 +1,8 @@
/**
* Witness API models for reachability evidence.
* Sprint: SPRINT_3700_0005_0001_witness_ui_cli (UI-005)
* Sprint: SPRINT_20260112_013_FE_witness_ui_wiring (FE-WIT-002)
* Updated: Added node_hashes, path_hash, evidence URIs, runtime evidence metadata
*/
/**
@@ -53,6 +55,50 @@ export interface ReachabilityWitness {
/** VEX recommendation based on reachability. */
vexRecommendation?: string;
// Sprint: SPRINT_20260112_013_FE_witness_ui_wiring (FE-WIT-002)
// Path witness node hashes and path hash fields
/**
* Hashes of individual nodes in the call path.
* Each hash is prefixed with algorithm (e.g., "sha256:abc123").
*/
nodeHashes?: string[];
/**
* Hash of the complete path for deduplication and verification.
* Prefixed with algorithm (e.g., "blake3:def456").
*/
pathHash?: string;
/**
* Runtime evidence metadata (if available from dynamic analysis).
*/
runtimeEvidence?: RuntimeEvidenceMetadata;
}
/**
* Runtime evidence metadata for dynamic analysis results.
* Sprint: SPRINT_20260112_013_FE_witness_ui_wiring (FE-WIT-002)
*/
export interface RuntimeEvidenceMetadata {
/** Whether runtime data is available. */
available: boolean;
/** Source of runtime data (e.g., "opentelemetry", "profiler", "tracer"). */
source?: string;
/** Timestamp of last runtime observation. */
lastObservedAt?: string;
/** Number of runtime invocations observed. */
invocationCount?: number;
/** Whether runtime data confirms static analysis. */
confirmsStatic?: boolean;
/** URI to full runtime trace if available. */
traceUri?: string;
}
/**
@@ -112,6 +158,24 @@ export interface WitnessEvidence {
/** Additional evidence artifacts. */
artifacts?: EvidenceArtifact[];
// Sprint: SPRINT_20260112_013_FE_witness_ui_wiring (FE-WIT-002)
// Evidence URIs for linking to external artifacts
/** URI to DSSE envelope if signed. */
dsseUri?: string;
/** URI to Rekor transparency log entry. */
rekorUri?: string;
/** URI to SBOM used for analysis. */
sbomUri?: string;
/** URI to call graph artifact. */
callGraphUri?: string;
/** URI to attestation bundle. */
attestationUri?: string;
}
/**

View File

@@ -0,0 +1,221 @@
/**
* Evidence Pack Viewer Component Tests
* Sprint: SPRINT_20260112_006_FE_evidence_card_ui (EVPCARD-FE-003)
*
* Tests for evidence pack viewer including evidence-card export functionality.
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { of, throwError } from 'rxjs';
import { EVIDENCE_PACK_API, type EvidencePackApi } from '../../core/api/evidence-pack.client';
import { EvidencePackViewerComponent } from './evidence-pack-viewer.component';
import type { EvidencePack, EvidencePackExportFormat } from '../../core/api/evidence-pack.models';
describe('EvidencePackViewerComponent', () => {
let fixture: ComponentFixture<EvidencePackViewerComponent>;
let component: EvidencePackViewerComponent;
let api: jasmine.SpyObj<EvidencePackApi>;
const mockPack: EvidencePack = {
packId: 'pack-123',
version: '1.0.0',
createdAt: '2026-01-14T12:00:00Z',
tenantId: 'test-tenant',
subject: {
type: 'Finding',
findingId: 'FIND-001',
cveId: 'CVE-2024-1234',
component: 'pkg:npm/lodash@4.17.20',
},
claims: [
{
claimId: 'claim-001',
text: 'Vulnerability is not reachable',
type: 'Reachability',
status: 'not_affected',
confidence: 0.85,
evidenceIds: ['ev-001'],
source: 'system',
},
],
evidence: [
{
evidenceId: 'ev-001',
type: 'Reachability',
uri: 'stellaops://reachability/FIND-001',
digest: 'sha256:abc123',
collectedAt: '2026-01-14T11:00:00Z',
snapshot: { type: 'reachability', data: { status: 'unreachable' } },
},
],
contentDigest: 'sha256:def456',
};
beforeEach(async () => {
api = jasmine.createSpyObj<EvidencePackApi>('EvidencePackApi', [
'get',
'list',
'sign',
'verify',
'export',
'create',
'listByRun',
]);
api.get.and.returnValue(of(mockPack));
api.export.and.returnValue(of(new Blob(['{}'], { type: 'application/json' })));
await TestBed.configureTestingModule({
imports: [RouterTestingModule, EvidencePackViewerComponent],
providers: [{ provide: EVIDENCE_PACK_API, useValue: api }],
}).compileComponents();
fixture = TestBed.createComponent(EvidencePackViewerComponent);
component = fixture.componentInstance;
});
it('creates the component', () => {
expect(component).toBeTruthy();
});
describe('export menu', () => {
beforeEach(fakeAsync(() => {
component.packId = 'pack-123';
fixture.detectChanges();
tick();
}));
it('renders export menu with evidence card options', () => {
// Open the export menu
component.toggleExportMenu();
fixture.detectChanges();
const exportMenu = fixture.nativeElement.querySelector('.export-menu');
expect(exportMenu).toBeTruthy();
const buttons = exportMenu.querySelectorAll('button');
const buttonTexts = Array.from(buttons).map((b: HTMLButtonElement) => b.textContent?.trim());
expect(buttonTexts).toContain('JSON');
expect(buttonTexts).toContain('Signed JSON');
expect(buttonTexts).toContain('Markdown');
expect(buttonTexts).toContain('HTML');
expect(buttonTexts).toContain('Evidence Card');
expect(buttonTexts).toContain('Evidence Card (Compact)');
});
it('renders export divider before evidence card options', () => {
component.toggleExportMenu();
fixture.detectChanges();
const divider = fixture.nativeElement.querySelector('.export-divider');
expect(divider).toBeTruthy();
});
it('calls export API with EvidenceCard format', fakeAsync(() => {
component.onExport('EvidenceCard');
tick();
expect(api.export).toHaveBeenCalledWith('pack-123', 'EvidenceCard', jasmine.any(Object));
}));
it('calls export API with EvidenceCardCompact format', fakeAsync(() => {
component.onExport('EvidenceCardCompact');
tick();
expect(api.export).toHaveBeenCalledWith('pack-123', 'EvidenceCardCompact', jasmine.any(Object));
}));
it('triggers download for evidence card export', fakeAsync(() => {
const mockBlob = new Blob(['{"cardId":"card-123"}'], {
type: 'application/vnd.stellaops.evidence-card+json',
});
api.export.and.returnValue(of(mockBlob));
// Spy on URL.createObjectURL and link creation
const createObjectURLSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:test');
const revokeObjectURLSpy = spyOn(URL, 'revokeObjectURL');
component.onExport('EvidenceCard');
tick();
expect(createObjectURLSpy).toHaveBeenCalled();
expect(revokeObjectURLSpy).toHaveBeenCalled();
}));
});
describe('export formats', () => {
const exportFormats: EvidencePackExportFormat[] = [
'Json',
'SignedJson',
'Markdown',
'Html',
'EvidenceCard',
'EvidenceCardCompact',
];
beforeEach(fakeAsync(() => {
component.packId = 'pack-123';
fixture.detectChanges();
tick();
}));
exportFormats.forEach((format) => {
it(`exports ${format} format successfully`, fakeAsync(() => {
const mockBlob = new Blob(['test'], { type: 'application/octet-stream' });
api.export.and.returnValue(of(mockBlob));
component.onExport(format);
tick();
expect(api.export).toHaveBeenCalledWith('pack-123', format, jasmine.any(Object));
}));
});
});
describe('evidence card button styling', () => {
beforeEach(fakeAsync(() => {
component.packId = 'pack-123';
fixture.detectChanges();
tick();
}));
it('evidence card buttons have correct class', () => {
component.toggleExportMenu();
fixture.detectChanges();
const evidenceCardBtns = fixture.nativeElement.querySelectorAll('.evidence-card-btn');
expect(evidenceCardBtns.length).toBe(2);
});
it('evidence card buttons have icons', () => {
component.toggleExportMenu();
fixture.detectChanges();
const evidenceCardBtns = fixture.nativeElement.querySelectorAll('.evidence-card-btn');
evidenceCardBtns.forEach((btn: HTMLButtonElement) => {
const icon = btn.querySelector('.card-icon');
expect(icon).toBeTruthy();
});
});
});
describe('error handling', () => {
beforeEach(fakeAsync(() => {
component.packId = 'pack-123';
fixture.detectChanges();
tick();
}));
it('handles export error gracefully', fakeAsync(() => {
api.export.and.returnValue(throwError(() => new Error('Export failed')));
// Should not throw
expect(() => {
component.onExport('EvidenceCard');
tick();
}).not.toThrow();
}));
});
});

View File

@@ -94,6 +94,21 @@ import { EVIDENCE_PACK_API } from '../../core/api/evidence-pack.client';
<button type="button" (click)="onExport('SignedJson')">Signed JSON</button>
<button type="button" (click)="onExport('Markdown')">Markdown</button>
<button type="button" (click)="onExport('Html')">HTML</button>
<hr class="export-divider" />
<button type="button" (click)="onExport('EvidenceCard')" class="evidence-card-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="card-icon">
<rect x="3" y="4" width="18" height="16" rx="2"/>
<path d="M9 9h6M9 13h4"/>
</svg>
Evidence Card
</button>
<button type="button" (click)="onExport('EvidenceCardCompact')" class="evidence-card-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="card-icon">
<rect x="3" y="4" width="18" height="16" rx="2"/>
<path d="M9 12h6"/>
</svg>
Evidence Card (Compact)
</button>
</div>
}
</div>
@@ -458,6 +473,25 @@ import { EVIDENCE_PACK_API } from '../../core/api/evidence-pack.client';
background: var(--bg-hover, #f3f4f6);
}
/* Sprint: SPRINT_20260112_006_FE_evidence_card_ui (EVPCARD-FE-002) */
.export-divider {
margin: 0.25rem 0;
border: none;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.evidence-card-btn {
display: flex !important;
align-items: center;
gap: 0.5rem;
}
.evidence-card-btn .card-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.pack-section {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);

View File

@@ -0,0 +1,256 @@
// Sprint: SPRINT_20260112_013_FE_determinization_config_pane (FE-CONFIG-004)
// Determinization Configuration Pane Component Tests
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { DeterminizationConfigPaneComponent } from './determinization-config-pane.component';
import {
DeterminizationConfig,
EffectiveConfigResponse,
CONFLICT_ACTION_LABELS,
ENVIRONMENT_LABELS,
} from '../../core/api/determinization-config.client';
describe('DeterminizationConfigPaneComponent', () => {
let component: DeterminizationConfigPaneComponent;
let fixture: ComponentFixture<DeterminizationConfigPaneComponent>;
const mockConfig: DeterminizationConfig = {
triggers: {
epssDeltaThreshold: 0.2,
triggerOnThresholdCrossing: true,
triggerOnRekorEntry: true,
triggerOnVexStatusChange: true,
triggerOnRuntimeTelemetryChange: true,
triggerOnPatchProofAdded: true,
triggerOnDsseValidationChange: true,
triggerOnToolVersionChange: false,
},
conflicts: {
vexReachabilityContradiction: 'RequireManualReview',
staticRuntimeMismatch: 'RequireManualReview',
backportStatusAmbiguity: 'RequireManualReview',
vexStatusConflict: 'RequestVendorClarification',
escalationSeverityThreshold: 0.85,
conflictTtlHours: 48,
},
thresholds: {
development: {
epssThreshold: 0.7,
uncertaintyFactor: 0.8,
exploitPressureWeight: 0.3,
reachabilityWeight: 0.4,
minScore: 0,
maxScore: 100,
},
staging: {
epssThreshold: 0.5,
uncertaintyFactor: 0.5,
exploitPressureWeight: 0.5,
reachabilityWeight: 0.5,
minScore: 0,
maxScore: 100,
},
production: {
epssThreshold: 0.2,
uncertaintyFactor: 0.2,
exploitPressureWeight: 0.8,
reachabilityWeight: 0.7,
minScore: 0,
maxScore: 100,
},
},
};
const mockEffectiveConfig: EffectiveConfigResponse = {
config: mockConfig,
isDefault: false,
tenantId: 'test-tenant',
lastUpdatedAt: '2026-01-15T10:00:00Z',
lastUpdatedBy: 'admin@example.com',
version: 3,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DeterminizationConfigPaneComponent],
providers: [provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
fixture = TestBed.createComponent(DeterminizationConfigPaneComponent);
component = fixture.componentInstance;
// Manually set config for testing
component['effectiveConfig'].set(mockEffectiveConfig);
component['loading'].set(false);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('config display', () => {
it('should display custom config badge when not default', () => {
expect(component.effectiveConfig()?.isDefault).toBe(false);
});
it('should display default config badge when using defaults', () => {
component['effectiveConfig'].set({ ...mockEffectiveConfig, isDefault: true });
fixture.detectChanges();
expect(component.effectiveConfig()?.isDefault).toBe(true);
});
it('should display EPSS delta threshold', () => {
expect(component.config()?.triggers.epssDeltaThreshold).toBe(0.2);
});
it('should display trigger toggle states', () => {
const triggers = component.config()?.triggers;
expect(triggers?.triggerOnThresholdCrossing).toBe(true);
expect(triggers?.triggerOnToolVersionChange).toBe(false);
});
});
describe('edit mode', () => {
it('should not be in edit mode by default', () => {
expect(component.editMode()).toBe(false);
});
it('should enter edit mode when toggled', () => {
component.toggleEditMode();
expect(component.editMode()).toBe(true);
expect(component.editConfig()).toBeTruthy();
});
it('should deep clone config when entering edit mode', () => {
component.toggleEditMode();
const editCfg = component.editConfig();
expect(editCfg).not.toBe(component.config());
expect(editCfg?.triggers.epssDeltaThreshold).toBe(0.2);
});
it('should cancel edit mode and clear edits', () => {
component.toggleEditMode(); // Enter
component.editConfig()!.triggers.epssDeltaThreshold = 0.5;
component.toggleEditMode(); // Cancel
expect(component.editMode()).toBe(false);
expect(component.editConfig()).toBeNull();
// Original should be unchanged
expect(component.config()?.triggers.epssDeltaThreshold).toBe(0.2);
});
it('should only show edit button for admins', () => {
component.isAdmin.set(true);
fixture.detectChanges();
expect(component.isAdmin()).toBe(true);
component.isAdmin.set(false);
fixture.detectChanges();
expect(component.isAdmin()).toBe(false);
});
});
describe('conflict action labels', () => {
it('should return correct labels for conflict actions', () => {
expect(component.getConflictActionLabel('RequireManualReview')).toBe('Require Manual Review');
expect(component.getConflictActionLabel('Escalate')).toBe('Escalate');
expect(component.getConflictActionLabel('RequestVendorClarification')).toBe(
'Request Vendor Clarification'
);
});
it('should have labels for all conflict actions', () => {
const actions = component.conflictActions;
actions.forEach((action) => {
expect(CONFLICT_ACTION_LABELS[action]).toBeTruthy();
});
});
});
describe('environment labels', () => {
it('should return correct labels for environments', () => {
expect(component.getEnvironmentLabel('development')).toBe('Development');
expect(component.getEnvironmentLabel('staging')).toBe('Staging');
expect(component.getEnvironmentLabel('production')).toBe('Production');
});
it('should have labels for all environments', () => {
const envs = component.environments;
envs.forEach((env) => {
expect(ENVIRONMENT_LABELS[env]).toBeTruthy();
});
});
});
describe('validation', () => {
it('should display validation errors when present', () => {
component['validationErrors'].set(['EPSS threshold must be between 0 and 1']);
fixture.detectChanges();
expect(component.validationErrors().length).toBe(1);
});
it('should display validation warnings when present', () => {
component['validationWarnings'].set(['Low EPSS threshold may cause excessive reanalysis']);
fixture.detectChanges();
expect(component.validationWarnings().length).toBe(1);
});
it('should clear validation state when canceling edit', () => {
component.toggleEditMode();
component['validationErrors'].set(['Error']);
component['validationWarnings'].set(['Warning']);
component.toggleEditMode(); // Cancel
expect(component.validationErrors().length).toBe(0);
expect(component.validationWarnings().length).toBe(0);
});
});
describe('deterministic rendering', () => {
it('should render trigger fields in consistent order', () => {
const fieldKeys = component.triggerFields.map((f) => f.key);
expect(fieldKeys).toEqual([
'triggerOnThresholdCrossing',
'triggerOnRekorEntry',
'triggerOnVexStatusChange',
'triggerOnRuntimeTelemetryChange',
'triggerOnPatchProofAdded',
'triggerOnDsseValidationChange',
'triggerOnToolVersionChange',
]);
});
it('should render conflict fields in consistent order', () => {
const fieldKeys = component.conflictFields.map((f) => f.key);
expect(fieldKeys).toEqual([
'vexReachabilityContradiction',
'staticRuntimeMismatch',
'backportStatusAmbiguity',
'vexStatusConflict',
]);
});
it('should render environments in consistent order', () => {
expect(component.environments).toEqual(['development', 'staging', 'production']);
});
it('should render conflict actions in consistent order', () => {
expect(component.conflictActions).toEqual([
'RequireManualReview',
'AutoAcceptLowerSeverity',
'AutoRejectHigherSeverity',
'Escalate',
'DeferToNextReanalysis',
'RequestVendorClarification',
]);
});
});
describe('metadata display', () => {
it('should display last updated info', () => {
expect(component.effectiveConfig()?.lastUpdatedAt).toBe('2026-01-15T10:00:00Z');
expect(component.effectiveConfig()?.lastUpdatedBy).toBe('admin@example.com');
expect(component.effectiveConfig()?.version).toBe(3);
});
});
});

View File

@@ -0,0 +1,465 @@
// Sprint: SPRINT_20260112_013_FE_determinization_config_pane (FE-CONFIG-002, FE-CONFIG-003)
// Determinization Configuration Pane Component
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
DeterminizationConfigClient,
DeterminizationConfig,
EffectiveConfigResponse,
ReanalysisTriggerConfig,
ConflictHandlingPolicy,
EnvironmentThreshold,
ConflictAction,
ValidationResponse,
AuditEntry,
CONFLICT_ACTION_LABELS,
ENVIRONMENT_LABELS,
DEFAULT_TRIGGER_CONFIG,
} from '../../core/api/determinization-config.client';
@Component({
selector: 'stella-determinization-config-pane',
standalone: true,
imports: [CommonModule, DatePipe, FormsModule],
template: `
<div class="determinization-config-pane">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-xl font-semibold text-gray-900">Determinization Configuration</h2>
<p class="text-sm text-gray-500 mt-1">
Configure reanalysis triggers, conflict handling, and environment thresholds
</p>
</div>
<div class="flex items-center gap-2">
@if (effectiveConfig()?.isDefault) {
<span class="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded">Using Defaults</span>
} @else {
<span class="px-2 py-1 text-xs bg-blue-100 text-blue-600 rounded">Custom Config</span>
}
@if (isAdmin()) {
<button
(click)="toggleEditMode()"
[class]="editMode() ? 'bg-gray-200' : 'bg-blue-600 text-white'"
class="px-3 py-1 text-sm rounded"
>
{{ editMode() ? 'Cancel' : 'Edit' }}
</button>
}
</div>
</div>
@if (loading()) {
<div class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
} @else if (error()) {
<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 mb-4">
{{ error() }}
</div>
} @else if (config()) {
<!-- Validation Errors -->
@if (validationErrors().length > 0) {
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<h4 class="font-medium text-red-800 mb-2">Validation Errors</h4>
<ul class="list-disc list-inside text-sm text-red-700">
@for (err of validationErrors(); track err) {
<li>{{ err }}</li>
}
</ul>
</div>
}
@if (validationWarnings().length > 0) {
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
<h4 class="font-medium text-yellow-800 mb-2">Warnings</h4>
<ul class="list-disc list-inside text-sm text-yellow-700">
@for (warn of validationWarnings(); track warn) {
<li>{{ warn }}</li>
}
</ul>
</div>
}
<!-- Reanalysis Triggers Section -->
<div class="bg-white rounded-lg border p-4 mb-4">
<h3 class="text-lg font-medium text-gray-900 mb-4">Reanalysis Triggers</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- EPSS Delta Threshold -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
EPSS Delta Threshold
</label>
@if (editMode()) {
<input
type="number"
[(ngModel)]="editConfig()!.triggers.epssDeltaThreshold"
min="0"
max="1"
step="0.05"
class="w-full border rounded px-3 py-2 text-sm"
/>
} @else {
<span class="text-sm text-gray-900">
{{ config()!.triggers.epssDeltaThreshold | number:'1.2-2' }}
</span>
}
<p class="text-xs text-gray-500 mt-1">Minimum EPSS change to trigger reanalysis</p>
</div>
<!-- Toggle triggers -->
@for (trigger of triggerFields; track trigger.key) {
<div class="flex items-center justify-between p-2 bg-gray-50 rounded">
<span class="text-sm text-gray-700">{{ trigger.label }}</span>
@if (editMode()) {
<input
type="checkbox"
[(ngModel)]="editConfig()!.triggers[trigger.key]"
class="h-4 w-4 text-blue-600 rounded"
/>
} @else {
<span [class]="config()!.triggers[trigger.key] ? 'text-green-600' : 'text-gray-400'">
{{ config()!.triggers[trigger.key] ? 'Enabled' : 'Disabled' }}
</span>
}
</div>
}
</div>
</div>
<!-- Conflict Handling Section -->
<div class="bg-white rounded-lg border p-4 mb-4">
<h3 class="text-lg font-medium text-gray-900 mb-4">Conflict Handling Policy</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@for (conflict of conflictFields; track conflict.key) {
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ conflict.label }}
</label>
@if (editMode()) {
<select
[(ngModel)]="editConfig()!.conflicts[conflict.key]"
class="w-full border rounded px-3 py-2 text-sm"
>
@for (action of conflictActions; track action) {
<option [value]="action">{{ getConflictActionLabel(action) }}</option>
}
</select>
} @else {
<span class="text-sm text-gray-900">
{{ getConflictActionLabel(config()!.conflicts[conflict.key]) }}
</span>
}
</div>
}
<!-- Escalation Threshold -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Escalation Severity Threshold
</label>
@if (editMode()) {
<input
type="number"
[(ngModel)]="editConfig()!.conflicts.escalationSeverityThreshold"
min="0"
max="1"
step="0.05"
class="w-full border rounded px-3 py-2 text-sm"
/>
} @else {
<span class="text-sm text-gray-900">
{{ config()!.conflicts.escalationSeverityThreshold | number:'1.2-2' }}
</span>
}
</div>
<!-- Conflict TTL -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Conflict TTL (hours)
</label>
@if (editMode()) {
<input
type="number"
[(ngModel)]="editConfig()!.conflicts.conflictTtlHours"
min="1"
class="w-full border rounded px-3 py-2 text-sm"
/>
} @else {
<span class="text-sm text-gray-900">
{{ config()!.conflicts.conflictTtlHours }} hours
</span>
}
</div>
</div>
</div>
<!-- Environment Thresholds Section -->
<div class="bg-white rounded-lg border p-4 mb-4">
<h3 class="text-lg font-medium text-gray-900 mb-4">Environment Thresholds</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Environment</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">EPSS Threshold</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Uncertainty</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Min Score</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Max Score</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@for (env of environments; track env) {
<tr>
<td class="px-4 py-2 text-sm font-medium">{{ getEnvironmentLabel(env) }}</td>
<td class="px-4 py-2">
@if (editMode()) {
<input
type="number"
[(ngModel)]="editConfig()!.thresholds[env].epssThreshold"
min="0"
max="1"
step="0.01"
class="w-20 border rounded px-2 py-1 text-sm"
/>
} @else {
<span class="text-sm">{{ config()!.thresholds[env].epssThreshold | number:'1.2-2' }}</span>
}
</td>
<td class="px-4 py-2">
@if (editMode()) {
<input
type="number"
[(ngModel)]="editConfig()!.thresholds[env].uncertaintyFactor"
min="0"
max="1"
step="0.1"
class="w-20 border rounded px-2 py-1 text-sm"
/>
} @else {
<span class="text-sm">{{ config()!.thresholds[env].uncertaintyFactor | number:'1.1-1' }}</span>
}
</td>
<td class="px-4 py-2">
@if (editMode()) {
<input
type="number"
[(ngModel)]="editConfig()!.thresholds[env].minScore"
min="0"
max="100"
class="w-20 border rounded px-2 py-1 text-sm"
/>
} @else {
<span class="text-sm">{{ config()!.thresholds[env].minScore }}</span>
}
</td>
<td class="px-4 py-2">
@if (editMode()) {
<input
type="number"
[(ngModel)]="editConfig()!.thresholds[env].maxScore"
min="0"
max="100"
class="w-20 border rounded px-2 py-1 text-sm"
/>
} @else {
<span class="text-sm">{{ config()!.thresholds[env].maxScore }}</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Save/Cancel buttons -->
@if (editMode()) {
<div class="flex items-center justify-end gap-3 p-4 bg-gray-50 rounded-lg">
<div class="flex-1">
<input
type="text"
[(ngModel)]="saveReason"
placeholder="Reason for change (required)"
class="w-full border rounded px-3 py-2 text-sm"
/>
</div>
<button
(click)="validateBeforeSave()"
class="px-4 py-2 text-sm bg-gray-200 rounded hover:bg-gray-300"
>
Validate
</button>
<button
(click)="saveConfig()"
[disabled]="saving() || !saveReason"
class="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{{ saving() ? 'Saving...' : 'Save Changes' }}
</button>
</div>
}
<!-- Metadata -->
@if (effectiveConfig()?.lastUpdatedAt) {
<div class="mt-4 text-xs text-gray-500">
Last updated: {{ effectiveConfig()!.lastUpdatedAt | date:'medium' }}
by {{ effectiveConfig()!.lastUpdatedBy || 'system' }}
(version {{ effectiveConfig()!.version }})
</div>
}
}
</div>
`,
styles: [
`
.determinization-config-pane {
max-width: 900px;
}
`,
],
})
export class DeterminizationConfigPaneComponent implements OnInit {
private readonly client = inject(DeterminizationConfigClient);
readonly loading = signal(true);
readonly saving = signal(false);
readonly error = signal<string | null>(null);
readonly effectiveConfig = signal<EffectiveConfigResponse | null>(null);
readonly editMode = signal(false);
readonly editConfig = signal<DeterminizationConfig | null>(null);
readonly validationErrors = signal<string[]>([]);
readonly validationWarnings = signal<string[]>([]);
saveReason = '';
readonly config = computed(() => this.effectiveConfig()?.config);
// For admin check - in real app would come from auth service
isAdmin = signal(true);
readonly triggerFields = [
{ key: 'triggerOnThresholdCrossing' as const, label: 'Threshold Crossing' },
{ key: 'triggerOnRekorEntry' as const, label: 'New Rekor Entry' },
{ key: 'triggerOnVexStatusChange' as const, label: 'VEX Status Change' },
{ key: 'triggerOnRuntimeTelemetryChange' as const, label: 'Runtime Telemetry Change' },
{ key: 'triggerOnPatchProofAdded' as const, label: 'Patch Proof Added' },
{ key: 'triggerOnDsseValidationChange' as const, label: 'DSSE Validation Change' },
{ key: 'triggerOnToolVersionChange' as const, label: 'Tool Version Change' },
];
readonly conflictFields = [
{ key: 'vexReachabilityContradiction' as const, label: 'VEX/Reachability Contradiction' },
{ key: 'staticRuntimeMismatch' as const, label: 'Static/Runtime Mismatch' },
{ key: 'backportStatusAmbiguity' as const, label: 'Backport Status Ambiguity' },
{ key: 'vexStatusConflict' as const, label: 'VEX Status Conflict' },
];
readonly conflictActions: ConflictAction[] = [
'RequireManualReview',
'AutoAcceptLowerSeverity',
'AutoRejectHigherSeverity',
'Escalate',
'DeferToNextReanalysis',
'RequestVendorClarification',
];
readonly environments: Array<'development' | 'staging' | 'production'> = [
'development',
'staging',
'production',
];
ngOnInit(): void {
this.loadConfig();
}
loadConfig(): void {
this.loading.set(true);
this.error.set(null);
this.client.getEffectiveConfig().subscribe({
next: (response) => {
this.effectiveConfig.set(response);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load configuration');
this.loading.set(false);
},
});
}
toggleEditMode(): void {
if (this.editMode()) {
// Cancel edit
this.editMode.set(false);
this.editConfig.set(null);
this.validationErrors.set([]);
this.validationWarnings.set([]);
this.saveReason = '';
} else {
// Enter edit mode - deep clone config
this.editConfig.set(JSON.parse(JSON.stringify(this.config())));
this.editMode.set(true);
}
}
validateBeforeSave(): void {
const cfg = this.editConfig();
if (!cfg) return;
this.client.validateConfig(cfg).subscribe({
next: (response) => {
this.validationErrors.set(response.errors);
this.validationWarnings.set(response.warnings);
if (response.isValid) {
alert('Configuration is valid!');
}
},
error: (err) => {
this.validationErrors.set([err.message || 'Validation failed']);
},
});
}
saveConfig(): void {
const cfg = this.editConfig();
if (!cfg || !this.saveReason) return;
this.saving.set(true);
this.client
.updateConfig({
config: cfg,
reason: this.saveReason,
})
.subscribe({
next: (response) => {
this.effectiveConfig.set(response);
this.editMode.set(false);
this.editConfig.set(null);
this.validationErrors.set([]);
this.validationWarnings.set([]);
this.saveReason = '';
this.saving.set(false);
alert('Configuration saved successfully!');
},
error: (err) => {
this.error.set(err.message || 'Failed to save configuration');
this.saving.set(false);
},
});
}
getConflictActionLabel(action: ConflictAction): string {
return CONFLICT_ACTION_LABELS[action] || action;
}
getEnvironmentLabel(env: string): string {
return ENVIRONMENT_LABELS[env as keyof typeof ENVIRONMENT_LABELS] || env;
}
}

View File

@@ -1,19 +1,152 @@
/**
* @file setup-wizard-api.service.spec.ts
* @sprint Sprint 5: UI Integrations + Settings Store
* @description Unit tests for SetupWizardApiService
* @sprint SPRINT_20260112_005_FE_setup_wizard_ui_wiring
* @tasks FE-SETUP-003
* @description Unit tests for SetupWizardApiService with deterministic fixtures
*/
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { SetupWizardApiService } from './setup-wizard-api.service';
import { ExecuteStepRequest, SkipStepRequest } from '../models/setup-wizard.models';
import { HttpErrorResponse } from '@angular/common/http';
import {
SetupWizardApiService,
ApiResponse,
SetupSessionResponse,
ExecuteStepResponse,
ValidationCheckResponse,
ConnectionTestResponse,
FinalizeSetupResponse,
ProblemDetails,
SetupApiError,
} from './setup-wizard-api.service';
import { ExecuteStepRequest, SkipStepRequest, SetupStep, SetupStepId } from '../models/setup-wizard.models';
// ─────────────────────────────────────────────────────────────────────────────
// Deterministic Test Fixtures
// ─────────────────────────────────────────────────────────────────────────────
const FIXTURE_SESSION_ID = 'test-session-12345';
const FIXTURE_TIMESTAMP = '2026-01-15T10:00:00.000Z';
const createSessionFixture = (): ApiResponse<SetupSessionResponse> => ({
data: {
sessionId: FIXTURE_SESSION_ID,
startedAt: FIXTURE_TIMESTAMP,
completedSteps: [],
skippedSteps: [],
configValues: {},
},
dataAsOf: FIXTURE_TIMESTAMP,
});
const createStepsFixture = (): ApiResponse<SetupStep[]> => ({
data: [
{
id: 'database',
name: 'PostgreSQL Database',
description: 'Configure the database connection',
category: 'Infrastructure',
order: 1,
isRequired: true,
isSkippable: false,
dependencies: [],
validationChecks: ['check.database.connectivity', 'check.database.migrations'],
status: 'pending',
},
{
id: 'cache',
name: 'Valkey/Redis Cache',
description: 'Configure the cache connection',
category: 'Infrastructure',
order: 2,
isRequired: true,
isSkippable: false,
dependencies: [],
validationChecks: ['check.cache.connectivity'],
status: 'pending',
},
],
dataAsOf: FIXTURE_TIMESTAMP,
});
const createExecuteStepFixture = (stepId: SetupStepId, dryRun: boolean): ApiResponse<ExecuteStepResponse> => ({
data: {
stepId,
status: 'completed',
message: dryRun ? `[DRY RUN] Step ${stepId} would be configured` : `Step ${stepId} configured successfully`,
appliedConfig: { [`${stepId}.host`]: 'localhost' },
canRetry: true,
executionDurationMs: 1500,
},
dataAsOf: FIXTURE_TIMESTAMP,
});
const createValidationChecksFixture = (): ApiResponse<ValidationCheckResponse[]> => ({
data: [
{
checkId: 'check.database.connectivity',
name: 'Database Connectivity',
description: 'Verify connection to the PostgreSQL database',
status: 'passed',
severity: 'info',
message: 'Connected to PostgreSQL 16.2',
executedAt: FIXTURE_TIMESTAMP,
durationMs: 250,
},
{
checkId: 'check.database.migrations',
name: 'Database Migrations',
description: 'Check that database migrations are up to date',
status: 'passed',
severity: 'info',
message: 'All migrations applied',
executedAt: FIXTURE_TIMESTAMP,
durationMs: 100,
},
],
dataAsOf: FIXTURE_TIMESTAMP,
});
const createConnectionTestFixture = (success: boolean): ApiResponse<ConnectionTestResponse> => ({
data: {
success,
message: success ? 'Connection successful' : 'Connection failed',
latencyMs: success ? 45 : undefined,
serverVersion: success ? '16.2' : undefined,
},
dataAsOf: FIXTURE_TIMESTAMP,
});
const createFinalizeFixture = (): ApiResponse<FinalizeSetupResponse> => ({
data: {
success: true,
message: 'Setup completed successfully. Please restart the services to apply configuration.',
restartRequired: true,
nextSteps: ['Restart services', 'Run stella doctor'],
},
dataAsOf: FIXTURE_TIMESTAMP,
});
const createProblemDetailsFixture = (
status: number,
title: string,
detail?: string
): ProblemDetails => ({
type: 'urn:stellaops:error:validation',
title,
status,
detail,
traceId: 'trace-12345',
});
describe('SetupWizardApiService', () => {
let service: SetupWizardApiService;
let httpMock: HttpTestingController;
const setupBaseUrl = '/api/v1/setup';
const onboardingBaseUrl = '/api/v1/platform/onboarding';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
@@ -34,72 +167,142 @@ describe('SetupWizardApiService', () => {
expect(service).toBeTruthy();
});
// ═══════════════════════════════════════════════════════════════════════════
// Session Management Tests
// ═══════════════════════════════════════════════════════════════════════════
describe('createSession', () => {
it('should create a new session', (done) => {
it('should create a new session via POST', (done) => {
service.createSession().subscribe((session) => {
expect(session).toBeTruthy();
expect(session.sessionId).toBeTruthy();
expect(session.startedAt).toBeTruthy();
expect(session.sessionId).toBe(FIXTURE_SESSION_ID);
expect(session.startedAt).toBe(FIXTURE_TIMESTAMP);
expect(session.completedSteps).toEqual([]);
expect(session.skippedSteps).toEqual([]);
expect(session.configValues).toEqual({});
done();
});
const req = httpMock.expectOne(`${setupBaseUrl}/sessions`);
expect(req.request.method).toBe('POST');
req.flush(createSessionFixture());
});
it('should handle network error', (done) => {
service.createSession().subscribe({
error: (error: SetupApiError) => {
expect(error.code).toBe('NETWORK_ERROR');
expect(error.retryable).toBeTrue();
done();
},
});
const req = httpMock.expectOne(`${setupBaseUrl}/sessions`);
req.error(new ProgressEvent('Network error'));
});
});
describe('resumeSession', () => {
it('should return null for non-existent session', (done) => {
service.resumeSession('non-existent-id').subscribe((session) => {
it('should get existing session via GET', (done) => {
service.resumeSession(FIXTURE_SESSION_ID).subscribe((session) => {
expect(session).toBeTruthy();
expect(session?.sessionId).toBe(FIXTURE_SESSION_ID);
done();
});
const req = httpMock.expectOne(`${setupBaseUrl}/sessions/${FIXTURE_SESSION_ID}`);
expect(req.request.method).toBe('GET');
req.flush(createSessionFixture());
});
it('should return null for 404', (done) => {
service.resumeSession('non-existent').subscribe((session) => {
expect(session).toBeNull();
done();
});
const req = httpMock.expectOne(`${setupBaseUrl}/sessions/non-existent`);
req.flush(null, { status: 404, statusText: 'Not Found' });
});
});
describe('getCurrentSession', () => {
it('should get current session via GET /current', (done) => {
service.getCurrentSession().subscribe((session) => {
expect(session?.sessionId).toBe(FIXTURE_SESSION_ID);
done();
});
const req = httpMock.expectOne(`${setupBaseUrl}/sessions/current`);
expect(req.request.method).toBe('GET');
req.flush(createSessionFixture());
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Step Management Tests
// ═══════════════════════════════════════════════════════════════════════════
describe('getSteps', () => {
it('should return default steps', (done) => {
it('should get all steps via GET', (done) => {
service.getSteps().subscribe((steps) => {
expect(steps.length).toBe(6);
expect(steps.length).toBe(2);
expect(steps[0].id).toBe('database');
expect(steps[1].id).toBe('cache');
done();
});
const req = httpMock.expectOne(`${setupBaseUrl}/steps`);
expect(req.request.method).toBe('GET');
req.flush(createStepsFixture());
});
});
describe('getStep', () => {
it('should return specific step', (done) => {
it('should get specific step via GET', (done) => {
service.getStep('database').subscribe((step) => {
expect(step).toBeTruthy();
expect(step?.id).toBe('database');
expect(step?.name).toBe('PostgreSQL Database');
done();
});
const req = httpMock.expectOne(`${setupBaseUrl}/steps/database`);
expect(req.request.method).toBe('GET');
req.flush({ data: createStepsFixture().data[0] });
});
it('should return null for non-existent step', (done) => {
service.getStep('nonexistent' as any).subscribe((step) => {
it('should return null for 404', (done) => {
service.getStep('nonexistent' as SetupStepId).subscribe((step) => {
expect(step).toBeNull();
done();
});
const req = httpMock.expectOne(`${setupBaseUrl}/steps/nonexistent`);
req.flush(null, { status: 404, statusText: 'Not Found' });
});
});
describe('checkPrerequisites', () => {
it('should return met=true for valid config', (done) => {
service.checkPrerequisites('session-id', 'database', {}).subscribe((result) => {
it('should check prerequisites via POST', (done) => {
service.checkPrerequisites(FIXTURE_SESSION_ID, 'database', {}).subscribe((result) => {
expect(result.met).toBeTrue();
expect(result.missingPrerequisites).toEqual([]);
done();
});
const req = httpMock.expectOne(
`${setupBaseUrl}/sessions/${FIXTURE_SESSION_ID}/steps/database/prerequisites`
);
expect(req.request.method).toBe('POST');
req.flush({ data: { met: true, missingPrerequisites: [] } });
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Step Execution Tests
// ═══════════════════════════════════════════════════════════════════════════
describe('executeStep', () => {
it('should return completed status for successful execution', (done) => {
it('should execute step via POST', (done) => {
const request: ExecuteStepRequest = {
sessionId: 'session-id',
sessionId: FIXTURE_SESSION_ID,
stepId: 'database',
configValues: { 'database.host': 'localhost' },
dryRun: false,
@@ -108,15 +311,22 @@ describe('SetupWizardApiService', () => {
service.executeStep(request).subscribe((result) => {
expect(result.stepId).toBe('database');
expect(result.status).toBe('completed');
expect(result.appliedConfig).toEqual(request.configValues);
expect(result.canRetry).toBeTrue();
done();
});
const req = httpMock.expectOne(
`${setupBaseUrl}/sessions/${FIXTURE_SESSION_ID}/steps/database/execute`
);
expect(req.request.method).toBe('POST');
expect(req.request.body.configValues).toEqual({ 'database.host': 'localhost' });
expect(req.request.body.dryRun).toBeFalse();
req.flush(createExecuteStepFixture('database', false));
});
it('should indicate dry run in message', (done) => {
it('should include dryRun flag in request', (done) => {
const request: ExecuteStepRequest = {
sessionId: 'session-id',
sessionId: FIXTURE_SESSION_ID,
stepId: 'database',
configValues: {},
dryRun: true,
@@ -126,13 +336,19 @@ describe('SetupWizardApiService', () => {
expect(result.message).toContain('DRY RUN');
done();
});
const req = httpMock.expectOne(
`${setupBaseUrl}/sessions/${FIXTURE_SESSION_ID}/steps/database/execute`
);
expect(req.request.body.dryRun).toBeTrue();
req.flush(createExecuteStepFixture('database', true));
});
});
describe('skipStep', () => {
it('should return skipped status', (done) => {
it('should skip step via POST', (done) => {
const request: SkipStepRequest = {
sessionId: 'session-id',
sessionId: FIXTURE_SESSION_ID,
stepId: 'vault',
reason: 'Not needed',
};
@@ -140,68 +356,230 @@ describe('SetupWizardApiService', () => {
service.skipStep(request).subscribe((result) => {
expect(result.stepId).toBe('vault');
expect(result.status).toBe('skipped');
expect(result.message).toContain('Not needed');
expect(result.canRetry).toBeFalse();
done();
});
const req = httpMock.expectOne(
`${setupBaseUrl}/sessions/${FIXTURE_SESSION_ID}/steps/vault/skip`
);
expect(req.request.method).toBe('POST');
expect(req.request.body.reason).toBe('Not needed');
req.flush({
data: {
stepId: 'vault',
status: 'skipped',
message: 'Skipped: Not needed',
canRetry: false,
},
});
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Validation Check Tests
// ═══════════════════════════════════════════════════════════════════════════
describe('getValidationChecks', () => {
it('should get checks via GET', (done) => {
service.getValidationChecks(FIXTURE_SESSION_ID, 'database').subscribe((checks) => {
expect(checks.length).toBe(2);
expect(checks[0].checkId).toBe('check.database.connectivity');
done();
});
const req = httpMock.expectOne(
`${setupBaseUrl}/sessions/${FIXTURE_SESSION_ID}/steps/database/checks`
);
expect(req.request.method).toBe('GET');
req.flush(createValidationChecksFixture());
});
});
describe('runValidationChecks', () => {
it('should return validation checks for step', (done) => {
service.runValidationChecks('session-id', 'database').subscribe((checks) => {
expect(checks.length).toBeGreaterThan(0);
expect(checks[0].checkId).toContain('database');
expect(checks[0].status).toBe('pending');
it('should run all checks via POST', (done) => {
service.runValidationChecks(FIXTURE_SESSION_ID, 'database').subscribe((checks) => {
expect(checks.length).toBe(2);
expect(checks.every(c => c.status === 'passed')).toBeTrue();
done();
});
});
it('should return empty for non-existent step', (done) => {
service.runValidationChecks('session-id', 'nonexistent' as any).subscribe((checks) => {
expect(checks).toEqual([]);
done();
});
const req = httpMock.expectOne(
`${setupBaseUrl}/sessions/${FIXTURE_SESSION_ID}/steps/database/checks/run`
);
expect(req.request.method).toBe('POST');
req.flush(createValidationChecksFixture());
});
});
describe('runValidationCheck', () => {
it('should return passed check', (done) => {
service.runValidationCheck('session-id', 'check.database.connectivity', {}).subscribe((check) => {
expect(check.checkId).toBe('check.database.connectivity');
expect(check.status).toBe('passed');
expect(check.message).toBe('Check passed successfully');
done();
});
it('should run specific check via POST', (done) => {
service
.runValidationCheck(FIXTURE_SESSION_ID, 'check.database.connectivity', {})
.subscribe((check) => {
expect(check.checkId).toBe('check.database.connectivity');
expect(check.status).toBe('passed');
done();
});
const req = httpMock.expectOne(
`${setupBaseUrl}/sessions/${FIXTURE_SESSION_ID}/checks/check.database.connectivity/run`
);
expect(req.request.method).toBe('POST');
req.flush({ data: createValidationChecksFixture().data[0] });
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Connection Testing Tests
// ═══════════════════════════════════════════════════════════════════════════
describe('testConnection', () => {
it('should return success for valid config', (done) => {
it('should test connection via POST', (done) => {
service.testConnection('database', { 'database.host': 'localhost' }).subscribe((result) => {
expect(result.success).toBeTrue();
expect(result.message).toBe('Connection successful');
expect(result.latencyMs).toBe(45);
done();
});
const req = httpMock.expectOne(`${setupBaseUrl}/steps/database/test-connection`);
expect(req.request.method).toBe('POST');
req.flush(createConnectionTestFixture(true));
});
it('should handle failed connection', (done) => {
service.testConnection('database', { 'database.host': 'bad-host' }).subscribe((result) => {
expect(result.success).toBeFalse();
expect(result.message).toBe('Connection failed');
done();
});
const req = httpMock.expectOne(`${setupBaseUrl}/steps/database/test-connection`);
req.flush(createConnectionTestFixture(false));
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Configuration & Finalization Tests
// ═══════════════════════════════════════════════════════════════════════════
describe('saveConfiguration', () => {
it('should return success', (done) => {
service.saveConfiguration('session-id', { key: 'value' }).subscribe((result) => {
it('should save config via PUT', (done) => {
service.saveConfiguration(FIXTURE_SESSION_ID, { key: 'value' }).subscribe((result) => {
expect(result.success).toBeTrue();
done();
});
const req = httpMock.expectOne(`${setupBaseUrl}/sessions/${FIXTURE_SESSION_ID}/config`);
expect(req.request.method).toBe('PUT');
expect(req.request.body.configValues).toEqual({ key: 'value' });
req.flush({ data: { saved: true } });
});
});
describe('finalizeSetup', () => {
it('should return success with restart message', (done) => {
service.finalizeSetup('session-id').subscribe((result) => {
it('should finalize via POST', (done) => {
service.finalizeSetup(FIXTURE_SESSION_ID).subscribe((result) => {
expect(result.success).toBeTrue();
expect(result.message).toContain('restart');
expect(result.restartRequired).toBeTrue();
expect(result.nextSteps?.length).toBe(2);
done();
});
const req = httpMock.expectOne(`${setupBaseUrl}/sessions/${FIXTURE_SESSION_ID}/finalize`);
expect(req.request.method).toBe('POST');
req.flush(createFinalizeFixture());
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Onboarding Integration Tests
// ═══════════════════════════════════════════════════════════════════════════
describe('getOnboardingStatus', () => {
it('should get status via GET', (done) => {
service.getOnboardingStatus().subscribe((result) => {
expect(result.status).toBe('in_progress');
expect(result.completedSteps).toContain('welcome');
done();
});
const req = httpMock.expectOne(`${onboardingBaseUrl}/status`);
expect(req.request.method).toBe('GET');
req.flush({
status: 'in_progress',
currentStep: 'connect-registry',
steps: [
{ id: 'welcome', completed: true },
{ id: 'connect-registry', completed: false },
],
});
});
});
describe('completeOnboardingStep', () => {
it('should complete step via POST', (done) => {
service.completeOnboardingStep('connect-registry').subscribe((result) => {
expect(result.success).toBeTrue();
done();
});
const req = httpMock.expectOne(`${onboardingBaseUrl}/complete/connect-registry`);
expect(req.request.method).toBe('POST');
req.flush({ status: 'in_progress' });
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Error Handling Tests
// ═══════════════════════════════════════════════════════════════════════════
describe('parseError', () => {
it('should parse Problem+JSON error', () => {
const httpError = new HttpErrorResponse({
error: createProblemDetailsFixture(400, 'Validation Failed', 'Invalid host format'),
status: 400,
statusText: 'Bad Request',
});
const parsed = service.parseError(httpError);
expect(parsed.code).toBe('VALIDATION');
expect(parsed.message).toBe('Validation Failed');
expect(parsed.detail).toBe('Invalid host format');
expect(parsed.retryable).toBeFalse();
});
it('should mark 5xx errors as retryable', () => {
const httpError = new HttpErrorResponse({
error: createProblemDetailsFixture(503, 'Service Unavailable'),
status: 503,
statusText: 'Service Unavailable',
});
const parsed = service.parseError(httpError);
expect(parsed.retryable).toBeTrue();
});
it('should mark 429 as retryable', () => {
const httpError = new HttpErrorResponse({
error: createProblemDetailsFixture(429, 'Too Many Requests'),
status: 429,
statusText: 'Too Many Requests',
});
const parsed = service.parseError(httpError);
expect(parsed.retryable).toBeTrue();
});
it('should handle generic errors', () => {
const httpError = new HttpErrorResponse({
error: 'Internal error',
status: 500,
statusText: 'Internal Server Error',
});
const parsed = service.parseError(httpError);
expect(parsed.code).toBe('HTTP_500');
expect(parsed.message).toBe('Server error');
});
});
});

View File

@@ -1,12 +1,17 @@
/**
* @file setup-wizard-api.service.ts
* @sprint Sprint 4: UI Wizard Core
* @sprint SPRINT_20260112_005_FE_setup_wizard_ui_wiring
* @tasks FE-SETUP-001
* @description API service for setup wizard backend communication
*
* Replaces mock calls with real HttpClient calls to:
* - /api/v1/setup/* - Setup wizard endpoints
* - /api/v1/platform/onboarding/* - Onboarding endpoints
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, delay } from 'rxjs';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable, of, catchError, map, throwError, retry, timer } from 'rxjs';
import {
SetupStep,
SetupStepId,
@@ -16,223 +21,587 @@ import {
SkipStepRequest,
ValidationCheck,
PrerequisiteResult,
DEFAULT_SETUP_STEPS,
CheckStatus,
CheckSeverity,
} from '../models/setup-wizard.models';
/** API response wrapper */
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
// ─────────────────────────────────────────────────────────────────────────────
// API Response Types (Problem+JSON aligned)
// ─────────────────────────────────────────────────────────────────────────────
/** RFC 7807 Problem+JSON error response */
export interface ProblemDetails {
type?: string;
title: string;
status: number;
detail?: string;
instance?: string;
traceId?: string;
errors?: Record<string, string[]>;
}
/** API response wrapper with timestamp metadata */
export interface ApiResponse<T> {
data: T;
dataAsOf?: string;
cacheHit?: boolean;
}
/** Setup session response from backend */
export interface SetupSessionResponse {
sessionId: string;
startedAt: string;
expiresAt?: string;
completedSteps: SetupStepId[];
skippedSteps: SetupStepId[];
configValues: Record<string, string>;
currentStep?: SetupStepId;
metadata?: Record<string, string>;
}
/** Step execution response from backend */
export interface ExecuteStepResponse {
stepId: SetupStepId;
status: 'completed' | 'failed' | 'skipped';
message: string;
appliedConfig?: Record<string, string>;
outputValues?: Record<string, string>;
error?: string;
canRetry: boolean;
validationResults?: ValidationCheckResponse[];
executionDurationMs?: number;
}
/** Validation check response from backend */
export interface ValidationCheckResponse {
checkId: string;
name: string;
description: string;
status: CheckStatus;
severity: CheckSeverity;
message?: string;
remediation?: string;
executedAt?: string;
durationMs?: number;
}
/** Connection test response */
export interface ConnectionTestResponse {
success: boolean;
message: string;
latencyMs?: number;
serverVersion?: string;
capabilities?: string[];
}
/** Finalize setup response */
export interface FinalizeSetupResponse {
success: boolean;
message: string;
restartRequired: boolean;
configFilePath?: string;
nextSteps?: string[];
}
// ─────────────────────────────────────────────────────────────────────────────
// UI Error Model
// ─────────────────────────────────────────────────────────────────────────────
/** Parsed error for UI display */
export interface SetupApiError {
code: string;
message: string;
detail?: string;
field?: string;
retryable: boolean;
suggestedFixes?: string[];
}
// ─────────────────────────────────────────────────────────────────────────────
// Service Implementation
// ─────────────────────────────────────────────────────────────────────────────
/**
* API service for setup wizard operations.
* Communicates with the CLI/Platform backend for setup operations.
* Communicates with the Platform backend for setup and onboarding operations.
*/
@Injectable()
export class SetupWizardApiService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/setup';
private readonly setupBaseUrl = '/api/v1/setup';
private readonly onboardingBaseUrl = '/api/v1/platform/onboarding';
/** Retry configuration for transient failures */
private readonly retryConfig = {
count: 2,
delay: 1000,
};
// ═══════════════════════════════════════════════════════════════════════════
// Session Management
// ═══════════════════════════════════════════════════════════════════════════
/**
* Create a new setup session
* Create a new setup session.
*/
createSession(): Observable<SetupSession> {
// For now, return a mock session
// TODO: Replace with actual API call when backend is ready
const session: SetupSession = {
sessionId: crypto.randomUUID(),
startedAt: new Date().toISOString(),
completedSteps: [],
skippedSteps: [],
configValues: {},
};
return of(session).pipe(delay(300));
return this.http
.post<ApiResponse<SetupSessionResponse>>(`${this.setupBaseUrl}/sessions`, {})
.pipe(
map(response => this.mapSessionResponse(response.data)),
catchError(error => this.handleError(error))
);
}
/**
* Resume an existing setup session
* Resume an existing setup session.
*/
resumeSession(sessionId: string): Observable<SetupSession | null> {
// TODO: Replace with actual API call
return of(null).pipe(delay(300));
return this.http
.get<ApiResponse<SetupSessionResponse>>(`${this.setupBaseUrl}/sessions/${sessionId}`)
.pipe(
map(response => this.mapSessionResponse(response.data)),
catchError(error => {
if (error.status === 404) {
return of(null);
}
return this.handleError(error);
})
);
}
/**
* Get all available setup steps
* Get the current active session (if any).
*/
getCurrentSession(): Observable<SetupSession | null> {
return this.http
.get<ApiResponse<SetupSessionResponse>>(`${this.setupBaseUrl}/sessions/current`)
.pipe(
map(response => this.mapSessionResponse(response.data)),
catchError(error => {
if (error.status === 404) {
return of(null);
}
return this.handleError(error);
})
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Step Management
// ═══════════════════════════════════════════════════════════════════════════
/**
* Get all available setup steps.
*/
getSteps(): Observable<SetupStep[]> {
// TODO: Replace with actual API call
return of([...DEFAULT_SETUP_STEPS]).pipe(delay(200));
return this.http
.get<ApiResponse<SetupStep[]>>(`${this.setupBaseUrl}/steps`)
.pipe(
map(response => response.data),
retry({
count: this.retryConfig.count,
delay: () => timer(this.retryConfig.delay),
}),
catchError(error => this.handleError(error))
);
}
/**
* Get a specific setup step
* Get a specific setup step.
*/
getStep(stepId: SetupStepId): Observable<SetupStep | null> {
const step = DEFAULT_SETUP_STEPS.find(s => s.id === stepId);
return of(step ?? null).pipe(delay(100));
return this.http
.get<ApiResponse<SetupStep>>(`${this.setupBaseUrl}/steps/${stepId}`)
.pipe(
map(response => response.data),
catchError(error => {
if (error.status === 404) {
return of(null);
}
return this.handleError(error);
})
);
}
/**
* Check prerequisites for a step
* Check prerequisites for a step.
*/
checkPrerequisites(
sessionId: string,
stepId: SetupStepId,
configValues: Record<string, string>
): Observable<PrerequisiteResult> {
// TODO: Replace with actual API call
// Mock: always return met for now
return of({ met: true, missingPrerequisites: [] }).pipe(delay(500));
return this.http
.post<ApiResponse<PrerequisiteResult>>(
`${this.setupBaseUrl}/sessions/${sessionId}/steps/${stepId}/prerequisites`,
{ configValues }
)
.pipe(
map(response => response.data),
catchError(error => this.handleError(error))
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Step Execution
// ═══════════════════════════════════════════════════════════════════════════
/**
* Execute a setup step
* Execute a setup step.
*/
executeStep(request: ExecuteStepRequest): Observable<SetupStepResult> {
// TODO: Replace with actual API call
// Mock successful execution
const result: SetupStepResult = {
stepId: request.stepId,
status: 'completed',
message: request.dryRun
? `[DRY RUN] Step ${request.stepId} would be configured`
: `Step ${request.stepId} configured successfully`,
appliedConfig: request.configValues,
canRetry: true,
};
return of(result).pipe(delay(1500));
return this.http
.post<ApiResponse<ExecuteStepResponse>>(
`${this.setupBaseUrl}/sessions/${request.sessionId}/steps/${request.stepId}/execute`,
{
configValues: request.configValues,
dryRun: request.dryRun,
}
)
.pipe(
map(response => this.mapStepResult(response.data)),
catchError(error => this.handleError(error))
);
}
/**
* Skip a setup step
* Skip a setup step.
*/
skipStep(request: SkipStepRequest): Observable<SetupStepResult> {
const result: SetupStepResult = {
stepId: request.stepId,
status: 'skipped',
message: `Step ${request.stepId} skipped: ${request.reason}`,
canRetry: false,
};
return of(result).pipe(delay(300));
return this.http
.post<ApiResponse<ExecuteStepResponse>>(
`${this.setupBaseUrl}/sessions/${request.sessionId}/steps/${request.stepId}/skip`,
{ reason: request.reason }
)
.pipe(
map(response => this.mapStepResult(response.data)),
catchError(error => this.handleError(error))
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Validation Checks
// ═══════════════════════════════════════════════════════════════════════════
/**
* Get validation checks for a step.
*/
getValidationChecks(
sessionId: string,
stepId: SetupStepId
): Observable<ValidationCheck[]> {
return this.http
.get<ApiResponse<ValidationCheckResponse[]>>(
`${this.setupBaseUrl}/sessions/${sessionId}/steps/${stepId}/checks`
)
.pipe(
map(response => response.data.map(c => this.mapValidationCheck(c))),
catchError(error => this.handleError(error))
);
}
/**
* Run validation checks for a step
* Run all validation checks for a step.
*/
runValidationChecks(
sessionId: string,
stepId: SetupStepId
): Observable<ValidationCheck[]> {
// TODO: Replace with actual API call
// Mock validation checks based on step
const step = DEFAULT_SETUP_STEPS.find(s => s.id === stepId);
if (!step) return of([]);
const checks: ValidationCheck[] = step.validationChecks.map(checkId => ({
checkId,
name: this.getCheckName(checkId),
description: this.getCheckDescription(checkId),
status: 'pending',
severity: 'info',
}));
return of(checks).pipe(delay(200));
return this.http
.post<ApiResponse<ValidationCheckResponse[]>>(
`${this.setupBaseUrl}/sessions/${sessionId}/steps/${stepId}/checks/run`,
{}
)
.pipe(
map(response => response.data.map(c => this.mapValidationCheck(c))),
catchError(error => this.handleError(error))
);
}
/**
* Run a specific validation check
* Run a specific validation check.
*/
runValidationCheck(
sessionId: string,
checkId: string,
configValues: Record<string, string>
): Observable<ValidationCheck> {
// TODO: Replace with actual API call
// Mock: simulate check running and passing
const check: ValidationCheck = {
checkId,
name: this.getCheckName(checkId),
description: this.getCheckDescription(checkId),
status: 'passed',
severity: 'info',
message: 'Check passed successfully',
};
return of(check).pipe(delay(800 + Math.random() * 400));
return this.http
.post<ApiResponse<ValidationCheckResponse>>(
`${this.setupBaseUrl}/sessions/${sessionId}/checks/${checkId}/run`,
{ configValues }
)
.pipe(
map(response => this.mapValidationCheck(response.data)),
catchError(error => this.handleError(error))
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Connection Testing
// ═══════════════════════════════════════════════════════════════════════════
/**
* Test connection for a step configuration
* Test connection for a step configuration.
*/
testConnection(
stepId: SetupStepId,
configValues: Record<string, string>
): Observable<{ success: boolean; message: string }> {
// TODO: Replace with actual API call
// Mock successful connection
return of({
success: true,
message: 'Connection successful',
}).pipe(delay(1000));
): Observable<{ success: boolean; message: string; latencyMs?: number }> {
return this.http
.post<ApiResponse<ConnectionTestResponse>>(
`${this.setupBaseUrl}/steps/${stepId}/test-connection`,
{ configValues }
)
.pipe(
map(response => ({
success: response.data.success,
message: response.data.message,
latencyMs: response.data.latencyMs,
})),
catchError(error => this.handleError(error))
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Configuration & Finalization
// ═══════════════════════════════════════════════════════════════════════════
/**
* Save the completed setup configuration
* Save configuration values to the session.
*/
saveConfiguration(
sessionId: string,
configValues: Record<string, string>
): Observable<{ success: boolean }> {
// TODO: Replace with actual API call
return of({ success: true }).pipe(delay(500));
return this.http
.put<ApiResponse<{ saved: boolean }>>(
`${this.setupBaseUrl}/sessions/${sessionId}/config`,
{ configValues }
)
.pipe(
map(response => ({ success: response.data.saved })),
catchError(error => this.handleError(error))
);
}
/**
* Finalize the setup wizard
* Finalize the setup wizard and apply configuration.
*/
finalizeSetup(sessionId: string): Observable<{ success: boolean; message: string }> {
// TODO: Replace with actual API call
return of({
success: true,
message: 'Setup completed successfully. Please restart the services to apply configuration.',
}).pipe(delay(1000));
finalizeSetup(sessionId: string): Observable<{
success: boolean;
message: string;
restartRequired?: boolean;
nextSteps?: string[];
}> {
return this.http
.post<ApiResponse<FinalizeSetupResponse>>(
`${this.setupBaseUrl}/sessions/${sessionId}/finalize`,
{}
)
.pipe(
map(response => ({
success: response.data.success,
message: response.data.message,
restartRequired: response.data.restartRequired,
nextSteps: response.data.nextSteps,
})),
catchError(error => this.handleError(error))
);
}
// === Helper Methods ===
// ═══════════════════════════════════════════════════════════════════════════
// Onboarding Integration
// ═══════════════════════════════════════════════════════════════════════════
private getCheckName(checkId: string): string {
const names: Record<string, string> = {
'check.database.connectivity': 'Database Connectivity',
'check.database.migrations': 'Database Migrations',
'check.cache.connectivity': 'Cache Connectivity',
'check.cache.persistence': 'Cache Persistence',
'check.integration.vault.connectivity': 'Vault Connectivity',
'check.integration.vault.auth': 'Vault Authentication',
'check.integration.settingsstore.connectivity': 'Settings Store Connectivity',
'check.integration.settingsstore.auth': 'Settings Store Authentication',
'check.integration.registry.connectivity': 'Registry Connectivity',
'check.integration.registry.auth': 'Registry Authentication',
'check.telemetry.otlp.connectivity': 'OTLP Endpoint Connectivity',
};
return names[checkId] ?? checkId;
/**
* Get onboarding status.
*/
getOnboardingStatus(): Observable<{
status: string;
currentStep: string;
completedSteps: string[];
}> {
return this.http
.get<{ status: string; currentStep: string; steps: { id: string; completed: boolean }[] }>(
`${this.onboardingBaseUrl}/status`
)
.pipe(
map(response => ({
status: response.status,
currentStep: response.currentStep,
completedSteps: response.steps.filter(s => s.completed).map(s => s.id),
})),
catchError(error => this.handleError(error))
);
}
private getCheckDescription(checkId: string): string {
const descriptions: Record<string, string> = {
'check.database.connectivity': 'Verify connection to the PostgreSQL database',
'check.database.migrations': 'Check that database migrations are up to date',
'check.cache.connectivity': 'Verify connection to the cache server',
'check.cache.persistence': 'Check cache persistence configuration',
'check.integration.vault.connectivity': 'Verify connection to the secrets vault',
'check.integration.vault.auth': 'Verify vault authentication credentials',
'check.integration.settingsstore.connectivity': 'Verify connection to the settings store',
'check.integration.settingsstore.auth': 'Verify settings store authentication',
'check.integration.registry.connectivity': 'Verify connection to the container registry',
'check.integration.registry.auth': 'Verify registry authentication credentials',
'check.telemetry.otlp.connectivity': 'Verify connection to the OTLP endpoint',
/**
* Mark an onboarding step as complete.
*/
completeOnboardingStep(stepId: string): Observable<{ success: boolean }> {
return this.http
.post<{ status: string }>(`${this.onboardingBaseUrl}/complete/${stepId}`, {})
.pipe(
map(() => ({ success: true })),
catchError(error => this.handleError(error))
);
}
// ═══════════════════════════════════════════════════════════════════════════
// Helper Methods
// ═══════════════════════════════════════════════════════════════════════════
private mapSessionResponse(response: SetupSessionResponse): SetupSession {
return {
sessionId: response.sessionId,
startedAt: response.startedAt,
completedSteps: response.completedSteps,
skippedSteps: response.skippedSteps,
configValues: response.configValues,
currentStep: response.currentStep,
};
return descriptions[checkId] ?? 'Validation check';
}
private mapStepResult(response: ExecuteStepResponse): SetupStepResult {
return {
stepId: response.stepId,
status: response.status,
message: response.message,
appliedConfig: response.appliedConfig,
outputValues: response.outputValues,
error: response.error,
canRetry: response.canRetry,
};
}
private mapValidationCheck(response: ValidationCheckResponse): ValidationCheck {
return {
checkId: response.checkId,
name: response.name,
description: response.description,
status: response.status,
severity: response.severity,
message: response.message,
remediation: response.remediation,
};
}
/**
* Handle HTTP errors and convert to SetupApiError.
*/
private handleError(error: HttpErrorResponse): Observable<never> {
const apiError = this.parseError(error);
console.error('[SetupWizardApiService] Error:', apiError);
return throwError(() => apiError);
}
/**
* Parse HTTP error into SetupApiError.
* Handles Problem+JSON (RFC 7807) responses.
*/
parseError(error: HttpErrorResponse): SetupApiError {
// Network or client-side error
if (error.error instanceof ErrorEvent) {
return {
code: 'NETWORK_ERROR',
message: 'Unable to connect to the server',
detail: error.error.message,
retryable: true,
suggestedFixes: [
'Check your network connection',
'Verify the server is running',
'Check firewall settings',
],
};
}
// Server returned Problem+JSON
if (this.isProblemDetails(error.error)) {
const problem = error.error as ProblemDetails;
return {
code: this.extractErrorCode(problem),
message: problem.title,
detail: problem.detail,
retryable: this.isRetryable(error.status),
suggestedFixes: this.getSuggestedFixes(problem),
};
}
// Generic server error
return {
code: `HTTP_${error.status}`,
message: this.getStatusMessage(error.status),
detail: error.message,
retryable: this.isRetryable(error.status),
};
}
private isProblemDetails(error: unknown): error is ProblemDetails {
return (
typeof error === 'object' &&
error !== null &&
'title' in error &&
'status' in error
);
}
private extractErrorCode(problem: ProblemDetails): string {
if (problem.type) {
// Extract code from URI type like "urn:stellaops:error:validation"
const parts = problem.type.split(':');
return parts[parts.length - 1].toUpperCase();
}
return `HTTP_${problem.status}`;
}
private isRetryable(status: number): boolean {
// 5xx errors and some 4xx are retryable
return status >= 500 || status === 408 || status === 429;
}
private getStatusMessage(status: number): string {
const messages: Record<number, string> = {
400: 'Invalid request',
401: 'Authentication required',
403: 'Access denied',
404: 'Resource not found',
408: 'Request timeout',
409: 'Conflict with current state',
422: 'Validation failed',
429: 'Too many requests',
500: 'Server error',
502: 'Service unavailable',
503: 'Service temporarily unavailable',
504: 'Gateway timeout',
};
return messages[status] ?? `Error (${status})`;
}
private getSuggestedFixes(problem: ProblemDetails): string[] | undefined {
// Map common error types to suggested fixes
const fixesByType: Record<string, string[]> = {
'urn:stellaops:error:validation': [
'Check required fields are filled',
'Verify input format matches expected pattern',
],
'urn:stellaops:error:connection': [
'Verify the service is running',
'Check network connectivity',
'Verify credentials are correct',
],
'urn:stellaops:error:timeout': [
'The operation took too long',
'Try again with a shorter timeout',
'Check if the target service is responding',
],
};
if (problem.type && fixesByType[problem.type]) {
return fixesByType[problem.type];
}
return undefined;
}
}

View File

@@ -1,16 +1,17 @@
/**
* @file setup-wizard-state.service.spec.ts
* @sprint Sprint 5: UI Integrations + Settings Store
* @description Unit tests for SetupWizardStateService
* @sprint SPRINT_20260112_005_FE_setup_wizard_ui_wiring
* @tasks FE-SETUP-003
* @description Unit tests for SetupWizardStateService with retry and data freshness
*/
import { TestBed } from '@angular/core/testing';
import { SetupWizardStateService } from './setup-wizard-state.service';
import {
SetupSession,
SetupStep,
DEFAULT_SETUP_STEPS,
} from '../models/setup-wizard.models';
import { SetupApiError } from './setup-wizard-api.service';
describe('SetupWizardStateService', () => {
let service: SetupWizardStateService;
@@ -54,6 +55,24 @@ describe('SetupWizardStateService', () => {
it('should have 0% progress initially', () => {
expect(service.progressPercent()).toBe(0);
});
it('should have no step error initially', () => {
expect(service.stepError()).toBeNull();
});
it('should have initial retry state', () => {
const retry = service.retryState();
expect(retry.attemptCount).toBe(0);
expect(retry.maxAttempts).toBe(3);
expect(retry.canRetry).toBeTrue();
});
it('should have null data freshness initially', () => {
const freshness = service.dataFreshness();
expect(freshness.dataAsOf).toBeNull();
expect(freshness.isCached).toBeFalse();
expect(freshness.isStale).toBeFalse();
});
});
describe('initializeSession', () => {
@@ -239,6 +258,36 @@ describe('SetupWizardStateService', () => {
service.currentStepId.set('vault');
expect(service.canSkipCurrentStep()).toBeTrue();
});
it('should compute failedChecks', () => {
service.setValidationChecks([
{ checkId: 'check1', name: 'C1', description: '', status: 'passed', severity: 'info' },
{ checkId: 'check2', name: 'C2', description: '', status: 'failed', severity: 'error' },
]);
expect(service.failedChecks().length).toBe(1);
expect(service.failedChecks()[0].checkId).toBe('check2');
});
it('should compute allChecksPassed', () => {
service.setValidationChecks([
{ checkId: 'check1', name: 'C1', description: '', status: 'passed', severity: 'info' },
{ checkId: 'check2', name: 'C2', description: '', status: 'passed', severity: 'info' },
]);
expect(service.allChecksPassed()).toBeTrue();
service.updateValidationCheck('check2', { status: 'failed' });
expect(service.allChecksPassed()).toBeFalse();
});
it('should compute checksRunning', () => {
service.setValidationChecks([
{ checkId: 'check1', name: 'C1', description: '', status: 'pending', severity: 'info' },
]);
expect(service.checksRunning()).toBeFalse();
service.updateValidationCheck('check1', { status: 'running' });
expect(service.checksRunning()).toBeTrue();
});
});
describe('validation checks', () => {
@@ -262,13 +311,162 @@ describe('SetupWizardStateService', () => {
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Retry Management Tests
// ═══════════════════════════════════════════════════════════════════════════
describe('retry management', () => {
it('should record retry attempts', () => {
expect(service.retryState().attemptCount).toBe(0);
service.recordRetryAttempt();
expect(service.retryState().attemptCount).toBe(1);
expect(service.retryState().lastAttemptAt).not.toBeNull();
service.recordRetryAttempt();
expect(service.retryState().attemptCount).toBe(2);
});
it('should disable retry after max attempts', () => {
service.recordRetryAttempt();
service.recordRetryAttempt();
expect(service.retryState().canRetry).toBeTrue();
service.recordRetryAttempt();
expect(service.retryState().canRetry).toBeFalse();
});
it('should reset retry state', () => {
service.recordRetryAttempt();
service.recordRetryAttempt();
expect(service.retryState().attemptCount).toBe(2);
service.resetRetryState();
expect(service.retryState().attemptCount).toBe(0);
expect(service.retryState().canRetry).toBeTrue();
expect(service.retryState().lastAttemptAt).toBeNull();
});
it('should set step error with retry context', () => {
const error: SetupApiError = {
code: 'CONNECTION_FAILED',
message: 'Connection failed',
retryable: true,
};
service.setStepError(error);
expect(service.stepError()?.error).toEqual(error);
expect(service.stepError()?.retryState.canRetry).toBeTrue();
});
it('should set non-retryable error', () => {
const error: SetupApiError = {
code: 'VALIDATION_FAILED',
message: 'Invalid input',
retryable: false,
};
service.setStepError(error, false);
expect(service.stepError()?.retryState.canRetry).toBeFalse();
});
it('should clear error', () => {
service.setStepError({ code: 'ERR', message: 'Test', retryable: true });
expect(service.stepError()).not.toBeNull();
service.clearError();
expect(service.stepError()).toBeNull();
expect(service.error()).toBeNull();
});
it('should track retrying check', () => {
service.setValidationChecks([
{ checkId: 'check1', name: 'C1', description: '', status: 'failed', severity: 'error' },
]);
service.setRetryingCheck('check1');
expect(service.retryingCheckId()).toBe('check1');
expect(service.validationChecks()[0].status).toBe('running');
service.setRetryingCheck(null);
expect(service.retryingCheckId()).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Data Freshness Tests
// ═══════════════════════════════════════════════════════════════════════════
describe('data freshness', () => {
it('should update data freshness from timestamp', () => {
const timestamp = new Date().toISOString();
service.updateDataFreshness(timestamp, false);
const freshness = service.dataFreshness();
expect(freshness.dataAsOf).not.toBeNull();
expect(freshness.isCached).toBeFalse();
expect(freshness.isStale).toBeFalse();
});
it('should mark cached data', () => {
service.updateDataFreshness(new Date().toISOString(), true);
expect(service.dataFreshness().isCached).toBeTrue();
});
it('should mark stale data (older than 5 minutes)', () => {
const oldTimestamp = new Date(Date.now() - 6 * 60 * 1000).toISOString();
service.updateDataFreshness(oldTimestamp, false);
expect(service.dataFreshness().isStale).toBeTrue();
});
it('should not mark recent data as stale', () => {
const recentTimestamp = new Date(Date.now() - 2 * 60 * 1000).toISOString();
service.updateDataFreshness(recentTimestamp, false);
expect(service.dataFreshness().isStale).toBeFalse();
});
it('should compute showStaleBanner', () => {
expect(service.showStaleBanner()).toBeFalse();
const oldTimestamp = new Date(Date.now() - 10 * 60 * 1000).toISOString();
service.updateDataFreshness(oldTimestamp, false);
expect(service.showStaleBanner()).toBeTrue();
});
it('should compute dataAsOfDisplay for recent data', () => {
service.updateDataFreshness(new Date().toISOString(), false);
expect(service.dataAsOfDisplay()).toBe('Just now');
});
it('should compute dataAsOfDisplay for minutes ago', () => {
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
service.updateDataFreshness(fiveMinAgo, false);
expect(service.dataAsOfDisplay()).toContain('5 minutes ago');
});
it('should mark refreshing state', () => {
service.markRefreshing();
expect(service.loading()).toBeTrue();
});
it('should mark refreshed state', () => {
service.markRefreshing();
service.markRefreshed();
expect(service.loading()).toBeFalse();
expect(service.dataFreshness().isStale).toBeFalse();
});
});
describe('reset', () => {
it('should reset all state', () => {
it('should reset all state including retry and freshness', () => {
// Set some state
service.currentStepId.set('database');
service.setConfigValue('key', 'value');
service.loading.set(true);
service.error.set('Some error');
service.recordRetryAttempt();
service.updateDataFreshness(new Date().toISOString(), true);
service.setStepError({ code: 'ERR', message: 'Test', retryable: true });
// Reset
service.reset();
@@ -278,6 +476,10 @@ describe('SetupWizardStateService', () => {
expect(service.configValues()).toEqual({});
expect(service.loading()).toBeFalse();
expect(service.error()).toBeNull();
expect(service.stepError()).toBeNull();
expect(service.retryState().attemptCount).toBe(0);
expect(service.dataFreshness().dataAsOf).toBeNull();
expect(service.retryingCheckId()).toBeNull();
});
});
});

View File

@@ -1,7 +1,13 @@
/**
* @file setup-wizard-state.service.ts
* @sprint Sprint 4: UI Wizard Core
* @sprint SPRINT_20260112_005_FE_setup_wizard_ui_wiring
* @tasks FE-SETUP-002
* @description State management service for the setup wizard using Angular signals
*
* Updated to handle:
* - Validation checks with retry support
* - "Data as of" banners with metadata timestamps
* - Step ID alignment with backend contract
*/
import { Injectable, computed, signal } from '@angular/core';
@@ -12,8 +18,10 @@ import {
SetupSession,
WizardMode,
ValidationCheck,
CheckStatus,
DEFAULT_SETUP_STEPS,
} from '../models/setup-wizard.models';
import { SetupApiError } from './setup-wizard-api.service';
/** Wizard navigation state */
interface WizardNavigation {
@@ -23,6 +31,29 @@ interface WizardNavigation {
canComplete: boolean;
}
/** Data freshness metadata for cache/stale data display */
export interface DataFreshness {
dataAsOf: Date | null;
isCached: boolean;
isStale: boolean;
}
/** Retry state for step operations */
export interface RetryState {
attemptCount: number;
maxAttempts: number;
lastAttemptAt: Date | null;
canRetry: boolean;
retryAfterMs: number | null;
}
/** Step error with retry context */
export interface StepError {
error: SetupApiError | Error;
retryState: RetryState;
dismissable: boolean;
}
/**
* State service for the setup wizard.
* Uses Angular signals for reactive state management.
@@ -55,12 +86,37 @@ export class SetupWizardStateService {
/** Whether a step is executing */
readonly executing = signal(false);
/** Global error message */
/** Global error message (deprecated - use stepError for structured errors) */
readonly error = signal<string | null>(null);
/** Structured error with retry context */
readonly stepError = signal<StepError | null>(null);
/** Whether dry-run mode is enabled */
readonly dryRunMode = signal(true);
/** Data freshness metadata for current view */
readonly dataFreshness = signal<DataFreshness>({
dataAsOf: null,
isCached: false,
isStale: false,
});
/** Per-step retry state */
readonly retryState = signal<RetryState>({
attemptCount: 0,
maxAttempts: 3,
lastAttemptAt: null,
canRetry: true,
retryAfterMs: null,
});
/** Whether a retry operation is pending */
readonly retryPending = signal(false);
/** Validation check being retried (by checkId) */
readonly retryingCheckId = signal<string | null>(null);
// === Computed Signals ===
/** Current step object */
@@ -118,7 +174,6 @@ export class SetupWizardStateService {
readonly navigation = computed<WizardNavigation>(() => {
const index = this.currentStepIndex();
const ordered = this.orderedSteps();
const current = this.currentStep();
return {
currentStepIndex: index,
@@ -148,6 +203,46 @@ export class SetupWizardStateService {
return step.dependencies.every(depId => completedIds.has(depId));
});
/** Validation checks that failed and can be retried */
readonly failedChecks = computed(() => {
return this.validationChecks().filter(c => c.status === 'failed');
});
/** Whether all validation checks passed */
readonly allChecksPassed = computed(() => {
const checks = this.validationChecks();
return checks.length > 0 && checks.every(c => c.status === 'passed' || c.status === 'skipped');
});
/** Whether there are checks currently running */
readonly checksRunning = computed(() => {
return this.validationChecks().some(c => c.status === 'running');
});
/** Whether data is stale and should show refresh banner */
readonly showStaleBanner = computed(() => {
const freshness = this.dataFreshness();
return freshness.isStale && freshness.dataAsOf !== null;
});
/** Formatted "data as of" timestamp for display */
readonly dataAsOfDisplay = computed(() => {
const freshness = this.dataFreshness();
if (!freshness.dataAsOf) return null;
const now = new Date();
const diff = now.getTime() - freshness.dataAsOf.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
return freshness.dataAsOf.toLocaleString();
});
// === State Mutation Methods ===
/**
@@ -344,6 +439,116 @@ export class SetupWizardStateService {
this.loading.set(false);
this.executing.set(false);
this.error.set(null);
this.stepError.set(null);
this.dataFreshness.set({ dataAsOf: null, isCached: false, isStale: false });
this.retryState.set({
attemptCount: 0,
maxAttempts: 3,
lastAttemptAt: null,
canRetry: true,
retryAfterMs: null,
});
this.retryPending.set(false);
this.retryingCheckId.set(null);
}
// === Retry Management Methods ===
/**
* Record a retry attempt
*/
recordRetryAttempt(): void {
this.retryState.update(state => ({
...state,
attemptCount: state.attemptCount + 1,
lastAttemptAt: new Date(),
canRetry: state.attemptCount + 1 < state.maxAttempts,
}));
}
/**
* Reset retry state (e.g., after successful operation)
*/
resetRetryState(): void {
this.retryState.set({
attemptCount: 0,
maxAttempts: 3,
lastAttemptAt: null,
canRetry: true,
retryAfterMs: null,
});
this.retryPending.set(false);
this.retryingCheckId.set(null);
}
/**
* Set error with retry context
*/
setStepError(error: SetupApiError | Error, canRetry: boolean = true): void {
const current = this.retryState();
this.stepError.set({
error,
retryState: {
...current,
canRetry: canRetry && current.attemptCount < current.maxAttempts,
},
dismissable: true,
});
}
/**
* Clear current error
*/
clearError(): void {
this.error.set(null);
this.stepError.set(null);
}
/**
* Mark a specific validation check as retrying
*/
setRetryingCheck(checkId: string | null): void {
this.retryingCheckId.set(checkId);
if (checkId) {
this.updateValidationCheck(checkId, { status: 'running' });
}
}
// === Data Freshness Methods ===
/**
* Update data freshness metadata from API response
*/
updateDataFreshness(dataAsOf: string | undefined, isCached: boolean = false): void {
const timestamp = dataAsOf ? new Date(dataAsOf) : new Date();
const now = new Date();
const ageMs = now.getTime() - timestamp.getTime();
const staleThresholdMs = 5 * 60 * 1000; // 5 minutes
this.dataFreshness.set({
dataAsOf: timestamp,
isCached,
isStale: ageMs > staleThresholdMs,
});
}
/**
* Mark data as refreshing
*/
markRefreshing(): void {
this.loading.set(true);
}
/**
* Mark data as refreshed
*/
markRefreshed(): void {
this.loading.set(false);
this.dataFreshness.update(f => ({
...f,
dataAsOf: new Date(),
isStale: false,
}));
}
// === Private Helper Methods ===

View File

@@ -0,0 +1,213 @@
// Sprint: SPRINT_20260112_011_FE_policy_unknowns_queue_integration (FE-UNK-008)
// Determinization Review Component Tests
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter, ActivatedRoute } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { of } from 'rxjs';
import { DeterminizationReviewComponent } from './determinization-review.component';
import {
PolicyUnknown,
BAND_COLORS,
OBSERVATION_STATE_COLORS,
hasConflicts,
getConflictSeverityColor,
} from '../../core/api/unknowns.models';
describe('DeterminizationReviewComponent', () => {
let component: DeterminizationReviewComponent;
let fixture: ComponentFixture<DeterminizationReviewComponent>;
const mockUnknown: PolicyUnknown = {
id: 'test-unknown-123',
packageId: 'pkg:npm/lodash',
packageVersion: '4.17.21',
band: 'hot',
score: 85.5,
uncertaintyFactor: 0.7,
exploitPressure: 0.9,
firstSeenAt: '2026-01-10T12:00:00Z',
lastEvaluatedAt: '2026-01-15T08:00:00Z',
reasonCode: 'Reachability',
reasonCodeShort: 'U-RCH',
fingerprintId: 'sha256:abc123def456',
triggers: [
{
eventType: 'epss.updated',
eventVersion: 1,
source: 'concelier',
receivedAt: '2026-01-15T07:00:00Z',
correlationId: 'corr-123',
},
{
eventType: 'vex.updated',
eventVersion: 1,
source: 'excititor',
receivedAt: '2026-01-15T08:00:00Z',
correlationId: 'corr-456',
},
],
nextActions: ['request_vex', 'verify_reachability'],
conflictInfo: {
hasConflict: true,
severity: 0.8,
suggestedPath: 'RequireManualReview',
conflicts: [
{
signal1: 'VEX:not_affected',
signal2: 'Reachability:reachable',
type: 'VexReachabilityContradiction',
description: 'VEX says not affected but reachability shows path',
severity: 0.8,
},
],
},
observationState: 'Disputed',
evidenceRefs: [
{ type: 'sbom', uri: 'oci://registry/sbom@sha256:abc', digest: 'sha256:abc' },
{ type: 'attestation', uri: 'oci://registry/att@sha256:def', digest: 'sha256:def' },
],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DeterminizationReviewComponent],
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: {
get: (key: string) => (key === 'unknownId' ? 'test-unknown-123' : null),
},
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(DeterminizationReviewComponent);
component = fixture.componentInstance;
// Manually set unknown for testing
component['unknown'].set(mockUnknown);
component['unknownId'].set('test-unknown-123');
component['loading'].set(false);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('triggers sorting', () => {
it('should sort triggers by receivedAt descending (most recent first)', () => {
const sorted = component.sortedTriggers();
expect(sorted.length).toBe(2);
// vex.updated at 08:00 should be first
expect(sorted[0].eventType).toBe('vex.updated');
// epss.updated at 07:00 should be second
expect(sorted[1].eventType).toBe('epss.updated');
});
it('should handle empty triggers', () => {
component['unknown'].set({ ...mockUnknown, triggers: [] });
fixture.detectChanges();
expect(component.sortedTriggers().length).toBe(0);
});
it('should handle undefined triggers', () => {
component['unknown'].set({ ...mockUnknown, triggers: undefined });
fixture.detectChanges();
expect(component.sortedTriggers().length).toBe(0);
});
it('should maintain stable order across renders', () => {
const order1 = component.sortedTriggers().map((t) => t.eventType);
fixture.detectChanges();
const order2 = component.sortedTriggers().map((t) => t.eventType);
expect(order1).toEqual(order2);
});
});
describe('band display', () => {
it('should return correct band color for HOT', () => {
expect(component.getBandColor()).toBe(BAND_COLORS['hot']);
});
it('should return correct band label', () => {
expect(component.getBandLabel()).toBe('HOT');
});
});
describe('observation state', () => {
it('should return correct state color', () => {
expect(component.getObservationStateColor()).toBe(OBSERVATION_STATE_COLORS['Disputed']);
});
it('should return correct state label', () => {
expect(component.getObservationStateLabel()).toBe('Disputed');
});
it('should identify grey queue state', () => {
expect(component.isInGreyQueue()).toBe(true);
});
it('should not identify non-grey queue state', () => {
component['unknown'].set({ ...mockUnknown, observationState: 'DeterminedPass' });
fixture.detectChanges();
expect(component.isInGreyQueue()).toBe(false);
});
});
describe('conflict handling', () => {
it('should detect conflicts', () => {
expect(component.hasConflicts()).toBe(true);
});
it('should return correct conflict severity color', () => {
expect(component.getConflictSeverityColor()).toBe('text-red-600');
});
it('should handle no conflicts', () => {
component['unknown'].set({ ...mockUnknown, conflictInfo: undefined });
fixture.detectChanges();
expect(component.hasConflicts()).toBe(false);
});
});
describe('export proof', () => {
it('should generate proof object with all required fields', () => {
// Test the proof structure without actually triggering download
const u = component['unknown']();
expect(u).toBeTruthy();
expect(u!.id).toBe('test-unknown-123');
expect(u!.fingerprintId).toBe('sha256:abc123def456');
expect(u!.triggers?.length).toBe(2);
expect(u!.evidenceRefs?.length).toBe(2);
});
});
});
describe('Conflict Severity Color Helper', () => {
it('should return red for high severity (>= 0.8)', () => {
expect(getConflictSeverityColor(0.8)).toBe('text-red-600');
expect(getConflictSeverityColor(0.9)).toBe('text-red-600');
expect(getConflictSeverityColor(1.0)).toBe('text-red-600');
});
it('should return orange for medium severity (>= 0.5, < 0.8)', () => {
expect(getConflictSeverityColor(0.5)).toBe('text-orange-600');
expect(getConflictSeverityColor(0.6)).toBe('text-orange-600');
expect(getConflictSeverityColor(0.79)).toBe('text-orange-600');
});
it('should return yellow for low severity (< 0.5)', () => {
expect(getConflictSeverityColor(0.1)).toBe('text-yellow-600');
expect(getConflictSeverityColor(0.3)).toBe('text-yellow-600');
expect(getConflictSeverityColor(0.49)).toBe('text-yellow-600');
});
});

View File

@@ -0,0 +1,392 @@
// Sprint: SPRINT_20260112_011_FE_policy_unknowns_queue_integration (FE-UNK-007)
// Determinization Review Component - provides context for grey queue items
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { UnknownsClient, PolicyUnknownDetailResponse } from '../../core/api/unknowns.client';
import {
PolicyUnknown,
TriageAction,
TriageRequest,
BAND_COLORS,
BAND_LABELS,
OBSERVATION_STATE_COLORS,
OBSERVATION_STATE_LABELS,
TRIAGE_ACTION_LABELS,
isGreyQueueState,
hasConflicts,
getConflictSeverityColor,
} from '../../core/api/unknowns.models';
import { GreyQueuePanelComponent } from '../unknowns/grey-queue-panel.component';
@Component({
selector: 'app-determinization-review',
standalone: true,
imports: [CommonModule, DatePipe, RouterLink, GreyQueuePanelComponent],
template: `
<div class="determinization-review min-h-screen bg-gray-50 p-6">
<!-- Breadcrumb -->
<nav class="mb-6 text-sm">
<ol class="flex items-center space-x-2">
<li>
<a routerLink="/analyze/unknowns" class="text-blue-600 hover:underline">Unknowns</a>
</li>
<li class="text-gray-400">/</li>
<li>
<a [routerLink]="['/analyze/unknowns', unknownId()]" class="text-blue-600 hover:underline">
{{ unknownId() | slice:0:8 }}...
</a>
</li>
<li class="text-gray-400">/</li>
<li class="text-gray-600">Determinization Review</li>
</ol>
</nav>
@if (loading()) {
<div class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-3 text-gray-600">Loading determinization context...</span>
</div>
} @else if (error()) {
<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{{ error() }}
</div>
} @else if (unknown()) {
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main content -->
<div class="lg:col-span-2 space-y-6">
<!-- Header -->
<div class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 mb-2">
Determinization Review
</h1>
<p class="text-gray-600">
{{ unknown()!.packageId }}&#64;{{ unknown()!.packageVersion }}
</p>
</div>
<div class="flex items-center gap-2">
<span [class]="'px-3 py-1 rounded-full text-sm font-medium border ' + getBandColor()">
{{ getBandLabel() }}
</span>
@if (unknown()!.observationState) {
<span [class]="'px-2 py-1 rounded text-xs font-medium ' + getObservationStateColor()">
{{ getObservationStateLabel() }}
</span>
}
</div>
</div>
</div>
<!-- Fingerprint Details -->
@if (unknown()!.fingerprintId) {
<div class="bg-white rounded-lg shadow-sm border p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Fingerprint Details</h2>
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt class="text-sm text-gray-500">Fingerprint ID</dt>
<dd class="mt-1">
<code class="text-sm bg-gray-100 px-2 py-1 rounded font-mono break-all">
{{ unknown()!.fingerprintId }}
</code>
</dd>
</div>
<div>
<dt class="text-sm text-gray-500">Score</dt>
<dd class="mt-1 text-lg font-semibold">
{{ unknown()!.score | number:'1.1-1' }}
</dd>
</div>
<div>
<dt class="text-sm text-gray-500">Uncertainty Factor</dt>
<dd class="mt-1">{{ unknown()!.uncertaintyFactor | percent:'1.0-0' }}</dd>
</div>
<div>
<dt class="text-sm text-gray-500">Exploit Pressure</dt>
<dd class="mt-1">{{ unknown()!.exploitPressure | percent:'1.0-0' }}</dd>
</div>
</dl>
</div>
}
<!-- Conflict Analysis -->
@if (hasConflicts()) {
<div class="bg-white rounded-lg shadow-sm border p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<span class="text-red-500">⚠</span>
Conflict Analysis
</h2>
<div class="mb-4 p-3 bg-red-50 rounded-lg">
<div class="flex items-center justify-between">
<span class="text-sm text-red-700">
{{ unknown()!.conflictInfo!.conflicts.length }} conflict(s) detected
</span>
<span [class]="'text-sm font-medium ' + getConflictSeverityColor()">
Severity: {{ unknown()!.conflictInfo!.severity | number:'1.2-2' }}
</span>
</div>
</div>
<div class="space-y-3">
@for (conflict of unknown()!.conflictInfo!.conflicts; track $index) {
<div class="border border-red-100 rounded-lg p-4">
<div class="font-medium text-red-800 mb-1">{{ conflict.type }}</div>
<div class="text-sm text-gray-600 mb-2">
<span class="font-mono bg-gray-100 px-1 rounded">{{ conflict.signal1 }}</span>
<span class="mx-2">vs</span>
<span class="font-mono bg-gray-100 px-1 rounded">{{ conflict.signal2 }}</span>
</div>
@if (conflict.description) {
<p class="text-sm text-gray-500">{{ conflict.description }}</p>
}
</div>
}
</div>
@if (unknown()!.conflictInfo!.suggestedPath) {
<div class="mt-4 p-3 bg-blue-50 rounded-lg">
<span class="text-sm text-blue-700">
<strong>Suggested Resolution:</strong> {{ unknown()!.conflictInfo!.suggestedPath }}
</span>
</div>
}
</div>
}
<!-- Trigger History -->
@if (unknown()!.triggers && unknown()!.triggers!.length > 0) {
<div class="bg-white rounded-lg shadow-sm border p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Reanalysis Trigger History</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Event</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Version</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Source</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Received</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@for (trigger of sortedTriggers(); track trigger.receivedAt) {
<tr>
<td class="px-4 py-2 text-sm font-medium">{{ trigger.eventType }}</td>
<td class="px-4 py-2 text-sm text-gray-500">v{{ trigger.eventVersion }}</td>
<td class="px-4 py-2 text-sm text-gray-500">{{ trigger.source || '-' }}</td>
<td class="px-4 py-2 text-sm text-gray-500">{{ trigger.receivedAt | date:'medium' }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- Evidence References -->
@if (unknown()!.evidenceRefs && unknown()!.evidenceRefs!.length > 0) {
<div class="bg-white rounded-lg shadow-sm border p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Evidence References</h2>
<div class="space-y-2">
@for (ref of unknown()!.evidenceRefs; track ref.uri) {
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<span class="text-xs font-medium text-gray-500 uppercase">{{ ref.type }}</span>
<div class="text-sm font-mono text-gray-700 break-all">{{ ref.uri }}</div>
</div>
@if (ref.digest) {
<code class="text-xs bg-white px-2 py-1 rounded border">{{ ref.digest | slice:0:16 }}...</code>
}
</div>
}
</div>
</div>
}
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Grey Queue Panel -->
<stella-grey-queue-panel
[unknown]="unknown()!"
[showTriageActions]="isInGreyQueue()"
(triageAction)="onTriageAction($event)"
/>
<!-- Quick Actions -->
<div class="bg-white rounded-lg shadow-sm border p-4">
<h3 class="text-sm font-medium text-gray-700 mb-3">Quick Actions</h3>
<div class="space-y-2">
<button
(click)="copyFingerprintId()"
class="w-full px-3 py-2 text-sm text-left rounded border hover:bg-gray-50"
>
Copy Fingerprint ID
</button>
<button
(click)="exportProof()"
class="w-full px-3 py-2 text-sm text-left rounded border hover:bg-gray-50"
>
Export Proof JSON
</button>
<a
[routerLink]="['/analyze/unknowns', unknownId()]"
class="block w-full px-3 py-2 text-sm text-left rounded border hover:bg-gray-50"
>
Back to Unknown Detail
</a>
</div>
</div>
</div>
</div>
}
</div>
`,
styles: [
`
.determinization-review {
min-height: 100vh;
}
`,
],
})
export class DeterminizationReviewComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly client = inject(UnknownsClient);
readonly unknownId = signal<string>('');
readonly unknown = signal<PolicyUnknown | null>(null);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly sortedTriggers = computed(() => {
const triggers = this.unknown()?.triggers;
if (!triggers) return [];
return [...triggers].sort(
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
});
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('unknownId');
if (!id) {
this.error.set('Unknown ID not provided');
this.loading.set(false);
return;
}
this.unknownId.set(id);
this.loadUnknown(id);
}
private loadUnknown(id: string): void {
this.loading.set(true);
this.error.set(null);
this.client.getPolicyUnknownDetail(id).subscribe({
next: (response) => {
this.unknown.set(response.unknown);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load unknown details');
this.loading.set(false);
},
});
}
getBandColor(): string {
const band = this.unknown()?.band;
if (!band) return '';
return BAND_COLORS[band] || 'bg-gray-100 text-gray-800';
}
getBandLabel(): string {
const band = this.unknown()?.band;
if (!band) return '';
return BAND_LABELS[band] || band.toUpperCase();
}
getObservationStateColor(): string {
const state = this.unknown()?.observationState;
if (!state) return '';
return OBSERVATION_STATE_COLORS[state] || '';
}
getObservationStateLabel(): string {
const state = this.unknown()?.observationState;
if (!state) return '';
return OBSERVATION_STATE_LABELS[state] || state;
}
isInGreyQueue(): boolean {
return isGreyQueueState(this.unknown()?.observationState);
}
hasConflicts(): boolean {
const u = this.unknown();
return u ? hasConflicts(u) : false;
}
getConflictSeverityColor(): string {
const severity = this.unknown()?.conflictInfo?.severity;
if (severity === undefined) return '';
return getConflictSeverityColor(severity);
}
onTriageAction(event: { unknownId: string; action: TriageAction }): void {
const reason = prompt(`Enter reason for ${TRIAGE_ACTION_LABELS[event.action]}:`);
if (!reason) return;
const request: TriageRequest = {
action: event.action,
reason,
};
this.client.triageUnknown(event.unknownId, request).subscribe({
next: (updated) => {
this.unknown.set(updated);
alert(`Triage action '${event.action}' applied successfully.`);
},
error: (err) => {
alert(`Failed to apply triage action: ${err.message}`);
},
});
}
copyFingerprintId(): void {
const fingerprintId = this.unknown()?.fingerprintId;
if (fingerprintId) {
navigator.clipboard.writeText(fingerprintId);
alert('Fingerprint ID copied to clipboard');
}
}
exportProof(): void {
const u = this.unknown();
if (!u) return;
const proof = {
id: u.id,
fingerprintId: u.fingerprintId,
packageId: u.packageId,
packageVersion: u.packageVersion,
band: u.band,
score: u.score,
reasonCode: u.reasonCode,
triggers: u.triggers,
evidenceRefs: u.evidenceRefs,
observationState: u.observationState,
conflictInfo: u.conflictInfo,
exportedAt: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(proof, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `unknown-proof-${u.id}.json`;
a.click();
URL.revokeObjectURL(url);
}
}

View File

@@ -0,0 +1,221 @@
// Sprint: SPRINT_20260112_011_FE_policy_unknowns_queue_integration (FE-UNK-008)
// Grey Queue Dashboard Component Tests
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { GreyQueueDashboardComponent } from './grey-queue-dashboard.component';
import {
PolicyUnknown,
BAND_COLORS,
BAND_LABELS,
getBandPriority,
isGreyQueueState,
} from '../../core/api/unknowns.models';
describe('GreyQueueDashboardComponent', () => {
let component: GreyQueueDashboardComponent;
let fixture: ComponentFixture<GreyQueueDashboardComponent>;
const mockItems: PolicyUnknown[] = [
{
id: 'item-1',
packageId: 'pkg:npm/lodash',
packageVersion: '4.17.21',
band: 'hot',
score: 85,
uncertaintyFactor: 0.7,
exploitPressure: 0.9,
firstSeenAt: '2026-01-10T12:00:00Z',
lastEvaluatedAt: '2026-01-15T08:00:00Z',
reasonCode: 'Reachability',
reasonCodeShort: 'U-RCH',
observationState: 'Disputed',
conflictInfo: {
hasConflict: true,
severity: 0.8,
suggestedPath: 'RequireManualReview',
conflicts: [{ signal1: 'a', signal2: 'b', type: 'Test', description: '', severity: 0.8 }],
},
},
{
id: 'item-2',
packageId: 'pkg:npm/express',
packageVersion: '4.18.0',
band: 'warm',
score: 65,
uncertaintyFactor: 0.5,
exploitPressure: 0.6,
firstSeenAt: '2026-01-11T12:00:00Z',
lastEvaluatedAt: '2026-01-15T09:00:00Z',
reasonCode: 'VEX',
reasonCodeShort: 'U-VEX',
observationState: 'ManualReviewRequired',
},
{
id: 'item-3',
packageId: 'pkg:npm/axios',
packageVersion: '1.0.0',
band: 'cold',
score: 30,
uncertaintyFactor: 0.3,
exploitPressure: 0.2,
firstSeenAt: '2026-01-12T12:00:00Z',
lastEvaluatedAt: '2026-01-15T10:00:00Z',
reasonCode: 'Static',
reasonCodeShort: 'U-STA',
observationState: 'DeterminedPass', // Not grey queue
},
{
id: 'item-4',
packageId: 'pkg:npm/react',
packageVersion: '18.0.0',
band: 'hot',
score: 90,
uncertaintyFactor: 0.8,
exploitPressure: 0.95,
firstSeenAt: '2026-01-13T12:00:00Z',
lastEvaluatedAt: '2026-01-15T11:00:00Z',
reasonCode: 'Reachability',
reasonCodeShort: 'U-RCH',
observationState: 'Disputed',
},
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GreyQueueDashboardComponent],
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()],
}).compileComponents();
fixture = TestBed.createComponent(GreyQueueDashboardComponent);
component = fixture.componentInstance;
// Manually set items for testing
component['items'].set(mockItems);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('grey queue filtering', () => {
it('should only include grey queue items in filteredItems', () => {
const filtered = component.filteredItems();
// item-3 (DeterminedPass) should be excluded
expect(filtered.length).toBe(3);
expect(filtered.every((i) => isGreyQueueState(i.observationState))).toBe(true);
});
it('should count grey queue items correctly', () => {
expect(component.greyQueueCount()).toBe(3);
});
it('should filter by band', () => {
component['bandFilter'].set('hot');
fixture.detectChanges();
const filtered = component.filteredItems();
expect(filtered.length).toBe(2);
expect(filtered.every((i) => i.band === 'hot')).toBe(true);
});
it('should filter by observation state', () => {
component['stateFilter'].set('Disputed');
fixture.detectChanges();
const filtered = component.filteredItems();
expect(filtered.length).toBe(2);
expect(filtered.every((i) => i.observationState === 'Disputed')).toBe(true);
});
it('should combine band and state filters', () => {
component['bandFilter'].set('hot');
component['stateFilter'].set('Disputed');
fixture.detectChanges();
const filtered = component.filteredItems();
expect(filtered.length).toBe(2);
});
});
describe('deterministic ordering', () => {
it('should order by band priority first (HOT < WARM < COLD)', () => {
const filtered = component.filteredItems();
const bands = filtered.map((i) => i.band);
// HOT items first, then WARM
expect(bands[0]).toBe('hot');
expect(bands[1]).toBe('hot');
expect(bands[2]).toBe('warm');
});
it('should order by score descending within same band', () => {
const filtered = component.filteredItems();
const hotItems = filtered.filter((i) => i.band === 'hot');
expect(hotItems.length).toBe(2);
// Higher score first
expect(hotItems[0].score).toBeGreaterThan(hotItems[1].score);
});
it('should maintain stable order across renders', () => {
const order1 = component.filteredItems().map((i) => i.id);
fixture.detectChanges();
const order2 = component.filteredItems().map((i) => i.id);
expect(order1).toEqual(order2);
});
});
describe('band priority helper', () => {
it('should return correct priority values', () => {
expect(getBandPriority('hot')).toBe(0);
expect(getBandPriority('warm')).toBe(1);
expect(getBandPriority('cold')).toBe(2);
});
});
describe('grey queue state helper', () => {
it('should identify Disputed as grey queue', () => {
expect(isGreyQueueState('Disputed')).toBe(true);
});
it('should identify ManualReviewRequired as grey queue', () => {
expect(isGreyQueueState('ManualReviewRequired')).toBe(true);
});
it('should not identify DeterminedPass as grey queue', () => {
expect(isGreyQueueState('DeterminedPass')).toBe(false);
});
it('should not identify DeterminedFail as grey queue', () => {
expect(isGreyQueueState('DeterminedFail')).toBe(false);
});
it('should handle undefined', () => {
expect(isGreyQueueState(undefined)).toBe(false);
});
});
describe('color helpers', () => {
it('should return correct band colors', () => {
expect(component.getBandColor('hot')).toBe(BAND_COLORS['hot']);
expect(component.getBandColor('warm')).toBe(BAND_COLORS['warm']);
expect(component.getBandColor('cold')).toBe(BAND_COLORS['cold']);
});
it('should return correct band labels', () => {
expect(component.getBandLabel('hot')).toBe(BAND_LABELS['hot']);
expect(component.getBandLabel('warm')).toBe(BAND_LABELS['warm']);
expect(component.getBandLabel('cold')).toBe(BAND_LABELS['cold']);
});
});
describe('conflict detection', () => {
it('should detect items with conflicts', () => {
const itemWithConflict = mockItems.find((i) => i.id === 'item-1')!;
expect(component.hasConflicts(itemWithConflict)).toBe(true);
});
it('should handle items without conflicts', () => {
const itemWithoutConflict = mockItems.find((i) => i.id === 'item-2')!;
expect(component.hasConflicts(itemWithoutConflict)).toBe(false);
});
});
});

View File

@@ -0,0 +1,294 @@
// Sprint: SPRINT_20260112_011_FE_policy_unknowns_queue_integration (FE-UNK-007)
// Grey Queue Dashboard Component - dedicated view for grey queue items
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { UnknownsClient, PolicyUnknownsListResponse } from '../../core/api/unknowns.client';
import {
PolicyUnknown,
PolicyUnknownsSummary,
UnknownBand,
BAND_COLORS,
BAND_LABELS,
OBSERVATION_STATE_COLORS,
OBSERVATION_STATE_LABELS,
isGreyQueueState,
hasConflicts,
getBandPriority,
} from '../../core/api/unknowns.models';
@Component({
selector: 'app-grey-queue-dashboard',
standalone: true,
imports: [CommonModule, DatePipe, RouterLink],
template: `
<div class="grey-queue-dashboard min-h-screen bg-gray-50 p-6">
<!-- Header -->
<div class="mb-6">
<nav class="text-sm mb-2">
<a routerLink="/analyze/unknowns" class="text-blue-600 hover:underline">Unknowns</a>
<span class="text-gray-400 mx-2">/</span>
<span class="text-gray-600">Grey Queue</span>
</nav>
<h1 class="text-2xl font-bold text-gray-900">Grey Queue Dashboard</h1>
<p class="text-gray-600 mt-1">
Items requiring manual review due to conflicting signals or disputed evidence
</p>
</div>
<!-- Summary Cards -->
@if (summary()) {
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow-sm border p-4">
<div class="text-sm text-gray-500">Total Grey Queue</div>
<div class="text-2xl font-bold text-purple-600">{{ greyQueueCount() }}</div>
</div>
<div class="bg-red-50 rounded-lg border border-red-200 p-4">
<div class="text-sm text-red-600">HOT</div>
<div class="text-2xl font-bold text-red-700">{{ summary()!.hot }}</div>
</div>
<div class="bg-orange-50 rounded-lg border border-orange-200 p-4">
<div class="text-sm text-orange-600">WARM</div>
<div class="text-2xl font-bold text-orange-700">{{ summary()!.warm }}</div>
</div>
<div class="bg-blue-50 rounded-lg border border-blue-200 p-4">
<div class="text-sm text-blue-600">COLD</div>
<div class="text-2xl font-bold text-blue-700">{{ summary()!.cold }}</div>
</div>
</div>
}
<!-- Filters -->
<div class="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div class="flex flex-wrap items-center gap-4">
<div>
<label class="text-sm text-gray-600 mr-2">Band:</label>
<select
(change)="onBandFilterChange($event)"
class="border rounded px-3 py-1 text-sm"
>
<option value="">All Bands</option>
<option value="hot">HOT</option>
<option value="warm">WARM</option>
<option value="cold">COLD</option>
</select>
</div>
<div>
<label class="text-sm text-gray-600 mr-2">State:</label>
<select
(change)="onStateFilterChange($event)"
class="border rounded px-3 py-1 text-sm"
>
<option value="">All Grey Queue</option>
<option value="Disputed">Disputed</option>
<option value="ManualReviewRequired">Manual Review Required</option>
</select>
</div>
<div class="flex-1"></div>
<button
(click)="refresh()"
class="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700"
>
Refresh
</button>
</div>
</div>
<!-- Loading -->
@if (loading()) {
<div class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-3 text-gray-600">Loading grey queue items...</span>
</div>
}
<!-- Error -->
@if (error()) {
<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
{{ error() }}
</div>
}
<!-- Grey Queue List -->
@if (!loading() && !error()) {
<div class="bg-white rounded-lg shadow-sm border overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Package</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Band</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">State</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Score</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Conflicts</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Triggers</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Last Evaluated</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@for (item of filteredItems(); track item.id) {
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<div class="text-sm font-medium text-gray-900">{{ item.packageId }}</div>
<div class="text-xs text-gray-500">{{ item.packageVersion }}</div>
</td>
<td class="px-4 py-3">
<span [class]="'px-2 py-1 rounded text-xs font-medium ' + getBandColor(item.band)">
{{ getBandLabel(item.band) }}
</span>
</td>
<td class="px-4 py-3">
@if (item.observationState) {
<span [class]="'px-2 py-1 rounded text-xs font-medium ' + getStateColor(item.observationState)">
{{ getStateLabel(item.observationState) }}
</span>
}
</td>
<td class="px-4 py-3 text-sm">
{{ item.score | number:'1.1-1' }}
</td>
<td class="px-4 py-3">
@if (hasConflicts(item)) {
<span class="text-red-600 text-sm font-medium">
{{ item.conflictInfo!.conflicts.length }}
</span>
} @else {
<span class="text-gray-400 text-sm">-</span>
}
</td>
<td class="px-4 py-3 text-sm text-gray-500">
{{ item.triggers?.length || 0 }}
</td>
<td class="px-4 py-3 text-sm text-gray-500">
{{ item.lastEvaluatedAt | date:'short' }}
</td>
<td class="px-4 py-3 text-right">
<a
[routerLink]="['/analyze/unknowns', item.id, 'determinization']"
class="text-blue-600 hover:underline text-sm"
>
Review
</a>
</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-500">
No grey queue items found matching the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
`,
styles: [
`
.grey-queue-dashboard {
min-height: 100vh;
}
`,
],
})
export class GreyQueueDashboardComponent implements OnInit {
private readonly client = inject(UnknownsClient);
readonly items = signal<PolicyUnknown[]>([]);
readonly summary = signal<PolicyUnknownsSummary | null>(null);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly bandFilter = signal<UnknownBand | null>(null);
readonly stateFilter = signal<string | null>(null);
readonly greyQueueCount = computed(() => {
return this.items().filter(
(i) => isGreyQueueState(i.observationState)
).length;
});
readonly filteredItems = computed(() => {
let result = this.items().filter((i) => isGreyQueueState(i.observationState));
const band = this.bandFilter();
if (band) {
result = result.filter((i) => i.band === band);
}
const state = this.stateFilter();
if (state) {
result = result.filter((i) => i.observationState === state);
}
// Deterministic ordering: band priority, then score descending
return result.sort((a, b) => {
const bandDiff = getBandPriority(a.band) - getBandPriority(b.band);
if (bandDiff !== 0) return bandDiff;
return b.score - a.score;
});
});
ngOnInit(): void {
this.loadData();
}
loadData(): void {
this.loading.set(true);
this.error.set(null);
// Load summary
this.client.getPolicyUnknownsSummary().subscribe({
next: (summary) => this.summary.set(summary),
error: () => {}, // Non-critical
});
// Load items
this.client.listPolicyUnknowns(undefined, 500).subscribe({
next: (response) => {
this.items.set(response.items);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load grey queue items');
this.loading.set(false);
},
});
}
refresh(): void {
this.loadData();
}
onBandFilterChange(event: Event): void {
const value = (event.target as HTMLSelectElement).value;
this.bandFilter.set(value ? (value as UnknownBand) : null);
}
onStateFilterChange(event: Event): void {
const value = (event.target as HTMLSelectElement).value;
this.stateFilter.set(value || null);
}
getBandColor(band: UnknownBand): string {
return BAND_COLORS[band] || 'bg-gray-100 text-gray-800';
}
getBandLabel(band: UnknownBand): string {
return BAND_LABELS[band] || band.toUpperCase();
}
getStateColor(state: string): string {
return OBSERVATION_STATE_COLORS[state as keyof typeof OBSERVATION_STATE_COLORS] || '';
}
getStateLabel(state: string): string {
return OBSERVATION_STATE_LABELS[state as keyof typeof OBSERVATION_STATE_LABELS] || state;
}
hasConflicts(item: PolicyUnknown): boolean {
return hasConflicts(item);
}
}

View File

@@ -1,4 +1,5 @@
// Sprint: SPRINT_20251229_033_FE - Unknowns Tracking UI
// Sprint: SPRINT_20260112_011_FE_policy_unknowns_queue_integration (FE-UNK-007)
import { Routes } from '@angular/router';
export const unknownsRoutes: Routes = [
@@ -12,4 +13,16 @@ export const unknownsRoutes: Routes = [
loadComponent: () =>
import('./unknown-detail.component').then((m) => m.UnknownDetailComponent),
},
// Sprint: SPRINT_20260112_011_FE_policy_unknowns_queue_integration (FE-UNK-007)
// Grey queue navigation routes
{
path: ':unknownId/determinization',
loadComponent: () =>
import('./determinization-review.component').then((m) => m.DeterminizationReviewComponent),
},
{
path: 'queue/grey',
loadComponent: () =>
import('./grey-queue-dashboard.component').then((m) => m.GreyQueueDashboardComponent),
},
];

View File

@@ -0,0 +1,211 @@
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-003)
// Grey Queue Panel Component Tests
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GreyQueuePanelComponent } from './grey-queue-panel.component';
import {
PolicyUnknown,
BAND_COLORS,
OBSERVATION_STATE_COLORS,
} from '../../core/api/unknowns.models';
describe('GreyQueuePanelComponent', () => {
let component: GreyQueuePanelComponent;
let fixture: ComponentFixture<GreyQueuePanelComponent>;
const mockUnknown: PolicyUnknown = {
id: 'test-id-123',
packageId: 'pkg:npm/lodash',
packageVersion: '4.17.21',
band: 'hot',
score: 85.5,
uncertaintyFactor: 0.7,
exploitPressure: 0.9,
firstSeenAt: '2026-01-10T12:00:00Z',
lastEvaluatedAt: '2026-01-15T08:00:00Z',
reasonCode: 'Reachability',
reasonCodeShort: 'U-RCH',
fingerprintId: 'sha256:abc123def456',
triggers: [
{
eventType: 'epss.updated',
eventVersion: 1,
source: 'concelier',
receivedAt: '2026-01-15T07:00:00Z',
correlationId: 'corr-123',
},
{
eventType: 'vex.updated',
eventVersion: 1,
source: 'excititor',
receivedAt: '2026-01-15T08:00:00Z',
correlationId: 'corr-456',
},
],
nextActions: ['request_vex', 'verify_reachability'],
conflictInfo: {
hasConflict: true,
severity: 0.8,
suggestedPath: 'RequireManualReview',
conflicts: [
{
signal1: 'VEX:not_affected',
signal2: 'Reachability:reachable',
type: 'VexReachabilityContradiction',
description: 'VEX says not affected but reachability shows path',
severity: 0.8,
},
],
},
observationState: 'Disputed',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GreyQueuePanelComponent],
}).compileComponents();
fixture = TestBed.createComponent(GreyQueuePanelComponent);
component = fixture.componentInstance;
component.unknown = mockUnknown;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('band display', () => {
it('should display HOT band with correct color', () => {
expect(component.getBandColor()).toBe(BAND_COLORS['hot']);
expect(component.getBandLabel()).toBe('HOT');
});
it('should display WARM band with correct color', () => {
component.unknown = { ...mockUnknown, band: 'warm' };
fixture.detectChanges();
expect(component.getBandColor()).toBe(BAND_COLORS['warm']);
expect(component.getBandLabel()).toBe('WARM');
});
it('should display COLD band with correct color', () => {
component.unknown = { ...mockUnknown, band: 'cold' };
fixture.detectChanges();
expect(component.getBandColor()).toBe(BAND_COLORS['cold']);
expect(component.getBandLabel()).toBe('COLD');
});
});
describe('observation state', () => {
it('should display Disputed state', () => {
expect(component.getObservationStateLabel()).toBe('Disputed');
expect(component.isInGreyQueue()).toBe(true);
});
it('should display ManualReviewRequired state as grey queue', () => {
component.unknown = { ...mockUnknown, observationState: 'ManualReviewRequired' };
fixture.detectChanges();
expect(component.getObservationStateLabel()).toBe('Review Required');
expect(component.isInGreyQueue()).toBe(true);
});
it('should not show grey queue for DeterminedPass', () => {
component.unknown = { ...mockUnknown, observationState: 'DeterminedPass' };
fixture.detectChanges();
expect(component.isInGreyQueue()).toBe(false);
});
});
describe('triggers', () => {
it('should sort triggers by receivedAt descending', () => {
const sorted = component.sortedTriggers();
expect(sorted.length).toBe(2);
// Most recent first (vex.updated at 08:00)
expect(sorted[0].eventType).toBe('vex.updated');
expect(sorted[1].eventType).toBe('epss.updated');
});
it('should handle empty triggers', () => {
component.unknown = { ...mockUnknown, triggers: [] };
fixture.detectChanges();
expect(component.sortedTriggers().length).toBe(0);
});
it('should handle undefined triggers', () => {
component.unknown = { ...mockUnknown, triggers: undefined };
fixture.detectChanges();
expect(component.sortedTriggers().length).toBe(0);
});
});
describe('conflicts', () => {
it('should show conflicts when present', () => {
expect(component.showConflicts()).toBe(true);
});
it('should not show conflicts when hasConflict is false', () => {
component.unknown = {
...mockUnknown,
conflictInfo: { ...mockUnknown.conflictInfo!, hasConflict: false },
};
fixture.detectChanges();
expect(component.showConflicts()).toBe(false);
});
it('should not show conflicts when conflictInfo is undefined', () => {
component.unknown = { ...mockUnknown, conflictInfo: undefined };
fixture.detectChanges();
expect(component.showConflicts()).toBe(false);
});
it('should return correct severity color for high severity', () => {
expect(component.getConflictSeverityColor()).toBe('text-red-600');
});
it('should return correct severity color for medium severity', () => {
component.unknown = {
...mockUnknown,
conflictInfo: { ...mockUnknown.conflictInfo!, severity: 0.6 },
};
fixture.detectChanges();
expect(component.getConflictSeverityColor()).toBe('text-orange-600');
});
});
describe('next actions', () => {
it('should format action names correctly', () => {
expect(component.formatAction('request_vex')).toBe('Request Vex');
expect(component.formatAction('verify_reachability')).toBe('Verify Reachability');
});
});
describe('triage actions', () => {
it('should emit triage action when button clicked', () => {
component.showTriageActions = true;
fixture.detectChanges();
const emitSpy = jest.spyOn(component.triageAction, 'emit');
component.onTriage('accept-risk');
expect(emitSpy).toHaveBeenCalledWith({
unknownId: 'test-id-123',
action: 'accept-risk',
});
});
it('should not show triage buttons by default', () => {
expect(component.showTriageActions).toBe(false);
});
});
describe('deterministic ordering', () => {
it('should maintain stable trigger order across renders', () => {
const triggers1 = component.sortedTriggers();
fixture.detectChanges();
const triggers2 = component.sortedTriggers();
// Same order on multiple renders
expect(triggers1.map(t => t.eventType)).toEqual(triggers2.map(t => t.eventType));
});
});
});

View File

@@ -0,0 +1,239 @@
// Sprint: SPRINT_20260112_009_FE_unknowns_queue_ui (FE-UNK-002)
// Grey Queue Panel Component - displays fingerprint, triggers, and manual adjudication state
import { Component, Input, Output, EventEmitter, computed } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import {
PolicyUnknown,
ReanalysisTrigger,
ConflictInfo,
TriageAction,
BAND_COLORS,
BAND_LABELS,
OBSERVATION_STATE_COLORS,
OBSERVATION_STATE_LABELS,
TRIAGE_ACTION_LABELS,
isGreyQueueState,
hasConflicts,
getConflictSeverityColor,
} from '../../core/api/unknowns.models';
@Component({
selector: 'stella-grey-queue-panel',
standalone: true,
imports: [CommonModule, DatePipe],
template: `
<div class="grey-queue-panel bg-white rounded-lg shadow-sm border p-4">
<!-- Header with band and observation state -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span
[class]="'px-3 py-1 rounded-full text-sm font-medium border ' + getBandColor()"
>
{{ getBandLabel() }}
</span>
@if (unknown.observationState) {
<span
[class]="'px-2 py-1 rounded text-xs font-medium ' + getObservationStateColor()"
>
{{ getObservationStateLabel() }}
</span>
}
@if (isInGreyQueue()) {
<span class="text-xs text-purple-600 font-medium">
Grey Queue
</span>
}
</div>
<div class="text-sm text-gray-500">
Score: <span class="font-medium">{{ unknown.score | number: '1.1-1' }}</span>
</div>
</div>
<!-- Fingerprint section -->
@if (unknown.fingerprintId) {
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-700 mb-1">Fingerprint</h4>
<code class="text-xs bg-gray-100 px-2 py-1 rounded font-mono break-all">
{{ unknown.fingerprintId }}
</code>
</div>
}
<!-- Triggers section -->
@if (unknown.triggers && unknown.triggers.length > 0) {
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">
Triggers ({{ unknown.triggers.length }})
</h4>
<div class="space-y-1 max-h-32 overflow-y-auto">
@for (trigger of sortedTriggers(); track trigger.receivedAt) {
<div class="flex items-center justify-between text-xs bg-gray-50 px-2 py-1 rounded">
<span class="font-medium">
{{ trigger.eventType }}&#64;{{ trigger.eventVersion }}
</span>
<span class="text-gray-500">
{{ trigger.receivedAt | date: 'short' }}
</span>
</div>
}
</div>
</div>
}
<!-- Conflicts section -->
@if (showConflicts()) {
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
<span class="text-red-500">!</span>
Conflicts
<span [class]="'text-xs ' + getConflictSeverityColor()">
(Severity: {{ unknown.conflictInfo!.severity | number: '1.2-2' }})
</span>
</h4>
<div class="space-y-2">
@for (conflict of unknown.conflictInfo!.conflicts; track $index) {
<div class="text-xs bg-red-50 border border-red-100 px-2 py-1 rounded">
<div class="font-medium text-red-800">{{ conflict.type }}</div>
<div class="text-red-600">
{{ conflict.signal1 }} vs {{ conflict.signal2 }}
</div>
@if (conflict.description) {
<div class="text-gray-600 mt-1">{{ conflict.description }}</div>
}
</div>
}
@if (unknown.conflictInfo!.suggestedPath) {
<div class="text-xs text-gray-600">
Suggested: <span class="font-medium">{{ unknown.conflictInfo!.suggestedPath }}</span>
</div>
}
</div>
</div>
}
<!-- Next actions section -->
@if (unknown.nextActions && unknown.nextActions.length > 0) {
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Next Actions</h4>
<div class="flex flex-wrap gap-1">
@for (action of unknown.nextActions; track action) {
<span class="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded">
{{ formatAction(action) }}
</span>
}
</div>
</div>
}
<!-- Triage actions -->
@if (showTriageActions) {
<div class="border-t pt-4 mt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Triage Actions</h4>
<div class="flex flex-wrap gap-2">
<button
(click)="onTriage('accept-risk')"
class="px-3 py-1 text-xs rounded border border-green-300 bg-green-50 text-green-700 hover:bg-green-100"
>
Accept Risk
</button>
<button
(click)="onTriage('require-fix')"
class="px-3 py-1 text-xs rounded border border-red-300 bg-red-50 text-red-700 hover:bg-red-100"
>
Require Fix
</button>
<button
(click)="onTriage('defer')"
class="px-3 py-1 text-xs rounded border border-yellow-300 bg-yellow-50 text-yellow-700 hover:bg-yellow-100"
>
Defer
</button>
<button
(click)="onTriage('escalate')"
class="px-3 py-1 text-xs rounded border border-orange-300 bg-orange-50 text-orange-700 hover:bg-orange-100"
>
Escalate
</button>
<button
(click)="onTriage('dispute')"
class="px-3 py-1 text-xs rounded border border-purple-300 bg-purple-50 text-purple-700 hover:bg-purple-100"
>
Dispute
</button>
</div>
</div>
}
</div>
`,
styles: [
`
.grey-queue-panel {
min-width: 320px;
}
`,
],
})
export class GreyQueuePanelComponent {
@Input({ required: true }) unknown!: PolicyUnknown;
@Input() showTriageActions = false;
@Output() triageAction = new EventEmitter<{
unknownId: string;
action: TriageAction;
}>();
// Computed: sort triggers by receivedAt descending (most recent first)
sortedTriggers = computed(() => {
if (!this.unknown.triggers) return [];
return [...this.unknown.triggers].sort(
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
});
getBandColor(): string {
return BAND_COLORS[this.unknown.band] || 'bg-gray-100 text-gray-800';
}
getBandLabel(): string {
return BAND_LABELS[this.unknown.band] || this.unknown.band.toUpperCase();
}
getObservationStateColor(): string {
if (!this.unknown.observationState) return '';
return OBSERVATION_STATE_COLORS[this.unknown.observationState] || '';
}
getObservationStateLabel(): string {
if (!this.unknown.observationState) return '';
return OBSERVATION_STATE_LABELS[this.unknown.observationState] || this.unknown.observationState;
}
isInGreyQueue(): boolean {
return isGreyQueueState(this.unknown.observationState);
}
showConflicts(): boolean {
return hasConflicts(this.unknown);
}
getConflictSeverityColor(): string {
if (!this.unknown.conflictInfo) return '';
return getConflictSeverityColor(this.unknown.conflictInfo.severity);
}
formatAction(action: string): string {
// Convert snake_case to Title Case
return action
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
onTriage(action: TriageAction): void {
this.triageAction.emit({
unknownId: this.unknown.id,
action,
});
}
}

View File

@@ -90,6 +90,118 @@
</section>
}
<!-- Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-002) -->
<!-- Hard-Fail Section -->
@if (isHardFailOutcome()) {
<section class="hard-fail-section" aria-label="Hard fail status">
<h3 class="section-title">[!] Hard Fail</h3>
<div class="hard-fail-alert">
<span class="hard-fail-label">{{ hardFailLabel() }}</span>
@if (shortCircuitLabel()) {
<span class="short-circuit-reason">Reason: {{ shortCircuitLabel() }}</span>
}
</div>
</section>
}
<!-- Reduction Profile Section -->
@if (hasReductionApplied()) {
<section class="reduction-section" aria-label="Score reduction profile">
<h3 class="section-title">Reduction Profile</h3>
@if (reductionDetails(); as rd) {
<div class="reduction-details">
<div class="reduction-row">
<span class="reduction-label">Mode:</span>
<span class="reduction-value">{{ rd.modeLabel }}</span>
</div>
<div class="reduction-row">
<span class="reduction-label">Original Score:</span>
<span class="reduction-value">{{ rd.originalScore }}</span>
</div>
<div class="reduction-row">
<span class="reduction-label">Reduction:</span>
<span class="reduction-value">-{{ rd.reductionAmount }} ({{ rd.reductionPercent }}%)</span>
</div>
@if (rd.contributingEvidence.length > 0) {
<div class="reduction-evidence">
<span class="reduction-label">Contributing Evidence:</span>
<ul class="evidence-list">
@for (ev of rd.contributingEvidence; track ev) {
<li>{{ ev }}</li>
}
</ul>
</div>
}
@if (rd.cappedByPolicy) {
<div class="reduction-capped">
<span class="capped-indicator">[Capped by Policy]</span>
</div>
}
</div>
}
</section>
}
<!-- Short-Circuit Section (when not hard-fail) -->
@if (wasShortCircuited() && !isHardFailOutcome()) {
<section class="short-circuit-section" aria-label="Short circuit reason">
<h3 class="section-title">Short-Circuited</h3>
<div class="short-circuit-info">
<span class="short-circuit-label">{{ shortCircuitLabel() }}</span>
</div>
</section>
}
<!-- Proof Anchor Section -->
@if (hasAnchoredEvidence()) {
<section class="anchor-section" aria-label="Proof anchor details">
<h3 class="section-title">[A] Anchored Evidence</h3>
@if (anchorDetails(); as anchor) {
<div class="anchor-details">
@if (anchor.dsseDigest) {
<div class="anchor-row">
<span class="anchor-label">DSSE Digest:</span>
<span class="anchor-value mono" [attr.title]="anchor.fullDsseDigest">
{{ anchor.dsseDigest }}
</span>
</div>
}
@if (anchor.rekorLogIndex !== undefined) {
<div class="anchor-row">
<span class="anchor-label">Rekor Log Index:</span>
<span class="anchor-value">{{ anchor.rekorLogIndex }}</span>
</div>
}
@if (anchor.rekorEntryId) {
<div class="anchor-row">
<span class="anchor-label">Rekor Entry ID:</span>
<span class="anchor-value mono">{{ anchor.rekorEntryId }}</span>
</div>
}
@if (anchor.verificationStatus) {
<div class="anchor-row">
<span class="anchor-label">Status:</span>
<span class="anchor-value verification-status">{{ anchor.verificationStatus }}</span>
</div>
}
@if (anchor.verificationError) {
<div class="anchor-error">
<span class="error-label">Error:</span>
<span class="error-message">{{ anchor.verificationError }}</span>
</div>
}
@if (anchor.attestationUri) {
<div class="anchor-row">
<a class="attestation-link" [href]="anchor.attestationUri" target="_blank" rel="noopener">
View Full Attestation
</a>
</div>
}
</div>
}
</section>
}
<!-- Explanations -->
@if (scoreResult().explanations.length > 0) {
<section class="explanations-section" aria-label="Score explanations">

View File

@@ -386,3 +386,181 @@
font-size: 28px;
}
}
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-002)
// Styles for reduction profile, hard-fail, and anchor sections
.hard-fail-section {
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
background-color: #fef2f2;
}
.hard-fail-alert {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
background-color: #dc2626;
color: #ffffff;
border-radius: 4px;
}
.hard-fail-label {
font-weight: 600;
}
.short-circuit-reason {
font-size: 12px;
opacity: 0.9;
}
.reduction-section {
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
}
.reduction-details {
display: flex;
flex-direction: column;
gap: 6px;
}
.reduction-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.reduction-label {
color: #6b7280;
font-size: 12px;
}
.reduction-value {
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.reduction-evidence {
margin-top: 4px;
}
.evidence-list {
margin: 4px 0 0 16px;
padding: 0;
font-size: 12px;
color: #4b5563;
list-style-type: disc;
}
.reduction-capped {
margin-top: 4px;
}
.capped-indicator {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
color: #92400e;
background-color: #fef3c7;
border-radius: 4px;
}
.short-circuit-section {
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
background-color: #fefce8;
}
.short-circuit-info {
padding: 8px 12px;
background-color: #f59e0b;
color: #000000;
border-radius: 4px;
}
.short-circuit-label {
font-weight: 500;
}
.anchor-section {
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
background-color: #f5f3ff;
}
.anchor-details {
display: flex;
flex-direction: column;
gap: 8px;
}
.anchor-row {
display: flex;
flex-direction: column;
gap: 2px;
}
.anchor-label {
color: #6b7280;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.anchor-value {
font-weight: 500;
word-break: break-all;
&.mono {
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
font-size: 12px;
}
&.verification-status {
color: #059669;
}
}
.anchor-error {
padding: 8px;
background-color: #fef2f2;
border-radius: 4px;
}
.error-label {
color: #dc2626;
font-size: 11px;
font-weight: 600;
}
.error-message {
display: block;
margin-top: 2px;
color: #7f1d1d;
font-size: 12px;
}
.attestation-link {
display: inline-block;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
color: #7c3aed;
background-color: #ede9fe;
border-radius: 4px;
text-decoration: none;
transition: background-color 0.15s;
&:hover {
background-color: #ddd6fe;
}
&:focus-visible {
outline: 2px solid #7c3aed;
outline-offset: 2px;
}
}

View File

@@ -18,6 +18,16 @@ import {
FLAG_DISPLAY,
getBucketForScore,
ScoreFlag,
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-002)
REDUCTION_MODE_LABELS,
SHORT_CIRCUIT_LABELS,
HARD_FAIL_LABELS,
ANCHOR_VERIFICATION_LABELS,
isAnchored,
isHardFail,
wasShortCircuited,
hasReduction,
getReductionPercent,
} from '../../../core/api/scoring.models';
/**
@@ -115,6 +125,76 @@ export class ScoreBreakdownPopoverComponent {
return guardrails;
});
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-002)
// Reduction profile and anchor computed properties
/** Whether score has reduction applied */
readonly hasReductionApplied = computed(() => hasReduction(this.scoreResult()));
/** Reduction profile details */
readonly reductionDetails = computed(() => {
const score = this.scoreResult();
if (!score.reductionProfile) return null;
const profile = score.reductionProfile;
return {
modeLabel: REDUCTION_MODE_LABELS[profile.mode],
originalScore: profile.originalScore,
reductionAmount: profile.reductionAmount,
reductionPercent: getReductionPercent(score),
contributingEvidence: profile.contributingEvidence,
cappedByPolicy: profile.cappedByPolicy,
};
});
/** Whether score was short-circuited */
readonly wasShortCircuited = computed(() => wasShortCircuited(this.scoreResult()));
/** Short-circuit reason label */
readonly shortCircuitLabel = computed(() => {
const reason = this.scoreResult().shortCircuitReason;
return reason ? SHORT_CIRCUIT_LABELS[reason] : null;
});
/** Whether score is a hard-fail outcome */
readonly isHardFailOutcome = computed(() => isHardFail(this.scoreResult()));
/** Hard-fail status label */
readonly hardFailLabel = computed(() => {
const status = this.scoreResult().hardFailStatus;
return status ? HARD_FAIL_LABELS[status] : null;
});
/** Whether score has anchored evidence */
readonly hasAnchoredEvidence = computed(() => isAnchored(this.scoreResult()));
/** Proof anchor details */
readonly anchorDetails = computed(() => {
const anchor = this.scoreResult().proofAnchor;
if (!anchor || !anchor.anchored) return null;
return {
dsseDigest: anchor.dsseDigest ? this.truncateDigest(anchor.dsseDigest) : null,
fullDsseDigest: anchor.dsseDigest,
rekorLogIndex: anchor.rekorLogIndex,
rekorEntryId: anchor.rekorEntryId,
attestationUri: anchor.attestationUri,
verificationStatus: anchor.verificationStatus
? ANCHOR_VERIFICATION_LABELS[anchor.verificationStatus]
: null,
verificationError: anchor.verificationError,
verifiedAt: anchor.verifiedAt,
};
});
/** Truncate digest for display */
private truncateDigest(digest: string): string {
if (digest.length <= 24) return digest;
const prefix = digest.substring(0, 16);
const suffix = digest.substring(digest.length - 8);
return `${prefix}...${suffix}`;
}
constructor() {
// Update position when anchor changes
effect(() => {