Skip to content

Commit bac20b4

Browse files
committed
test(unit): pin manifest register sentinels to a register that attaches the schema
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:<key>` register sentinel in the manifest is provisioned by `Application::boot()` — an unprovisioned one substitutes null and the page fetches `/api/objects/null/<schema>`; 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.
1 parent ee6b389 commit bac20b4

1 file changed

Lines changed: 297 additions & 0 deletions

File tree

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
<?php
2+
3+
/**
4+
* Manifest register-sentinel wiring tests.
5+
*
6+
* `src/manifest.json` addresses OpenRegister collections as a
7+
* `(register, schema)` pair, where the register is written as a runtime
8+
* sentinel — `"register": "@resolve:voorzieningen_register"`. The sentinel is
9+
* substituted in the browser by @conduction/nextcloud-vue's
10+
* `resolveManifestSentinels()`, which reads the value provisioned by
11+
* `Application::boot()` through `@nextcloud/initial-state`.
12+
*
13+
* Two things can go wrong with that arrangement, and NEITHER is visible to any
14+
* check this repo ran before these tests existed:
15+
*
16+
* 1. **A sentinel nobody provisions.** The manifest validator only checks the
17+
* value's SHAPE, and the gate that cross-references the manifest against
18+
* the register JSON deliberately skips sentinels — `isLiteralSlug()` in
19+
* the gate package excludes any value containing `@`. So an unprovisioned
20+
* key substitutes `null` and the page fetches
21+
* `/api/objects/null/<schema>`.
22+
*
23+
* 2. **A sentinel pointing at a register that does not carry the schema.**
24+
* This one shipped. The Standards pages read `schema: "element"` while
25+
* naming `@resolve:voorzieningen_register`, and
26+
* `lib/Settings/softwarecatalogus_register.json` attaches `element` to the
27+
* SECOND register in the same file (`vng-gemma` / AMEF), not to
28+
* `voorzieningen`. Declaring a schema is not attaching it: only an
29+
* attached schema is fetchable through `/api/objects/{register}/{schema}`,
30+
* and since OpenRegister's 2026-08-16 change to
31+
* `ObjectService::setSchema()` an unattached slug THROWS rather than
32+
* falling back to a global lookup. Every load of `/standaarden` answered
33+
* `404 {"message":"Schema not found: 'element'"}`.
34+
*
35+
* These tests close both holes statically, from the repository's own files.
36+
*
37+
* ⚠️ The sentinel → register-slug mapping below is DECLARED HERE, on purpose.
38+
* Nothing in the app declares it: the register ids are discovered at runtime by
39+
* `SettingsService::configureVoorzieningen()` / `configureAmef()`, the latter by
40+
* detecting which register carries the AMEF core schemas. So this map is the
41+
* written-down intent, and an unmapped sentinel FAILS rather than being skipped
42+
* — a new sentinel must be a decision, not a silent gap.
43+
*
44+
* @category Test
45+
* @package OCA\SoftwareCatalog\Tests\Unit\AppInfo
46+
* @author Conduction b.v. <info@conduction.nl>
47+
* @copyright 2026 Conduction B.V.
48+
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
49+
* @link https://codeberg.org/Conduction/SoftwareCatalog
50+
*/
51+
52+
declare(strict_types=1);
53+
54+
namespace OCA\SoftwareCatalog\Tests\Unit\AppInfo;
55+
56+
use PHPUnit\Framework\TestCase;
57+
58+
/**
59+
* Every manifest register sentinel must be provisioned, and must name a
60+
* register that actually attaches the schema the page asks for.
61+
*/
62+
class ManifestRegisterSentinelTest extends TestCase {
63+
64+
/**
65+
* Sentinel key => the register slug it is expected to resolve to in
66+
* `lib/Settings/softwarecatalogus_register.json`.
67+
*
68+
* @var array<string, string>
69+
*/
70+
private const SENTINEL_REGISTERS = [
71+
'voorzieningen_register' => 'voorzieningen',
72+
'amef_register' => 'vng-gemma',
73+
];
74+
75+
/**
76+
* Repository root.
77+
*
78+
* @var string
79+
*/
80+
private string $root;
81+
82+
/**
83+
* Decoded `src/manifest.json`.
84+
*
85+
* @var array<string, mixed>
86+
*/
87+
private array $manifest;
88+
89+
/**
90+
* Decoded `lib/Settings/softwarecatalogus_register.json`.
91+
*
92+
* @var array<string, mixed>
93+
*/
94+
private array $registerConfig;
95+
96+
/**
97+
* Source of `lib/AppInfo/Application.php`.
98+
*
99+
* @var string
100+
*/
101+
private string $applicationPhp;
102+
103+
/**
104+
* Load the three artefacts under test.
105+
*
106+
* @return void
107+
*/
108+
protected function setUp(): void {
109+
parent::setUp();
110+
111+
$this->root = dirname(__DIR__, 3);
112+
113+
$manifest = json_decode(
114+
(string)file_get_contents($this->root . '/src/manifest.json'),
115+
true
116+
);
117+
$this->assertIsArray($manifest, 'src/manifest.json did not decode');
118+
$this->manifest = $manifest;
119+
120+
$registerConfig = json_decode(
121+
(string)file_get_contents(
122+
$this->root . '/lib/Settings/softwarecatalogus_register.json'
123+
),
124+
true
125+
);
126+
$this->assertIsArray($registerConfig, 'the register JSON did not decode');
127+
$this->registerConfig = $registerConfig;
128+
129+
$this->applicationPhp = (string)file_get_contents(
130+
$this->root . '/lib/AppInfo/Application.php'
131+
);
132+
}//end setUp()
133+
134+
/**
135+
* Collect every `(sentinel key, schema slug)` pair the manifest declares.
136+
*
137+
* Walks the whole tree rather than just `pages[].config`, because a detail
138+
* page's widgets carry their own `content.{register,schema}` and those hit
139+
* the same endpoint.
140+
*
141+
* @param mixed $node Current node.
142+
* @param array<int, array<string>> $out Accumulator, by reference.
143+
*
144+
* @return void
145+
*/
146+
private function collectPairs($node, array &$out): void {
147+
if (is_array($node) === false) {
148+
return;
149+
}
150+
151+
$register = ($node['register'] ?? null);
152+
$schema = ($node['schema'] ?? null);
153+
if (is_string($register) === true
154+
&& is_string($schema) === true
155+
&& str_starts_with($register, '@resolve:') === true
156+
&& $schema !== ''
157+
) {
158+
$out[] = [substr($register, strlen('@resolve:')), $schema];
159+
}
160+
161+
foreach ($node as $child) {
162+
$this->collectPairs($child, $out);
163+
}
164+
}//end collectPairs()
165+
166+
/**
167+
* Every sentinel the manifest uses is provisioned by `Application::boot()`.
168+
*
169+
* @return void
170+
*/
171+
public function testEverySentinelIsProvisioned(): void {
172+
$pairs = [];
173+
$this->collectPairs($this->manifest, $pairs);
174+
175+
$keys = array_values(array_unique(array_column($pairs, 0)));
176+
sort($keys);
177+
178+
// A zero-pair run would pass every assertion below without checking
179+
// anything, so state the subject count first.
180+
$this->assertGreaterThan(
181+
0,
182+
count($keys),
183+
'no @resolve: register sentinels found in src/manifest.json — the '
184+
. 'collector is broken, not the manifest'
185+
);
186+
187+
foreach ($keys as $key) {
188+
$this->assertStringContainsString(
189+
"provideInitialState('" . $key . "'",
190+
$this->applicationPhp,
191+
sprintf(
192+
'src/manifest.json uses "@resolve:%s" but '
193+
. 'lib/AppInfo/Application.php::boot() never provisions it, so '
194+
. 'it resolves to null and the page fetches '
195+
. '/api/objects/null/<schema>.',
196+
$key
197+
)
198+
);
199+
}
200+
}//end testEverySentinelIsProvisioned()
201+
202+
/**
203+
* Every `(sentinel, schema)` pair names a register that ATTACHES that
204+
* schema in the register configuration.
205+
*
206+
* @return void
207+
*/
208+
public function testEveryPairNamesARegisterThatAttachesTheSchema(): void {
209+
$registers = ($this->registerConfig['components']['registers'] ?? []);
210+
$this->assertNotEmpty(
211+
$registers,
212+
'the register JSON declares no registers — the reader is broken'
213+
);
214+
215+
$pairs = [];
216+
$this->collectPairs($this->manifest, $pairs);
217+
$this->assertGreaterThan(0, count($pairs), 'no (register, schema) pairs collected');
218+
219+
foreach ($pairs as [$key, $schema]) {
220+
$this->assertArrayHasKey(
221+
$key,
222+
self::SENTINEL_REGISTERS,
223+
sprintf(
224+
'"@resolve:%s" is not in this test\'s sentinel map. Add it '
225+
. 'together with the register slug it resolves to — an '
226+
. 'unmapped sentinel is unchecked, and the defect this test '
227+
. 'exists for is exactly a sentinel pointing at the wrong '
228+
. 'register.',
229+
$key
230+
)
231+
);
232+
233+
$slug = self::SENTINEL_REGISTERS[$key];
234+
$this->assertArrayHasKey(
235+
$slug,
236+
$registers,
237+
sprintf('register "%s" is not declared in the register JSON', $slug)
238+
);
239+
240+
$attached = ($registers[$slug]['schemas'] ?? []);
241+
$this->assertContains(
242+
$schema,
243+
$attached,
244+
sprintf(
245+
'src/manifest.json reads schema "%s" from "@resolve:%s" '
246+
. '(register "%s"), but that register attaches only [%s]. '
247+
. 'GET /api/objects/{%s}/%s answers 404 "Schema not found". '
248+
. 'Point the page at the register that carries the schema — '
249+
. 'attaching the schema to this register instead would make '
250+
. 'the request succeed and return NOTHING, because objects '
251+
. 'live per register.',
252+
$schema,
253+
$key,
254+
$slug,
255+
implode(', ', $attached),
256+
$slug,
257+
$schema
258+
)
259+
);
260+
}
261+
}//end testEveryPairNamesARegisterThatAttachesTheSchema()
262+
263+
/**
264+
* Positive control for the check above: the attachment assertion really
265+
* does reject a schema the register does not carry.
266+
*
267+
* Without this, `testEveryPairNamesARegisterThatAttachesTheSchema` passing
268+
* is equally consistent with the register JSON listing every schema under
269+
* every register, or with the reader silently yielding an empty list.
270+
*
271+
* @return void
272+
*/
273+
public function testTheAttachmentCheckCanFail(): void {
274+
$registers = ($this->registerConfig['components']['registers'] ?? []);
275+
$schemas = array_keys(($this->registerConfig['components']['schemas'] ?? []));
276+
277+
$this->assertContains(
278+
'element',
279+
$schemas,
280+
'"element" is not even declared as a schema — the fixture moved'
281+
);
282+
$this->assertNotContains(
283+
'element',
284+
($registers['voorzieningen']['schemas'] ?? []),
285+
'"element" is now attached to voorzieningen. If that was deliberate, '
286+
. 'note that it makes the Standards fetch SUCCEED and return an '
287+
. 'empty list, because AMEF elements are written to the AMEF '
288+
. 'register — a visible error traded for an invisible pass.'
289+
);
290+
$this->assertContains(
291+
'element',
292+
($registers['vng-gemma']['schemas'] ?? []),
293+
'"element" is no longer attached to the AMEF register, so the '
294+
. 'Standards pages have nowhere to read from'
295+
);
296+
}//end testTheAttachmentCheckCanFail()
297+
}//end class

0 commit comments

Comments
 (0)