Skip to content

fix: four production defects found by live testing the market-gap wave - #392

Merged
rubenvdlinde merged 4 commits into
developmentfrom
fix/facet-objectentity-normalization
Jul 24, 2026
Merged

fix: four production defects found by live testing the market-gap wave#392
rubenvdlinde merged 4 commits into
developmentfrom
fix/facet-objectentity-normalization

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Live-tested the merged 2026-07-23 market-gap wave on a real NC 34 instance (8080). All 396 unit tests were green, yet four defects were dead in production. All four are fixed and verified live.

1. GET /api/facets/module → 500 (endpoint 100% dead)

FacetService helpers are array-typed, but OpenRegister's searchObjectsPaginated()/searchObjects() return ObjectEntity instances. The test stub returned plain arrays — textbook test-fake drift.
Fix: normalizeObject() at both fetch boundaries (fetchBaseObjects(), fetchModulesByIdentifiers()), preferring jsonSerialize() (confirmed against ObjectEntity, which merges payload + @self + top-level id).
Regression tests added that feed ObjectEntity-shaped results for both the module and dienst paths — these would have caught it. Array-shaped tests kept.
Verified live: 200, totalMatched: 18, 35ms.
Audited the sibling wave services (PortfolioReport, SbomImport, EolSync, MergeOrganisatie) — none shared the bug.

2. Portfolio report organisation picker dead (page unusable)

loadOrganisations() resolved the schema via getSchemaConfig('organisatie'), but voorzieningen_config.organisatie_schema is never populated, so registerObjectType() was skipped and fetchCollection() threw before any request.
Fix: register by schema slug against voorzieningenConfig.register, mirroring the proven path in useSelfFetchList.js (OpenRegister accepts slug or numeric id).
Verified live: picker lists all 17 organisations; selecting one renders the TIME report.

3. SBOM endpoint 500 instead of 404

DoesNotExistException from OpenRegister's find() escaped SbomController uncaught.
Fix: translated to a proper 404 (MODULE_VERSION_NOT_FOUND) on both endpoints, following the fleet convention. Tests added.

4. App root 404'd — Nextcloud app-switcher entry broken for every user

dashboard#page was registered twice (bare / and the /{path} SPA catch-all). Both generate the same internal route name, so the catch-all displaced the root route — and its .+ requirement can never match an empty path.
Fix: postfix on the catch-all. Verified live: root 200, sub-paths 200.

Tests: 400 tests / 1264 assertions, 0 failures from these changes (run inside the NC container — the OCP symlink only resolves there). One pre-existing unrelated error: PortfolioReportControllerTest::testCsvFormatReturnsDownloadResponse (Symfony HeaderUtils absent in the test env; CSV export verified working live — filing separately).

Also filed from this session: #391 (occ upgrade doesn't apply register schema changes — systemic), #390 (contract schema bare grants), and a priority escalation on #379.

🤖 Generated with Claude Code

…t was dead in production

GET /apps/softwarecatalog/api/facets/module returned HTTP 500 on every real
request: FacetService::objectIdentifier(): Argument #1 ($object) must be of
type array, OCA\OpenRegister\Db\ObjectEntity given.

Root cause: OpenRegister's real ObjectService::searchObjectsPaginated() and
searchObjects() return `results` as OCA\OpenRegister\Db\ObjectEntity
instances, not plain arrays. Every downstream FacetService method
(objectIdentifier(), extractRelatedIdentifiers(), extractRelatedNames(), ...)
is array-typed. The unit test double fed the service plain arrays only, so
the mismatch never surfaced in tests — classic test-fake drift — and the
endpoint shipped 100% broken against real data.

Fix: add a single normalizeObject() boundary in FacetService and map every
OpenRegister search result through it before it enters any other method.
Prefers jsonSerialize() (mirrors the real ObjectEntity contract — merges
payload properties with @self metadata and the top-level id), falls back to
getObject(), then to a raw array cast. Applied at both places FacetService
consumes OR search results: fetchBaseObjects() (searchObjectsPaginated) and
fetchModulesByIdentifiers() (searchObjects, the dienst-schema module batch
lookup).

Audited PortfolioReportService, SbomImportService, EolSyncService, and
MergeOrganisatieService for the same assumption — all four already normalize
correctly (normalizeResults()/normalizeRow() helpers or explicit ->getObject()
calls), so they do not share this bug.

Added FakeObjectEntity, a minimal JsonSerializable fake mirroring the real
ObjectEntity's jsonSerialize() contract, and two regression tests that feed
searchObjectsPaginated()/searchObjects() ObjectEntity-shaped results through
both boundaries and assert identical facet counts to the array-shaped tests.
…as dead

The Portfolio rationalization organisation picker rendered "No results" on
every real instance despite the register holding real Gemeente/Samenwerking/
supplier organisations. Console: Error fetching organisatie collection:
Object type "organisatie" is not registered in the store.

Root cause: loadOrganisations() resolved the schema via
objectStore.getSchemaConfig('organisatie'), which only succeeds when the
voorzieningenConfig blob's organisatie_schema key holds a NUMERIC schema id.
That key is only ever populated by the voorzieningen auto-configure flow for
module/compliancy/moduleVersie/sbomComponent — organisatie_schema (along with
dienst/contactpersoon/gebruik/contract/koppeling/beoordeeling/suite/sector)
is always empty on instances that never ran a legacy manual config step, so
getSchemaConfig() silently produced no config and registerObjectType() was
never called before fetchCollection() threw.

The manifest-driven Organisaties index page never hits this: the shared
library's self-fetch path (useSelfFetchList.js) registers the type using the
SCHEMA SLUG itself ('organisatie') as the id, not a numeric id resolved from
a config blob — OpenRegister's objects endpoint accepts a schema slug or a
numeric id interchangeably. loadOrganisations() now follows that same proven
path: register 'organisatie' as both the store key and the schema id against
voorzieningenConfig.register (which IS reliably populated), with no
dependency on organisatie_schema ever being set.

Live-verified: the picker now lists all 17 organisations (4 Gemeente, 2
Samenwerking, suppliers) and selecting one successfully loads the TIME
quadrant report. Frontend bundle rebuilt (npm run build) so the fix is live.
GET /api/moduleversies/{uuid}/sbom for a non-existent moduleVersie uuid
500'd with an uncaught OCP\AppFramework\Db\DoesNotExistException ("Object
with identifier '...' not found in any magic table").

Root cause: SbomImportService::getStatus() and ::importForModuleVersie()
(via authorizeManage() -> resolveParentModuleUuid(), and via its own find()
call) both call OpenRegister's real ObjectService::find(). Despite its
?ObjectEntity return type suggesting null on a miss, and despite the
existing "if ($moduleVersie !== null)"/"if ($moduleVersie === null) throw
RuntimeException" guards already written under that assumption, find()'s
cross-table fallback lookup re-throws DoesNotExistException instead of
returning null for a well-formed but unresolvable uuid. Both controller
methods let that exception escape uncaught.

Fixed by catching DoesNotExistException in both getSbomImportStatus() and
importSbom() (the whole method body, since the exception can originate from
either the authorization guard or the import call) and translating it to a
404 JSONResponse with error: MODULE_VERSION_NOT_FOUND — following the same
try/catch-at-the-find()-boundary pattern already used elsewhere in the fleet
(e.g. launchpad's DashboardMetadataController::loadDashboard()).

Added two regression tests asserting 404 (not 500) for a non-existent
moduleVersieUuid on both endpoints. Live-verified via authenticated fetch:
the endpoint now returns 404 with {"message":"moduleVersie not found: ...",
"error":"MODULE_VERSION_NOT_FOUND"}.
dashboard#page was registered twice (bare '/' and the '/{path}' SPA
catch-all). Both generate the same internal route name, so the catch-all
silently displaced the bare-root route — and its own path requirement
('.+') can never match an empty path, so /apps/softwarecatalog/ 404'd for
every user, breaking the Nextcloud app-switcher entry point.

Found by live testing on 8080; verified fixed (root 200, sub-paths 200).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant