diff --git a/lib/Settings/softwarecatalogus_register.json b/lib/Settings/softwarecatalogus_register.json index 6d43542c..d3a058b6 100644 --- a/lib/Settings/softwarecatalogus_register.json +++ b/lib/Settings/softwarecatalogus_register.json @@ -3,8 +3,8 @@ "info": { "title": "Software Catalog Register", "description": "Register containing AMEF and Voorzieningen schemas for the VNG Software Catalog application. This configuration includes schemas for applications, services, organizations, and compliance tracking.", - "version": "2.4.3", - "changelog": "2.4.3: Re-authored Dutch schema-level titles to English (dienst, kwetsbaarheid, contactpersoon, organisatie, gebruik, koppeling, beoordeeling, module, bioMaatregel, moduleVersie, sbomComponent); schema keys unchanged, Dutch labels now come from the app's l10n translation files. 2.4.2: Moved SBOM provenance properties (sbomLastImportedAt, sbomFormat, sbomFileName, sbomComponents) from the organisatie schema to moduleVersie, where SBOM imports actually record them; without this the moduleVersie magic table lacked the columns so recordProvenance() writes were silently dropped and the import-status endpoint always reported 'never imported'. 2.4.1: Re-authored Dutch schema property titles to English (property keys unchanged); Dutch labels now come from the app's l10n translation files." + "version": "2.4.4", + "changelog": "2.4.4: organization.status was left behind by #520's enum translation — its `default` was still 'Concept' and its whole x-openregister-lifecycle block still named Concept/Actief/Deactief, while the enum and the migrated rows are Draft/Active/Inactive/merged. A default outside its own enum makes every newly created organisation fall out of the Organisations index filter, and a lifecycle whose from/to values match no row offers no transition at all — neither raises an error. The schema version is bumped with it because a deployed version >= the declared one makes the import SKIP, and OpenRegister's schemaContentDiffers() escape hatch compares only properties/required/authorization — never `configuration` — so a lifecycle-only edit would never have deployed. 2.4.3: Re-authored Dutch schema-level titles to English (dienst, kwetsbaarheid, contactpersoon, organisatie, gebruik, koppeling, beoordeeling, module, bioMaatregel, moduleVersie, sbomComponent); schema keys unchanged, Dutch labels now come from the app's l10n translation files. 2.4.2: Moved SBOM provenance properties (sbomLastImportedAt, sbomFormat, sbomFileName, sbomComponents) from the organisatie schema to moduleVersie, where SBOM imports actually record them; without this the moduleVersie magic table lacked the columns so recordProvenance() writes were silently dropped and the import-status endpoint always reported 'never imported'. 2.4.1: Re-authored Dutch schema property titles to English (property keys unchanged); Dutch labels now come from the app's l10n translation files." }, "x-openregister": { "type": "application", @@ -2023,7 +2023,7 @@ "x-schema-org": "schema:Organization", "title": "Organization", "description": "An organisation that offers provisions. Absorbs the former ArchiMate `organization` schema: its identity and statutory identifiers (name, summary, description, oin, tooi, rsin, pki, image) and its ArchiMate round-trip `xml` are declared here, so there is one organisation schema rather than two that shared no property.", - "version": "0.5.0", + "version": "0.5.1", "omschrijving": "", "icon": "OfficeBuildingOutline", "required": [ @@ -2318,7 +2318,7 @@ "description": "Geeft aan of de VNG de organisatie positief beoordeeld heeft voor toegang tot de Softwarecatalogus", "title": "Status", "type": "string", - "default": "Concept", + "default": "Draft", "visible": false, "hideOnCollection": true, "facetable": false, @@ -2618,32 +2618,33 @@ "fair": 0.5 } }, + "$comment-lifecycle": "🔴 THE STATE NAMES HERE ARE THE ENUM'S VALUES, NOT LABELS. #520 translated the status enum (Concept/Actief/Deactief -> Draft/Active/Inactive) and migrated the stored rows, but left this block in Dutch, so `initial`, `final` and every `from`/`to` named a value no row can hold: the initial state wrote an out-of-enum value and no transition could ever match. Nothing errors — a transition whose `from` matches nothing is simply never offered — so it reads as a lifecycle nobody uses.", "x-openregister-lifecycle": { "field": "status", - "initial": "Concept", + "initial": "Draft", "final": [ - "Deactief" + "Inactive" ], "transitions": { "activate": { "from": [ - "Concept" + "Draft" ], - "to": "Actief", + "to": "Active", "description": "Activate the organisation." }, "deactivate": { "from": [ - "Actief" + "Active" ], - "to": "Deactief", + "to": "Inactive", "description": "Deactivate the organisation." }, "reactivate": { "from": [ - "Deactief" + "Inactive" ], - "to": "Actief", + "to": "Active", "description": "Re-activate a deactivated organisation." } } diff --git a/src/components/sbom/SbomComponentsPanel.vue b/src/components/sbom/SbomComponentsPanel.vue index 61773493..1fc65de0 100644 --- a/src/components/sbom/SbomComponentsPanel.vue +++ b/src/components/sbom/SbomComponentsPanel.vue @@ -180,7 +180,7 @@ import { matchComponents } from '../../utils/sbomVulnerabilityMatch.js' * @license EUPL-1.2 * * ModuleversieDetail "Components" sidebar tab: renders the imported - * `sbomComponent` set for a `moduleVersie` (name/version/purl/licenses) with + * `sbomComponent` set for a `moduleVersion` (name/version/purl/licenses) with * summary counts (total, distinct licenses, matched vulnerabilities) and an * upload control that posts a CycloneDX/SPDX JSON file to `SbomController`. * Re-importing REPLACES the previous set server-side (design Decision 3); @@ -205,7 +205,7 @@ export default { }, props: { - /** The moduleVersie OR object uuid (passed by CnObjectSidebar as `objectId`). */ + /** The moduleVersion OR object uuid (passed by CnObjectSidebar as `objectId`). */ objectId: { type: [String, Number], default: null, @@ -248,13 +248,22 @@ export default { computed: { /** - * The moduleVersie being inspected: the active object, else looked up + * The module version being inspected: the active object, else looked up * by objectId in the fetched collection. * - * @return {object|null} The moduleVersie record. + * ⚠️ THE NAME IS A CONTRACT. `moduleVersionData` below reads this + * property by name. When the schema slug `moduleVersie` was translated + * to `moduleVersion` the consumer was renamed and this producer was not, + * so the consumer resolved `undefined`, returned an empty data bag, and + * every derived value went quietly empty — no error, no warning, and the + * provenance line simply never rendered. Renaming one half of a + * producer/consumer pair is a silent break; `tests/vitest/ + * sbomProvenanceLabel.spec.js` fails when the pair drifts again. + * + * @return {object|null} The module version record. * @spec openspec/specs/sbom-import/spec.md#requirement-moduleversie-records-sbom-import-provenance */ - moduleVersie() { + moduleVersion() { const active = typeof objectStore.getActiveObject === 'function' ? objectStore.getActiveObject('moduleVersion') @@ -279,12 +288,12 @@ export default { }, /** - * The moduleVersie's raw data bag. + * The module version's raw data bag. * * @return {object} The property bag. * @spec openspec/specs/sbom-import/spec.md#requirement-imported-components-persist-as-openregister-objects-scoped-to-a-moduleversie */ - moduleVersieData() { + moduleVersionData() { if (!this.moduleVersion) { return {} } @@ -292,18 +301,18 @@ export default { }, /** - * The moduleVersie's parent module uuid — scopes the possible-match + * The module version's parent module uuid — scopes the possible-match * heuristic (design Decision 6, never a catalogue-wide scan). * * @return {string} The parent module uuid, or ''. * @spec openspec/specs/sbom-import/spec.md#requirement-components-are-matched-against-existing-kwetsbaarheden-without-external-calls */ parentModuleId() { - return resolveUuid(this.moduleVersieData.module) + return resolveUuid(this.moduleVersionData.module) }, /** - * The imported `sbomComponent` set for this moduleVersie, sorted by name. + * The imported `sbomComponent` set for this moduleVersion, sorted by name. * * @return {Array} The component records. * @spec openspec/specs/sbom-import/spec.md#requirement-imported-components-persist-as-openregister-objects-scoped-to-a-moduleversie @@ -428,7 +437,7 @@ export default { * @spec openspec/specs/sbom-import/spec.md#requirement-moduleversie-records-sbom-import-provenance */ lastImportedLabel() { - const data = this.moduleVersieData + const data = this.moduleVersionData if (!data.sbomLastImportedAt) { return '' } @@ -547,7 +556,7 @@ export default { /** * Upload the selected file to `SbomController::importSbom`. On success, - * refetches the sbomComponent and moduleVersie collections so the + * refetches the sbomComponent and moduleVersion collections so the * table/summary/provenance reflect the REPLACED set (design Decision * 3) with no page reload. * diff --git a/src/manifest.json b/src/manifest.json index 6c8bc8ca..f36b2708 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -179,11 +179,11 @@ "viewMode": "cards", "cardComponent": "OrganisatieCard", "columns": ["name", "type", "status", "website"], - "filter": { "status": ["Concept", "Actief", "Deactief"] }, + "filter": { "status": ["Draft", "Active", "Inactive"] }, "sidebar": { "enabled": true, "showMetadata": true }, "documentationUrl": "https://softwarecatalog.conduction.nl" }, - "_note": "Decomposed from the bespoke OrganisatieIndexView to a standard type:index (Phase 8): renders organisatie OR objects as a card grid via config.cardComponent=OrganisatieCard. The card keeps its inline contactpersoon toggle internally; CnIndexPage provides the toolbar, search, view-toggle and create/edit/delete dialogs. config.filter excludes organisation-merge tombstones (status='samengevoegd') from the default listing per the organisation-merge spec — a merged-away source stays readable by direct UUID lookup (OrganisatieDetail route) but never appears in this index." + "_note": "Decomposed from the bespoke OrganisatieIndexView to a standard type:index (Phase 8): renders organisatie OR objects as a card grid via config.cardComponent=OrganisatieCard. The card keeps its inline contactpersoon toggle internally; CnIndexPage provides the toolbar, search, view-toggle and create/edit/delete dialogs. config.filter excludes organisation-merge tombstones (status='merged') from the default listing per the organisation-merge spec — a merged-away source stays readable by direct UUID lookup (OrganisatieDetail route) but never appears in this index. 🔴 THIS FILTER IS COUPLED TO THE SCHEMA'S status ENUM AND WENT STALE: #520 translated the stored enum values and migrated the rows (Concept/Actief/Deactief/samengevoegd -> Draft/Active/Inactive/merged) but did NOT translate this list, so the index filtered on three values no row can hold any more. A value filter that matches nothing is not an error — OpenRegister answers 200 with total=0 — so the Organisations index rendered 'No items found' for every user and read as an empty catalogue. Measured on the running instance: ?status[]=Draft&status[]=Active returns the seeded row, ?status[]=Concept&status[]=Actief&status[]=Deactief returns total=0. Any future enum rename must move this list in the same commit." }, { "id": "OrganisatieDetail", diff --git a/tests/e2e/spec-coverage/dashboard.spec.ts b/tests/e2e/spec-coverage/dashboard.spec.ts index 5b335004..b4eb158d 100644 --- a/tests/e2e/spec-coverage/dashboard.spec.ts +++ b/tests/e2e/spec-coverage/dashboard.spec.ts @@ -99,19 +99,40 @@ test('dashboard: "Ga naar Organisaties" quick-nav button is clickable and error- // The organisaties index is genuinely reachable via the real app nav entry // "Organisations" — this is the user's actual navigation path and lands on the -// CnIndexPage list surface (Add Organisatie + Cards/Table toggle). +// CnIndexPage list surface (Add button + Cards/Table toggle). test('dashboard: "Organisations" nav entry reaches the organisaties index', async ({ page, }) => { const bag = collectAppErrors(page) await navClickTo(page, 'Organisations') const main = page.locator(APP_MAIN).first() - // Organisations is a `type: custom` page (OrganisatieIndexView), not a - // CnIndexPage — its create action reads "Add organisation" and it has no - // Cards/Table toggle. Assert the custom surface's primary create action. + + // ⚠️ THIS USED TO ASSERT A SURFACE THE PRODUCT NO LONGER RENDERS. The + // comment here claimed Organisations was a `type: custom` page + // (OrganisatieIndexView) "with no Cards/Table toggle" whose create action + // read "Add organisation". src/manifest.json decomposed it into a standard + // `type: index` page (its own `_note` records the change), so the surface + // is CnIndexPage: a heading, a Cards/Table view toggle, and an Add button + // whose label CnIndexPage derives as `'Add ' + schema.title`. + // + // The schema title is authored "Organization" while every other string in + // this app is British ("Organisations" nav entry, "Organisation + // relationships" page title) — and a deployed instance can still carry the + // older "Organisation" title, because OpenRegister's import skips a schema + // whose deployed version is not older and its escape hatch never compares + // the title. Accept either spelling of the one word rather than pin the + // test to whichever an environment happens to hold; the assertion still + // names the action and the entity, so it cannot match another page. + await expect( + main.getByRole('heading', { name: 'Organisation relationships' }).first(), + ).toBeVisible({ timeout: 30000 }) await expect( - main.getByRole('button', { name: /Add organisation/i }).first(), + main.getByRole('button', { name: /^Add Organi[sz]ation$/i }).first(), ).toBeVisible({ timeout: 30000 }) + // The view toggle exists only on the index surface — it is what + // distinguishes "landed on the index" from "landed on any page with a + // create button". + await expect(main.getByRole('button', { name: 'Table' }).first()).toBeVisible() expectNoAppErrors(bag) }) diff --git a/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts b/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts index 67321ed2..805e547d 100644 --- a/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts +++ b/tests/e2e/spec-coverage/gemma-faceted-search.spec.ts @@ -146,19 +146,28 @@ test('facets: an unsupported schema is rejected with 400 naming the supported on const message = String(body?.message ?? '') // The scenario requires the error to NAME the supported schemas, not merely // to reject — so both names are asserted, not just a non-2xx. + // + // ⚠️ The supported set is `FacetService::SUPPORTED_SCHEMAS = ['module', + // 'service']`. This assertion used to look for `dienst`, the pre-#518 Dutch + // slug, and so did the 200 control below — a slug rename moved the API and + // left the test naming a schema the service has never heard of. expect( message, `error message did not name the supported schemas: ${message}`, ).toMatch(/module/) - expect(message).toMatch(/dienst/) + expect(message).toMatch(/service/) // The supported set is also machine-readable, and must be exactly the two. - expect(body?.supportedSchemas?.sort?.()).toEqual(['service', 'module']) + // ⚠️ `Array.prototype.sort()` sorts IN PLACE and returns the sorted array, + // so the expected literal has to be sorted too — comparing a sorted actual + // against `['service', 'module']` could never have held whichever names the + // service used. Copy before sorting so the response body is not mutated. + expect([...(body?.supportedSchemas ?? [])].sort()).toEqual(['module', 'service']) // Control: the same endpoint shape with a SUPPORTED schema is a 200, so the // 400 above is about the schema and not about the route being broken. - const ok = await ctx.get(`${FACETS}/dienst`) - expect(ok.status(), `GET ${FACETS}/dienst returned ${ok.status()}`).toBe(200) + const ok = await ctx.get(`${FACETS}/service`) + expect(ok.status(), `GET ${FACETS}/service returned ${ok.status()}`).toBe(200) }) // ⚠️ `no-text-query-returns-facets-over-the-full-rbac-scoped-set` IS DELIBERATELY diff --git a/tests/e2e/spec-coverage/index-pages.spec.ts b/tests/e2e/spec-coverage/index-pages.spec.ts index 7a67ade8..33a44c9f 100644 --- a/tests/e2e/spec-coverage/index-pages.spec.ts +++ b/tests/e2e/spec-coverage/index-pages.spec.ts @@ -40,9 +40,10 @@ interface IndexPage { } // True manifest `type: index` pages (CnIndexPage against a voorzieningen -// schema). NOTE: "Organisations" is intentionally NOT here — it is a -// `type: custom` page (component OrganisatieIndexView) with its own surface, -// covered by a dedicated test below. +// schema). NOTE: "Organisations" IS one of these now (it was decomposed from a +// bespoke `type: custom` view), but its create label spells one word +// differently depending on which schema title an environment carries, and this +// list matches labels exactly — so it keeps a dedicated test below. // ⚠️ `addLabel` is not free text. nc-vue's CnIndexPage derives its primary // create action as `'Add ' + schema.title` (CnIndexPage.vue), and the // softwarecatalog schema titles were rewritten from Dutch to English on @@ -88,34 +89,83 @@ test('index contactpersonen: the route reaches the CnIndexPage surface (toggle + expectNoAppErrors(bag) }) -// BUG (pre-existing, app config/manifest): the "Standards" index page is wired -// to the schema slug `standaard`, but NO `standaard` schema exists in the -// softwarecatalog voorzieningen register/config (the app config exposes -// organisatie/contactpersoon/contract/beoordeeling/compliancy/moduleVersie/... -// schemas but never a `standaard` one). So the page's list fetch fails with a -// console error: "Error fetching 11-standaard collection: {status: undefined, -// ...}", and the CnIndexPage list body never loads. Driving this page can -// therefore never be app-error-free until the `standaard` schema is provisioned -// (or the Standards page is removed/repointed in the manifest). Kept as a -// documented fixme so it re-activates once the schema gap is closed. Not a test -// defect — the page genuinely cannot load its data. -test.fixme('index standards: nav entry reaches the CnIndexPage surface (blocked: missing `standaard` schema)', async ({ +// ⚠️ THIS TEST'S SKIP REASON WAS NO LONGER TRUE, so it was an invisible pass. +// It was a `test.fixme` "blocked: missing `standaard` schema", on the stated +// grounds that the Standards page is wired to the schema slug `standaard` and +// no such schema exists. It is not: `src/manifest.json` binds the Standaarden +// page to `"schema": "element"`, and `element` IS provisioned — the CI seed +// enumerates it among the 36 schemas present on the instance. Verified on a +// running instance as well: /standaarden renders the index chrome and its +// create action resolves to "Add Element" (not the "Add Item" fallback the +// skipped body asserted), with zero app-origin console errors. +// +// A skip whose reason has stopped being true reads exactly like a passing +// test, so the reason is not repaired here — the test is put back to work. +// +// 🔴 IT IS RED, AND THE CAUSE IS MEASURED — DO NOT RE-SKIP IT. +// Un-skipping it produced a real, previously invisible defect. The surface +// assertions all hold (chrome, "Add Element", list body), and the failure is +// `expectNoAppErrors`: +// +// Error fetching 14-element collection +// +// The page config is `register: "@resolve:voorzieningen_register"` + +// `schema: "element"` — but `element` is NOT attached to the voorzieningen +// register. `lib/Settings/softwarecatalogus_register.json` binds it to the +// SECOND register in the same file: +// +// components.registers.voorzieningen.schemas (15) — no `element` +// components.registers.vng-gemma.schemas (5) — element, model, +// property-definition, +// relation, view +// +// So the page addresses schema `element` under a register that does not carry +// it. Same family as openconnector#1275's `synchronization_run`: declaring a +// schema does not attach it, and only an attached schema is fetchable through +// /api/objects/{register}/{schema}. +// +// ⚠️ THE OBVIOUS FIX IS THE WRONG ONE. Adding `element` to +// `registers.voorzieningen.schemas` would make the request succeed and return +// NOTHING — objects live per register, and the GEMMA elements were imported +// under vng-gemma. That converts a visible error into an empty list, i.e. an +// invisible pass, which is worse than this red. +// +// The honest fix is to point the page at the register that holds the data, +// and that needs a second `@resolve:` sentinel: `voorzieningen_register` is +// currently the ONLY one (34 uses), it is provisioned in +// lib/AppInfo/Application.php::boot() from the `voorzieningen_config` blob, +// and NO app-config key holds a vng-gemma register id — nor does +// tests/e2e/ci-seed.sh provision that register at all. Choosing where that id +// lives is a config-ownership decision, not an E2E repair, so it is escalated +// on the fleet board rather than guessed at here. +test('index standards: nav entry reaches the CnIndexPage surface (toggle + add + list body)', async ({ page, }) => { const bag = collectAppErrors(page) await navClickTo(page, 'Standards') - await expectIndexSurface(page, 'Add Item') + await expectIndexSurface(page, 'Add Element') expectNoAppErrors(bag) }) // --------------------------------------------------------------------------- -// Organisations is a `type: custom` page (OrganisatieIndexView), not a -// CnIndexPage. Its surface is the custom organisations view: the primary -// "Add organisation" create action, reached by clicking the nav entry. We -// assert that custom surface mounts WITHOUT an app-origin error (the register -// sentinel now resolves, so no @resolve 404). +// Organisations. ⚠️ THIS TEST DESCRIBED A SURFACE THAT NO LONGER EXISTS: it +// asserted the bespoke `type: custom` OrganisatieIndexView and its +// "Add organisation" button. src/manifest.json decomposed that view into a +// standard `type: index` page (its own `_note` records the change), so the page +// is a CnIndexPage like every entry in INDEX_PAGES above — heading, Cards/Table +// toggle, create action, list body. +// +// It is kept as a dedicated test rather than folded into INDEX_PAGES for one +// reason: `expectIndexSurface` matches the create label EXACTLY, and this is +// the one page whose label spelling is not stable across environments. +// CnIndexPage derives it as `'Add ' + schema.title`; the repo authors that +// title "Organization" while the rest of the app is British, and a deployed +// instance can still serve the older "Organisation" because OpenRegister skips +// importing a schema whose deployed version is not older, and its +// schemaContentDiffers() escape hatch compares properties/required/ +// authorization — never the title. Accept either spelling of that one word. // --------------------------------------------------------------------------- -test('custom organisaties: nav entry reaches the OrganisatieIndexView surface', async ({ +test('index organisaties: nav entry reaches the CnIndexPage surface (toggle + add + list body)', async ({ page, }) => { const bag = collectAppErrors(page) @@ -123,12 +173,24 @@ test('custom organisaties: nav entry reaches the OrganisatieIndexView surface', const main = page.locator(APP_MAIN).first() await expect(main).toBeVisible({ timeout: 30000 }) - // The custom view exposes a primary create action. Its empty-state button - // reads "Add organisation"; assert the create affordance is present. + // Index chrome — the view toggle is what separates "this is the index" from + // "this is any page that happens to have a create button". + await expect(main.getByText('Cards', { exact: true }).first()).toBeVisible({ + timeout: 30000, + }) + await expect(main.getByText('Table', { exact: true }).first()).toBeVisible() + + // Primary create action. await expect( - main.getByRole('button', { name: /Add organisation/i }).first(), + main.getByRole('button', { name: /^Add Organi[sz]ation$/i }).first(), ).toBeVisible({ timeout: 30000 }) + // List body mounted — empty-state OR a populated list. Proves the data layer + // ran (the `@resolve` register sentinel resolved), not just the chrome. + const emptyState = main.getByText('No items found', { exact: false }).first() + const populated = main.getByText(/Showing\s+\d+\s+of\s+\d+/i).first() + await expect(emptyState.or(populated)).toBeVisible({ timeout: 30000 }) + expectNoAppErrors(bag) }) diff --git a/tests/e2e/workflows/_ui.ts b/tests/e2e/workflows/_ui.ts index 6d229b3a..1d65a1d9 100644 --- a/tests/e2e/workflows/_ui.ts +++ b/tests/e2e/workflows/_ui.ts @@ -81,15 +81,23 @@ export async function openRowActions(page: Page, token: string): Promise { /** * Open the CnIndexPage create form via the primary "Add ..." button and return * the modal/dialog locator. + * + * `addLabel` accepts a RegExp as well as a string: CnIndexPage derives the + * label as `'Add ' + schema.title`, and at least one schema title differs + * between the repo and an already-deployed instance (OpenRegister skips + * importing a schema whose deployed version is not older, and its + * schemaContentDiffers() escape hatch never compares the title). `exact` is + * meaningless for a RegExp, so it is only passed for a string. */ export async function openCreateDialog( page: Page, - addLabel: string, + addLabel: string | RegExp, ): Promise { - await indexMain(page) - .getByRole('button', { name: addLabel, exact: true }) - .first() - .click() + const byName = + typeof addLabel === 'string' + ? { name: addLabel, exact: true } + : { name: addLabel } + await indexMain(page).getByRole('button', byName).first().click() const dialog = page.locator('[role="dialog"], .modal-container').first() await dialog.waitFor({ state: 'visible', timeout: 15000 }) return dialog diff --git a/tests/e2e/workflows/organisatie-crud.spec.ts b/tests/e2e/workflows/organisatie-crud.spec.ts index a6508e12..f860f73f 100644 --- a/tests/e2e/workflows/organisatie-crud.spec.ts +++ b/tests/e2e/workflows/organisatie-crud.spec.ts @@ -1,27 +1,26 @@ // SPDX-License-Identifier: EUPL-1.2 // SPDX-FileCopyrightText: 2026 Conduction B.V. /** - * DEEP, data-dependent persistence workflow for the ORGANISATIE entity, driven - * through the bespoke `type: custom` OrganisatieIndexView (a card grid, not a - * CnIndexPage). + * DEEP, data-dependent persistence workflow for the ORGANISATION entity, driven + * through the `/organisaties` index. That page is a standard manifest + * `type: index` (CnIndexPage rendering OrganisatieCard as its card component); + * it used to be a bespoke `type: custom` OrganisatieIndexView and this file + * still described that removed surface, which is why three of its assertions + * named strings the product no longer renders. * * What is proven through the UI: * - read-persistence: an organisation SEEDED via the OpenRegister API - * RENDERS as a real card in the custom organisations view (NOT the - * "No organisations" empty-state) — direct proof the list now fetches a - * real register (the `@resolve` sentinel fix) and the bespoke view binds - * the collection; + * RENDERS as a real card in the index (NOT the "No items found" + * empty-state) — direct proof the list fetches a real register (the + * `@resolve` sentinel) AND that the page's `config.filter` still selects + * values the rows can actually hold; * - the seeded card shows the organisation's name + type; - * - the "Add organisation" affordance opens the create modal. + * - the primary create action opens the create dialog. * - * What is NOT headlessly drivable here (documented test.fixme): - * - UI-driven CREATE of an organisation. "Add organisation" opens the generic - * ObjectModal whose first step is a Catalogus -> Register -> Schema cascade. - * The Catalogus select is populated from the `catalog` collection, which is - * EMPTY in this dev container (no catalog object is provisioned), so the - * cascade can never be completed and the object cannot be saved through the - * modal. This is a dev-env data gap, not an app bug. (Create + cleanup are - * exercised here via the OR API instead.) + * What is NOT verified here: see the `test.fixme` at the bottom — its old + * reason (an ObjectModal Catalogus cascade) describes a surface that no longer + * exists, and the leg has not been re-authored against the dialog that replaced + * it. (Create + cleanup are exercised here via the OR API instead.) * * Cleanup: the seeded org carries the RUN_ID token; afterAll deletes it via the * OR deleteObject verb. @@ -98,7 +97,16 @@ test('seeded organisation renders as a card (proves the list loads real data)', // prior runs leave rows behind, so the freshly-seeded org may land on a later // page — assert that real cards render (proving the @resolve list loaded data) // rather than requiring our specific seeded row to be on the first page. - await expect(main.getByText('No organisations', { exact: false })).toHaveCount(0) + // + // ⚠️ THE EMPTY-STATE ASSERTION USED TO NAME A STRING THAT NEVER RENDERS. + // It looked for "No organisations", which was the bespoke + // OrganisatieIndexView's wording; the page is a CnIndexPage now and its + // empty state reads "No items found". `toHaveCount(0)` against a string + // nothing ever renders passes unconditionally — so when this list really + // was empty (the manifest filter named three status values #520 had + // translated out of existence), the guard that was supposed to catch it + // said nothing and the failure surfaced one line later as a missing card. + await expect(main.getByText('No items found', { exact: false })).toHaveCount(0) const cards = main.locator('[class*=organisatie], [class*=card]') await expect(cards.first()).toBeVisible({ timeout: 30000 }) expect(await cards.count()).toBeGreaterThan(0) @@ -110,13 +118,20 @@ test('seeded organisation renders as a card (proves the list loads real data)', // The create affordance opens the create modal (the modal itself cannot be // completed here — see fixme below — but the entry point works). // --------------------------------------------------------------------------- -test('"Add organisation" opens the create modal', async ({ page }) => { +test('the create action opens the create dialog', async ({ page }) => { await navClickTo(page, 'Organisations') await dismissSupportDialog(page) const main = indexMain(page) + // CnIndexPage derives this label as `'Add ' + schema.title`. The repo + // authors that title "Organization" while the rest of the app is British, + // and an already-deployed instance can still carry "Organisation" — + // OpenRegister skips importing a schema whose deployed version is not older, + // and its schemaContentDiffers() escape hatch never compares the title. So + // accept either spelling of the one word rather than pin the test to + // whichever an environment happens to hold. await main - .getByRole('button', { name: /Add organisation/i }) + .getByRole('button', { name: /^Add Organi[sz]ation$/i }) .first() .click() const modal = page @@ -142,20 +157,42 @@ test('"Add organisation" opens the create modal', async ({ page }) => { }) // --------------------------------------------------------------------------- -// UI-driven CREATE — blocked by the empty `catalog` collection in this dev -// container. The ObjectModal's first step is a Catalogus select with zero -// options, so the Register/Schema cascade can never resolve and the object -// can't be saved. Re-enable once a catalog is provisioned in the dev dataset. +// UI-driven CREATE. ⚠️ THIS SKIP'S REASON WAS UNTRUE AND HAS BEEN CORRECTED +// RATHER THAN RE-STATED. +// +// It read "blocked: empty catalog collection", on the grounds that the create +// affordance opens the legacy ObjectModal whose first step is a Catalogus -> +// Register -> Schema cascade that cannot resolve in a dev container with no +// catalog object. That surface is gone: src/manifest.json decomposed the +// bespoke OrganisatieIndexView into a standard `type: index` page, so the +// create action opens nc-vue's CnIndexPage form dialog, which is already bound +// to the register and schema from the page config and asks for no cascade at +// all — as the sibling test above records, and as a running instance confirms +// (the dialog opens on "Create Organisation" with the schema's own fields and +// a disabled Create button until the required ones are filled). +// +// So this leg is NOT blocked. It is UNVERIFIED: the body below still drives the +// removed cascade and has never been re-authored against the dialog the product +// actually renders, and the assertion it ends on ("the new card appears by +// name") cannot simply be ported, because which fields the dialog exposes is +// governed by the schema's own `visible` flags and that has to be measured +// against the CI instance rather than guessed. +// +// Recorded on the fleet board for an owner. It is deliberately NOT dressed up +// as an environment gap again — a skip whose stated reason is false reads +// exactly like a passing test, and this one hid the fact that the whole create +// path had moved. // --------------------------------------------------------------------------- -test.fixme('UI create -> new organisation card appears (blocked: empty catalog collection)', async ({ +test.fixme('UI create -> new organisation card appears (unverified: body still drives the removed ObjectModal cascade)', async ({ page, }) => { await navClickTo(page, 'Organisations') await dismissSupportDialog(page) const uiOrgName = `${RUN_ID} UI Organisatie` - const modal = await openCreateDialog(page, 'Add organisation') - // Select the (currently non-existent) catalogus, then register + schema. + const modal = await openCreateDialog(page, /^Add Organi[sz]ation$/i) + // ⚠️ Stale body — the cascade below no longer exists. Kept verbatim so the + // re-author is obvious rather than silently deleted. const catalogSelect = modal .locator('.detail-item') .filter({ hasText: 'Catalogus' }) @@ -163,7 +200,6 @@ test.fixme('UI create -> new organisation card appears (blocked: empty catalog c .first() await catalogSelect.click() await page.locator('.vs__dropdown-option').first().click() - // ... register + schema cascade + JSON editor would follow here. await modal.getByRole('button', { name: 'Add', exact: true }).first().click() await navClickTo(page, 'Organisations') await expect( diff --git a/tests/vitest/manifestFilterEnumParity.spec.js b/tests/vitest/manifestFilterEnumParity.spec.js new file mode 100644 index 00000000..58185de8 --- /dev/null +++ b/tests/vitest/manifestFilterEnumParity.spec.js @@ -0,0 +1,119 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction / SoftwareCatalog Contributors + * SPDX-License-Identifier: EUPL-1.2 + * + * A manifest page's `config.filter` values must exist in the schema enum they + * filter on. + * + * WHY THIS FILE EXISTS. `src/manifest.json`'s Organisaties page filtered on + * `status: ["Concept", "Actief", "Deactief"]`. #520 translated that enum to + * Draft/Active/Inactive/merged AND migrated the stored rows, but did not move + * the manifest, so the index filtered on three values no row could hold. + * + * ⚠️ THAT IS NOT AN ERROR ANYWHERE. OpenRegister answers a filter on a valid + * property with a non-existent value as `200 {"total": 0}` — measured on a + * running instance: `?status[]=Draft&status[]=Active` returned the seeded row, + * `?status[]=Concept&status[]=Actief&status[]=Deactief` returned `total: 0`. + * So the Organisations index rendered "No items found" and read as an empty + * catalogue rather than as a broken page: no console error, no failed request, + * nothing in the log. Three e2e tests failed on the consequences (a missing + * card, a missing create affordance) rather than on the cause. + * + * This check is the cheap half of that day's work: a filter value that is not a + * member of its property's enum is always a bug, and it is visible from two + * static files. + * + * The last case is a POSITIVE CONTROL: the checker is handed a filter that IS + * stale and must report it. Without that, a checker that silently resolves no + * schemas at all would pass this file while proving nothing — the failure mode + * that makes a green gate worthless. + */ +import { describe, expect, it } from 'vitest' +import register from '../../lib/Settings/softwarecatalogus_register.json' +import manifest from '../../src/manifest.json' + +const SCHEMAS = register?.components?.schemas ?? {} + +/** + * Collect every `config.filter` entry whose value is not a member of the + * enum declared for that property on the page's schema. + * + * Only ENUM-typed properties are judged: a filter on a free-text property + * (a uuid, a slug, a route token) has no closed value set to check against, + * and `@`-prefixed / `:`-prefixed values are route interpolations resolved at + * fetch time, not literals. + * + * @param {object} man The app manifest. + * @param {object} schemas The register's `components.schemas` map. + * @return {Array} One human-readable line per violation. + */ +function staleFilterValues(man, schemas) { + const problems = [] + for (const page of man?.pages ?? []) { + const filter = page?.config?.filter + const slug = page?.config?.schema + if (!filter || typeof filter !== 'object' || !slug) continue + + const properties = schemas?.[slug]?.properties + if (!properties) { + problems.push( + `page "${page.id}" filters on schema "${slug}", which the register does not declare`, + ) + continue + } + + for (const [property, raw] of Object.entries(filter)) { + const declared = properties?.[property]?.enum + if (!Array.isArray(declared) || declared.length === 0) continue + + for (const value of Array.isArray(raw) ? raw : [raw]) { + if (typeof value !== 'string') continue + if (value.startsWith('@') || value.startsWith(':')) continue + if (declared.includes(value)) continue + problems.push( + `page "${page.id}" filters ${slug}.${property} on "${value}", ` + + `which is not in its enum [${declared.join(', ')}]`, + ) + } + } + } + return problems +} + +describe('manifest filters address values the schema enums actually declare', () => { + it('finds at least one page with an enum-typed filter to judge', () => { + // A run that judged NOTHING prints the same "no problems" as a clean one. + const judged = (manifest?.pages ?? []).filter((page) => { + const filter = page?.config?.filter + const slug = page?.config?.schema + if (!filter || !slug) return false + const properties = SCHEMAS?.[slug]?.properties ?? {} + return Object.keys(filter).some((key) => + Array.isArray(properties?.[key]?.enum), + ) + }) + expect(judged.length).toBeGreaterThan(0) + }) + + it('has no manifest filter value outside its schema enum', () => { + expect(staleFilterValues(manifest, SCHEMAS)).toEqual([]) + }) + + it('POSITIVE CONTROL: the checker reports a filter left behind by a rename', () => { + const stale = { + pages: [ + { + id: 'Organisaties', + config: { + schema: 'organization', + // The exact list the manifest carried before this fix. + filter: { status: ['Concept', 'Actief', 'Deactief'] }, + }, + }, + ], + } + const found = staleFilterValues(stale, SCHEMAS) + expect(found).toHaveLength(3) + expect(found[0]).toContain('Concept') + }) +}) diff --git a/tests/vitest/sbomProvenanceLabel.spec.js b/tests/vitest/sbomProvenanceLabel.spec.js new file mode 100644 index 00000000..980d51ab --- /dev/null +++ b/tests/vitest/sbomProvenanceLabel.spec.js @@ -0,0 +1,154 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction / SoftwareCatalog Contributors + * SPDX-License-Identifier: EUPL-1.2 + * + * SbomComponentsPanel — the module-version record must actually reach the + * computeds that read it. + * + * WHY THIS FILE EXISTS. `SbomComponentsPanel` resolves the inspected record in + * one computed and consumes it in `moduleVersionData`, which every other + * consumer reads through. When the schema slug `moduleVersie` was translated to + * `moduleVersion`, the CONSUMER was renamed (`this.moduleVersion`) and the + * PRODUCER was not (it stayed `moduleVersie()`), so `moduleVersionData` read an + * identifier that no longer existed on the instance, silently evaluated to `{}`, + * and every derived value went empty: + * + * - `lastImportedLabel` returned `''`, so the `data-testid="sbom-provenance"` + * line is behind `v-if` and NEVER rendered — the e2e assertion + * `expect(page.getByTestId('sbom-provenance')).toBeVisible()` failed on + * `development` with "element(s) not found" even though the import itself + * had succeeded (the success note, the table and the counts all rendered); + * - `parentModuleId` returned `''`, which is the scope of the + * vulnerability-match heuristic — an empty scope matches nothing and + * reports no error. + * + * Neither symptom throws. Vue resolves an unknown property to `undefined`, and + * `undefined` takes the "no import yet" branch, which is a legitimate state — + * so the broken build is indistinguishable from a module version that has + * genuinely never been imported. + * + * WHAT THIS ASSERTS. The computeds are exercised the way Vue evaluates them: + * every entry in the component's own `computed` map is installed as a getter on + * one object, so a producer/consumer name mismatch resolves to `undefined` here + * exactly as it does in the browser. The test therefore FAILS on the broken + * code and passes on the fixed code, rather than asserting the new name. + * + * The negative control ("never imported" -> empty label) is asserted too, so a + * label that is non-empty unconditionally cannot pass this file either. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// `@conduction/nextcloud-vue` re-exports through `@nextcloud/vue`, whose +// package exports map is not resolvable in this offline suite, and +// `src/store/store.js` instantiates real Pinia stores off the same library. +// Nothing here RENDERS and nothing here talks to a server, so both are stubbed; +// what is under test is the component's own computed chain, which runs for +// real. +const stubs = vi.hoisted(() => ({ + active: null, + collections: {}, +})) + +vi.mock('@conduction/nextcloud-vue', () => ({ + CnDataTable: { name: 'CnDataTable', render: () => null }, +})) + +vi.mock('../../src/store/store.js', () => ({ + objectStore: { + getActiveObject: () => stubs.active, + getCollection: (type) => stubs.collections[type] ?? { results: [] }, + }, +})) + +const Panel = (await import('../../src/components/sbom/SbomComponentsPanel.vue')) + .default + +const OBJECT_ID = '11111111-2222-3333-4444-555555555555' +const MODULE_UUID = '99999999-8888-7777-6666-555555555555' + +/** + * Build a record the panel is supposed to find for `OBJECT_ID`. + * + * @param {object} data Extra data-bag fields. + * @return {object} An OpenRegister-shaped moduleVersion record. + */ +function record(data = {}) { + return { + uuid: OBJECT_ID, + object: { + module: MODULE_UUID, + ...data, + }, + } +} + +/** + * Install the component's real computeds as getters on a bare object, so the + * producer -> consumer chain resolves the same way Vue resolves it. + * + * @param {object} base Instance data (props / data fields). + * @return {object} A stand-in component instance. + */ +function instance(base = {}) { + const vm = { objectId: OBJECT_ID, ...base } + for (const [name, getter] of Object.entries(Panel.computed)) { + Object.defineProperty(vm, name, { + get: () => getter.call(vm), + configurable: true, + }) + } + return vm +} + +/** + * Point the shared object store at a fixed collection for this test. + * + * @param {Array} results The moduleVersion collection rows. + * @return {void} + */ +function seedStore(results) { + stubs.active = null + stubs.collections = { moduleVersion: { results } } +} + +describe('SbomComponentsPanel — the resolved module version reaches its consumers', () => { + beforeEach(() => { + seedStore([]) + }) + + it('renders the provenance label once the record carries an import stamp', () => { + seedStore([ + record({ + sbomLastImportedAt: '2026-08-16T10:00:00+00:00', + sbomFormat: 'cyclonedx-json', + sbomFileName: 'bom.json', + }), + ]) + + const label = instance().lastImportedLabel + + // Non-empty is what the `v-if` gates on, and the file name is the part + // that can only come from the resolved record — checking for the + // EXPECTED content, not merely "not empty". + expect(label).toContain('bom.json') + expect(label).toContain('CycloneDX') + }) + + it('NEGATIVE CONTROL: a module version that was never imported has no label', () => { + seedStore([record()]) + + expect(instance().lastImportedLabel).toBe('') + }) + + it('scopes the vulnerability-match heuristic to the resolved parent module', () => { + seedStore([record({ sbomLastImportedAt: '2026-08-16T10:00:00+00:00' })]) + + expect(instance().parentModuleId).toBe(MODULE_UUID) + }) + + it('NEGATIVE CONTROL: an unresolvable objectId yields no module scope', () => { + seedStore([record()]) + + expect(instance({ objectId: 'no-such-uuid' }).parentModuleId).toBe('') + }) +}) diff --git a/tests/vitest/stubs/nextcloud-l10n.js b/tests/vitest/stubs/nextcloud-l10n.js index e27ebeea..163eeaac 100644 --- a/tests/vitest/stubs/nextcloud-l10n.js +++ b/tests/vitest/stubs/nextcloud-l10n.js @@ -11,6 +11,12 @@ * * Tests mutate `__setLanguage('nl')` between cases; the stub returns the * current value from each `getLanguage()` call. + * + * `translate()` is the other export components pull in (`import { translate as + * t }`). Without it `t` is `undefined` and any component computed that builds a + * label throws a TypeError — which is NOT the failure mode under test, so the + * stub returns the source string with `{placeholder}` substitution, exactly the + * shape the real package produces for an untranslated (English) string. */ let currentLanguage = 'en' @@ -19,6 +25,15 @@ export function getLanguage() { return currentLanguage } +export function translate(app, text, vars) { + if (!vars || typeof vars !== 'object') { + return String(text) + } + return String(text).replace(/\{(\w+)\}/g, (match, key) => + Object.hasOwn(vars, key) ? String(vars[key]) : match, + ) +} + export function __setLanguage(lang) { currentLanguage = lang } @@ -29,4 +44,5 @@ export function __resetLanguage() { export default { getLanguage, + translate, }