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:
StellaOps Bot
2025-12-23 09:23:42 +02:00
parent 7e384ab610
commit 56e2dc01ee
96 changed files with 8555 additions and 1455 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">&times;</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">&#10003;</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);
}
});