From edd4320ad2e4633b155a39c9ad38c365a18c9971 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 17 Aug 2026 09:25:22 +0200 Subject: [PATCH 1/3] fix(manifest): the Standards pages read `element` from a register that does not carry it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src/manifest.json` binds Standaarden + StandaardDetail to `register: "@resolve:voorzieningen_register"` with `schema: "element"`, but `lib/Settings/softwarecatalogus_register.json` attaches `element` to the SECOND register it declares — `vng-gemma` (title "AMEF") — and not to `voorzieningen`. Declaring a schema is not attaching it. Only an attached schema is fetchable through `/api/objects/{register}/{schema}`, and OpenRegister's 2026-08-16 change to `ObjectService::setSchema()` turned a register-scoped slug miss from a silent fallback into a throw. Measured from the CI Playwright trace of run 31981873526: GET /api/objects/14/element?_limit=20&_page=1&gemmaType=standaard&_facets=extend -> 404 {"message":"Schema not found: 'element'"} Attaching `element` to `voorzieningen` was considered and rejected: the request would then succeed and return an EMPTY list, because objects live per register and AMEF elements are written to the AMEF one. That trades a visible error for an invisible pass. The fix points the pages at the register that carries the schema, through a second `@resolve:` sentinel. `amef_register` is provisioned in `Application::boot()` from the `amef_config` blob written by `SettingsService::configureAmef()`, exactly as `voorzieningen_register` is from `voorzieningen_config`. configureAmef() detects its register by the PRESENCE of the AMEF core schemas rather than by slug, which is the property this sentinel needs: whatever it selects carries `element` by construction. The nested `st-compliance` object-list on StandaardDetail deliberately keeps `@resolve:voorzieningen_register` — `compliancy` really does live there. --- lib/AppInfo/Application.php | 57 +++++++++++++++++++++++++++++++++++++ src/manifest.json | 8 +++--- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index e37b490b..e41a15d5 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -856,8 +856,65 @@ public function boot(IBootContext $context): void { } $initialState->provideInitialState('voorzieningen_register', $provisioned); + + // The SECOND register this app owns. lib/Settings/softwarecatalogus_register.json + // declares two: `voorzieningen` (15 schemas) and `vng-gemma` / AMEF + // (element, model, property-definition, relation, view). A schema is only + // fetchable through /api/objects/{register}/{schema} from a register that + // ATTACHES it — OpenRegister's ObjectService::setSchema() resolves a slug + // register-scoped and, since its 2026-08-16 change, THROWS on a scoped miss + // instead of falling back to a global lookup. So the manifest pages whose + // schema lives in the AMEF register (Standaarden / StandaardDetail, both + // `schema: element`) need their OWN sentinel; pointing them at + // `voorzieningen_register` yields `404 {"message":"Schema not found: + // 'element'"}` on every fetch. + // + // Its canonical home is the `amef_config` JSON blob written by + // SettingsService::configureAmef(), exactly as `voorzieningen_config` is for + // the register above. configureAmef() detects the register by the PRESENCE of + // the AMEF core schemas rather than by slug, which is the property this + // sentinel needs — whatever it selects is by construction a register that + // carries `element`. + $amefRegisterId = $this->resolveAmefRegisterId(appConfig: $appConfig); + $amefProvisioned = null; + if ($amefRegisterId !== '') { + $amefProvisioned = $amefRegisterId; + } + + $initialState->provideInitialState('amef_register', $amefProvisioned); }//end boot() + /** + * Resolve the numeric AMEF (vng-gemma) register id from the canonical config. + * + * Mirrors resolveVoorzieningenRegisterId() below, over the AMEF key family. + * Resolution order: + * 1. `amef_config` JSON blob's `register` field — the canonical home written + * by SettingsService::setAmefConfig() from configureAmef(). + * 2. The same blob's `register_id` field — the legacy shape + * SettingsService::getAmefConfig() assembles when the blob is absent. + * 3. The flat `amef_register_id` scalar key — legacy fallback. + * + * @param IAppConfig $appConfig The app config service. + * + * @return string The numeric register id, or '' when none is configured. + */ + private function resolveAmefRegisterId(IAppConfig $appConfig): string { + $configJson = $appConfig->getValueString(self::APP_ID, 'amef_config', ''); + if ($configJson !== '') { + $decoded = json_decode($configJson, true); + if (is_array($decoded) === true) { + foreach (['register', 'register_id'] as $key) { + if (isset($decoded[$key]) === true && $decoded[$key] !== '') { + return (string)$decoded[$key]; + } + } + } + } + + return $appConfig->getValueString(self::APP_ID, 'amef_register_id', ''); + }//end resolveAmefRegisterId() + /** * Resolve the numeric voorzieningen register id from the canonical config. * diff --git a/src/manifest.json b/src/manifest.json index f36b2708..b190a8d0 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -489,7 +489,7 @@ "type": "index", "title": "Standards", "config": { - "register": "@resolve:voorzieningen_register", + "register": "@resolve:amef_register", "schema": "element", "filter": { "gemmaType": "standaard" }, "columns": [ @@ -504,7 +504,7 @@ }, "documentationUrl": "https://softwarecatalog.conduction.nl" }, - "_note": "The register has NO dedicated 'standard' schema — GEMMA standards are `element` objects (the generic ArchiMate/GEMMA model-element schema, shared with referentiecomponenten, applications, capabilities, …) discriminated by `gemmaType`. Fixed a pre-existing bug: this page previously pointed at a non-existent `schema: \"standaard\"`, which would 404 on every fetch. Scoped with `filter.gemmaType: \"standaard\"` (see lib/Service/ArchiMateImportService.php's `extractGemmaType` comparisons and its `?gemmaType=referentiecomponent` query comment) so only standard-typed elements list here." + "_note": "The register has NO dedicated 'standard' schema — GEMMA standards are `element` objects (the generic ArchiMate/GEMMA model-element schema, shared with referentiecomponenten, applications, capabilities, …) discriminated by `gemmaType`. Fixed a pre-existing bug: this page previously pointed at a non-existent `schema: \"standaard\"`, which would 404 on every fetch. Scoped with `filter.gemmaType: \"standaard\"` (see lib/Service/ArchiMateImportService.php's `extractGemmaType` comparisons and its `?gemmaType=referentiecomponent` query comment) so only standard-typed elements list here. REGISTER FIX (measured): `element` is declared under `components.registers.vng-gemma.schemas`, NOT under `voorzieningen`, so `@resolve:voorzieningen_register` produced `GET /api/objects//element -> 404 {\"message\":\"Schema not found: 'element'\"}` on every load (CI trace, run 31981873526). Attaching `element` to `voorzieningen` was rejected deliberately: the request would then succeed and return an EMPTY list, because objects live per register and the AMEF elements are written to the AMEF one — a visible error traded for an invisible pass. Repointed at `@resolve:amef_register`, the second sentinel, provisioned in lib/AppInfo/Application.php::boot() from the `amef_config` blob exactly as `voorzieningen_register` is from `voorzieningen_config`." }, { "id": "StandaardDetail", @@ -512,9 +512,9 @@ "type": "detail", "title": "Standard", "config": { - "register": "@resolve:voorzieningen_register", + "register": "@resolve:amef_register", "schema": "element", - "_note": "Catalog archetype (a normative GEMMA standard). What matters about a standard is (a) its definition, (b) the specification documents, and (c) the ecosystem around it: which modules implement it and what compliance records claim it. Body: standard data 8-wide top-left (name, gemmaThema, gemmaStatus, documentation, url, versieaanduiding, beschikbaarheid — the `element` schema's GEMMA-standard fields); a Specification documents (files) panel 4-wide top-right for the published spec/PDFs. Below, a Related panel surfaces the element's own FK web (standaardVersies, aanbevolenVoorReferentiecomponent, verplichteVoorReferentiecomponent, gekoppeldeStandaardVersies — all self-references into other `element` objects), then a compliancy object-list shows every compliance claim naming this standard (compliancy.standaardGemma matched to the standard's own `name`) so a reader sees adoption at a glance. FIX: the register has no dedicated 'standard' schema — this page previously pointed at a non-existent `schema: \"standaard\"` (guaranteed fetch failure); corrected to `element` (the shared GEMMA/ArchiMate element schema, discriminated by `gemmaType`). A standard does not communicate, so per the comms hard-rule NO Emails/Meetings/Talk widgets appear. Audit trail stays a sidebar tab.", + "_note": "Catalog archetype (a normative GEMMA standard). What matters about a standard is (a) its definition, (b) the specification documents, and (c) the ecosystem around it: which modules implement it and what compliance records claim it. Body: standard data 8-wide top-left (name, gemmaThema, gemmaStatus, documentation, url, versieaanduiding, beschikbaarheid — the `element` schema's GEMMA-standard fields); a Specification documents (files) panel 4-wide top-right for the published spec/PDFs. Below, a Related panel surfaces the element's own FK web (standaardVersies, aanbevolenVoorReferentiecomponent, verplichteVoorReferentiecomponent, gekoppeldeStandaardVersies — all self-references into other `element` objects), then a compliancy object-list shows every compliance claim naming this standard (compliancy.standaardGemma matched to the standard's own `name`) so a reader sees adoption at a glance. FIX: the register has no dedicated 'standard' schema — this page previously pointed at a non-existent `schema: \"standaard\"` (guaranteed fetch failure); corrected to `element` (the shared GEMMA/ArchiMate element schema, discriminated by `gemmaType`). A standard does not communicate, so per the comms hard-rule NO Emails/Meetings/Talk widgets appear. Audit trail stays a sidebar tab. REGISTER FIX: the page-level register moved from `@resolve:voorzieningen_register` to `@resolve:amef_register` for the same reason as the Standaarden index — `element` is attached to the AMEF register, not to voorzieningen, and an unattached slug now throws rather than resolving globally. The nested `st-compliance` object-list deliberately KEEPS `@resolve:voorzieningen_register`: `compliancy` really does live in voorzieningen, and a detail page's widgets carry their own register.", "widgets": [ { "id": "st-data", "type": "data", "title": "Standard", "icon": "ShieldCheckOutline", "content": { "columns": 2 } }, { "id": "st-files", "type": "integration", "integrationId": "files", "title": "Specification documents", "icon": "FolderOutline" }, From ee6b3890f5c10a40b799cba591e25dc04cddcf1e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 17 Aug 2026 09:25:38 +0200 Subject: [PATCH 2/3] test(e2e): seed the AMEF register and assert the Standards page LISTS rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repointing the page removes the console error. It does not prove the page works — a repointed page with no rows is quiet, renders "No items found", and passes every surface assertion in the suite. That is the invisible pass the register fix was chosen to avoid, so the test has to go past "the error is gone". ci-seed.sh: * requires `vng-gemma` alongside `voorzieningen`. It was unchecked, so an import producing only one register reported a clean seed. * resolves the AMEF register from the app's own `/api/amef/config`, i.e. the same value the `@resolve:amef_register` sentinel resolves to, and fails loudly when it or `element_schema` is unset. * probes `/api/objects//element` — the request the PAGE makes. Verifying a slug is present in /api/schemas is a different question from whether it is attached to the register you are addressing, and only the second one decides whether the endpoint answers. This is the check whose absence let the defect ship. * seeds two `element` objects, idempotent by `identifier`: `Digikoppeling` (gemmaType `standaard`) and `Zaakregistratiecomponent` (gemmaType `referentiecomponent`). GEMMA elements normally arrive via the ArchiMate import of GEMMA_release.xml, which no CI job runs. * verifies them with a FRESH read (never the save response, which echoes back properties OpenRegister discarded) under three guards: at least one standaard; a nonsense gemmaType matching zero; and strictly fewer standaards than elements, so the page's filter has something to exclude. All three guards were demonstrated able to fail against a live instance: deleting the referentiecomponent trips the third, deleting both trips the first, and the nonsense-value control was measured at 0 with the positive control at 1 of 2. index-pages.spec.ts now asserts a POPULATED list ("Showing N of M", which CnIndexPage renders only for a non-empty collection), the seeded standard by name, and the ABSENCE of the seeded referentiecomponent. The absence assertion has a real subject: that row exists in the same register and schema, is listed by an unfiltered page, and the seed fails the job if it is missing — so a zero means the filter worked, not that the string never existed. --- tests/e2e/ci-seed.sh | 178 +++++++++++++++++++- tests/e2e/spec-coverage/index-pages.spec.ts | 87 +++++++--- 2 files changed, 244 insertions(+), 21 deletions(-) diff --git a/tests/e2e/ci-seed.sh b/tests/e2e/ci-seed.sh index 39617f01..63bc63b7 100755 --- a/tests/e2e/ci-seed.sh +++ b/tests/e2e/ci-seed.sh @@ -113,7 +113,12 @@ verify() { import json, sys path, kind = sys.argv[1], sys.argv[2] required = { - 'registers': ['voorzieningen'], + # `vng-gemma` (title "AMEF") is the SECOND register this app declares. It + # carries element / model / property-definition / relation / view, and the + # Standaarden + StandaardDetail manifest pages read `element` from it via the + # `@resolve:amef_register` sentinel. It was previously unchecked here, so an + # import that produced only `voorzieningen` reported a clean seed. + 'registers': ['voorzieningen', 'vng-gemma'], # The schemas the e2e fixtures create/read through, per _fixtures.ts # (organization, contactPerson, module, contract, moduleVersion) plus the # ones the spec-coverage index pages render. @@ -192,6 +197,177 @@ if missing: print('[ci-seed] app-level voorzieningen mapping OK.') PY +# ── 3b. The AMEF register: resolve it, PROBE IT, and give it rows ──────────── +# The Standaarden / StandaardDetail manifest pages read `schema: element`, which +# lib/Settings/softwarecatalogus_register.json attaches to the `vng-gemma` (AMEF) +# register and NOT to `voorzieningen`. They resolve it through the +# `@resolve:amef_register` sentinel that lib/AppInfo/Application.php::boot() +# provisions from the `amef_config` blob. +# +# ⚠️ THE CHECK ABOVE CANNOT CATCH THE FAILURE THIS ONE EXISTS FOR. Verifying that +# a schema slug is PRESENT in /api/schemas is a different question from whether it +# is ATTACHED to the register you are about to address, and only the second one +# decides whether /api/objects/{register}/{schema} answers. OpenRegister used to +# fall back to a global slug lookup on a scoped miss; since 2026-08-16 it throws, +# so an unattached slug returns `404 {"message":"Schema not found: 'element'"}`. +# The only honest probe is the request the page itself makes — so we make it. +# +# And a 200 is still not enough. An empty list renders as a legitimate "No items +# found", i.e. a page that is broken and quiet looks exactly like a page that is +# healthy and unpopulated. GEMMA elements normally arrive via the ArchiMate import +# of a multi-megabyte GEMMA_release.xml, which no CI job runs, so we seed two +# `element` fixtures here — one `gemmaType: standaard` (which the page MUST list) +# and one `gemmaType: referentiecomponent` (which it must NOT). The pair makes the +# page's own `filter.gemmaType` falsifiable in both directions. +AMEF_BODY="$(mktemp)" +curl -sS -u "${USER_NAME}:${USER_PASS}" -H 'OCS-APIRequest: true' \ + "${BASE}/index.php/apps/softwarecatalog/api/amef/config" -o "$AMEF_BODY" + +AMEF_REGISTER="$( + python3 - "$AMEF_BODY" <<'PY' +import json, sys +with open(sys.argv[1]) as fh: + raw = fh.read() +try: + body = json.loads(raw) +except json.JSONDecodeError: + print('::error::amef/config did not return JSON. First 500 bytes:', file=sys.stderr) + print(raw[:500], file=sys.stderr) + sys.exit(1) +config = (body or {}).get('config') or {} +print(f'[ci-seed] amef config: {json.dumps(config)[:400]}', file=sys.stderr) +register = str(config.get('register') or config.get('register_id') or '') +if not register: + print('::error::softwarecatalog has no AMEF register mapping — the ' + '@resolve:amef_register sentinel would resolve to null and the ' + 'Standards pages would fetch /api/objects/@resolve:amef_register/element.', + file=sys.stderr) + sys.exit(1) +if not config.get('element_schema'): + print('::error::the AMEF config names a register but no element schema.', file=sys.stderr) + sys.exit(1) +print(register) +PY +)" +echo "[ci-seed] amef register id: ${AMEF_REGISTER}" + +# The page's own request, verbatim. A non-200 here is the whole defect this +# section guards, so report the status AND the body — "Schema not found" and +# "Register not found" are different faults with different fixes. +ELEM_BODY="$(mktemp)" +ELEM_CODE="$( + curl -sS -o "$ELEM_BODY" -w '%{http_code}' \ + -u "${USER_NAME}:${USER_PASS}" -H 'OCS-APIRequest: true' \ + "${BASE}/index.php/apps/openregister/api/objects/${AMEF_REGISTER}/element?_limit=1" || echo 000 +)" +echo "[ci-seed] GET /api/objects/${AMEF_REGISTER}/element -> ${ELEM_CODE}" +if [ "$ELEM_CODE" != "200" ]; then + head -c 500 "$ELEM_BODY"; echo + echo "::error::The AMEF register does not carry the 'element' schema (HTTP ${ELEM_CODE})." + echo "::error::Declaring a schema does not attach it — check components.registers.vng-gemma.schemas in lib/Settings/softwarecatalogus_register.json." + exit 1 +fi + +# Seed the two fixtures, idempotently. `identifier` is a declared property, so it +# is addressable as a bare query filter (the HTTP list API takes bare property +# names; `filters[identifier]=…` matches nothing). Verified with a negative +# control: an unknown identifier returns total 0, so a hit really is a hit. +python3 - "$BASE" "$USER_NAME" "$USER_PASS" "$AMEF_REGISTER" <<'PY' +import base64, json, sys, urllib.error, urllib.parse, urllib.request + +base, user, password, register = sys.argv[1:5] +auth = base64.b64encode(f'{user}:{password}'.encode()).decode() +collection = f'{base}/index.php/apps/openregister/api/objects/{register}/element' + +# `identifier`, `type` and `properties` are the element schema's required fields +# and it runs with hardValidation on, so all three must be present or the create +# is refused. +FIXTURES = [ + { + 'identifier': 'e2e-gemma-standaard-digikoppeling', + 'type': 'Standard', + 'properties': [], + 'name': 'Digikoppeling', + 'gemmaType': 'standaard', + 'gemmaThema': 'Gegevensuitwisseling', + 'gemmaStatus': 'In gebruik', + 'url': 'https://gemmaonline.nl/index.php/Digikoppeling', + }, + { + # The discriminator control. Same register, same schema, different + # gemmaType — so it is listed by an unfiltered page and hidden by a + # correctly filtered one. Without it, `filter.gemmaType` could be dropped + # entirely and every assertion would still pass. + 'identifier': 'e2e-gemma-referentiecomponent-zaakregistratie', + 'type': 'ApplicationComponent', + 'properties': [], + 'name': 'Zaakregistratiecomponent', + 'gemmaType': 'referentiecomponent', + 'gemmaThema': 'Zaakgericht werken', + }, +] + + +def request(url, method='GET', payload=None): + data = None + headers = {'Authorization': f'Basic {auth}', 'OCS-APIRequest': 'true'} + if payload is not None: + data = json.dumps(payload).encode() + headers['Content-Type'] = 'application/json' + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req) as resp: + return resp.status, json.loads(resp.read().decode() or '{}') + + +def count(query): + url = f'{collection}?{urllib.parse.urlencode(query)}' + _, body = request(url) + return body.get('total', 0), body + + +for fixture in FIXTURES: + existing, _ = count({'_limit': 1, 'identifier': fixture['identifier']}) + if existing: + print(f"[ci-seed] element fixture already present: {fixture['identifier']}") + continue + try: + status, _ = request(collection, method='POST', payload=fixture) + except urllib.error.HTTPError as exc: + print(f"::error::Creating element fixture {fixture['identifier']} failed " + f'(HTTP {exc.code}): {exc.read()[:300]!r}') + sys.exit(1) + print(f"[ci-seed] created element fixture {fixture['identifier']} (HTTP {status})") + +# Verify with a FRESH read, never with the create response — OpenRegister echoes +# back properties it discarded, so a save response cannot tell you what was +# stored. Three assertions, of which the middle one is the point: the filtered +# query must be strictly smaller than the unfiltered one. +total_all, _ = count({'_limit': 50}) +total_std, listed = count({'_limit': 50, 'gemmaType': 'standaard'}) +total_none, _ = count({'_limit': 50, 'gemmaType': 'NO-SUCH-GEMMA-TYPE'}) + +names = sorted(str(r.get('name') or '') for r in listed.get('results', [])) +print(f'[ci-seed] element objects: {total_all} total, {total_std} with ' + f'gemmaType=standaard, {total_none} with a nonsense gemmaType') +print(f'[ci-seed] standards the index page will list: {names}') + +if total_std < 1: + print('::error::No element object carries gemmaType=standaard, so the ' + 'Standards index would render its empty state. A quiet empty page is ' + 'the failure this seed exists to prevent.') + sys.exit(1) +if total_none != 0: + print('::error::A nonsense gemmaType still matched rows — the bare-property ' + 'filter is not filtering, so "the page lists standards" would be ' + 'satisfied by any element at all.') + sys.exit(1) +if total_all <= total_std: + print('::error::Every element carries gemmaType=standaard, so the page\'s ' + 'filter has nothing to exclude and cannot be shown to work.') + sys.exit(1) +print('[ci-seed] AMEF element fixtures verified (positive + negative control).') +PY + echo "[ci-seed] SoftwareCatalog registers + schemas provisioned." # ── 4. Warm the SPA so the first spec doesn't pay the cold start ───────────── diff --git a/tests/e2e/spec-coverage/index-pages.spec.ts b/tests/e2e/spec-coverage/index-pages.spec.ts index 33a44c9f..cb560b8f 100644 --- a/tests/e2e/spec-coverage/index-pages.spec.ts +++ b/tests/e2e/spec-coverage/index-pages.spec.ts @@ -19,6 +19,13 @@ * Application::boot), so the list object-fetch hits a real register and no * longer 404s. collectAppErrors no longer filters that 404, so these suites * assert it is genuinely absent alongside any other app-origin error / 5xx. + * + * There are now TWO such sentinels. The Standards pages read `schema: element`, + * which lib/Settings/softwarecatalogus_register.json attaches to the `vng-gemma` + * (AMEF) register rather than to `voorzieningen`, and they resolve it through + * `@resolve:amef_register` — provisioned the same way, from the `amef_config` + * blob. See the block above the standards test for why repointing the page was + * the right fix and attaching the schema to `voorzieningen` was not. */ import { test, expect } from '@playwright/test' import { @@ -102,14 +109,18 @@ test('index contactpersonen: the route reaches the CnIndexPage surface (toggle + // 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. +// ✅ THE RED IT EXPOSED IS NOW FIXED, AND THE FIX IS NOT THE OBVIOUS ONE. // Un-skipping it produced a real, previously invisible defect. The surface -// assertions all hold (chrome, "Add Element", list body), and the failure is -// `expectNoAppErrors`: +// assertions all held (chrome, "Add Element", list body); the failure was +// `expectNoAppErrors`, on: +// +// Error fetching 14-element collection: Proxy(Object) // -// Error fetching 14-element collection +// The console message names neither the status nor the cause. The Playwright +// trace does — `GET /api/objects/14/element?…` returned +// `404 {"message":"Schema not found: 'element'"}` (run 31981873526). // -// The page config is `register: "@resolve:voorzieningen_register"` + +// The page config was `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: @@ -119,31 +130,67 @@ test('index contactpersonen: the route reaches the CnIndexPage surface (toggle + // 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}. +// 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}. ⚠️ This only became a HARD failure on +// 2026-08-16: OpenRegister's `ObjectService::setSchema()` used to fall back to a +// global slug lookup after a register-scoped miss, and now THROWS instead. An +// instance running an older openregister still serves this page, so "it works +// here" is not evidence — check the version you are measuring against. // // ⚠️ 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. +// NOTHING — objects live per register, and AMEF elements are written to the AMEF +// one. That converts a visible error into an empty list, i.e. an invisible pass, +// which is worse than the 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. +// The fix taken instead points the page at the register that carries the schema, +// through a SECOND `@resolve:` sentinel — `@resolve:amef_register`, provisioned +// in lib/AppInfo/Application.php::boot() from the `amef_config` blob exactly as +// `voorzieningen_register` is from `voorzieningen_config`. +// +// 🔑 AND THE ASSERTIONS BELOW GO PAST "THE ERROR IS GONE". A repointed page with +// no rows is quiet, renders "No items found", and passes every surface check — +// the exact invisible pass the fix above was chosen to avoid. So the page must +// be shown to LIST something. tests/e2e/ci-seed.sh seeds two `element` objects +// into the AMEF register and verifies them through the page's own query with a +// positive and a negative control: `Digikoppeling` (gemmaType `standaard`) and +// `Zaakregistratiecomponent` (gemmaType `referentiecomponent`). The second one +// is the discriminator: it exists, in the same register and schema, and the page +// must NOT show it. Without it, `filter.gemmaType` could be deleted outright and +// every assertion here would still hold. 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 Element') + + const main = page.locator(APP_MAIN).first() + + // A POPULATED list, not `emptyState.or(populated)`. CnIndexPage renders this + // header only once a non-empty collection has loaded, so it fails on both an + // empty register and a failed fetch. + await expect( + main.getByText(/Showing\s+\d+\s+of\s+\d+/i).first(), + 'the Standards index rendered no rows — the AMEF register resolved but carries no gemmaType=standaard element', + ).toBeVisible({ timeout: 30000 }) + + // The seeded standard itself, by name. + await expect( + main.getByText('Digikoppeling', { exact: false }).first(), + ).toBeVisible({ timeout: 30000 }) + + // …and the seeded NON-standard must be absent. This is an absence assertion + // whose subject the product really does emit: `Zaakregistratiecomponent` is a + // live row in the same register + schema, listed by an unfiltered page, and + // ci-seed.sh fails the job if it is missing. So a zero here means the filter + // worked, not that the string never existed. + await expect( + main.getByText('Zaakregistratiecomponent', { exact: false }), + 'a referentiecomponent is listed on the Standards index — config.filter.gemmaType is not being applied', + ).toHaveCount(0) + expectNoAppErrors(bag) }) From bac20b493a866cf5f799d4897c877fe162e58a19 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 17 Aug 2026 09:25:51 +0200 Subject: [PATCH 3/3] test(unit): pin manifest register sentinels to a register that attaches the schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The defect this PR fixes was invisible to every static check in the repo. The manifest validator checks the value's SHAPE; the gate package's manifest cross-reference deliberately skips sentinels (its `isLiteralSlug()` excludes any value containing `@`); and nothing at all compares a page's `(register, schema)` pair against `lib/Settings/softwarecatalogus_register.json`. It took an E2E run and a Playwright network trace to see a 404. This closes both holes from the repository's own files: 1. every `@resolve:` register sentinel in the manifest is provisioned by `Application::boot()` — an unprovisioned one substitutes null and the page fetches `/api/objects/null/`; 2. every `(sentinel, schema)` pair names a register that ATTACHES that schema. The sentinel -> register-slug map is declared in the test on purpose: nothing in the app declares it (the ids are discovered at runtime by configureVoorzieningen / configureAmef), so an unmapped sentinel FAILS rather than being skipped. A new sentinel has to be a decision, not a silent gap. Both checks were shown able to fail before being committed: reverting the manifest to `@resolve:voorzieningen_register` fails check 2 with the register's actual schema list quoted in the message, and renaming the provisioned initial-state key fails check 1. A third test is a standing positive control on the fixture itself — it asserts `element` is declared, is NOT attached to voorzieningen, and IS attached to vng-gemma, so check 2 passing cannot be explained by every register listing every schema. --- .../AppInfo/ManifestRegisterSentinelTest.php | 297 ++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 tests/Unit/AppInfo/ManifestRegisterSentinelTest.php diff --git a/tests/Unit/AppInfo/ManifestRegisterSentinelTest.php b/tests/Unit/AppInfo/ManifestRegisterSentinelTest.php new file mode 100644 index 00000000..f52fb2ea --- /dev/null +++ b/tests/Unit/AppInfo/ManifestRegisterSentinelTest.php @@ -0,0 +1,297 @@ +`. + * + * 2. **A sentinel pointing at a register that does not carry the schema.** + * This one shipped. The Standards pages read `schema: "element"` while + * naming `@resolve:voorzieningen_register`, and + * `lib/Settings/softwarecatalogus_register.json` attaches `element` to the + * SECOND register in the same file (`vng-gemma` / AMEF), not to + * `voorzieningen`. Declaring a schema is not attaching it: only an + * attached schema is fetchable through `/api/objects/{register}/{schema}`, + * and since OpenRegister's 2026-08-16 change to + * `ObjectService::setSchema()` an unattached slug THROWS rather than + * falling back to a global lookup. Every load of `/standaarden` answered + * `404 {"message":"Schema not found: 'element'"}`. + * + * These tests close both holes statically, from the repository's own files. + * + * ⚠️ The sentinel → register-slug mapping below is DECLARED HERE, on purpose. + * Nothing in the app declares it: the register ids are discovered at runtime by + * `SettingsService::configureVoorzieningen()` / `configureAmef()`, the latter by + * detecting which register carries the AMEF core schemas. So this map is the + * written-down intent, and an unmapped sentinel FAILS rather than being skipped + * — a new sentinel must be a decision, not a silent gap. + * + * @category Test + * @package OCA\SoftwareCatalog\Tests\Unit\AppInfo + * @author Conduction b.v. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\AppInfo; + +use PHPUnit\Framework\TestCase; + +/** + * Every manifest register sentinel must be provisioned, and must name a + * register that actually attaches the schema the page asks for. + */ +class ManifestRegisterSentinelTest extends TestCase { + + /** + * Sentinel key => the register slug it is expected to resolve to in + * `lib/Settings/softwarecatalogus_register.json`. + * + * @var array + */ + private const SENTINEL_REGISTERS = [ + 'voorzieningen_register' => 'voorzieningen', + 'amef_register' => 'vng-gemma', + ]; + + /** + * Repository root. + * + * @var string + */ + private string $root; + + /** + * Decoded `src/manifest.json`. + * + * @var array + */ + private array $manifest; + + /** + * Decoded `lib/Settings/softwarecatalogus_register.json`. + * + * @var array + */ + private array $registerConfig; + + /** + * Source of `lib/AppInfo/Application.php`. + * + * @var string + */ + private string $applicationPhp; + + /** + * Load the three artefacts under test. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->root = dirname(__DIR__, 3); + + $manifest = json_decode( + (string)file_get_contents($this->root . '/src/manifest.json'), + true + ); + $this->assertIsArray($manifest, 'src/manifest.json did not decode'); + $this->manifest = $manifest; + + $registerConfig = json_decode( + (string)file_get_contents( + $this->root . '/lib/Settings/softwarecatalogus_register.json' + ), + true + ); + $this->assertIsArray($registerConfig, 'the register JSON did not decode'); + $this->registerConfig = $registerConfig; + + $this->applicationPhp = (string)file_get_contents( + $this->root . '/lib/AppInfo/Application.php' + ); + }//end setUp() + + /** + * Collect every `(sentinel key, schema slug)` pair the manifest declares. + * + * Walks the whole tree rather than just `pages[].config`, because a detail + * page's widgets carry their own `content.{register,schema}` and those hit + * the same endpoint. + * + * @param mixed $node Current node. + * @param array> $out Accumulator, by reference. + * + * @return void + */ + private function collectPairs($node, array &$out): void { + if (is_array($node) === false) { + return; + } + + $register = ($node['register'] ?? null); + $schema = ($node['schema'] ?? null); + if (is_string($register) === true + && is_string($schema) === true + && str_starts_with($register, '@resolve:') === true + && $schema !== '' + ) { + $out[] = [substr($register, strlen('@resolve:')), $schema]; + } + + foreach ($node as $child) { + $this->collectPairs($child, $out); + } + }//end collectPairs() + + /** + * Every sentinel the manifest uses is provisioned by `Application::boot()`. + * + * @return void + */ + public function testEverySentinelIsProvisioned(): void { + $pairs = []; + $this->collectPairs($this->manifest, $pairs); + + $keys = array_values(array_unique(array_column($pairs, 0))); + sort($keys); + + // A zero-pair run would pass every assertion below without checking + // anything, so state the subject count first. + $this->assertGreaterThan( + 0, + count($keys), + 'no @resolve: register sentinels found in src/manifest.json — the ' + . 'collector is broken, not the manifest' + ); + + foreach ($keys as $key) { + $this->assertStringContainsString( + "provideInitialState('" . $key . "'", + $this->applicationPhp, + sprintf( + 'src/manifest.json uses "@resolve:%s" but ' + . 'lib/AppInfo/Application.php::boot() never provisions it, so ' + . 'it resolves to null and the page fetches ' + . '/api/objects/null/.', + $key + ) + ); + } + }//end testEverySentinelIsProvisioned() + + /** + * Every `(sentinel, schema)` pair names a register that ATTACHES that + * schema in the register configuration. + * + * @return void + */ + public function testEveryPairNamesARegisterThatAttachesTheSchema(): void { + $registers = ($this->registerConfig['components']['registers'] ?? []); + $this->assertNotEmpty( + $registers, + 'the register JSON declares no registers — the reader is broken' + ); + + $pairs = []; + $this->collectPairs($this->manifest, $pairs); + $this->assertGreaterThan(0, count($pairs), 'no (register, schema) pairs collected'); + + foreach ($pairs as [$key, $schema]) { + $this->assertArrayHasKey( + $key, + self::SENTINEL_REGISTERS, + sprintf( + '"@resolve:%s" is not in this test\'s sentinel map. Add it ' + . 'together with the register slug it resolves to — an ' + . 'unmapped sentinel is unchecked, and the defect this test ' + . 'exists for is exactly a sentinel pointing at the wrong ' + . 'register.', + $key + ) + ); + + $slug = self::SENTINEL_REGISTERS[$key]; + $this->assertArrayHasKey( + $slug, + $registers, + sprintf('register "%s" is not declared in the register JSON', $slug) + ); + + $attached = ($registers[$slug]['schemas'] ?? []); + $this->assertContains( + $schema, + $attached, + sprintf( + 'src/manifest.json reads schema "%s" from "@resolve:%s" ' + . '(register "%s"), but that register attaches only [%s]. ' + . 'GET /api/objects/{%s}/%s answers 404 "Schema not found". ' + . 'Point the page at the register that carries the schema — ' + . 'attaching the schema to this register instead would make ' + . 'the request succeed and return NOTHING, because objects ' + . 'live per register.', + $schema, + $key, + $slug, + implode(', ', $attached), + $slug, + $schema + ) + ); + } + }//end testEveryPairNamesARegisterThatAttachesTheSchema() + + /** + * Positive control for the check above: the attachment assertion really + * does reject a schema the register does not carry. + * + * Without this, `testEveryPairNamesARegisterThatAttachesTheSchema` passing + * is equally consistent with the register JSON listing every schema under + * every register, or with the reader silently yielding an empty list. + * + * @return void + */ + public function testTheAttachmentCheckCanFail(): void { + $registers = ($this->registerConfig['components']['registers'] ?? []); + $schemas = array_keys(($this->registerConfig['components']['schemas'] ?? [])); + + $this->assertContains( + 'element', + $schemas, + '"element" is not even declared as a schema — the fixture moved' + ); + $this->assertNotContains( + 'element', + ($registers['voorzieningen']['schemas'] ?? []), + '"element" is now attached to voorzieningen. If that was deliberate, ' + . 'note that it makes the Standards fetch SUCCEED and return an ' + . 'empty list, because AMEF elements are written to the AMEF ' + . 'register — a visible error traded for an invisible pass.' + ); + $this->assertContains( + 'element', + ($registers['vng-gemma']['schemas'] ?? []), + '"element" is no longer attached to the AMEF register, so the ' + . 'Standards pages have nowhere to read from' + ); + }//end testTheAttachmentCheckCanFail() +}//end class