old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 }}@{{ 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }}@{{ 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user