|
| 1 | +// SPDX-License-Identifier: EUPL-1.2 |
| 2 | +// SPDX-FileCopyrightText: 2026 Conduction B.V. |
| 3 | +/** |
| 4 | + * E2e coverage file for openspec/specs/sbom-import/spec.md |
| 5 | + * |
| 6 | + * Coverage status |
| 7 | + * --------------- |
| 8 | + * The parse/replace/batch/matching CONTRACTS are pure server-side or |
| 9 | + * pure-function logic, verified by PHPUnit (`tests/Unit/SbomParserServiceTest`, |
| 10 | + * `tests/Unit/SbomImportServiceTest`, `tests/Unit/Controller/SbomControllerTest`) |
| 11 | + * and vitest (`tests/vitest/sbomVulnerabilityMatch.spec.js`) against real |
| 12 | + * CycloneDX fixtures — excluded from Playwright coverage below: |
| 13 | + * |
| 14 | + * @e2e sbom-import::a-valid-cyclonedx-16-document-parses-into-components |
| 15 | + * @e2e sbom-import::an-unsupported-bomformat-or-specversion-is-rejected |
| 16 | + * @e2e sbom-import::an-oversized-file-is-rejected-before-parsing |
| 17 | + * @e2e sbom-import::a-non-json-file-is-rejected |
| 18 | + * @e2e sbom-import::import-requires-admin-or-manage-acl |
| 19 | + * @e2e sbom-import::a-parsed-component-persists-with-its-moduleversie-relation |
| 20 | + * @e2e sbom-import::a-prior-replaces-trashed-rows-are-not-reprocessed |
| 21 | + * @e2e sbom-import::a-large-sbom-import-reports-incremental-progress |
| 22 | + * @e2e sbom-import::a-small-sbom-import-completes-without-a-progress-operation |
| 23 | + * @e2e sbom-import::a-component-with-vex-declared-cve-data-gets-a-confirmed-match |
| 24 | + * @e2e sbom-import::a-component-name-matching-a-module-scoped-vulnerability-gets-a-possible-match |
| 25 | + * @e2e sbom-import::a-name-match-outside-the-modules-own-vulnerabilities-is-not-surfaced |
| 26 | + * @e2e sbom-import::editing-a-vulnerability-changes-the-match-with-no-re-import |
| 27 | + * @e2e sbom-import::no-outbound-http-call-is-made-during-matching |
| 28 | + * @e2e sbom-import::a-successful-import-records-provenance-on-the-version |
| 29 | + * @e2e sbom-import::existing-versions-are-unaffected-by-the-schema-addition |
| 30 | + * |
| 31 | + * The two REMAINING scenarios describe the rendered Components tab and are |
| 32 | + * covered below by driving the REAL DOM (file input via `setInputFiles`, |
| 33 | + * NcSelect combobox, real button clicks) — no Vue `$data` patching: |
| 34 | + * |
| 35 | + * @e2e sbom-import::the-components-tab-reflects-an-import |
| 36 | + * @e2e sbom-import::a-version-with-no-imported-sbom-shows-an-empty-state |
| 37 | + * |
| 38 | + * Fixture setup (module + moduleVersie) is seeded through the OpenRegister |
| 39 | + * object API per the gate-19 program (setup only — assertions stay on the |
| 40 | + * rendered DOM); a real CycloneDX fixture file already used by the PHPUnit |
| 41 | + * suite (`tests/fixtures/sbom/cyclonedx-1.6-valid.json`, |
| 42 | + * `cyclonedx-1.5-valid.json`) is uploaded through the real file input. |
| 43 | + */ |
| 44 | + |
| 45 | +import { test, expect, type Page } from '@playwright/test' |
| 46 | +import * as path from 'path' |
| 47 | +import { |
| 48 | + newApiContext, |
| 49 | + resolveConfig, |
| 50 | + createObject, |
| 51 | + cleanupByToken, |
| 52 | + RUN_ID, |
| 53 | +} from './workflows/_fixtures' |
| 54 | + |
| 55 | +const FIXTURES_DIR = path.resolve(__dirname, '../fixtures/sbom') |
| 56 | +const CYCLONEDX_16 = path.join(FIXTURES_DIR, 'cyclonedx-1.6-valid.json') // 3 components |
| 57 | +const CYCLONEDX_15 = path.join(FIXTURES_DIR, 'cyclonedx-1.5-valid.json') // 2 components |
| 58 | + |
| 59 | +const MODULE_NAME = `E2E SBOM Module ${RUN_ID}` |
| 60 | + |
| 61 | +let moduleVersieId: string |
| 62 | + |
| 63 | +test.beforeAll(async () => { |
| 64 | + const ctx = await newApiContext() |
| 65 | + try { |
| 66 | + const config = await resolveConfig(ctx) |
| 67 | + const moduleId = await createObject(ctx, config.register, config.module_schema, { |
| 68 | + naam: MODULE_NAME, |
| 69 | + }) |
| 70 | + moduleVersieId = await createObject(ctx, config.register, config.moduleVersie_schema, { |
| 71 | + module: moduleId, |
| 72 | + versie: '1.0.0-e2e', |
| 73 | + }) |
| 74 | + } finally { |
| 75 | + await ctx.dispose() |
| 76 | + } |
| 77 | +}) |
| 78 | + |
| 79 | +test.afterAll(async () => { |
| 80 | + const ctx = await newApiContext() |
| 81 | + try { |
| 82 | + const config = await resolveConfig(ctx) |
| 83 | + await cleanupByToken(ctx, config, RUN_ID) |
| 84 | + } finally { |
| 85 | + await ctx.dispose() |
| 86 | + } |
| 87 | +}) |
| 88 | + |
| 89 | +/** Navigate to a moduleVersie's detail page and open the Components sidebar tab. */ |
| 90 | +async function openComponentsTab(page: Page): Promise<void> { |
| 91 | + await page.goto(`/apps/softwarecatalog/moduleversies/${moduleVersieId}`, { waitUntil: 'networkidle' }) |
| 92 | + await page.getByRole('tab', { name: 'Components' }).click() |
| 93 | +} |
| 94 | + |
| 95 | +// --------------------------------------------------------------------------- |
| 96 | +// Scenario: A version with no imported SBOM shows an empty state |
| 97 | +// @e2e sbom-import::a-version-with-no-imported-sbom-shows-an-empty-state |
| 98 | +// --------------------------------------------------------------------------- |
| 99 | +test( |
| 100 | + 'sbom-import empty-state: a freshly-created moduleVersie Components tab shows the empty state and upload control', |
| 101 | + async ({ page }) => { |
| 102 | + await openComponentsTab(page) |
| 103 | + |
| 104 | + await expect(page.getByTestId('sbom-empty')).toBeVisible({ timeout: 15000 }) |
| 105 | + await expect(page.getByTestId('sbom-file-input')).toBeVisible() |
| 106 | + await expect(page.getByTestId('sbom-import-button')).toBeVisible() |
| 107 | + |
| 108 | + // Summary tiles read zero — "no summary counts shown as non-zero". |
| 109 | + const summary = page.getByTestId('sbom-summary') |
| 110 | + await expect(summary).toContainText('0') |
| 111 | + }, |
| 112 | +) |
| 113 | + |
| 114 | +// --------------------------------------------------------------------------- |
| 115 | +// Scenario: The Components tab reflects an import |
| 116 | +// @e2e sbom-import::the-components-tab-reflects-an-import |
| 117 | +// |
| 118 | +// Also exercises re-import-replaces (design Decision 3): a second upload |
| 119 | +// with a different fixture leaves only the new set's 2 rows, not 3+2. |
| 120 | +// --------------------------------------------------------------------------- |
| 121 | +test( |
| 122 | + 'sbom-import upload-and-replace: uploading a CycloneDX file renders the component list and summary counts; a second import replaces the first', |
| 123 | + async ({ page }) => { |
| 124 | + await openComponentsTab(page) |
| 125 | + |
| 126 | + // First import: 3-component fixture. |
| 127 | + await page.getByTestId('sbom-file-input').setInputFiles(CYCLONEDX_16) |
| 128 | + await page.getByTestId('sbom-import-button').click() |
| 129 | + await expect(page.getByTestId('sbom-upload-success')).toBeVisible({ timeout: 20000 }) |
| 130 | + |
| 131 | + const table = page.getByTestId('sbom-component-table') |
| 132 | + await expect(table).toBeVisible() |
| 133 | + await expect(table.getByText('lodash')).toBeVisible() |
| 134 | + await expect(table.locator('tbody tr')).toHaveCount(3) |
| 135 | + |
| 136 | + const summary = page.getByTestId('sbom-summary') |
| 137 | + await expect(summary).toContainText('3') |
| 138 | + |
| 139 | + // Provenance line renders after a successful import. |
| 140 | + await expect(page.getByTestId('sbom-provenance')).toBeVisible() |
| 141 | + |
| 142 | + // Second import (different fixture, 2 components) REPLACES the first — |
| 143 | + // only the new set is live afterwards. |
| 144 | + await page.getByTestId('sbom-file-input').setInputFiles(CYCLONEDX_15) |
| 145 | + await page.getByTestId('sbom-import-button').click() |
| 146 | + await expect(page.getByTestId('sbom-upload-success')).toBeVisible({ timeout: 20000 }) |
| 147 | + |
| 148 | + await expect(table.locator('tbody tr')).toHaveCount(2) |
| 149 | + await expect(table.getByText('lodash')).toHaveCount(0) |
| 150 | + await expect(table.getByText('express')).toBeVisible() |
| 151 | + }, |
| 152 | +) |
0 commit comments