Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
8 changes: 4 additions & 4 deletions src/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@
"type": "index",
"title": "Standards",
"config": {
"register": "@resolve:voorzieningen_register",
"register": "@resolve:amef_register",
"schema": "element",
"filter": { "gemmaType": "standaard" },
"columns": [
Expand All @@ -504,17 +504,17 @@
},
"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/<voorzieningen>/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",
"route": "/standaarden/:id",
"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" },
Expand Down
297 changes: 297 additions & 0 deletions tests/Unit/AppInfo/ManifestRegisterSentinelTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
<?php

/**
* Manifest register-sentinel wiring tests.
*
* `src/manifest.json` addresses OpenRegister collections as a
* `(register, schema)` pair, where the register is written as a runtime
* sentinel — `"register": "@resolve:voorzieningen_register"`. The sentinel is
* substituted in the browser by @conduction/nextcloud-vue's
* `resolveManifestSentinels()`, which reads the value provisioned by
* `Application::boot()` through `@nextcloud/initial-state`.
*
* Two things can go wrong with that arrangement, and NEITHER is visible to any
* check this repo ran before these tests existed:
*
* 1. **A sentinel nobody provisions.** The manifest validator only checks the
* value's SHAPE, and the gate that cross-references the manifest against
* the register JSON deliberately skips sentinels — `isLiteralSlug()` in
* the gate package excludes any value containing `@`. So an unprovisioned
* key substitutes `null` and the page fetches
* `/api/objects/null/<schema>`.
*
* 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. <info@conduction.nl>
* @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<string, string>
*/
private const SENTINEL_REGISTERS = [
'voorzieningen_register' => 'voorzieningen',
'amef_register' => 'vng-gemma',
];

/**
* Repository root.
*
* @var string
*/
private string $root;

/**
* Decoded `src/manifest.json`.
*
* @var array<string, mixed>
*/
private array $manifest;

/**
* Decoded `lib/Settings/softwarecatalogus_register.json`.
*
* @var array<string, mixed>
*/
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<int, array<string>> $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/<schema>.',
$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
Loading
Loading