Add unit tests for AST parsing and security sink detection
- Created `StellaOps.AuditPack.Tests.csproj` for unit testing the AuditPack library. - Implemented comprehensive unit tests in `index.test.js` for AST parsing, covering various JavaScript and TypeScript constructs including functions, classes, decorators, and JSX. - Added `sink-detect.test.js` to test security sink detection patterns, validating command injection, SQL injection, file write, deserialization, SSRF, NoSQL injection, and more. - Included tests for taint source detection in various contexts such as Express, Koa, and AWS Lambda.
This commit is contained in:
@@ -50,6 +50,9 @@ describe('ViewModeService', () => {
|
||||
it('should load from localStorage on init', () => {
|
||||
localStorage.setItem('stella-view-mode', 'auditor');
|
||||
|
||||
// Need to reset TestBed to get a fresh service instance that reads localStorage
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({});
|
||||
const newService = TestBed.inject(ViewModeService);
|
||||
expect(newService.mode()).toBe('auditor');
|
||||
});
|
||||
|
||||
@@ -104,7 +104,10 @@ describe('ExceptionApprovalQueueComponent', () => {
|
||||
});
|
||||
|
||||
it('approves selected exceptions', async () => {
|
||||
component.exceptions.set([mockPendingException]);
|
||||
// Trigger ngOnInit to load queue (first call to listExceptions)
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
component.toggleSelection('exc-pending-001');
|
||||
|
||||
await component.approveSelected();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { signal, WritableSignal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { of, throwError, Subject, EMPTY } from 'rxjs';
|
||||
|
||||
import { ExceptionDashboardComponent } from './exception-dashboard.component';
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
import { ExceptionEventDto } from '../../core/api/exception-events.models';
|
||||
import { Exception } from '../../core/api/exception.contract.models';
|
||||
import { AuthSessionStore } from '../../core/auth/auth-session.store';
|
||||
import { AuthSession } from '../../core/auth/auth-session.model';
|
||||
import { StellaOpsScopes } from '../../core/auth/scopes';
|
||||
|
||||
describe('ExceptionDashboardComponent', () => {
|
||||
@@ -18,8 +20,9 @@ describe('ExceptionDashboardComponent', () => {
|
||||
let component: ExceptionDashboardComponent;
|
||||
let mockExceptionApi: jasmine.SpyObj<ExceptionApi>;
|
||||
let mockEventsApi: jasmine.SpyObj<ExceptionEventsApi>;
|
||||
let mockAuthStore: jasmine.SpyObj<AuthSessionStore>;
|
||||
let mockAuthStore: { session: WritableSignal<AuthSession | null> };
|
||||
let mockRouter: jasmine.SpyObj<Router>;
|
||||
let paramMapSubject: Subject<{ get: (key: string) => string | null }>;
|
||||
let eventsSubject: Subject<ExceptionEventDto>;
|
||||
|
||||
const mockException: Exception = {
|
||||
@@ -50,6 +53,9 @@ describe('ExceptionDashboardComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
eventsSubject = new Subject<ExceptionEventDto>();
|
||||
paramMapSubject = new Subject<{ get: (key: string) => string | null }>();
|
||||
// Emit initial empty params
|
||||
setTimeout(() => paramMapSubject.next({ get: () => null }), 0);
|
||||
|
||||
mockExceptionApi = jasmine.createSpyObj('ExceptionApi', [
|
||||
'listExceptions',
|
||||
@@ -58,12 +64,16 @@ describe('ExceptionDashboardComponent', () => {
|
||||
'transitionStatus',
|
||||
]);
|
||||
mockEventsApi = jasmine.createSpyObj('ExceptionEventsApi', ['streamEvents']);
|
||||
mockAuthStore = jasmine.createSpyObj('AuthSessionStore', [], {
|
||||
session: jasmine.createSpy().and.returnValue({
|
||||
mockAuthStore = {
|
||||
session: signal<AuthSession | null>({
|
||||
scopes: [StellaOpsScopes.EXCEPTION_WRITE],
|
||||
}),
|
||||
} as unknown as AuthSession),
|
||||
};
|
||||
mockRouter = jasmine.createSpyObj('Router', ['navigate', 'createUrlTree', 'serializeUrl'], {
|
||||
events: of(),
|
||||
});
|
||||
mockRouter = jasmine.createSpyObj('Router', ['navigate']);
|
||||
mockRouter.createUrlTree.and.returnValue({} as any);
|
||||
mockRouter.serializeUrl.and.returnValue('');
|
||||
|
||||
mockExceptionApi.listExceptions.and.returnValue(
|
||||
of({ items: [mockException], count: 1, continuationToken: null })
|
||||
@@ -77,6 +87,14 @@ describe('ExceptionDashboardComponent', () => {
|
||||
{ provide: EXCEPTION_EVENTS_API, useValue: mockEventsApi },
|
||||
{ provide: AuthSessionStore, useValue: mockAuthStore },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: paramMapSubject.asObservable(),
|
||||
queryParams: of({}),
|
||||
snapshot: { paramMap: { get: () => null } },
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -111,6 +129,10 @@ describe('ExceptionDashboardComponent', () => {
|
||||
});
|
||||
|
||||
it('creates exception via wizard', async () => {
|
||||
// Trigger ngOnInit to load exceptions (first call)
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const draft = {
|
||||
title: 'New Exception',
|
||||
justification: 'Test reason',
|
||||
@@ -163,17 +185,15 @@ describe('ExceptionDashboardComponent', () => {
|
||||
expect(component.userRole()).toBe('user');
|
||||
|
||||
// Admin role
|
||||
(mockAuthStore.session as jasmine.Spy).and.returnValue({
|
||||
mockAuthStore.session.set({
|
||||
scopes: [StellaOpsScopes.ADMIN],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
} as unknown as AuthSession);
|
||||
expect(component.userRole()).toBe('admin');
|
||||
|
||||
// Approver role
|
||||
(mockAuthStore.session as jasmine.Spy).and.returnValue({
|
||||
mockAuthStore.session.set({
|
||||
scopes: [StellaOpsScopes.EXCEPTION_APPROVE],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
} as unknown as AuthSession);
|
||||
expect(component.userRole()).toBe('approver');
|
||||
});
|
||||
|
||||
|
||||
@@ -85,16 +85,19 @@ export class ExceptionDetailComponent {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const exception = this.exception();
|
||||
if (!exception) return;
|
||||
effect(
|
||||
() => {
|
||||
const exception = this.exception();
|
||||
if (!exception) return;
|
||||
|
||||
this.editDescription.set(exception.description ?? '');
|
||||
this.editJustification.set(exception.justification.text);
|
||||
this.labelEntries.set(this.mapLabels(exception.labels ?? {}));
|
||||
this.transitionComment.set('');
|
||||
this.error.set(null);
|
||||
});
|
||||
this.editDescription.set(exception.description ?? '');
|
||||
this.editJustification.set(exception.justification.text);
|
||||
this.labelEntries.set(this.mapLabels(exception.labels ?? {}));
|
||||
this.transitionComment.set('');
|
||||
this.error.set(null);
|
||||
},
|
||||
{ allowSignalWrites: true }
|
||||
);
|
||||
}
|
||||
|
||||
addLabel(): void {
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
class="field-textarea"
|
||||
placeholder="Enter CVE IDs, one per line (e.g., CVE-2024-1234)"
|
||||
[value]="draft().scope.cves?.join('\n') || ''"
|
||||
(input)="updateScope('cves', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
|
||||
(input)="parseScopeInput('cves', $any($event.target).value)"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
class="field-textarea"
|
||||
placeholder="Package names to scope (e.g., lodash, express)"
|
||||
[value]="draft().scope.packages?.join('\n') || ''"
|
||||
(input)="updateScope('packages', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
|
||||
(input)="parseScopeInput('packages', $any($event.target).value)"
|
||||
></textarea>
|
||||
</div>
|
||||
}
|
||||
@@ -84,7 +84,7 @@
|
||||
class="field-textarea"
|
||||
placeholder="License identifiers (e.g., GPL-3.0, AGPL-3.0)"
|
||||
[value]="draft().scope.licenses?.join('\n') || ''"
|
||||
(input)="updateScope('licenses', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
|
||||
(input)="parseScopeInput('licenses', $any($event.target).value)"
|
||||
></textarea>
|
||||
</div>
|
||||
}
|
||||
@@ -96,7 +96,7 @@
|
||||
class="field-textarea"
|
||||
placeholder="Policy rule IDs (e.g., SEC-001, COMP-002)"
|
||||
[value]="draft().scope.policyRules?.join('\n') || ''"
|
||||
(input)="updateScope('policyRules', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
|
||||
(input)="parseScopeInput('policyRules', $any($event.target).value)"
|
||||
></textarea>
|
||||
</div>
|
||||
}
|
||||
@@ -107,7 +107,7 @@
|
||||
class="field-textarea"
|
||||
placeholder="Image references (e.g., myregistry/myimage:*, myregistry/app:v1.0)"
|
||||
[value]="draft().scope.images?.join('\n') || ''"
|
||||
(input)="updateScope('images', $any($event.target).value.split('\n').filter((v: string) => v.trim()))"
|
||||
(input)="parseScopeInput('images', $any($event.target).value)"
|
||||
></textarea>
|
||||
<span class="field-hint">Use * for wildcards. Leave empty to apply to all images.</span>
|
||||
</div>
|
||||
@@ -119,10 +119,7 @@
|
||||
<button
|
||||
class="env-chip"
|
||||
[class.selected]="draft().scope.environments?.includes(env)"
|
||||
(click)="updateScope('environments',
|
||||
draft().scope.environments?.includes(env)
|
||||
? draft().scope.environments?.filter(e => e !== env)
|
||||
: [...(draft().scope.environments || []), env])"
|
||||
(click)="toggleScopeEnvironment(env)"
|
||||
>
|
||||
{{ env | titlecase }}
|
||||
</button>
|
||||
@@ -230,9 +227,9 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 4: Timebox -->
|
||||
@if (currentStep() === 'timebox') {
|
||||
<div class="step-panel">
|
||||
<!-- Step 4: Timebox -->
|
||||
@if (currentStep() === 'timebox') {
|
||||
<div class="step-panel">
|
||||
<h3 class="step-title">Set exception duration</h3>
|
||||
<p class="step-desc">
|
||||
Exceptions must have an expiration date. Maximum duration: {{ maxDurationDays() }} days.
|
||||
@@ -283,220 +280,216 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 5: Recheck Policy -->
|
||||
@if (currentStep() === 'recheck-policy') {
|
||||
<div class="step-panel">
|
||||
<h3 class="step-title">Configure recheck policy</h3>
|
||||
<p class="step-desc">
|
||||
Define the conditions that automatically re-evaluate this exception. Leave disabled if not needed.
|
||||
</p>
|
||||
|
||||
@if (!recheckPolicy()) {
|
||||
<div class="empty-panel">
|
||||
<p class="empty-text">No recheck policy is configured for this exception.</p>
|
||||
<button class="btn-secondary" (click)="enableRecheckPolicy()">Enable Recheck Policy</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="recheck-form">
|
||||
<div class="form-field">
|
||||
<label class="field-label">Policy name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="field-input"
|
||||
[value]="recheckPolicy()?.name"
|
||||
(input)="updateRecheckPolicy('name', $any($event.target).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Default action</label>
|
||||
<select
|
||||
class="field-select"
|
||||
[value]="recheckPolicy()?.defaultAction"
|
||||
(change)="updateRecheckPolicy('defaultAction', $any($event.target).value)"
|
||||
>
|
||||
@for (action of actionOptions; track action.value) {
|
||||
<option [value]="action.value">{{ action.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="conditions-header">
|
||||
<h4 class="section-title">Conditions</h4>
|
||||
<button class="btn-secondary" (click)="addRecheckCondition()">+ Add Condition</button>
|
||||
</div>
|
||||
|
||||
@if (recheckConditions().length === 0) {
|
||||
<div class="empty-inline">Add at least one condition to enable recheck enforcement.</div>
|
||||
}
|
||||
|
||||
<div class="condition-list">
|
||||
@for (condition of recheckConditions(); track condition.id) {
|
||||
<div class="condition-card">
|
||||
<div class="condition-grid">
|
||||
<div class="form-field">
|
||||
<label class="field-label">Condition</label>
|
||||
<select
|
||||
class="field-select"
|
||||
[value]="condition.type"
|
||||
(change)="updateRecheckCondition(condition.id, { type: $any($event.target).value, threshold: null })"
|
||||
>
|
||||
@for (option of conditionTypeOptions; track option.type) {
|
||||
<option [value]="option.type">{{ option.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (requiresThreshold(condition.type)) {
|
||||
<div class="form-field">
|
||||
<label class="field-label">Threshold</label>
|
||||
<input
|
||||
type="number"
|
||||
class="field-input"
|
||||
[placeholder]="conditionTypeOptions.find(o => o.type === condition.type)?.thresholdHint || ''"
|
||||
[value]="condition.threshold ?? ''"
|
||||
(input)="updateRecheckCondition(condition.id, { threshold: $any($event.target).value === '' ? null : +$any($event.target).value })"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Action</label>
|
||||
<select
|
||||
class="field-select"
|
||||
[value]="condition.action"
|
||||
(change)="updateRecheckCondition(condition.id, { action: $any($event.target).value })"
|
||||
>
|
||||
@for (action of actionOptions; track action.value) {
|
||||
<option [value]="action.value">{{ action.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Environment scope</label>
|
||||
<div class="env-chips">
|
||||
@for (env of environmentOptions; track env) {
|
||||
<button
|
||||
class="env-chip"
|
||||
[class.selected]="condition.environmentScope.includes(env)"
|
||||
(click)="updateRecheckCondition(condition.id, {
|
||||
environmentScope: condition.environmentScope.includes(env)
|
||||
? condition.environmentScope.filter(e => e !== env)
|
||||
: [...condition.environmentScope, env]
|
||||
})"
|
||||
>
|
||||
{{ env | titlecase }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span class="field-hint">Leave empty to apply in all environments.</span>
|
||||
</div>
|
||||
|
||||
<div class="condition-actions">
|
||||
<button class="btn-link danger" (click)="removeRecheckCondition(condition.id)">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="btn-link" (click)="disableRecheckPolicy()">Disable recheck policy</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 6: Evidence Requirements -->
|
||||
@if (currentStep() === 'evidence') {
|
||||
<div class="step-panel">
|
||||
<h3 class="step-title">Evidence requirements</h3>
|
||||
<p class="step-desc">
|
||||
Submit evidence to support the exception. Mandatory evidence must be provided before submission.
|
||||
</p>
|
||||
|
||||
@if (missingEvidence().length > 0) {
|
||||
<div class="missing-banner">
|
||||
<span class="warning-icon">[!]</span>
|
||||
{{ missingEvidence().length }} mandatory evidence item(s) missing.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="evidence-grid">
|
||||
@for (entry of evidenceEntries(); track entry.hook.hookId) {
|
||||
<div class="evidence-card">
|
||||
<div class="evidence-header">
|
||||
<div>
|
||||
<div class="evidence-title">
|
||||
{{ getEvidenceLabel(entry.hook.type) }}
|
||||
@if (entry.hook.isMandatory) {
|
||||
<span class="tag required">Required</span>
|
||||
} @else {
|
||||
<span class="tag optional">Optional</span>
|
||||
}
|
||||
</div>
|
||||
<div class="evidence-desc">{{ entry.hook.description }}</div>
|
||||
</div>
|
||||
<span class="status-badge" [class]="'status-' + entry.status.toLowerCase()">
|
||||
{{ entry.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="evidence-meta">
|
||||
@if (entry.hook.maxAge) {
|
||||
<span class="meta-chip">Max age: {{ entry.hook.maxAge }}</span>
|
||||
}
|
||||
@if (entry.hook.minTrustScore) {
|
||||
<span class="meta-chip">Min trust: {{ entry.hook.minTrustScore }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="evidence-body">
|
||||
<div class="form-field">
|
||||
<label class="field-label">Reference link</label>
|
||||
<input
|
||||
type="text"
|
||||
class="field-input"
|
||||
placeholder="https://... or launchdarkly://..."
|
||||
[value]="entry.submission?.reference || ''"
|
||||
(input)="updateEvidenceSubmission(entry.hook.hookId, { reference: $any($event.target).value })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Notes or evidence summary</label>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
[value]="entry.submission?.content || ''"
|
||||
(input)="updateEvidenceSubmission(entry.hook.hookId, { content: $any($event.target).value })"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Attach file (optional)</label>
|
||||
<input
|
||||
type="file"
|
||||
class="field-input"
|
||||
(change)="onEvidenceFileSelected(entry.hook.hookId, $event)"
|
||||
/>
|
||||
@if (entry.submission?.fileName) {
|
||||
<span class="field-hint">Attached: {{ entry.submission?.fileName }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 7: Review -->
|
||||
@if (currentStep() === 'review') {
|
||||
<div class="step-panel">
|
||||
<h3 class="step-title">Review and submit</h3>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 5: Recheck Policy -->
|
||||
@if (currentStep() === 'recheck-policy') {
|
||||
<div class="step-panel">
|
||||
<h3 class="step-title">Configure recheck policy</h3>
|
||||
<p class="step-desc">
|
||||
Define the conditions that automatically re-evaluate this exception. Leave disabled if not needed.
|
||||
</p>
|
||||
|
||||
@if (!recheckPolicy()) {
|
||||
<div class="empty-panel">
|
||||
<p class="empty-text">No recheck policy is configured for this exception.</p>
|
||||
<button class="btn-secondary" (click)="enableRecheckPolicy()">Enable Recheck Policy</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="recheck-form">
|
||||
<div class="form-field">
|
||||
<label class="field-label">Policy name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="field-input"
|
||||
[value]="recheckPolicy()?.name"
|
||||
(input)="updateRecheckPolicy('name', $any($event.target).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Default action</label>
|
||||
<select
|
||||
class="field-select"
|
||||
[value]="recheckPolicy()?.defaultAction"
|
||||
(change)="updateRecheckPolicy('defaultAction', $any($event.target).value)"
|
||||
>
|
||||
@for (action of actionOptions; track action.value) {
|
||||
<option [value]="action.value">{{ action.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="conditions-header">
|
||||
<h4 class="section-title">Conditions</h4>
|
||||
<button class="btn-secondary" (click)="addRecheckCondition()">+ Add Condition</button>
|
||||
</div>
|
||||
|
||||
@if (recheckConditions().length === 0) {
|
||||
<div class="empty-inline">Add at least one condition to enable recheck enforcement.</div>
|
||||
}
|
||||
|
||||
<div class="condition-list">
|
||||
@for (condition of recheckConditions(); track condition.id) {
|
||||
<div class="condition-card">
|
||||
<div class="condition-grid">
|
||||
<div class="form-field">
|
||||
<label class="field-label">Condition</label>
|
||||
<select
|
||||
class="field-select"
|
||||
[value]="condition.type"
|
||||
(change)="updateRecheckCondition(condition.id, { type: $any($event.target).value, threshold: null })"
|
||||
>
|
||||
@for (option of conditionTypeOptions; track option.type) {
|
||||
<option [value]="option.type">{{ option.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (requiresThreshold(condition.type)) {
|
||||
<div class="form-field">
|
||||
<label class="field-label">Threshold</label>
|
||||
<input
|
||||
type="number"
|
||||
class="field-input"
|
||||
[placeholder]="getThresholdHint(condition.type)"
|
||||
[value]="condition.threshold ?? ''"
|
||||
(input)="updateRecheckCondition(condition.id, { threshold: $any($event.target).value === '' ? null : +$any($event.target).value })"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Action</label>
|
||||
<select
|
||||
class="field-select"
|
||||
[value]="condition.action"
|
||||
(change)="updateRecheckCondition(condition.id, { action: $any($event.target).value })"
|
||||
>
|
||||
@for (action of actionOptions; track action.value) {
|
||||
<option [value]="action.value">{{ action.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Environment scope</label>
|
||||
<div class="env-chips">
|
||||
@for (env of environmentOptions; track env) {
|
||||
<button
|
||||
class="env-chip"
|
||||
[class.selected]="condition.environmentScope.includes(env)"
|
||||
(click)="toggleConditionEnvironment(condition.id, env)"
|
||||
>
|
||||
{{ env | titlecase }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<span class="field-hint">Leave empty to apply in all environments.</span>
|
||||
</div>
|
||||
|
||||
<div class="condition-actions">
|
||||
<button class="btn-link danger" (click)="removeRecheckCondition(condition.id)">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button class="btn-link" (click)="disableRecheckPolicy()">Disable recheck policy</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 6: Evidence Requirements -->
|
||||
@if (currentStep() === 'evidence') {
|
||||
<div class="step-panel">
|
||||
<h3 class="step-title">Evidence requirements</h3>
|
||||
<p class="step-desc">
|
||||
Submit evidence to support the exception. Mandatory evidence must be provided before submission.
|
||||
</p>
|
||||
|
||||
@if (missingEvidence().length > 0) {
|
||||
<div class="missing-banner">
|
||||
<span class="warning-icon">[!]</span>
|
||||
{{ missingEvidence().length }} mandatory evidence item(s) missing.
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="evidence-grid">
|
||||
@for (entry of evidenceEntries(); track entry.hook.hookId) {
|
||||
<div class="evidence-card">
|
||||
<div class="evidence-header">
|
||||
<div>
|
||||
<div class="evidence-title">
|
||||
{{ getEvidenceLabel(entry.hook.type) }}
|
||||
@if (entry.hook.isMandatory) {
|
||||
<span class="tag required">Required</span>
|
||||
} @else {
|
||||
<span class="tag optional">Optional</span>
|
||||
}
|
||||
</div>
|
||||
<div class="evidence-desc">{{ entry.hook.description }}</div>
|
||||
</div>
|
||||
<span class="status-badge" [class]="'status-' + entry.status.toLowerCase()">
|
||||
{{ entry.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="evidence-meta">
|
||||
@if (entry.hook.maxAge) {
|
||||
<span class="meta-chip">Max age: {{ entry.hook.maxAge }}</span>
|
||||
}
|
||||
@if (entry.hook.minTrustScore) {
|
||||
<span class="meta-chip">Min trust: {{ entry.hook.minTrustScore }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="evidence-body">
|
||||
<div class="form-field">
|
||||
<label class="field-label">Reference link</label>
|
||||
<input
|
||||
type="text"
|
||||
class="field-input"
|
||||
placeholder="https://... or launchdarkly://..."
|
||||
[value]="entry.submission?.reference || ''"
|
||||
(input)="updateEvidenceSubmission(entry.hook.hookId, { reference: $any($event.target).value })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Notes or evidence summary</label>
|
||||
<textarea
|
||||
class="field-textarea"
|
||||
[value]="entry.submission?.content || ''"
|
||||
(input)="updateEvidenceSubmission(entry.hook.hookId, { content: $any($event.target).value })"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Attach file (optional)</label>
|
||||
<input
|
||||
type="file"
|
||||
class="field-input"
|
||||
(change)="onEvidenceFileSelected(entry.hook.hookId, $event)"
|
||||
/>
|
||||
@if (entry.submission?.fileName) {
|
||||
<span class="field-hint">Attached: {{ entry.submission?.fileName }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 7: Review -->
|
||||
@if (currentStep() === 'review') {
|
||||
<div class="step-panel">
|
||||
<h3 class="step-title">Review and submit</h3>
|
||||
<p class="step-desc">Please review your exception request before submitting.</p>
|
||||
|
||||
<div class="review-summary">
|
||||
@@ -576,57 +569,57 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h4 class="section-title">Timebox</h4>
|
||||
<div class="review-row">
|
||||
<span class="review-label">Duration:</span>
|
||||
<span class="review-value">{{ draft().expiresInDays }} days</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="review-label">Expires:</span>
|
||||
<span class="review-value">{{ formatDate(expirationDate()) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h4 class="section-title">Recheck Policy</h4>
|
||||
@if (!recheckPolicy()) {
|
||||
<div class="review-row">
|
||||
<span class="review-label">Status:</span>
|
||||
<span class="review-value">Not configured</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="review-row">
|
||||
<span class="review-label">Policy:</span>
|
||||
<span class="review-value">{{ recheckPolicy()?.name }}</span>
|
||||
</div>
|
||||
@for (condition of recheckConditions(); track condition.id) {
|
||||
<div class="review-row">
|
||||
<span class="review-label">Condition:</span>
|
||||
<span class="review-value">
|
||||
{{ getConditionLabel(condition.type) }}
|
||||
@if (condition.threshold !== null) {
|
||||
({{ condition.threshold }})
|
||||
}
|
||||
- {{ condition.action }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h4 class="section-title">Evidence</h4>
|
||||
@for (entry of evidenceEntries(); track entry.hook.hookId) {
|
||||
<div class="review-row">
|
||||
<span class="review-label">{{ getEvidenceLabel(entry.hook.type) }}:</span>
|
||||
<span class="review-value">{{ entry.status }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="review-section">
|
||||
<h4 class="section-title">Timebox</h4>
|
||||
<div class="review-row">
|
||||
<span class="review-label">Duration:</span>
|
||||
<span class="review-value">{{ draft().expiresInDays }} days</span>
|
||||
</div>
|
||||
<div class="review-row">
|
||||
<span class="review-label">Expires:</span>
|
||||
<span class="review-value">{{ formatDate(expirationDate()) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h4 class="section-title">Recheck Policy</h4>
|
||||
@if (!recheckPolicy()) {
|
||||
<div class="review-row">
|
||||
<span class="review-label">Status:</span>
|
||||
<span class="review-value">Not configured</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="review-row">
|
||||
<span class="review-label">Policy:</span>
|
||||
<span class="review-value">{{ recheckPolicy()?.name }}</span>
|
||||
</div>
|
||||
@for (condition of recheckConditions(); track condition.id) {
|
||||
<div class="review-row">
|
||||
<span class="review-label">Condition:</span>
|
||||
<span class="review-value">
|
||||
{{ getConditionLabel(condition.type) }}
|
||||
@if (condition.threshold !== null) {
|
||||
({{ condition.threshold }})
|
||||
}
|
||||
- {{ condition.action }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<h4 class="section-title">Evidence</h4>
|
||||
@for (entry of evidenceEntries(); track entry.hook.hookId) {
|
||||
<div class="review-row">
|
||||
<span class="review-label">{{ getEvidenceLabel(entry.hook.type) }}:</span>
|
||||
<span class="review-value">{{ entry.status }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
|
||||
@@ -33,7 +33,8 @@ describe('ExceptionWizardComponent', () => {
|
||||
|
||||
expect(component.canGoNext()).toBeFalse();
|
||||
|
||||
const requiredHooks = component.evidenceHooks().filter((hook) => hook.isMandatory);
|
||||
// Use effectiveEvidenceHooks() which includes default hooks when no input is provided
|
||||
const requiredHooks = component.effectiveEvidenceHooks().filter((hook) => hook.isMandatory);
|
||||
for (const hook of requiredHooks) {
|
||||
component.updateEvidenceSubmission(hook.hookId, {
|
||||
reference: `https://evidence.local/${hook.hookId}`,
|
||||
|
||||
@@ -413,6 +413,36 @@ export class ExceptionWizardComponent {
|
||||
this.updateScopePreview();
|
||||
}
|
||||
|
||||
/** Parse a newline-separated text input and update the scope field. Used in templates. */
|
||||
parseScopeInput(field: keyof ExceptionScope, rawValue: string): void {
|
||||
const parsed = rawValue.split('\n').filter((v) => v.trim());
|
||||
this.updateScope(field, parsed as ExceptionScope[typeof field]);
|
||||
}
|
||||
|
||||
/** Toggle an environment in the scope's environments list. Used in templates. */
|
||||
toggleScopeEnvironment(env: string): void {
|
||||
const current = this.draft().scope.environments || [];
|
||||
const updated = current.includes(env)
|
||||
? current.filter((e) => e !== env)
|
||||
: [...current, env];
|
||||
this.updateScope('environments', updated.length > 0 ? updated : undefined);
|
||||
}
|
||||
|
||||
/** Toggle an environment in a recheck condition's environment scope. Used in templates. */
|
||||
toggleConditionEnvironment(conditionId: string, env: string): void {
|
||||
const policy = this.draft().recheckPolicy;
|
||||
if (!policy) return;
|
||||
|
||||
const condition = policy.conditions.find((c) => c.id === conditionId);
|
||||
if (!condition) return;
|
||||
|
||||
const current = condition.environmentScope || [];
|
||||
const updated = current.includes(env)
|
||||
? current.filter((e) => e !== env)
|
||||
: [...current, env];
|
||||
this.updateRecheckCondition(conditionId, { environmentScope: updated });
|
||||
}
|
||||
|
||||
private updateScopePreview(): void {
|
||||
const scope = this.draft().scope;
|
||||
const preview: string[] = [];
|
||||
@@ -645,4 +675,9 @@ export class ExceptionWizardComponent {
|
||||
getEvidenceLabel(type: EvidenceType): string {
|
||||
return this.evidenceTypeOptions.find((option) => option.value === type)?.label ?? type;
|
||||
}
|
||||
|
||||
/** Get the threshold hint for a condition type. Used in templates. */
|
||||
getThresholdHint(type: RecheckConditionType): string {
|
||||
return this.conditionTypeOptions.find((option) => option.type === type)?.thresholdHint ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,21 @@ describe('PolicyPackSelectorComponent', () => {
|
||||
store = jasmine.createSpyObj<PolicyPackStore>('PolicyPackStore', ['getPacks']);
|
||||
});
|
||||
|
||||
it('emits first pack id when API succeeds', fakeAsync(async () => {
|
||||
it('emits starter pack id when API succeeds and starter pack is present', fakeAsync(async () => {
|
||||
store.getPacks.and.returnValue(
|
||||
of([
|
||||
{
|
||||
id: 'starter-day1',
|
||||
name: 'Starter Day 1',
|
||||
description: '',
|
||||
version: '1.0',
|
||||
status: 'active',
|
||||
createdAt: '',
|
||||
modifiedAt: '',
|
||||
createdBy: '',
|
||||
modifiedBy: '',
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
id: 'pack-42',
|
||||
name: 'Test Pack',
|
||||
@@ -46,10 +58,10 @@ describe('PolicyPackSelectorComponent', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('pack-42');
|
||||
expect(spy).toHaveBeenCalledWith('starter-day1');
|
||||
}));
|
||||
|
||||
it('falls back to pack-1 on API error', fakeAsync(async () => {
|
||||
it('adds starter pack and emits when API returns empty list', fakeAsync(async () => {
|
||||
store.getPacks.and.returnValue(of([]));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -66,7 +78,9 @@ describe('PolicyPackSelectorComponent', () => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(component['packs'].length).toBe(0);
|
||||
// Component adds starter-day1 to empty list and selects it
|
||||
expect(spy).toHaveBeenCalledWith('starter-day1');
|
||||
expect(component['packs'].length).toBe(1);
|
||||
expect(component['packs'][0].id).toBe('starter-day1');
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
@@ -14,8 +15,14 @@ import { PolicyPackStore } from '../../features/policy-studio/services/policy-pa
|
||||
import { PolicyPackSummary } from '../../features/policy-studio/models/policy.models';
|
||||
|
||||
/**
|
||||
* Policy pack selector for the nav dropdown.
|
||||
* Fetches packs from PolicyApiService with an offline-safe fallback list.
|
||||
* Policy pack selector with starter policy recommendation.
|
||||
* Sprint: SPRINT_5200_0001_0001 - Task T10
|
||||
*
|
||||
* Features:
|
||||
* - "Starter (Recommended)" option for starter-day1 pack
|
||||
* - Tooltip explaining starter policy rules
|
||||
* - One-click activation
|
||||
* - Preview of rules before activation
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-policy-pack-selector',
|
||||
@@ -31,17 +38,65 @@ import { PolicyPackSummary } from '../../features/policy-studio/models/policy.mo
|
||||
[disabled]="loading"
|
||||
[attr.aria-busy]="loading"
|
||||
>
|
||||
<option *ngFor="let pack of packs" [value]="pack.id">{{ pack.name }}</option>
|
||||
<option *ngFor="let pack of sortedPacks" [value]="pack.id">
|
||||
{{ getPackLabel(pack) }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="hint" *ngIf="loading">Loading packs…</p>
|
||||
|
||||
<p class="hint" *ngIf="loading">Loading packs...</p>
|
||||
<p class="hint" *ngIf="!loading && packs.length === 0">No packs available.</p>
|
||||
|
||||
<!-- Tooltip for starter policy -->
|
||||
<div class="tooltip" *ngIf="showTooltip && selectedPackId === 'starter-day1'">
|
||||
<div class="tooltip-header">Starter Policy Pack</div>
|
||||
<div class="tooltip-body">
|
||||
Production-ready policy for Day 1 adoption:
|
||||
<ul>
|
||||
<li>Blocks reachable HIGH/CRITICAL vulnerabilities</li>
|
||||
<li>Allows VEX bypass with evidence</li>
|
||||
<li>Enforces unknowns budget (5%)</li>
|
||||
<li>Requires signed artifacts for production</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rule preview panel -->
|
||||
<div class="preview-panel" *ngIf="showPreview && selectedPack">
|
||||
<div class="preview-header">
|
||||
<span>Rule Preview</span>
|
||||
<button class="close-btn" (click)="showPreview = false" aria-label="Close preview">×</button>
|
||||
</div>
|
||||
<div class="preview-body">
|
||||
<div class="rule" *ngFor="let rule of previewRules">
|
||||
<span class="rule-action" [class]="rule.action">{{ rule.action | uppercase }}</span>
|
||||
<span class="rule-name">{{ rule.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="activate-btn" (click)="activatePack()" *ngIf="!isActivated">
|
||||
Activate Policy Pack
|
||||
</button>
|
||||
<div class="activated-badge" *ngIf="isActivated">
|
||||
<span class="check">✓</span> Activated
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick actions -->
|
||||
<div class="actions" *ngIf="!loading && selectedPack">
|
||||
<button class="action-btn" (click)="togglePreview()" [attr.aria-expanded]="showPreview">
|
||||
{{ showPreview ? 'Hide' : 'Preview' }} Rules
|
||||
</button>
|
||||
<button class="action-btn primary" (click)="activatePack()" *ngIf="!isActivated && !showPreview">
|
||||
Activate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.pack-selector {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
gap: 0.25rem;
|
||||
position: relative;
|
||||
}
|
||||
label {
|
||||
color: #cbd5e1;
|
||||
@@ -52,35 +107,259 @@ import { PolicyPackSummary } from '../../features/policy-studio/models/policy.mo
|
||||
color: #e5e7eb;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.45rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
select:hover {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
.hint {
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Tooltip styles */
|
||||
.tooltip {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.tooltip-header {
|
||||
color: #22d3ee;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.tooltip-body {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.tooltip-body ul {
|
||||
margin: 0.5rem 0 0 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
.tooltip-body li {
|
||||
margin: 0.25rem 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Preview panel */
|
||||
.preview-panel {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #e5e7eb;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.close-btn:hover {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.preview-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.rule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.rule-action {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rule-action.block {
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
.rule-action.warn {
|
||||
background: #713f12;
|
||||
color: #fcd34d;
|
||||
}
|
||||
.rule-action.allow {
|
||||
background: #14532d;
|
||||
color: #86efac;
|
||||
}
|
||||
.rule-name {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.action-btn {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
color: #e5e7eb;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: #334155;
|
||||
}
|
||||
.action-btn.primary {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
.action-btn.primary:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
.activate-btn {
|
||||
background: #2563eb;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
margin-top: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
.activate-btn:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
.activated-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
background: #14532d;
|
||||
color: #86efac;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.check {
|
||||
font-size: 1rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyPackSelectorComponent implements OnInit, OnDestroy {
|
||||
@Input() showTooltip = true;
|
||||
@Output() packSelected = new EventEmitter<string>();
|
||||
@Output() packActivated = new EventEmitter<string>();
|
||||
|
||||
protected packs: PolicyPackSummary[] = [];
|
||||
protected loading = false;
|
||||
protected showPreview = false;
|
||||
protected isActivated = false;
|
||||
protected selectedPackId: string | null = null;
|
||||
|
||||
private readonly packStore = inject(PolicyPackStore);
|
||||
private sub?: Subscription;
|
||||
|
||||
/** Starter policy rules for preview */
|
||||
protected readonly previewRules = [
|
||||
{ name: 'block-reachable-high-critical', action: 'block' },
|
||||
{ name: 'warn-reachable-medium', action: 'warn' },
|
||||
{ name: 'ignore-unreachable', action: 'allow' },
|
||||
{ name: 'fail-on-unknowns', action: 'block' },
|
||||
{ name: 'require-signed-sbom-prod', action: 'block' },
|
||||
{ name: 'require-signed-verdict-prod', action: 'block' },
|
||||
{ name: 'default-allow', action: 'allow' },
|
||||
];
|
||||
|
||||
/** Get selected pack */
|
||||
protected get selectedPack(): PolicyPackSummary | undefined {
|
||||
return this.packs.find(p => p.id === this.selectedPackId);
|
||||
}
|
||||
|
||||
/** Sort packs with starter-day1 first */
|
||||
protected get sortedPacks(): PolicyPackSummary[] {
|
||||
return [...this.packs].sort((a, b) => {
|
||||
if (a.id === 'starter-day1') return -1;
|
||||
if (b.id === 'starter-day1') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
/** Get display label with "(Recommended)" suffix for starter */
|
||||
protected getPackLabel(pack: PolicyPackSummary): string {
|
||||
if (pack.id === 'starter-day1') {
|
||||
return `${pack.name} (Recommended)`;
|
||||
}
|
||||
return pack.name;
|
||||
}
|
||||
|
||||
onChange(value: string): void {
|
||||
this.selectedPackId = value;
|
||||
this.isActivated = false;
|
||||
this.packSelected.emit(value);
|
||||
}
|
||||
|
||||
togglePreview(): void {
|
||||
this.showPreview = !this.showPreview;
|
||||
}
|
||||
|
||||
activatePack(): void {
|
||||
if (this.selectedPackId) {
|
||||
this.isActivated = true;
|
||||
this.packActivated.emit(this.selectedPackId);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loading = true;
|
||||
this.sub = this.packStore.getPacks().subscribe((packs) => {
|
||||
// Ensure starter-day1 is always in the list
|
||||
const hasStarter = packs.some(p => p.id === 'starter-day1');
|
||||
if (!hasStarter) {
|
||||
packs = [
|
||||
{
|
||||
id: 'starter-day1',
|
||||
name: 'Starter Day 1',
|
||||
description: 'Starter policy pack for Day 1 operations',
|
||||
version: '1.0.0',
|
||||
status: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: 'system',
|
||||
modifiedAt: new Date().toISOString(),
|
||||
modifiedBy: 'system',
|
||||
tags: ['starter', 'recommended'],
|
||||
} satisfies PolicyPackSummary,
|
||||
...packs,
|
||||
];
|
||||
}
|
||||
|
||||
this.packs = packs;
|
||||
this.loading = false;
|
||||
if (packs.length > 0) {
|
||||
|
||||
// Auto-select starter pack if available
|
||||
const starterPack = packs.find(p => p.id === 'starter-day1');
|
||||
if (starterPack) {
|
||||
this.selectedPackId = starterPack.id;
|
||||
this.packSelected.emit(starterPack.id);
|
||||
} else if (packs.length > 0) {
|
||||
this.selectedPackId = packs[0].id;
|
||||
this.packSelected.emit(packs[0].id);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user