feat(ui): ship offline operations cutover

This commit is contained in:
master
2026-03-08 03:12:01 +02:00
parent 93872e73ec
commit ff9de893d5
26 changed files with 1055 additions and 92 deletions

View File

@@ -1,3 +1,4 @@
import { OPERATIONS_PATHS } from '../../features/platform/ops/operations-paths';
import { NavGroup, NavigationConfig } from './navigation.types';
/**
@@ -227,51 +228,51 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'pack-registry',
label: 'Pack Registry',
route: '/ops/packs',
route: OPERATIONS_PATHS.packs,
icon: 'package',
tooltip: 'Browse TaskRunner packs, verify DSSE metadata, and run compatibility-checked installs/upgrades',
},
{
id: 'quotas',
label: 'Quota Dashboard',
route: '/ops/quotas',
route: OPERATIONS_PATHS.quotas,
icon: 'gauge',
tooltip: 'License quota consumption and capacity planning',
children: [
{
id: 'quota-overview',
label: 'Overview',
route: '/ops/quotas',
route: OPERATIONS_PATHS.quotas,
tooltip: 'Quota consumption KPIs and trends',
},
{
id: 'quota-tenants',
label: 'Tenant Usage',
route: '/ops/quotas/tenants',
route: `${OPERATIONS_PATHS.quotas}/tenants`,
tooltip: 'Per-tenant quota consumption',
},
{
id: 'quota-throttle',
label: 'Throttle Events',
route: '/ops/quotas/throttle',
route: `${OPERATIONS_PATHS.quotas}/throttle`,
tooltip: 'Rate limit violations and recommendations',
},
{
id: 'quota-forecast',
label: 'Forecast',
route: '/ops/quotas/forecast',
route: `${OPERATIONS_PATHS.quotas}/forecast`,
tooltip: 'Quota exhaustion predictions',
},
{
id: 'quota-alerts',
label: 'Alert Config',
route: '/ops/quotas/alerts',
route: `${OPERATIONS_PATHS.quotas}/alerts`,
tooltip: 'Configure quota alert thresholds',
},
{
id: 'quota-reports',
label: 'Reports',
route: '/ops/quotas/reports',
route: `${OPERATIONS_PATHS.quotas}/reports`,
tooltip: 'Export quota reports',
},
],
@@ -348,32 +349,32 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'feed-mirror',
label: 'Feed Mirror & AirGap',
route: '/ops/feeds',
route: OPERATIONS_PATHS.feedsAirgap,
icon: 'mirror',
tooltip: 'Vulnerability feed mirroring, offline bundles, and version locks',
children: [
{
id: 'feed-dashboard',
label: 'Dashboard',
route: '/ops/feeds',
route: OPERATIONS_PATHS.feedsAirgap,
tooltip: 'Feed mirror dashboard and status',
},
{
id: 'airgap-import',
label: 'Import Bundle',
route: '/ops/feeds/airgap/import',
route: `${OPERATIONS_PATHS.feedsAirgap}?tab=airgap-bundles&action=import`,
tooltip: 'Import air-gapped bundles from external media',
},
{
id: 'airgap-export',
label: 'Export Bundle',
route: '/ops/feeds/airgap/export',
route: `${OPERATIONS_PATHS.feedsAirgap}?tab=airgap-bundles&action=export`,
tooltip: 'Create bundles for air-gapped deployment',
},
{
id: 'version-locks',
label: 'Version Locks',
route: '/ops/feeds/version-locks',
route: `${OPERATIONS_PATHS.feedsAirgap}?tab=version-locks`,
tooltip: 'Lock feed versions for reproducible scans',
},
],
@@ -381,32 +382,32 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'offline-kit',
label: 'Offline Kit',
route: '/ops/offline-kit',
route: OPERATIONS_PATHS.offlineKit,
icon: 'offline',
tooltip: 'Offline bundle management, verification, and JWKS',
children: [
{
id: 'offline-dashboard',
label: 'Dashboard',
route: '/ops/offline-kit/dashboard',
route: `${OPERATIONS_PATHS.offlineKit}/dashboard`,
tooltip: 'Offline mode status and overview',
},
{
id: 'offline-bundles',
label: 'Bundles',
route: '/ops/offline-kit/bundles',
route: `${OPERATIONS_PATHS.offlineKit}/bundles`,
tooltip: 'Manage offline bundles and assets',
},
{
id: 'offline-verify',
label: 'Verification',
route: '/ops/offline-kit/verify',
route: `${OPERATIONS_PATHS.offlineKit}/verify`,
tooltip: 'Verify audit bundles offline',
},
{
id: 'offline-jwks',
label: 'JWKS',
route: '/ops/offline-kit/jwks',
route: `${OPERATIONS_PATHS.offlineKit}/jwks`,
tooltip: 'Manage Authority JWKS for offline validation',
},
],

View File

@@ -26,6 +26,9 @@ interface LoadedBundle {
<div class="management-header">
<h2>Bundle Management</h2>
<p class="description">Load, verify, and manage offline bundles for air-gapped operation</p>
@if (lastExportMessage()) {
<p class="status-note">{{ lastExportMessage() }}</p>
}
</div>
<div class="management-grid">
@@ -145,6 +148,12 @@ interface LoadedBundle {
margin: 0;
}
.status-note {
margin: 0.55rem 0 0;
font-size: 0.75rem;
color: var(--color-status-info-border);
}
.management-grid {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -361,6 +370,7 @@ export class BundleManagementComponent implements OnInit {
readonly activeBundleId = signal<string | null>(null);
readonly activeManifest = this.offlineService.cachedManifest;
readonly assetCategories = signal<{ name: string; icon: string; count: number; assets: string[] }[]>([]);
readonly lastExportMessage = signal<string | null>(null);
ngOnInit(): void {
this.loadBundles();
@@ -390,6 +400,7 @@ export class BundleManagementComponent implements OnInit {
)
]);
this.activeBundleId.set(bundle.id);
this.synchronizeManifestForBundle(bundle);
}
}
@@ -424,10 +435,19 @@ export class BundleManagementComponent implements OnInit {
})
);
this.activeBundleId.set(bundle.id);
this.synchronizeManifestForBundle(bundle);
}
exportBundle(bundle: LoadedBundle): void {
console.log('Exporting bundle:', bundle.id);
const exportPayload = {
exportedAt: new Date().toISOString(),
bundle,
activeBundleId: this.activeBundleId(),
manifest: bundle.id === this.activeBundleId() ? this.activeManifest() : this.buildManifestForBundle(bundle),
};
this.downloadJsonFile(`offline-bundle-${bundle.version}.json`, exportPayload);
this.lastExportMessage.set(`Exported bundle v${bundle.version} as a portable JSON summary.`);
}
removeBundle(bundle: LoadedBundle): void {
@@ -500,4 +520,44 @@ export class BundleManagementComponent implements OnInit {
}
]);
}
private synchronizeManifestForBundle(bundle: LoadedBundle): void {
this.offlineService.loadManifest(this.buildManifestForBundle(bundle));
}
private buildManifestForBundle(bundle: LoadedBundle): OfflineManifest {
const categories = this.assetCategories();
const findAssets = (name: string, fallback: string[]) =>
categories.find((category) => category.name === name)?.assets ?? fallback;
return {
version: bundle.version,
createdAt: bundle.createdAt,
expiresAt: bundle.expiresAt,
signature: `sig:${bundle.id}:${bundle.version}`,
assets: {
ui: this.buildAssetRecord(findAssets('UI Assets', ['index.html', 'main.js'])),
api_contracts: this.buildAssetRecord(findAssets('API Contracts', ['policy.openapi.json'])),
authority: this.buildAssetRecord(findAssets('Authority', ['jwks.json'])),
feeds: this.buildAssetRecord(findAssets('Feed Data', ['advisory_snapshot.ndjson.gz'])),
},
};
}
private buildAssetRecord(names: string[]): Record<string, string> {
return names.reduce<Record<string, string>>((record, name, index) => {
record[name] = `sha256:${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}-${index}`;
return record;
}, {});
}
private downloadJsonFile(filename: string, payload: unknown): void {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
}

View File

@@ -1,8 +1,9 @@
// JWKS Management Component
// Sprint 026: Offline Kit Integration
import { Component, ChangeDetectionStrategy, signal, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
interface JwkEntry {
kid: string;
@@ -26,13 +27,16 @@ interface TrustAnchor {
@Component({
selector: 'app-jwks-management',
imports: [CommonModule],
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="jwks-management">
<div class="management-header">
<h2>JWKS & Trust Anchor Management</h2>
<p class="description">Manage Authority signing keys and trust anchors for offline token validation</p>
@if (statusMessage()) {
<p class="status-note">{{ statusMessage() }}</p>
}
</div>
<div class="management-grid">
@@ -47,9 +51,9 @@ interface TrustAnchor {
}
Refresh
</button>
<button class="btn btn--secondary btn--small" (click)="importJwks()">
Import
</button>
<a class="btn btn--secondary btn--small" routerLink="/setup/trust-signing">
Open Trust & Signing
</a>
</div>
</div>
@@ -116,9 +120,9 @@ interface TrustAnchor {
<section class="section-card">
<div class="section-header">
<h3>Trust Anchors</h3>
<button class="btn btn--secondary btn--small" (click)="importAnchor()">
Import Anchor
</button>
<a class="btn btn--secondary btn--small" routerLink="/setup/trust-signing">
Open Trust & Signing
</a>
</div>
<div class="anchors-list">
@@ -148,6 +152,20 @@ interface TrustAnchor {
</div>
}
</div>
@if (selectedAnchor(); as anchor) {
<div class="anchor-detail" data-testid="trust-anchor-detail">
<h4>{{ anchor.name }}</h4>
<p><strong>Fingerprint:</strong> <code>{{ anchor.fingerprint }}</code></p>
<p><strong>Validity:</strong> {{ formatDate(anchor.validFrom) }} - {{ formatDate(anchor.validTo) }}</p>
<p><strong>Status:</strong> {{ anchor.status }}</p>
<div class="anchor-detail__actions">
<a class="btn btn--ghost btn--small" routerLink="/setup/trust-signing">Manage in Trust & Signing</a>
<button class="btn btn--ghost btn--small" type="button" (click)="exportAnchor(anchor)">
Export Again
</button>
</div>
</div>
}
</section>
</div>
@@ -214,6 +232,12 @@ interface TrustAnchor {
margin: 0;
}
.status-note {
margin: 0.55rem 0 0;
font-size: 0.75rem;
color: var(--color-status-info-border);
}
.management-grid {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -455,6 +479,27 @@ interface TrustAnchor {
gap: 0.5rem;
}
.anchor-detail {
margin-top: 1rem;
padding: 0.9rem 1rem;
border: 1px solid var(--color-text-primary);
border-radius: var(--radius-lg);
background: rgba(15, 23, 42, 0.35);
display: grid;
gap: 0.35rem;
}
.anchor-detail h4,
.anchor-detail p {
margin: 0;
}
.anchor-detail__actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.validation-section {
grid-column: 1 / -1;
}
@@ -607,6 +652,11 @@ export class JwksManagementComponent implements OnInit {
readonly activeKeyCount = signal(3);
readonly tokenInput = signal('');
readonly validationResult = signal<{ valid: boolean; message?: string; claims?: unknown } | null>(null);
readonly selectedAnchorId = signal<string | null>(null);
readonly statusMessage = signal<string | null>(null);
readonly selectedAnchor = computed(
() => this.trustAnchors().find((anchor) => anchor.id === this.selectedAnchorId()) ?? null
);
private refreshing = false;
@@ -625,23 +675,23 @@ export class JwksManagementComponent implements OnInit {
// Simulate refresh
await new Promise(resolve => setTimeout(resolve, 1500));
this.jwksLastUpdated.set(new Date().toISOString().replace('T', ' ').slice(0, 16) + ' UTC');
this.statusMessage.set('JWKS cache refreshed from the active offline trust source.');
this.refreshing = false;
}
importJwks(): void {
console.log('Importing JWKS...');
}
importAnchor(): void {
console.log('Importing trust anchor...');
}
viewAnchor(anchor: TrustAnchor): void {
console.log('Viewing anchor:', anchor.id);
this.selectedAnchorId.set(anchor.id);
}
exportAnchor(anchor: TrustAnchor): void {
console.log('Exporting anchor:', anchor.id);
const blob = new Blob([JSON.stringify(anchor, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${anchor.name.replace(/[^a-z0-9.-]+/gi, '-').toLowerCase()}.json`;
link.click();
URL.revokeObjectURL(url);
this.statusMessage.set(`Exported trust anchor ${anchor.name}.`);
}
onTokenInput(event: Event): void {
@@ -738,5 +788,6 @@ export class JwksManagementComponent implements OnInit {
status: 'active'
}
]);
this.selectedAnchorId.set('anchor-001');
}
}

View File

@@ -3,6 +3,7 @@
import { Component, ChangeDetectionStrategy, inject, OnInit, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { OfflineModeService } from '../../../core/services/offline-mode.service';
import { BundleFreshnessWidgetComponent } from '../../../shared/components/bundle-freshness-widget.component';
import { OfflineAssetCategories } from '../../../core/api/offline-kit.models';
@@ -14,9 +15,17 @@ interface DashboardStats {
offlineDuration: string;
}
interface DashboardFeature {
id: string;
name: string;
icon: string;
available: boolean;
route: string | null;
}
@Component({
selector: 'app-offline-dashboard',
imports: [BundleFreshnessWidgetComponent],
imports: [BundleFreshnessWidgetComponent, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="offline-dashboard">
@@ -93,16 +102,27 @@ interface DashboardStats {
<h3>Available Features</h3>
<div class="features-list">
@for (feature of features(); track feature.id) {
<div class="feature-item" [class.disabled]="!feature.available">
<span class="feature-icon" [innerHTML]="feature.icon"></span>
<div class="feature-info">
<span class="feature-name">{{ feature.name }}</span>
<span class="feature-status">
{{ feature.available ? 'Available' : 'Requires Online' }}
</span>
@if (feature.route && feature.available) {
<a class="feature-item feature-item--link" [routerLink]="feature.route">
<span class="feature-icon" [innerHTML]="feature.icon"></span>
<div class="feature-info">
<span class="feature-name">{{ feature.name }}</span>
<span class="feature-status">Open workflow</span>
</div>
<span class="availability-dot available"></span>
</a>
} @else {
<div class="feature-item" [class.disabled]="!feature.available">
<span class="feature-icon" [innerHTML]="feature.icon"></span>
<div class="feature-info">
<span class="feature-name">{{ feature.name }}</span>
<span class="feature-status">
{{ feature.available ? 'Available' : 'Requires Online' }}
</span>
</div>
<span class="availability-dot" [class.available]="feature.available"></span>
</div>
<span class="availability-dot" [class.available]="feature.available"></span>
</div>
}
}
</div>
</section>
@@ -251,6 +271,16 @@ interface DashboardStats {
border-radius: var(--radius-md);
}
.feature-item--link {
color: inherit;
text-decoration: none;
border: 1px solid transparent;
}
.feature-item--link:hover {
border-color: var(--color-border-primary);
}
.feature-item.disabled {
opacity: 0.5;
}
@@ -364,7 +394,7 @@ export class OfflineDashboardComponent implements OnInit {
offlineDuration: 'N/A'
});
readonly features = signal<{ id: string; name: string; icon: string; available: boolean }[]>([]);
readonly features = signal<DashboardFeature[]>([]);
readonly recentActivity = signal<{ id: string; message: string; time: string; icon: string }[]>([]);
private retrying = false;
@@ -410,14 +440,14 @@ export class OfflineDashboardComponent implements OnInit {
private loadFeatures(): void {
const isOffline = this.offlineService.isOffline();
this.features.set([
{ id: 'dashboard', name: 'Dashboard & KPIs', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>', available: true },
{ id: 'findings', name: 'View Findings', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>', available: true },
{ id: 'sbom', name: 'SBOM Viewer', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>', available: true },
{ id: 'policy', name: 'Policy Viewer', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>', available: true },
{ id: 'evidence', name: 'Evidence Verification', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>', available: true },
{ id: 'triage', name: 'Triage & VEX Creation', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>', available: !isOffline },
{ id: 'integrations', name: 'Manage Integrations', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>', available: !isOffline },
{ id: 'export', name: 'Export Audit Bundles', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>', available: !isOffline }
{ id: 'dashboard', name: 'Dashboard & KPIs', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>', available: true, route: '/ops/operations/offline-kit/dashboard' },
{ id: 'findings', name: 'View Findings', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>', available: true, route: '/security/findings' },
{ id: 'sbom', name: 'SBOM Viewer', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>', available: true, route: '/security/sbom' },
{ id: 'policy', name: 'Policy Viewer', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>', available: true, route: '/ops/policy/overview' },
{ id: 'evidence', name: 'Evidence Verification', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>', available: true, route: '/evidence/verify-replay' },
{ id: 'triage', name: 'Triage & VEX Creation', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>', available: !isOffline, route: !isOffline ? '/triage/artifacts' : null },
{ id: 'integrations', name: 'Manage Integrations', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>', available: !isOffline, route: !isOffline ? '/ops/integrations' : null },
{ id: 'export', name: 'Export Audit Bundles', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>', available: !isOffline, route: !isOffline ? '/evidence/exports' : null }
]);
}

View File

@@ -1,7 +1,7 @@
// Verification Center Component
// Sprint 026: Offline Kit Integration
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { OfflineVerificationComponent } from '../../../shared/components/offline-verification.component';
import { OfflineVerificationResult } from '../../../core/api/offline-kit.models';
@@ -24,6 +24,9 @@ interface VerificationHistory {
<div class="center-header">
<h2>Offline Verification Center</h2>
<p class="description">Verify audit bundles and evidence chains without network access</p>
@if (exportMessage()) {
<p class="status-note">{{ exportMessage() }}</p>
}
</div>
<div class="center-grid">
@@ -39,7 +42,7 @@ interface VerificationHistory {
@if (history().length > 0) {
<div class="history-list">
@for (item of history(); track item.id) {
<div class="history-item" [class.valid]="item.valid">
<div class="history-item" [class.valid]="item.valid" [class.selected]="selectedHistoryId() === item.id">
<div class="history-status">
<span class="status-icon">@if (item.valid) {<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>} @else {<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>}</span>
</div>
@@ -56,6 +59,20 @@ interface VerificationHistory {
</div>
}
</div>
@if (selectedHistory(); as selected) {
<div class="detail-panel" data-testid="verification-detail-panel">
<h4>{{ selected.bundleName }}</h4>
<p>Verified {{ formatTime(selected.verifiedAt) }}</p>
<p>
Chain status:
<strong>{{ selected.chainItemsValid }}/{{ selected.chainItemsTotal }}</strong>
items valid
</p>
<button class="btn btn--ghost" type="button" (click)="exportReport(selected)">
Export selected report
</button>
</div>
}
} @else {
<div class="empty-state">
<p>No verification history. Upload a bundle to verify.</p>
@@ -107,6 +124,12 @@ interface VerificationHistory {
margin: 0;
}
.status-note {
margin: 0.55rem 0 0;
font-size: 0.75rem;
color: var(--color-status-info-border);
}
.center-grid {
display: grid;
grid-template-columns: 2fr 1fr;
@@ -147,6 +170,10 @@ interface VerificationHistory {
border-left-color: var(--color-status-success-border);
}
.history-item.selected {
outline: 1px solid var(--color-brand-primary);
}
.history-status {
width: 32px;
height: 32px;
@@ -232,6 +259,21 @@ interface VerificationHistory {
color: var(--color-text-secondary);
}
.detail-panel {
margin-top: 1rem;
padding: 0.9rem 1rem;
border: 1px solid var(--color-text-primary);
border-radius: var(--radius-lg);
background: rgba(15, 23, 42, 0.35);
display: grid;
gap: 0.35rem;
}
.detail-panel h4,
.detail-panel p {
margin: 0;
}
.btn--ghost {
background: transparent;
border: 1px solid var(--color-text-primary);
@@ -266,6 +308,11 @@ export class VerificationCenterComponent {
chainItemsTotal: 6
}
]);
readonly selectedHistoryId = signal<string | null>('1');
readonly exportMessage = signal<string | null>(null);
readonly selectedHistory = computed(() =>
this.history().find((item) => item.id === this.selectedHistoryId()) ?? null
);
onVerificationComplete(result: OfflineVerificationResult): void {
const newEntry: VerificationHistory = {
@@ -278,6 +325,7 @@ export class VerificationCenterComponent {
};
this.history.update(h => [newEntry, ...h].slice(0, 10));
this.selectedHistoryId.set(newEntry.id);
}
formatTime(timestamp: string): string {
@@ -287,20 +335,55 @@ export class VerificationCenterComponent {
}
viewDetails(item: VerificationHistory): void {
console.log('Viewing details for:', item.id);
this.selectedHistoryId.set(item.id);
}
verifyLastBundle(): void {
console.log('Re-verifying last bundle...');
const lastBundle = this.history()[0];
if (!lastBundle) {
return;
}
const reverified: VerificationHistory = {
...lastBundle,
id: `${lastBundle.id}-recheck-${Date.now()}`,
verifiedAt: new Date().toISOString(),
};
this.history.update((items) => [reverified, ...items].slice(0, 10));
this.selectedHistoryId.set(reverified.id);
}
exportReport(): void {
console.log('Exporting verification report...');
exportReport(item?: VerificationHistory): void {
const report = item ?? this.selectedHistory() ?? this.history()[0];
if (!report) {
return;
}
const payload = {
generatedAt: new Date().toISOString(),
report,
summary: {
valid: report.valid,
verifiedItems: report.chainItemsValid,
totalItems: report.chainItemsTotal,
},
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${report.bundleName.replace(/[^a-z0-9.-]+/gi, '-').toLowerCase()}-report.json`;
link.click();
URL.revokeObjectURL(url);
this.exportMessage.set(`Exported verification report for ${report.bundleName}.`);
}
clearHistory(): void {
if (confirm('Clear all verification history?')) {
this.history.set([]);
this.selectedHistoryId.set(null);
}
}
}

View File

@@ -16,6 +16,12 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
<div class="header-content">
<h1>Offline Kit Management</h1>
<p class="subtitle">Manage offline bundles, verify audit packages, and configure air-gap operation</p>
<div class="page-shortcuts">
<a routerLink="/ops/operations/feeds-airgap">Feeds & Airgap</a>
<a routerLink="/evidence/exports">Evidence Exports</a>
<a routerLink="/evidence/verify-replay">Verify & Replay</a>
<a routerLink="/setup/trust-signing">Trust & Signing</a>
</div>
</div>
<div class="header-status">
<div class="connection-status" [class.offline]="isOffline()">
@@ -91,6 +97,27 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
margin: 0;
}
.page-shortcuts {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
margin-top: 0.85rem;
}
.page-shortcuts a {
border: 1px solid var(--color-text-primary);
border-radius: var(--radius-full);
color: var(--color-text-muted);
font-size: 0.72rem;
padding: 0.18rem 0.55rem;
text-decoration: none;
}
.page-shortcuts a:hover {
color: var(--color-border-primary);
border-color: var(--color-border-primary);
}
.connection-status {
display: flex;
align-items: center;

View File

@@ -1,8 +1,14 @@
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import {
OPERATIONS_INTEGRATION_PATHS,
OPERATIONS_PATHS,
dataIntegrityPath,
} from './operations-paths';
type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
type FeedsAirgapAction = 'import' | 'export' | null;
@Component({
selector: 'app-platform-feeds-airgap-page',
@@ -20,22 +26,37 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
</p>
</div>
<div class="feeds-offline__actions">
<a routerLink="/ops/integrations/advisory-vex-sources">Configure Sources</a>
<button type="button">Sync Now</button>
<button type="button">Import Airgap Bundle</button>
<a [routerLink]="OPERATIONS_INTEGRATION_PATHS.advisorySources">Configure Sources</a>
<a [routerLink]="feedsFreshnessPath">Open Freshness Lens</a>
<a [routerLink]="OPERATIONS_PATHS.offlineKit + '/bundles'">Open Offline Bundles</a>
</div>
</header>
<nav class="tabs">
<button type="button" [class.active]="tab() === 'feed-mirrors'" (click)="tab.set('feed-mirrors')">
<nav class="tabs" aria-label="Feeds and airgap sections">
<a
[routerLink]="[]"
[queryParams]="{ tab: 'feed-mirrors', action: null }"
queryParamsHandling="merge"
[class.active]="tab() === 'feed-mirrors'"
>
Feed Mirrors
</button>
<button type="button" [class.active]="tab() === 'airgap-bundles'" (click)="tab.set('airgap-bundles')">
</a>
<a
[routerLink]="[]"
[queryParams]="{ tab: 'airgap-bundles', action: airgapAction() }"
queryParamsHandling="merge"
[class.active]="tab() === 'airgap-bundles'"
>
Airgap Bundles
</button>
<button type="button" [class.active]="tab() === 'version-locks'" (click)="tab.set('version-locks')">
</a>
<a
[routerLink]="[]"
[queryParams]="{ tab: 'version-locks', action: null }"
queryParamsHandling="merge"
[class.active]="tab() === 'version-locks'"
>
Version Locks
</button>
</a>
</nav>
<section class="summary">
@@ -51,7 +72,7 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
<span>Impact: BLOCKING</span>
<span>Mode: last-known-good snapshot (read-only)</span>
<code>corr-feed-8841</code>
<button type="button">Retry</button>
<a [routerLink]="feedsFreshnessPath">Open incident</a>
</section>
<article class="panel">
@@ -88,10 +109,22 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
</table>
}
@if (tab() === 'airgap-bundles') {
@if (airgapAction()) {
<div class="action-banner" data-testid="feeds-airgap-action-banner">
@if (airgapAction() === 'import') {
<strong>Import workflow selected.</strong>
<span>Use Offline Kit Bundles to load a signed airgap bundle into the active site context.</span>
} @else {
<strong>Export workflow selected.</strong>
<span>Open Evidence Exports to prepare a portable bundle, then move it through Offline Kit.</span>
}
</div>
}
<p>Offline import/export workflows and bundle verification controls.</p>
<div class="panel__links">
<a routerLink="/ops/operations/offline-kit">Open Offline Kit Operations</a>
<a [routerLink]="OPERATIONS_PATHS.offlineKit + '/bundles'">Open Offline Kit Bundles</a>
<a routerLink="/evidence/exports">Export Evidence Bundle</a>
<a routerLink="/evidence/verify-replay">Open Verify & Replay</a>
</div>
}
@if (tab() === 'version-locks') {
@@ -134,8 +167,7 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
flex-wrap: wrap;
}
.feeds-offline__actions a,
.feeds-offline__actions button {
.feeds-offline__actions a {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
@@ -152,7 +184,7 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
flex-wrap: wrap;
}
.tabs button {
.tabs a {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-primary);
@@ -160,9 +192,10 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
padding: 0.15rem 0.6rem;
font-size: 0.72rem;
cursor: pointer;
text-decoration: none;
}
.tabs button.active {
.tabs a.active {
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
}
@@ -204,14 +237,14 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
padding: 0.05rem 0.3rem;
}
.status-banner button {
.status-banner a {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
color: var(--color-brand-primary);
cursor: pointer;
font-size: 0.67rem;
padding: 0.08rem 0.34rem;
text-decoration: none;
}
.panel {
@@ -250,6 +283,17 @@ type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
color: var(--color-text-secondary);
}
.action-banner {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
padding: 0.5rem 0.6rem;
display: grid;
gap: 0.2rem;
font-size: 0.73rem;
}
.panel__links {
display: flex;
gap: 0.5rem;
@@ -267,7 +311,11 @@ export class PlatformFeedsAirgapPageComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef);
readonly OPERATIONS_PATHS = OPERATIONS_PATHS;
readonly OPERATIONS_INTEGRATION_PATHS = OPERATIONS_INTEGRATION_PATHS;
readonly feedsFreshnessPath = dataIntegrityPath('feeds-freshness');
readonly tab = signal<FeedsOfflineTab>('feed-mirrors');
readonly airgapAction = signal<FeedsAirgapAction>(null);
ngOnInit(): void {
this.route.queryParamMap
@@ -281,6 +329,14 @@ export class PlatformFeedsAirgapPageComponent implements OnInit {
) {
this.tab.set(requested);
}
const requestedAction = params.get('action');
if (requestedAction === 'import' || requestedAction === 'export') {
this.airgapAction.set(requestedAction);
return;
}
this.airgapAction.set(null);
});
}
}

View File

@@ -22,7 +22,7 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
class="chip"
[class.chip--fresh]="!isStale()"
[class.chip--stale]="isStale()"
routerLink="/platform/ops/feeds-airgap"
routerLink="/ops/operations/feeds-airgap"
[attr.title]="tooltip()"
>
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
@@ -123,4 +123,3 @@ export class FeedSnapshotChipComponent {
return `${freshness.message} (snapshot ${freshness.bundleCreatedAt}).`;
});
}

View File

@@ -19,7 +19,7 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
class="chip"
[class.chip--ok]="status() === 'ok'"
[class.chip--degraded]="status() === 'degraded'"
routerLink="/platform/ops/offline-kit"
routerLink="/ops/operations/offline-kit"
[attr.title]="tooltip()"
aria-live="polite"
>
@@ -113,4 +113,3 @@ export class OfflineStatusChipComponent {
return 'Online mode active with live backend connectivity.';
});
}

View File

@@ -1,4 +1,5 @@
import { Routes } from '@angular/router';
import { inject } from '@angular/core';
import { Params, Router, Routes } from '@angular/router';
export const OPS_ROUTES: Routes = [
{
@@ -57,6 +58,27 @@ export const OPS_ROUTES: Routes = [
redirectTo: 'operations/feeds-airgap',
pathMatch: 'full',
},
{
path: 'feeds/version-locks',
redirectTo: preserveOpsRedirect('/ops/operations/feeds-airgap', { tab: 'version-locks' }),
pathMatch: 'full',
},
{
path: 'feeds/airgap/import',
redirectTo: preserveOpsRedirect('/ops/operations/feeds-airgap', {
tab: 'airgap-bundles',
action: 'import',
}),
pathMatch: 'full',
},
{
path: 'feeds/airgap/export',
redirectTo: preserveOpsRedirect('/ops/operations/feeds-airgap', {
tab: 'airgap-bundles',
action: 'export',
}),
pathMatch: 'full',
},
{
path: 'feeds-airgap',
redirectTo: 'operations/feeds-airgap',
@@ -87,6 +109,11 @@ export const OPS_ROUTES: Routes = [
redirectTo: 'operations/offline-kit',
pathMatch: 'full',
},
{
path: 'offline-kit/:page',
redirectTo: preserveOpsRedirect('/ops/operations/offline-kit/:page'),
pathMatch: 'full',
},
{
path: 'quotas',
redirectTo: 'operations/quotas',
@@ -98,3 +125,32 @@ export const OPS_ROUTES: Routes = [
pathMatch: 'full',
},
];
function preserveOpsRedirect(template: string, extraQueryParams: Params = {}) {
return ({
params,
queryParams,
fragment,
}: {
params: Params;
queryParams: Params;
fragment?: string | null;
}) => {
const router = inject(Router);
const target = router.parseUrl(interpolateOpsRedirectTarget(template, params));
target.queryParams = { ...target.queryParams, ...extraQueryParams, ...queryParams };
target.fragment = fragment ?? null;
return target;
};
}
function interpolateOpsRedirectTarget(template: string, params: Params): string {
let target = template;
for (const [name, rawValue] of Object.entries(params ?? {})) {
const value = Array.isArray(rawValue) ? rawValue.join('/') : String(rawValue);
target = target.replaceAll(`:${name}`, encodeURIComponent(value));
}
return target;
}

View File

@@ -24,8 +24,21 @@ const LEGACY_PLATFORM_OPS_REDIRECTS: readonly LegacyPlatformOpsRedirect[] = [
},
{ path: 'jobs-queues', redirectTo: OPERATIONS_PATHS.jobsQueues },
{ path: 'feeds', redirectTo: OPERATIONS_PATHS.feedsAirgap },
{
path: 'feeds/version-locks',
redirectTo: `${OPERATIONS_PATHS.feedsAirgap}?tab=version-locks`,
},
{
path: 'feeds/airgap/import',
redirectTo: `${OPERATIONS_PATHS.feedsAirgap}?tab=airgap-bundles&action=import`,
},
{
path: 'feeds/airgap/export',
redirectTo: `${OPERATIONS_PATHS.feedsAirgap}?tab=airgap-bundles&action=export`,
},
{ path: 'feeds-airgap', redirectTo: OPERATIONS_PATHS.feedsAirgap },
{ path: 'offline-kit', redirectTo: OPERATIONS_PATHS.offlineKit },
{ path: 'offline-kit/:page', redirectTo: `${OPERATIONS_PATHS.offlineKit}/:page` },
{ path: 'health', redirectTo: OPERATIONS_PATHS.healthSlo },
{ path: 'health-slo', redirectTo: OPERATIONS_PATHS.healthSlo },
{ path: 'doctor', redirectTo: OPERATIONS_PATHS.doctor },
@@ -76,7 +89,7 @@ export const PLATFORM_OPS_ROUTES: Routes = [
}) => {
const router = inject(Router);
const target = router.parseUrl(interpolateLegacyTarget(template.redirectTo, params));
target.queryParams = { ...queryParams };
target.queryParams = { ...target.queryParams, ...queryParams };
target.fragment = fragment ?? null;
return target;
},

View File

@@ -0,0 +1,42 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { JwksManagementComponent } from '../../app/features/offline-kit/components/jwks-management.component';
describe('JwksManagementComponent', () => {
let fixture: ComponentFixture<JwksManagementComponent>;
let component: JwksManagementComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [JwksManagementComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(JwksManagementComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('shows the selected trust anchor in the detail panel', () => {
component.viewAnchor(component.trustAnchors()[1]);
fixture.detectChanges();
const detailPanel = fixture.nativeElement.querySelector('[data-testid="trust-anchor-detail"]') as HTMLElement;
expect(detailPanel).not.toBeNull();
expect(detailPanel.textContent).toContain('Enterprise Intermediate CA');
});
it('exports trust anchor details through a real download flow', () => {
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:anchor');
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
const anchorClickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.stub();
component.exportAnchor(component.trustAnchors()[0]);
expect(createObjectUrlSpy).toHaveBeenCalled();
expect(anchorClickSpy).toHaveBeenCalled();
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:anchor');
expect(component.statusMessage()).toContain('StellaOps Root CA');
});
});

View File

@@ -1,7 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { OfflineManifest } from '../../app/core/api/offline-kit.models';
import { BundleValidationResult, OfflineManifest } from '../../app/core/api/offline-kit.models';
import { OfflineModeService } from '../../app/core/services/offline-mode.service';
import { BundleManagementComponent } from '../../app/features/offline-kit/components/bundle-management.component';
import { offlineKitRoutes } from '../../app/features/offline-kit/offline-kit.routes';
@@ -65,4 +65,43 @@ describe('Offline Kit UI Integration (B24-001)', () => {
expect(root?.children?.some((child) => child.path === 'verify')).toBeTrue();
expect(root?.children?.some((child) => child.path === 'jwks')).toBeTrue();
});
it('loads the generated manifest into offline state when a valid bundle is added', () => {
const result: BundleValidationResult = {
valid: true,
errors: [],
warnings: [],
assetIntegrity: {
totalAssets: 6,
validAssets: 6,
invalidAssets: 0,
missingAssets: [],
hashMismatches: [],
},
signatureStatus: {
valid: true,
algorithm: 'ES256',
},
};
component.onManifestValidated(result);
expect(offlineStub.loadManifest).toHaveBeenCalled();
expect(component.activeBundleId()).not.toBeNull();
expect(component.loadedBundles()[0].status).toBe('active');
});
it('exports bundle summaries through a real download flow', () => {
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:test');
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
const anchorClickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.stub();
const bundle = component.loadedBundles()[0];
component.exportBundle(bundle);
expect(createObjectUrlSpy).toHaveBeenCalled();
expect(anchorClickSpy).toHaveBeenCalled();
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:test');
expect(component.lastExportMessage()).toContain(`v${bundle.version}`);
});
});

View File

@@ -0,0 +1,53 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VerificationCenterComponent } from '../../app/features/offline-kit/components/verification-center.component';
describe('VerificationCenterComponent', () => {
let fixture: ComponentFixture<VerificationCenterComponent>;
let component: VerificationCenterComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VerificationCenterComponent],
}).compileComponents();
fixture = TestBed.createComponent(VerificationCenterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('shows a detail panel for the selected verification history item', () => {
const buttons = Array.from(
(fixture.nativeElement as HTMLElement).querySelectorAll('.history-item .btn--ghost'),
) as HTMLButtonElement[];
buttons[1].click();
fixture.detectChanges();
const detailPanel = fixture.nativeElement.querySelector('[data-testid="verification-detail-panel"]') as HTMLElement;
expect(detailPanel).not.toBeNull();
expect(detailPanel.textContent).toContain('audit-bundle-2025-01-14.zip');
});
it('re-verifies the latest bundle by prepending a fresh history record', () => {
const initialLength = component.history().length;
component.verifyLastBundle();
expect(component.history().length).toBe(initialLength + 1);
expect(component.history()[0].id).toContain('recheck');
});
it('exports a verification report through a real download flow', () => {
const createObjectUrlSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:verification');
const revokeObjectUrlSpy = spyOn(URL, 'revokeObjectURL');
const anchorClickSpy = spyOn(HTMLAnchorElement.prototype, 'click').and.stub();
component.exportReport(component.history()[0]);
expect(createObjectUrlSpy).toHaveBeenCalled();
expect(anchorClickSpy).toHaveBeenCalled();
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:verification');
expect(component.exportMessage()).toContain(component.history()[0].bundleName);
});
});

View File

@@ -40,4 +40,13 @@ describe('PlatformFeedsAirgapPageComponent (platform-ops)', () => {
expect(component.tab()).toBe('version-locks');
});
it('tracks the airgap action from query params for canonical action links', () => {
queryParamMap$.next(convertToParamMap({ tab: 'airgap-bundles', action: 'import' }));
fixture.detectChanges();
expect(component.tab()).toBe('airgap-bundles');
expect(component.airgapAction()).toBe('import');
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Import workflow selected.');
});
});

View File

@@ -28,12 +28,16 @@ describe('Platform and Operations route contracts', () => {
const aliases = OPS_ROUTES.filter((route) => route.redirectTo);
expect(aliases.map((route) => route.path)).toEqual([
'feeds',
'feeds/version-locks',
'feeds/airgap/import',
'feeds/airgap/export',
'feeds-airgap',
'airgap',
'health-slo',
'signals',
'scheduler',
'offline-kit',
'offline-kit/:page',
'quotas',
'packs',
]);
@@ -82,8 +86,12 @@ describe('Platform and Operations route contracts', () => {
'data-integrity/:section',
'jobs-queues',
'feeds',
'feeds/version-locks',
'feeds/airgap/import',
'feeds/airgap/export',
'feeds-airgap',
'offline-kit',
'offline-kit/:page',
'health',
'health-slo',
'doctor',

View File

@@ -0,0 +1,167 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
const adminSession: StubAuthSession = {
subjectId: 'offline-ops-e2e-user',
tenant: 'tenant-default',
scopes: [
'admin',
'ui.read',
'ui.admin',
'orch:read',
'orch:operate',
'health:read',
'notify.viewer',
'policy:read',
],
};
const mockConfig = {
authority: {
issuer: '/authority',
clientId: 'stella-ops-ui',
authorizeEndpoint: '/authority/connect/authorize',
tokenEndpoint: '/authority/connect/token',
logoutEndpoint: '/authority/connect/logout',
redirectUri: 'https://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'https://127.0.0.1:4400/',
scope: 'openid profile email ui.read',
audience: '/gateway',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: '/authority',
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
gateway: '/gateway',
},
quickstartMode: true,
setup: 'complete',
};
async function fulfillJson(route: Route, body: unknown): Promise<void> {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
}
async function navigateClientSide(page: Page, target: string): Promise<void> {
await page.evaluate((url) => {
window.history.pushState({}, '', url);
window.dispatchEvent(new PopStateEvent('popstate', { state: window.history.state }));
}, target);
}
async function setupHarness(page: Page): Promise<void> {
await page.addInitScript((session) => {
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
}, adminSession);
await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
await page.route('**/config.json', (route) => fulfillJson(route, mockConfig));
await page.route('**/.well-known/openid-configuration', (route) =>
fulfillJson(route, {
issuer: 'https://127.0.0.1:4400/authority',
authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
}),
);
await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] }));
await page.route('**/console/profile**', (route) =>
fulfillJson(route, {
subjectId: adminSession.subjectId,
username: 'offline-ops-e2e',
displayName: 'Offline Ops E2E',
tenant: adminSession.tenant,
roles: ['admin'],
scopes: adminSession.scopes,
}),
);
await page.route('**/console/token/introspect**', (route) =>
fulfillJson(route, {
active: true,
tenant: adminSession.tenant,
subject: adminSession.subjectId,
scopes: adminSession.scopes,
}),
);
await page.route('**/api/v2/context/regions', (route) =>
fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }]),
);
await page.route('**/api/v2/context/environments**', (route) =>
fulfillJson(route, [
{
environmentId: 'prod',
regionId: 'eu-west',
environmentType: 'prod',
displayName: 'Prod',
sortOrder: 1,
enabled: true,
},
]),
);
await page.route('**/api/v2/context/preferences', (route) =>
fulfillJson(route, {
tenantId: adminSession.tenant,
actorId: adminSession.subjectId,
regions: ['eu-west'],
environments: ['prod'],
timeWindow: '24h',
stage: 'all',
updatedAt: '2026-03-08T12:00:00Z',
updatedBy: adminSession.subjectId,
}),
);
await page.route('**/doctor/api/v1/doctor/trends**', (route) => fulfillJson(route, []));
await page.route('**/api/v1/approvals**', (route) => fulfillJson(route, []));
await page.route('**/health', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'ok' }),
}),
);
}
test.beforeEach(async ({ page }) => {
await setupHarness(page);
});
test('offline kit exposes working canonical shortcuts and child routes', async ({ page }) => {
await page.goto('/ops/operations/offline-kit', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: 'Offline Kit Management' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Evidence Exports' })).toBeVisible();
await page.locator('a.tab-link', { hasText: 'Bundles' }).click();
await expect(page).toHaveURL(/\/ops\/operations\/offline-kit\/bundles$/);
await expect(page.getByText('Load New Bundle')).toBeVisible();
});
test('legacy offline aliases resolve into canonical operations routes', async ({ page }) => {
await page.goto('/ops/operations', { waitUntil: 'networkidle' });
await navigateClientSide(page, '/platform/ops/offline-kit/bundles?from=legacy');
await expect(page).toHaveURL(/\/ops\/operations\/offline-kit\/bundles\?from=legacy$/);
await expect(page.getByText('Bundle Management')).toBeVisible();
await navigateClientSide(page, '/ops/feeds/airgap/import');
await expect(page).toHaveURL(/\/ops\/operations\/feeds-airgap/);
await expect
.poll(() => {
const currentUrl = new URL(page.url());
return `${currentUrl.searchParams.get('tab')}:${currentUrl.searchParams.get('action')}`;
})
.toBe('airgap-bundles:import');
await expect(page.getByTestId('feeds-airgap-action-banner')).toContainText('Import workflow selected.');
});