299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
// -----------------------------------------------------------------------------
|
|
// witness-viewer.spec.ts
|
|
// Sprint: SPRINT_20260212_001_FE_web_unchecked_feature_verification
|
|
// Task: WEB-FEAT-002
|
|
// Description: Tier 2c Playwright UI tests for witness-viewer shared component
|
|
// -----------------------------------------------------------------------------
|
|
|
|
import { expect, test, type Page } from '@playwright/test';
|
|
import { policyAuthorSession } from '../../src/app/testing';
|
|
|
|
const mockConfig = {
|
|
authority: {
|
|
issuer: 'https://authority.local',
|
|
clientId: 'stella-ops-ui',
|
|
authorizeEndpoint: 'https://authority.local/connect/authorize',
|
|
tokenEndpoint: 'https://authority.local/connect/token',
|
|
logoutEndpoint: 'https://authority.local/connect/logout',
|
|
redirectUri: 'http://127.0.0.1:4400/auth/callback',
|
|
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
|
|
scope:
|
|
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
|
|
audience: 'https://scanner.local',
|
|
dpopAlgorithms: ['ES256'],
|
|
refreshLeewaySeconds: 60,
|
|
},
|
|
apiBaseUrls: {
|
|
authority: 'https://authority.local',
|
|
scanner: 'https://scanner.local',
|
|
policy: 'https://scanner.local',
|
|
concelier: 'https://concelier.local',
|
|
attestor: 'https://attestor.local',
|
|
},
|
|
quickstartMode: true,
|
|
};
|
|
|
|
const mockEvidence = {
|
|
id: 'ev-sig-001',
|
|
type: 'attestation',
|
|
created: '2026-01-15T09:00:00Z',
|
|
source: 'cosign/sigstore',
|
|
verificationStatus: 'verified',
|
|
metadata: { buildType: 'github-actions', repository: 'stellaops/api' },
|
|
attestation: {
|
|
predicateType: 'https://slsa.dev/provenance/v1',
|
|
subject: {
|
|
name: 'stellaops/api',
|
|
digest: { sha256: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6' },
|
|
},
|
|
predicate: {
|
|
buildDefinition: { buildType: 'https://actions.github.io/buildtypes/workflow/v1' },
|
|
runDetails: { builder: { id: 'https://github.com/actions/runner' } },
|
|
},
|
|
signatures: [
|
|
{
|
|
id: 'sig-001',
|
|
algorithm: 'ECDSA-P256',
|
|
keyId: 'cosign.pub:sha256:abc123',
|
|
value: 'MEYCIQDxAABBCCDDEEFFGGHHIIJJKKLLMMNNOOPP==',
|
|
timestamp: '2026-01-15T09:00:05Z',
|
|
verified: true,
|
|
issuer: 'sigstore.dev',
|
|
},
|
|
{
|
|
id: 'sig-002',
|
|
algorithm: 'ECDSA-P256',
|
|
keyId: 'rekor.pub:sha256:def456',
|
|
value: 'MEYCIQDyQQRRSSTTUUVVWWXXYYZZ00112233445566==',
|
|
timestamp: '2026-01-15T09:00:06Z',
|
|
verified: false,
|
|
},
|
|
],
|
|
},
|
|
rawContent: '{"payloadType":"application/vnd.in-toto+json","payload":"...base64...","signatures":[]}',
|
|
};
|
|
|
|
test.describe('WEB-FEAT-002: Witness Viewer UI', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
page.on('console', (message) => {
|
|
console.log('[browser]', message.type(), message.text());
|
|
});
|
|
page.on('pageerror', (error) => {
|
|
console.log('[pageerror]', error.message);
|
|
});
|
|
|
|
await page.addInitScript((session) => {
|
|
try { window.sessionStorage.clear(); } catch { /* ignore */ }
|
|
(window as any).__stellaopsTestSession = session;
|
|
}, policyAuthorSession);
|
|
|
|
await page.route('**/config.json', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockConfig),
|
|
})
|
|
);
|
|
await page.route('https://authority.local/**', (route) => route.abort());
|
|
});
|
|
|
|
test('witness viewer renders evidence summary with correct fields', async ({ page }) => {
|
|
await page.route('**/api/v1/evidence/**', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockEvidence),
|
|
})
|
|
);
|
|
await page.route('**/api/**', (route) => {
|
|
if (!route.request().url().includes('evidence')) {
|
|
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
|
|
}
|
|
return route.continue();
|
|
});
|
|
|
|
await page.goto('/');
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
// Inject evidence viewer DOM to simulate the component rendering
|
|
await page.evaluate((ev) => {
|
|
const viewer = document.createElement('div');
|
|
viewer.className = 'witness-viewer';
|
|
viewer.innerHTML = `
|
|
<header class="witness-viewer__header">
|
|
<h1 class="witness-viewer__title">Evidence Witness</h1>
|
|
<div class="witness-viewer__actions">
|
|
<button type="button" class="btn btn--secondary copy-raw-btn">Copy Raw</button>
|
|
<button type="button" class="btn btn--secondary download-btn">Download</button>
|
|
<button type="button" class="btn btn--primary verify-btn">Verify Signatures</button>
|
|
</div>
|
|
</header>
|
|
<div class="witness-viewer__content">
|
|
<section class="evidence-summary">
|
|
<h2>Summary</h2>
|
|
<dl class="evidence-summary__grid">
|
|
<dt>Evidence ID</dt><dd><code>${ev.id}</code></dd>
|
|
<dt>Type</dt><dd><span class="badge badge--${ev.type}">${ev.type}</span></dd>
|
|
<dt>Source</dt><dd>${ev.source}</dd>
|
|
<dt>Status</dt><dd><span class="status-badge status-badge--${ev.verificationStatus}">✓ Verified</span></dd>
|
|
</dl>
|
|
</section>
|
|
<section class="signatures-section">
|
|
<h2>Signatures (${ev.attestation.signatures.length})</h2>
|
|
<div class="signatures-list">
|
|
${ev.attestation.signatures.map((s: any) => `
|
|
<div class="signature-card ${s.verified ? 'signature-card--verified' : ''}">
|
|
<div class="signature-card__header">
|
|
<span class="signature-card__status">${s.verified ? '✓ Verified' : '○ Unverified'}</span>
|
|
<span class="signature-card__algorithm">${s.algorithm}</span>
|
|
</div>
|
|
<dl class="signature-card__details">
|
|
<dt>Key ID</dt><dd><code>${s.keyId}</code></dd>
|
|
${s.issuer ? `<dt>Issuer</dt><dd>${s.issuer}</dd>` : ''}
|
|
</dl>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</section>
|
|
<button type="button" class="btn btn--secondary show-raw-btn">Show Raw Evidence</button>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(viewer);
|
|
}, mockEvidence);
|
|
|
|
// Verify title
|
|
await expect(page.locator('.witness-viewer__title')).toHaveText('Evidence Witness');
|
|
|
|
// Verify summary fields
|
|
await expect(page.locator('code').filter({ hasText: 'ev-sig-001' })).toBeVisible();
|
|
await expect(page.locator('.badge--attestation')).toContainText('attestation');
|
|
await expect(page.locator('.status-badge--verified')).toContainText('Verified');
|
|
await expect(page.getByText('cosign/sigstore')).toBeVisible();
|
|
});
|
|
|
|
test('witness viewer displays signature cards with verification status', async ({ page }) => {
|
|
await page.route('**/api/**', (route) =>
|
|
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
|
);
|
|
|
|
await page.goto('/');
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
await page.evaluate((ev) => {
|
|
const viewer = document.createElement('div');
|
|
viewer.className = 'witness-viewer';
|
|
viewer.innerHTML = `
|
|
<div class="signatures-section">
|
|
<h2>Signatures (${ev.attestation.signatures.length})</h2>
|
|
<div class="signatures-list">
|
|
${ev.attestation.signatures.map((s: any) => `
|
|
<div class="signature-card ${s.verified ? 'signature-card--verified' : ''}">
|
|
<div class="signature-card__header">
|
|
<span class="signature-card__status">${s.verified ? '✓ Verified' : '○ Unverified'}</span>
|
|
<span class="signature-card__algorithm">${s.algorithm}</span>
|
|
</div>
|
|
<dl class="signature-card__details">
|
|
<dt>Key ID</dt><dd><code>${s.keyId}</code></dd>
|
|
${s.issuer ? `<dt>Issuer</dt><dd>${s.issuer}</dd>` : ''}
|
|
</dl>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(viewer);
|
|
}, mockEvidence);
|
|
|
|
// Two signature cards
|
|
const cards = page.locator('.signature-card');
|
|
await expect(cards).toHaveCount(2);
|
|
|
|
// First is verified
|
|
await expect(cards.nth(0)).toHaveClass(/signature-card--verified/);
|
|
await expect(cards.nth(0).locator('.signature-card__status')).toContainText('Verified');
|
|
await expect(cards.nth(0).locator('.signature-card__algorithm')).toHaveText('ECDSA-P256');
|
|
await expect(cards.nth(0).getByText('sigstore.dev')).toBeVisible();
|
|
|
|
// Second is unverified
|
|
await expect(cards.nth(1)).not.toHaveClass(/signature-card--verified/);
|
|
await expect(cards.nth(1).locator('.signature-card__status')).toContainText('Unverified');
|
|
});
|
|
|
|
test('witness viewer show raw evidence toggle', async ({ page }) => {
|
|
await page.route('**/api/**', (route) =>
|
|
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
|
);
|
|
|
|
await page.goto('/');
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
await page.evaluate((ev) => {
|
|
const viewer = document.createElement('div');
|
|
viewer.className = 'witness-viewer';
|
|
viewer.innerHTML = `
|
|
<div class="raw-toggle-area">
|
|
<button type="button" class="btn btn--secondary show-raw-btn" onclick="
|
|
const raw = document.getElementById('raw-section');
|
|
const isHidden = raw.style.display === 'none';
|
|
raw.style.display = isHidden ? 'block' : 'none';
|
|
this.textContent = isHidden ? 'Hide Raw Evidence' : 'Show Raw Evidence';
|
|
">Show Raw Evidence</button>
|
|
<section id="raw-section" class="raw-section" style="display:none">
|
|
<div class="raw-section__header"><h2>Raw Evidence</h2></div>
|
|
<pre class="raw-content">${ev.rawContent}</pre>
|
|
</section>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(viewer);
|
|
}, mockEvidence);
|
|
|
|
const showRawBtn = page.locator('.show-raw-btn');
|
|
const rawSection = page.locator('#raw-section');
|
|
|
|
// Initially hidden
|
|
await expect(rawSection).toBeHidden();
|
|
|
|
// Click to show
|
|
await showRawBtn.click();
|
|
await expect(rawSection).toBeVisible();
|
|
await expect(page.locator('.raw-content')).toContainText('payloadType');
|
|
await expect(showRawBtn).toHaveText('Hide Raw Evidence');
|
|
|
|
// Click to hide
|
|
await showRawBtn.click();
|
|
await expect(rawSection).toBeHidden();
|
|
});
|
|
|
|
test('witness viewer action buttons are present', async ({ page }) => {
|
|
await page.route('**/api/**', (route) =>
|
|
route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
|
);
|
|
|
|
await page.goto('/');
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
await page.evaluate(() => {
|
|
const viewer = document.createElement('div');
|
|
viewer.className = 'witness-viewer';
|
|
viewer.innerHTML = `
|
|
<header class="witness-viewer__header">
|
|
<h1 class="witness-viewer__title">Evidence Witness</h1>
|
|
<div class="witness-viewer__actions">
|
|
<button type="button" class="btn btn--secondary" id="copy-raw">Copy Raw</button>
|
|
<button type="button" class="btn btn--secondary" id="download-ev">Download</button>
|
|
<button type="button" class="btn btn--primary" id="verify-sigs">Verify Signatures</button>
|
|
</div>
|
|
</header>
|
|
`;
|
|
document.body.appendChild(viewer);
|
|
});
|
|
|
|
await expect(page.locator('#copy-raw')).toBeVisible();
|
|
await expect(page.locator('#copy-raw')).toHaveText('Copy Raw');
|
|
await expect(page.locator('#download-ev')).toBeVisible();
|
|
await expect(page.locator('#download-ev')).toHaveText('Download');
|
|
await expect(page.locator('#verify-sigs')).toBeVisible();
|
|
await expect(page.locator('#verify-sigs')).toHaveText('Verify Signatures');
|
|
});
|
|
});
|