Skip to content

Commit 6a0c10e

Browse files
committed
docs(sbom-import): feature doc + Playwright e2e coverage
docs/features/sbom-import.md describes the upload flow, replace-on-reimport semantics, what gets stored, and the confirmed/possible vulnerability-match model. tests/e2e/sbom-import.spec.ts covers the two UI-only scenarios (empty state; Components tab reflects an import + a second import replaces the first) by driving the real file input, upload button, and rendered table via data-testid selectors; every other scenario is tagged @e2e with a pointer to its existing PHPUnit/vitest coverage. NOTE: this spec was written and type-checks / lists correctly under `playwright test --list`, but was not executed against a live Nextcloud instance in this session (no docker environment was started for this resume) — screenshots for the docs page are likewise deferred to a live-capture pass. @SPEC openspec/specs/sbom-import/spec.md
1 parent 2e186ab commit 6a0c10e

2 files changed

Lines changed: 285 additions & 0 deletions

File tree

docs/features/sbom-import.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
3+
- SPDX-License-Identifier: EUPL-1.2
4+
-->
5+
6+
# SBOM import
7+
8+
Imports a Software Bill of Materials (SBOM) — CycloneDX 1.5/1.6 JSON, with
9+
SPDX 2.3 JSON as an optional second format — for a specific `moduleVersie`
10+
(a released version of an application), parsing its components into
11+
`sbomComponent` OpenRegister objects and surfacing them on a **Components**
12+
tab with licenses, summary counts, and a render-time cross-reference against
13+
the existing `kwetsbaarheid` (vulnerability) register.
14+
15+
Specification: [`openspec/specs/sbom-import/spec.md`](../../openspec/specs/sbom-import/spec.md).
16+
17+
## Uploading an SBOM
18+
19+
On a module version's detail page, open the **Components** sidebar tab.
20+
Choose a format (CycloneDX or SPDX, both JSON) and a file, then **Import
21+
SBOM**:
22+
23+
```
24+
POST /apps/softwarecatalog/api/moduleversies/{moduleVersieUuid}/sbom
25+
multipart/form-data: sbomFile=<file>, format=cyclonedx-json|spdx-json
26+
```
27+
28+
The upload is rejected — before the parser ever runs — when it exceeds the
29+
configured maximum size (10 MB by default) or is not valid JSON. Importing
30+
requires admin group membership, or membership of a manage-tier group
31+
**and** manage-ACL (RBAC read) on the version's parent application; anyone
32+
else gets a 403 and no objects change.
33+
34+
```json
35+
{
36+
"success": true,
37+
"operationId": null,
38+
"moduleVersieUuid": "b2c3d4e5-...",
39+
"componentCount": 3,
40+
"previousComponentCount": 0,
41+
"distinctLicenseCount": 2,
42+
"vulnerabilityPairCount": 0,
43+
"sbomFormat": "cyclonedx-json",
44+
"sbomFileName": "sbom.json"
45+
}
46+
```
47+
48+
## Re-import replaces, never accumulates
49+
50+
Importing a second SBOM for the same version **replaces** the previous
51+
component set: the previous live `sbomComponent` objects are soft-deleted
52+
and the newly parsed set is created. Already-trashed rows from an earlier
53+
replace are never re-queried or re-deleted (OpenRegister's default search
54+
already excludes `_deleted` rows). If the create step fails partway through,
55+
the version is left with no live component set rather than a mixed
56+
old/new one — a re-run of the import starts clean either way. This mirrors
57+
the same replace-not-accumulate model used elsewhere in this app rather than
58+
introducing an import-history/audit-log concept.
59+
60+
Both the soft-delete and the create step run in bounded batches (~100
61+
objects per OpenRegister call). Imports whose parsed component count
62+
exceeds 50 start a `progress-tracking` operation, update it per batch, and
63+
complete it — the operation id is returned in the response so the frontend
64+
can poll `GET .../sbom?operationId=...` for `{ phase, percentage,
65+
processed_items }`. Smaller imports complete synchronously and the response
66+
already carries the final counts.
67+
68+
## What gets stored
69+
70+
Each parsed component persists as one `sbomComponent` OpenRegister object,
71+
related to its `moduleVersie`:
72+
73+
| Field | Source |
74+
|---|---|
75+
| `name`, `version` | CycloneDX/SPDX component name + version |
76+
| `purl` | Package URL (`pkg:...`) |
77+
| `licenses` | SPDX license id(s)/expression(s), or free text |
78+
| `type` | CycloneDX component type (`library`, `application`, …) |
79+
| `hashes` | Informational file hashes — never used for matching |
80+
| `bomRef` | CycloneDX `bom-ref` — within-import traceability only |
81+
| `vexCveIds` | CVE ids the SBOM's own VEX block associates with this component's `bom-ref` — a raw fact from the source document, not a stored vulnerability match |
82+
83+
Three optional provenance fields are set on the `moduleVersie` itself on
84+
every successful import: `sbomLastImportedAt`, `sbomFormat`, `sbomFileName`
85+
— shown as a "last imported ⟨date⟩ from ⟨file⟩" line on the Components tab.
86+
87+
## Vulnerability matching — computed, never stored
88+
89+
The Components tab cross-references each imported component against the
90+
existing `kwetsbaarheid` register using two bounded, local strategies —
91+
never an outbound HTTP call to an external advisory feed (OSV.dev, NVD, …):
92+
93+
1. **Confirmed match** — a component's VEX-extracted `vexCveIds` compared,
94+
case-insensitively, against `kwetsbaarheid.cveCode`.
95+
2. **Possible match** — the component's `name` (or the package segment of
96+
its `purl`) compared, case-insensitively (substring), against
97+
`kwetsbaarheid.naam`, scoped to `kwetsbaarheid` records whose `modules`
98+
already reference the version's parent `module`. A same-name
99+
vulnerability recorded against a *different* application never surfaces
100+
here.
101+
102+
Both matches are computed at render time by
103+
[`src/utils/sbomVulnerabilityMatch.js`](../../src/utils/sbomVulnerabilityMatch.js)
104+
— nothing is written back to either `sbomComponent` or `kwetsbaarheid`.
105+
Editing a `kwetsbaarheid`'s `cveCode`/`naam` after an import changes the
106+
match set on next render, with no re-import required. This feeds
107+
`module-vulnerability-tracking` rather than forking a parallel vulnerability
108+
model.
109+
110+
## Components tab
111+
112+
The **Components** tab on a module version's detail page (`SbomComponentsPanel`)
113+
shows:
114+
115+
- Summary counts — total components, distinct licenses, matched
116+
vulnerabilities.
117+
- The "last imported" provenance line, when an import has happened.
118+
- The upload control (format select + file input + Import button).
119+
- The component table (name, version, package URL, licenses) with a
120+
**Confirmed match** / **Possible match** badge per matched component.
121+
- An empty state with the upload control when no SBOM has been imported yet.
122+
123+
## Out of scope
124+
125+
- Outbound calls to an external vulnerability/advisory service — that
126+
integration, if built, belongs in `openconnector` (per
127+
`feedback_integrations-not-leaves`).
128+
- SBOM generation/export — this feature only imports.
129+
- License-policy evaluation (allow/deny lists, obligations) — only the raw
130+
license identifiers are captured.
131+
- Transitive dependency graphs — the component **list** only; `bomRef` is
132+
captured for future use but no dependency-edge graph is parsed or
133+
rendered.

tests/e2e/sbom-import.spec.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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

Comments
 (0)