feat(cmdb-import): import a TOPdesk CMDB export (xlsx) into the catalogue - #1209
Conversation
…sx) into the catalogue Proposal, specs (REQ-CMDB-001..014), design, contract, migration, test-plan and tasks for importing a TOPdesk application export into OpenRegister through Stackiq: per row a module, the manufacturer and the importing municipality as organisations, a usage linking them and owner contacts; upsert on Middel-ID; publicationDate set so OpenCatalogi lists the applications and Portaliq shows the usages. Jira: https://conduction.atlassian.net/browse/WOO-586 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ogue An admin uploads the TOPdesk application export in Stackiq's admin settings. Per row Stackiq creates or updates a module, the manufacturer and the chosen municipality as organisations, a usage linking the municipality to the module and owner contact persons, all through OpenRegister's object service. Column mapping is declarative (lib/Settings/cmdb-import/) and runs through OpenRegister's mapping engine. Modules are matched on the TOPdesk Middel-ID per municipality, so a re-import updates instead of duplicating; new modules get a publicationDate so OpenCatalogi lists them. Admin-only routes with CSRF, xlsx only, 10 MB and 10,000 rows per sheet, values read without evaluating formulas, one failing row never aborts the import. Includes sanitised fixtures, PHPUnit, Newman and Playwright tests and administrator docs. Jira: https://conduction.atlassian.net/browse/WOO-586 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Quality Report — ConductionNL/stackiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue-demi | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ❌ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 130/130 | |||
| npm | ✅ | ✅ 807/807 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| lockfile sync | ✅ | ||||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it locally with npx playwright test, or from the Actions tab on a branch with no open pull request into development. |
||||
| Hydra gates | ❌ |
Quality workflow — 2026-10-01 12:40 UTC
Download the full PDF report from the workflow artifacts.
| * | ||
| * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 | ||
| */ | ||
| public function import(): JSONResponse { |
There was a problem hiding this comment.
🔴 Blocker — Gate 5 route-auth fails: import and cancel declare no auth attribute
CI's required check quality / Hydra Gates fails on gate 5 (route-auth, PROTECTED): CmdbImportController.php:88 method=import and :167 method=cancel both report rule=missing-auth-attribute. The gate wants one of #[PublicPage], #[NoAdminRequired], #[NoCSRFRequired] or #[AuthorizedAdminSetting(...)] (docblock tags count too) in the 20 lines above each method. Neither method, nor the class, carries any; the class docblock only describes the posture in prose, which the gate does not read. Both gate misses reproduce locally (0 hits in the 20-line window above lines 88 and 167).
Actual exposure today, stated honestly: with no attribute Nextcloud's default applies (SecurityMiddleware: non-admin gets 403 NotAdminException, missing CSRF token gets 412), so this is not a live auth bypass. The block is on the declaration. The review checklist says absence is a valid admin-only mechanism unless the app already uses AuthorizedAdminSetting; this app does (ModerationController and FederationController use #[AuthorizedAdminSetting(settings: StackiqAdmin::class)] plus the docblock tag), so follow that precedent. Do NOT satisfy the gate with @NoCSRFRequired (contract.md and REQ-CMDB-001 deliberately require CSRF) and do not add a docblock line that merely mentions @NoAdminRequired: Nextcloud's regex parses it as the real annotation and the endpoint would become reachable by every signed-in user (the failure AdminAuthPostureTest documents).
Behaviour change to decide deliberately: StackiqAdmin implements IDelegatedSettings, and AuthorizedAdminSetting lets a group that an admin has delegated that settings section to reach the endpoint, not only full Nextcloud admins. An import writes catalogue-wide data for a municipality. If delegated admins are acceptable, say so in REQ-CMDB-001, contract.md (both Auth lines) and the openapi descriptions, which all currently say 'Nextcloud admin' and 'SHALL NOT carry NoAdminRequired/NoCSRFRequired' (the latter stays true). If not, the alternative that satisfies the gate and stays strictly admin is #[NoAdminRequired] plus an explicit IGroupManager::isAdmin() check in the body, which contradicts the spec sentence and fails open if the check is dropped, so the first option is preferred.
Skill: /home/wilco/hydra/.claude/skills/hydra-gate-route-auth/SKILL.md.
Persistence audit (A2): if the fix copies ModerationController's #[AuthorizedAdminSetting(settings: StackiqAdmin::class)], any group an admin delegated the Stackiq settings to can then bulk-write the catalogue with _rbac:false. Keep it full-admin unless delegation is a conscious decision, and then say so in REQ-CMDB-001 and contract.md. Do not add #[NoAdminRequired].
Done when:
quality / Hydra Gatesis green on the PR head, gate 5 reports nothing for CmdbImportController.php- import() and cancel() each carry
#[AuthorizedAdminSetting(settings: StackiqAdmin::class)](plus the docblock tag as in ModerationController), or the author documents a different gate-accepted choice - The delegated-admin consequence is decided and written into REQ-CMDB-001, contract.md and the openapi descriptions
- testBothRoutesAreAdminOnlyWithCsrf is updated to the new declaration (see A2)
Verification: cd /tmp/pr-1209-clone && for m in import cancel; do l=$(grep -nE "^\s*public\s+function\s+$m\s*\(" lib/Controller/CmdbImportController.php | cut -d: -f1); s=$((l-20)); sed -n "${s},${l}p" lib/Controller/CmdbImportController.php | grep -cE '#\[(PublicPage|NoAdminRequired|NoCSRFRequired|AuthorizedAdminSetting)\b|@(PublicPage|NoAdminRequired|NoCSRFRequired)\b' | sed "s/^/$m hits: /"; done # both must be >=1; today 0 and 0
Choosing the attribute. Gate-5 accepts #[AuthorizedAdminSetting(...)] whatever its argument (hydra-gate-route-auth, "Heuristics the check accepts"). In Nextcloud that attribute admits full admins and every group delegated to the named settings class, so AuthorizedAdminSetting(settings: StackiqAdmin::class), the form ModerationController uses, widens who may bulk-write with _rbac:false. Nextcloud has no attribute that means full-admin only. So decide:
- (a) accept delegation and write that down in REQ-CMDB-001, contract.md and openapi.json; or
- (b) keep full-admin: add an explicit
IGroupManager::isAdmin()check with a test. Note that the gate skill says such a check also passes, but its grep (SKILL.md line 56) only looks for attributes, so confirm gate-5 goes green before relying on it.
Do not add #[NoAdminRequired].
| * | ||
| * @link https://OpenRegister.app | ||
| * | ||
| * @spec openspec/specs/migration-mapping-packs/spec.md |
There was a problem hiding this comment.
🔴 Blocker — gate-46: unresolved @SPEC anchors in vendored OR copies
🔴 Blocker — gate-46 spec-anchor-existence fails on the vendored OpenRegister copies (required check)
tests/Unit/Support/OpenRegister/MappingEngine.php (lines 35, 57, 69, 143) and PackDefinitionValidator.php (lines 29, 50, 84, 110) carry eight @spec openspec/specs/migration-mapping-packs/spec.md[#anchor] tags. That spec lives in openregister, not in stackiq, so Hydra Gates is red. See the hydra-gate-spec-anchor-existence skill for the accepted fixes (drop the tag from the copied header, or a reason-bearing @spec exclude {reason}).
Second question you asked, whether a test-side copy is a valid oracle: it is a verbatim copy (I diffed it against openregister/lib/Service/MigrationPack/*.php at 2.1.34: only the added TEST COPY header differs), so today it cannot diverge. But it is a snapshot with no drift check. Where OpenRegister is checked out the tests load the real class; where it is not (the fallback), every pack-validation and mapping assertion runs against a frozen 2.1.34 copy, so a later change to mapRow() (new error shape, a changed lookup rule) passes CI and fails in production. The copies also sit in the OCA\OpenRegister\... namespace inside stackiq's tests, which is how the foreign @spec tags got here.
Impact: the required gate stays red; the copy gives false assurance once OpenRegister moves on.
Suggested fix: remove the eight @spec lines from the copies (the header already says they are copies) and add a drift test that, when CmdbTestSupport::openRegisterDir() finds OpenRegister, asserts sha1 of the copy body equals the real file (skip with a named reason otherwise). Prefer running the CMDB tests against the real classes in CI instead of the copy.
Done when:
git grep -n '@spec openspec/specs/migration-mapping-packs' -- tests/returns nothing, andHydra Gatesis green- a test fails when
tests/Unit/Support/OpenRegister/MappingEngine.phpis edited by one character while OpenRegister is on the path
Verification: gh pr checks 1209 -R ConductionNL/stackiq | grep 'Hydra Gates'; cd /tmp/pr-1209-clone && git grep -n '@spec openspec/specs/migration-mapping-packs' -- tests/ | wc -l -> 8
| $reader->setLoadSheetsOnly($present); | ||
|
|
||
| try { | ||
| $spreadsheet = $reader->load($path); |
There was a problem hiding this comment.
🔴 Blocker — 10 MB / 10,000-row caps do not bound memory (OOM fatal)
🔴 Blocker — the size and row caps do not bound what the reader loads; a 9.7 MB upload kills the PHP worker
read() calls $reader->load($path) (line 166) on the whole sheet, and only afterwards counts rows (readRows(), line 333). The 10 MB cap is on the compressed upload, and assertXlsx() never looks at the uncompressed size of the package. I built a 9.77 MB xlsx (2,000,000 <row> elements, 99.8 MB of sheet XML, one unread column) and loaded it exactly as the reader does (setReadDataOnly(true), setReadEmptyCells(false), setLoadSheetsOnly): PHP died with Allowed memory size of 536870912 bytes exhausted inside Cell.php. That is an uncatchable fatal, so the try/catch at lines 165-173 never runs, the admin gets an HTML 500, and TOO_MANY_ROWS is never reached. A better-compressing file (repeated identical rows) needs fewer bytes still. PR body: "10 MB per upload, 10,000 rows per sheet" and the spec's security NFR ("bounded size and row count") are therefore not true for memory or time.
XXE is fine: PhpSpreadsheet 5.10 refuses a DOCTYPE/ENTITY (Detected use of ENTITY in XML ...), which surfaces as NOT_XLSX. I checked that too.
Impact: denial of service of the PHP worker (and the Nextcloud instance's memory) by anyone who can use the admin route, with a CSRF-passing 10 MB file; no row cap is reached.
Suggested fix: in assertXlsx() open the ZipArchive and sum statIndex()['size'] of xl/worksheets/*.xml, xl/sharedStrings.xml and the workbook; refuse above a profile limit (e.g. maxUncompressedBytes, default 50 MB) before PhpSpreadsheet runs. In read() pass a IReadFilter that admits only row 1 and rows 2..maxRowsPerSheet+1 and only the referenced columns, so the library never materialises more.
Done when:
php -d memory_limit=512M /tmp/claude-1000/-home-wilco-hydra/4c54cb9e-dc0c-4004-8bbb-60471f7b46ca/scratchpad/b/rd.php /tmp/claude-1000/-home-wilco-hydra/4c54cb9e-dc0c-4004-8bbb-60471f7b46ca/scratchpad/b/bomb.xlsx(the 9.7 MB file) no longer dies: the import answers 422 before PhpSpreadsheet loads- a unit test builds a package whose
xl/worksheets/sheet1.xmlinflates past the limit and expectsCmdbImportException(TOO_MANY_ROWS or a new code), without callingload() - a unit test with
maxRowsPerSheet = 1on a sheet with 3 data rows proves the read filter, not the post-load loop, stops it
Verification: cd /tmp/claude-1000/-home-wilco-hydra/4c54cb9e-dc0c-4004-8bbb-60471f7b46ca/scratchpad/b && php mk.php 2000000 bomb.xlsx && php -d memory_limit=512M rd.php bomb.xlsx -> 'Allowed memory size of 536870912 bytes exhausted' (needs /home/wilco/woo586-rig/apps/openregister/vendor)
| "properties": { | ||
| "externalId": { | ||
| "type": "string", | ||
| "title": "Source id", |
There was a problem hiding this comment.
🔴 Blocker — Ten new schema strings have no l10n catalogue key
CI quality / Frontend Check (check:schema-l10n) fails: 691 schema strings, 489 uncovered, baseline 479. The fragment adds exactly ten strings (title + description of the five new module properties externalId, externalNumber, externalKey, externalCreatedAt, externalModifiedAt, lines 9/10, 18/19, 27/28, 40/41, 49/50). node scripts/check-schema-l10n.js --list on the head minus the same list on origin/development gives exactly these ten and nothing else.
Impact: the new fields render in English (label and helper text) inside an otherwise Dutch module form, and the ratchet is red so the PR cannot merge. Raising the baseline (--update) is not the fix: it hides the debt the ratchet exists to measure.
Suggested fix: add the ten strings to l10n/en.json (identity) and l10n/nl.json, then npm run l10n:build. Proposed Dutch: Source id = Bron-id; The identifier of the application in the source system it was imported from, such as the TOPdesk Middel-ID. = De identificatie van de applicatie in het bronsysteem waaruit is geimporteerd, zoals het TOPdesk Middel-ID.; Source number = Bronnummer; The application number in the source system, such as TOPdesk's ICT Applicatienummer. Shown for reference; not used to match records. = Het applicatienummer in het bronsysteem, zoals het ICT Applicatienummer van TOPdesk. Ter informatie; niet gebruikt om records te koppelen.; Import key = Importsleutel; The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit. = De sleutel waarop een herhaalde import deze applicatie herkent: topdesk::. Door de CMDB-import ingesteld; niet wijzigen.; Created in source = Aangemaakt in bron; The date the application was registered in the source system. = De datum waarop de applicatie in het bronsysteem is geregistreerd.; Changed in source = Gewijzigd in bron; The date the application was last changed in the source system. = De datum waarop de applicatie in het bronsysteem voor het laatst is gewijzigd. (use the proper i-diaeresis in geimporteerd.)
Done when:
node scripts/check-schema-l10n.jsexits 0 without a changed l10n/.schema-l10n-baseline.jsonnode scripts/check-schema-l10n.js --list | grep -c topdesk-cmdb-importprints 0npm run check:l10n-jsandnpm run test:l10nstill exit 0
Verification: cd /tmp/pr-1209-clone && node scripts/check-schema-l10n.js; echo exit=$? # red today: '691 schema string(s); 489 uncovered, baseline 479'; and: diff <(cd <origin/development checkout> && node scripts/check-schema-l10n.js --list) <(node scripts/check-schema-l10n.js --list) lists the 10 strings
| SANITISED = os.path.join(HERE, 'topdesk-export-anonymised.xlsx') | ||
|
|
||
| # Parts that never belong in a fixture. | ||
| DROP_PARTS = ('docProps/custom.xml', 'xl/connections.xml') |
There was a problem hiding this comment.
🔴 Blocker — Fixtures keep printer/device metadata and source timestamps from the original workbook
The four derived fixtures and topdesk-export-anonymised.xlsx are made by hand-anonymising the real export and then stripping only core.xml creator/lastModifiedBy, custom.xml, customXml, absPath and connections.xml (DROP_PARTS / DROP_PREFIXES, lines 36-37; sanitise(), lines 63-93). The tests/fixtures/cmdb/README.md says 'Document metadata ... removed'. That is not true for what is left:
- xl/printerSettings/printerSettings1..10.bin: 1 and 10 name 'HP055CC5 (HP Deskjet F4500 series)' and '... (vdi)', 2-9 name 'EPSON4E1D67 (XP-2200 Series)'. The suffixes are the device-specific part of the printer name (derived from its network address), and '(vdi)' shows it was a printer redirected into a virtual-desktop session, i.e. the machine the export was handled on. Referenced from the sheet rels, which sanitise() keeps.
- docProps/core.xml keeps
dcterms:created2022-05-04T12:35:44Z (creation time of the real export) in all four derived fixtures. - xl/workbook.xml keeps the
xr:revisionPtr documentIdGUID and the_xlnm._FilterDatabaseranges, which expose the size of the real CMDB ('Beheerde Applicaties CMDB'!$A$1:$AP$1066, 'Gearchiveerde Applicaties'!$A$1:$I$434); sheet3.xmldimension A1:CD1136. - Real (non-personal) organisation data remain as cell text: unit codes 'B10 Maatschappelijke Ontwikkeling', 'H10 IIFO (T)', 'H10 Bestuurs- en Concernondersteuning', 'B10 MOW Leiding & Ondersteuning 1', behandelgroep 'AC Liquide middelen', applications 'G4net' / 'Pronexus G4net' and 'Aangetekend B.V.'. These identify the source municipality; not person data, so a judgement call, but 'same structure, anonymised' (Jira WOO-586) suggests replacing them.
I enumerated all 224 shared strings and every cell value of all 10 sheets of topdesk-export-anonymised.xlsx: no person name, personnel number, phone number or non-placeholder e-mail address remains (see the surface table). The hard constraint on personal data holds for cell content; the residue above is device/provenance metadata, in a PUBLIC repo where it cannot be taken back.
Impact: a printer/device identifier and the creation time of the Rotterdam export are published permanently; the README's claim is inaccurate; the hygiene test (C4) cannot catch any of it.
Suggested fix: in sanitise(): drop xl/printerSettings/* plus their Relationship entries in xl/worksheets/_rels/*.rels, the r:id on <pageSetup> in every sheet, and the bin Default content type if unused; set dcterms:created/modified to a fixed fictional date; drop xr:revisionPtr; rewrite or drop the _xlnm._FilterDatabase defined names and the dimension of the data sheets; rebuild all five fixtures; optionally replace the real unit/vendor/application names with neutral ones.
Done when:
unzip -l tests/fixtures/cmdb/*.xlsx | grep -c printerSettingsprints 0 for every fixtureunzip -p tests/fixtures/cmdb/topdesk-export-anonymised.xlsx docProps/core.xmlshows no real creation timestamp- a copy of a fixture with
xl/printerSettings/printerSettings1.binre-added makes CmdbFixtureHygieneTest fail (see C4) - tests/fixtures/cmdb/README.md matches what the files hold
Verification: cd /tmp/pr-1209-clone/tests/fixtures/cmdb && for f in *.xlsx; do echo "$f printerSettings=$(unzip -l $f | grep -c printerSettings)"; done; unzip -p topdesk-export-anonymised.xlsx xl/printerSettings/printerSettings1.bin | strings -e l | head -2; unzip -p topdesk-export-anonymised.xlsx docProps/core.xml | grep -o 'created[^<]*<'
|
|
||
| ## ADDED Requirements | ||
|
|
||
| ### Requirement: REQ-CMDB-001 The import endpoint SHALL accept only a bounded xlsx upload from a Nextcloud admin |
There was a problem hiding this comment.
🟢 Minor — Requirement headings use the REQ-ID prefix form, not ADR-038 Form A
ADR-038 puts the ID as a parenthesised suffix: ### Requirement: <Title> (REQ-XX-NNN). All 14 headings here (REQ-CMDB-001..014 in the delta) are ### Requirement: REQ-CMDB-001 <Title> (Form D), while REQ-CMDB-000 in openspec/specs/cmdb-export-import/spec.md uses the suffix form, so the merged capability will mix both at archive. The repo as a whole is mixed (116 suffix, 61 prefix headings under openspec/specs), so this is consistency, not a break. Renaming changes the heading slugs that @spec ...#requirement-req-cmdb-0NN-... tags in src/ and lib/ point at (gate 46), so it needs a sweep of those anchors.
| - **spec_ref**: `SPEC#requirement-req-cmdb-002-the-workbook-shall-be-read-as-stored-data-without-evaluating-formulas-or-following-links` (cmdb-export-import#REQ-CMDB-002, also used by every other task) | ||
| - **files**: `tests/fixtures/cmdb/topdesk-export-anonymised.xlsx`, `tests/fixtures/cmdb/topdesk-missing-middel-id.xlsx`, `tests/fixtures/cmdb/topdesk-shuffled-columns.xlsx`, `tests/fixtures/cmdb/topdesk-formula-and-connection.xlsx`, `tests/fixtures/cmdb/README.md`, `tests/fixtures/cmdb/build-fixtures.py` | ||
| - **acceptance_criteria**: | ||
| - GIVEN the anonymised test export from the WOO-586 plan folder WHEN it is copied to `topdesk-export-anonymised.xlsx` THEN `docProps/core.xml` has no creator or lastModifiedBy, and `docProps/custom.xml`, `customXml/` and `xl/connections.xml` are removed, with their entries in `[Content_Types].xml` and the rels files |
There was a problem hiding this comment.
🟢 Minor — Repo text points readers at a private plan folder
'the anonymised test export from the WOO-586 plan folder' (tasks.md:11), 'route A in the WOO-586 plan' (design.md:63) and 'USER MANUAL TEST, WOO-586 Stap 6b' (test-plan.md:96) refer to a private workspace that contributors of this public repo cannot open. tasks.md:11 also reads as if the source file is kept somewhere next to the plan; the README rightly says it must never be committed. State the rule, not the location.
| } | ||
| return t( | ||
| 'stackiq', | ||
| 'Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.', |
There was a problem hiding this comment.
🟢 Minor — t() with variables HTML-escapes values that Vue escapes again
@nextcloud/l10n translate() escapes variable values (escape: true) and runs DOMPurify (sanitize: true) by default. The component renders the result through {{ }}, which escapes once more. A municipality typed as Berkel & Rodenrijs renders in 'Import for ... finished' (lines 490-503) and 'A new municipality "{name}" is created' (line 40) as Berkel & Rodenrijs; same for sheet/column names from details in errorText() (cmdbImport.js:331, 343, 359). I reproduced it with @nextcloud/l10n 3.4.1 + jsdom: translate('stackiq','Import for {name} finished.',{name:'Berkel & Rodenrijs <b>'}) gives Berkel & Rodenrijs <b>. The same pattern exists at 37 other single-line sites in src/**/*.vue, so this is a fleet quirk, not new to this PR; ampersands in municipality names are rare. XSS is not possible (no v-html).
| }} | ||
| </NcNoteCard> | ||
|
|
||
| <h4 class="cmdb-import__heading"> |
There was a problem hiding this comment.
🟢 Minor — Report headings skip a level; file label looks like a drop zone
The report uses <h4> (lines 204, 231) directly under the settings section's heading (NcSettingsSection renders an h2), skipping h3 (WCAG 1.3.1 best practice, not a failure). The file control is a dashed, large 'drop zone' look but has no drop handler; the spec (REQ-CMDB-014 NFR) correctly says dragging does not apply, so the visual affordance promises something the control does not do. Runtime behaviour not checked (no browser run in this review).
| .first() | ||
| .click() | ||
| await expect( | ||
| page.locator('[data-testid="cmdb-import-municipality"] .vs__selected'), |
There was a problem hiding this comment.
🟢 Minor — E2E leans on vue-select internals and skips instead of failing
.vs__selected (line 190) and td nth(1) (line 336) are library class/position selectors instead of data-testid; requireFixture() (line 76) turns a missing fixture into a skip, which the gates policy reads as 'no evidence', not a pass; the second test skips when the first wrote nothing (line 372), hiding a failed first run if serial mode ever changes; a first-run-wizard workaround calls DELETE /apps/firstrunwizard/wizard (line 148). The tests do upload the real fixtures and assert real outcomes (created/unchanged counts, module links, 422 body, 400 NOT_XLSX), which is the right shape.
WilcoLouwerse
left a comment
There was a problem hiding this comment.
Verdict: REQUEST_CHANGES (Strict) — self-review posted as COMMENT (GitHub blocks REQUEST_CHANGES on your own PR)
The import works as designed for a well-formed, sequential import, but it cannot merge yet. The required quality / Hydra Gates check is red (gate-5 route-auth, gate-46 spec anchors), the workbook reader has no memory bound, ten new schema strings have no translation key, and the fixtures still carry device metadata from the original export. Under Strict the concerns block too; several of them (B2, B6/B14, B10, B13, P1) are design decisions rather than code fixes.
🔴 Blockers (5)
- Gate 5 route-auth fails: import and cancel declare no auth attribute —
lib/Controller/CmdbImportController.php:88.import()andcancel()declare no auth attribute, so the required Hydra Gates check is red. Nextcloud's default keeps them admin-only today; choosingAuthorizedAdminSetting(StackiqAdmin)would also admit delegated groups. - gate-46: unresolved @spec anchors in vendored OR copies —
tests/Unit/Support/OpenRegister/MappingEngine.php:35. 8@spectags in the test-side copies of OpenRegister'sMappingEngine/PackDefinitionValidatorpoint atopenspec/specs/migration-mapping-packs/spec.md, which does not exist in stackiq. - 10 MB / 10,000-row caps do not bound memory (OOM fatal) —
lib/Service/Cmdb/CmdbWorkbookReader.php:166. The readerload()s the whole workbook before the row cap at line 245 applies, with no read filter or decompressed-size check; a 9.77 MB crafted file exhausted a 512 MB memory limit. - Ten new schema strings have no l10n catalogue key —
lib/Settings/register.d/topdesk-cmdb-import.json:9.check:schema-l10nis red (489 uncovered against a baseline of 479): the new fragment adds 10 schema strings with noen.json/nl.jsonkey. - Fixtures keep printer/device metadata and source timestamps from the original workbook —
tests/fixtures/cmdb/build-fixtures.py:36. Four of the five fixtures still contain 10xl/printerSettings/*.binparts with device names (HP055CC5 (HP Deskjet F4500…),EPSON4E1D67), the original 2022 creation timestamp and a revision GUID;build-fixtures.pydoes not strip them.
🟡 Concerns (29)
- Admin-only/CSRF requirement has no test that can fail for it —
tests/Unit/Controller/CmdbImportControllerTest.php:135. The admin/CSRF requirement is pinned only bygetAttributes() === [], which A1's fix must break, and the Newman 403/412 cases never run in CI (enable-newman: false). - Catch of \Exception lets \Error escape the IMPORT_FAILED envelope —
lib/Controller/CmdbImportController.php:102.catch (\Exception)lets an\Error/TypeErrorfrom the service escape theIMPORT_FAILEDenvelope as a bare 500. - updateExisting parser fails open to the overwriting mode —
lib/Controller/CmdbImportController.php:257. AnyupdateExistingvalue outsidefalse/0/no/off(a typo likeflase) means overwrite, so the parser fails open to the destructive mode. - Cancel accepts finished operations and leaves a stale cancel flag —
lib/Controller/CmdbImportController.php:168.cancelreturns success for a finished operation and leaves a cancel flag behind that a reused operationId inherits. - Import makes every imported application anonymously readable —
lib/Service/CmdbExportImportService.php:690. Every created module getspublicationDate = now, which the module schema'spublicread rule turns into anonymous visibility. The PR intends this, but there is no way to import unpublished. - Idempotency fails open: no check that module has the new properties —
lib/Service/CmdbExportImportService.php:1129. If themoduleschema has not been migrated to 0.3.5, theexternalKeyfilter becomes1 = 0, so every re-import creates duplicates instead of failing. - Concurrent or repeated submits duplicate every object —
lib/Service/CmdbExportImportService.php:687. No lock or in-flight check: a double submit or two admins importing the same file at once duplicates every object. - externalKey is a client-writable identity; import overwrites matches across tenants with RBAC off —
lib/Service/CmdbExportImportService.php:1131.externalKeyhas no write authorization, and the import matches it across tenants with_rbac:false, so any module writer can plant a key the next import overwrites. - Tombstoned and inactive organisations are matched as municipality or supplier —
lib/Service/CmdbExportImportService.php:1107. Organisations with statusmerged(tombstones) or inactive are still matched as municipality or supplier. - Municipality by name picks the first of several same-name matches —
lib/Service/CmdbExportImportService.php:1000. A municipality given by name takes the first of several same-name matches, and an unknown name silently creates one. - Synchronous 10k-row import: no time limit, operation left running on abort —
lib/Service/CmdbExportImportService.php:260. The 10,000-row import runs synchronously, with noset_time_limit/ignore_user_abort, and an aborted request leaves the operationrunning. - Owner personal data lands in the importing admin's address book —
lib/Service/CmdbExportImportService.php:908. Owners with an e-mail are written as vCards into the importing admin's first writable address book, and nothing removes them later. - Untrusted cell text flows unsanitised into the existing CSV export —
lib/Settings/cmdb-import/topdesk-module.json:8. Cell text reaches the existing CSV export (PortfolioReportServicefputcsv) unsanitised, so a=HYPERLINK(...)cell becomes formula injection. - Row-failure detail echoes raw exception text into report and log —
lib/Service/CmdbExportImportService.php:1323. Row failures put raw exception text into the report and the log; the 'no personal data' test injects a benign message, so it cannot catch this. - Re-import overwrites stackiq-side edits; create and update paths differ —
lib/Service/CmdbExportImportService.php:773. A re-import overwrites stackiq-side edits, including the lifecycle-managedusage.status, and create-only fields differ per pack without that being documented. - Created objects belong to the admin's organisation, not the municipality —
lib/Service/CmdbExportImportService.php:1172. OpenRegister stamps created objects with the importing admin's active organisation, not the municipality the rows belong to (needs a rig run to confirm). - Service tests run against a fake that always filters correctly —
tests/Unit/Service/CmdbExportImportServiceTest.php:219. The service tests use a search double that always filters correctly and never sees_rbac/_multitenancy, so they cannot catch B4 or a dropped filter. - CI gives no evidence for these tests; several assertions are source greps —
tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php:132. CI's PHPUnit dies at bootstrap (OCP\EventDispatcher\Eventnot found), and several reader tests are source greps, so there is no CI evidence for the '81 tests'. - Hygiene test only scans .xml/.rels parts and two core.xml fields —
tests/Unit/Fixtures/CmdbFixtureHygieneTest.php:185. The hygiene test scans only.xml/.relsparts and twocore.xmlfields, so it misses C3's.binparts,app.xmland the dates. - Name-column check is skipped for 'Invoer APP data' in the missing-middel-id fixture —
tests/Unit/Fixtures/CmdbFixtureHygieneTest.php:244. In the missing-id fixture the APP sheet's header isMiddelnummer, so the name-column checkbreak 2s and that sheet is never checked for names. - UI hard-codes limits and sheet names the server reports (and the docs say admins can change) —
src/utils/cmdbImport.js:19. The UI hard-codes the 10 MB / 10,000-row limits and the sheet names instead of reading the server'sdetails, which the docs say an admin can change. - A request that dies mid-import shows 'failed' and drops the report the server keeps —
src/utils/cmdbImport.js:186. A 502/503/504 during a long import showsIMPORT_FAILEDand discards the report the server keeps for the operation. - Cancel failure is swallowed and Cancel gives no feedback —
src/views/settings/sections/CmdbImport.vue:800. The cancel call's failure is swallowed in an emptycatch, and the user gets no feedback either way. - No frontend unit tests for cmdbImport.js (sibling helper has them) —
src/utils/cmdbImport.js:1.cmdbImport.js(494 lines of request and error mapping) has no unit tests, while its sibling helper does. - 29 new scenarios rely on @e2e exclude, which the e2e gate does not accept for added scenarios —
openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md:111. 29 added scenarios carry@e2e exclude; the typed-municipality and cancel flows have no browser test. - Task 10 and docs screenshots are unfinished; task checkboxes disagree with the PR body —
docs/features/cmdb-import.md:51. Task 10 and the three doc screenshots are unfinished (nodocs/images/cmdb-import-*), and the task checkboxes disagree with the PR body. - Report table renders every row with no pagination or cap —
src/views/settings/sections/CmdbImport.vue:246. The report table renders every row, up to 10,000 per sheet, with no pagination or cap. - Imported Active orgs can re-key a tenant by slug —
lib/Settings/cmdb-import/topdesk-manufacturer.json:10. Suppliers and municipalities are imported asActive, and the existingOrganizationSyncServicere-keys an organisation found by name slug, so a Fabrikant cell equal to a tenant's name can re-key that tenant (latent: the cron selects onlyactief). - No actor-level audit record of an import —
lib/Service/CmdbExportImportService.php:306. The only import-level log line names no user, file, municipality orupdateExisting, so a bulk_rbac:falsewrite leaves no record of who ran it.
🟢 Minor (17)
- Array-valued form fields become the string 'Array' —
lib/Controller/CmdbImportController.php:141. An array-valued form field becomes the stringArraythrough(string)casts. - Upload error codes collapse into NO_FILE_UPLOADED / FILE_TOO_LARGE —
lib/Controller/CmdbImportController.php:277. Every PHP upload error except size collapses intoNO_FILE_UPLOADED, so a server-side tmp-dir failure reads as user error. - openapi.json: operationId rules and security scheme missing —
openapi.json:106.openapi.jsonlacks the operationId replace rule, a security scheme and cancel's 401. - Controller unit tests miss param parsing, errors and logging —
tests/Unit/Controller/CmdbImportControllerTest.php:281. The controller tests miss parameter parsing, the\Errorpath and the logging. - Client-chosen operationId can overwrite a live operation —
lib/Controller/CmdbImportController.php:153. A client-chosen operationId that is already live overwrites that operation's progress record and owner. - Seed modules ship to every production register —
lib/Settings/register.d/topdesk-cmdb-import.json:58. ThreeVoorbeeldseed modules in the register fragment ship to every production register. - Cancel state: stale flag, cancel accepted after completion, early window —
lib/Service/CmdbExportImportService.php:224. The cancel state is stale after completion, and the id regex uses$, socmdb-12345678\nmatches (use\zorD). - Key normalisation: case, NBSP, key claimed before the Soort check —
lib/Service/CmdbExportImportService.php:575. Middel-ID keys are case- and NBSP-sensitive, a Soort-skipped row consumes its id, and soft-deleted modules are re-created. - municipalityByUuid turns any failure into MUNICIPALITY_INVALID —
lib/Service/CmdbExportImportService.php:1035.municipalityByUuidmaps every failure, infrastructure errors included, toMUNICIPALITY_INVALID. - Report is stored whole in the distributed cache —
lib/Service/CmdbExportImportService.php:510. The whole report, which can be large, is stored in the distributed cache. - Profile allowlist and pack nits —
lib/Settings/cmdb-import/topdesk-usage.json:36. Pack nits:clusterlost,Einddatummapped but unused,registeredBynot set. - Requirement headings use the REQ-ID prefix form, not ADR-038 Form A —
openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md:16. The 14 requirement headings use the### Requirement: REQ-…prefix form, not ADR-038's canonical form. - Repo text points readers at a private plan folder —
openspec/changes/cmdb-export-import/tasks.md:11.tasks.mdand other repo text point readers at a private plan folder that contributors cannot open. - t() with variables HTML-escapes values that Vue escapes again —
src/views/settings/sections/CmdbImport.vue:503.t()with variables HTML-escapes the values and Vue escapes them again, so&shows as&(the same pattern exists at 37 other places insrc/). - Report headings skip a level; file label looks like a drop zone —
src/views/settings/sections/CmdbImport.vue:204. The report headings skip from h2 to h4, and the file label is styled like a drop zone that does not accept drops. - E2E leans on vue-select internals and skips instead of failing —
tests/e2e/spec-coverage/cmdb-import.spec.ts:190. The e2e spec relies on vue-select internals (vs__selected,nth(1)) and skips instead of failing when its precondition is missing. - Newman folder gaps and a dead
cmdb_operation_idvariable —postman/stackiq-tests.json(the folder sits at line ~22,547; no diff anchor close enough, so it is listed here). Folder12 - CMDB importmisses 401, 400NO_FILE_UPLOADED/NOT_XLSX, 422MUNICIPALITY_REQUIREDand cancel's 403/412/200. The 200 request storescmdb_operation_id, but the cancel test uses a hard-coded id, and the 413 test is skipped silently whencmdb_oversized_fileis unset.
✅ Verified clean
- XXE and entity expansion are blocked: a package with a DOCTYPE entity was refused. Formula cells yield only the cached value (
setReadDataOnly), and nothing is evaluated. - The profile loader is safe against path traversal (
basenameguard), and a municipality uuid must resolve to an object of typeMunicipality. - A key row cannot match an object in another register or schema, and
@self,owner,organisationoridcannot be set from a cell (idStrategy: generate,idunset). - Error envelopes carry only the code, a translated message and server-known details: no exception message, path or trace. The operationId is regex-checked before it touches the cache. The upload's tmp file is never copied.
- The UI handles every error code the server emits; requests go through
@nextcloud/axios(CSRF token sent); nov-html. l10n: 103 keys added to each ofen.json/nl.json, with no one-sided key, and the Dutch is real. - Fixture cell content is anonymised: all 224 shared strings and every cell of all 10 sheets hold only placeholders (see C3 for the metadata that is not).
openapi.jsonfield names, codes and shapes matchCmdbImportReport::toArray()and the controller.
Scope decision: XL (54 files, 12,106 lines), analysed in three slices at 6ddc2943: (A) HTTP surface, (B) import engine, (C) frontend, l10n, docs, specs and fixtures, plus the persistence audit. Residual: PHPUnit, Newman and Playwright were not run (no vendor tree or Nextcloud here); B14 and the lifecycle part of B13 need a rig run; TopdeskCmdbFragmentTest, CmdbImportProfileTest and CmdbRowNormaliserTest were read by name only; design.md, proposal.md and test-plan.md were grepped, not read; accessibility (C16) is from code, not a browser.
Persistence audit: 6 COVERED, 7 PARTIAL, 0 MISSING. P1 and P2 are posted above. The other PARTIALs are folded into A1 (delegation), A11 (operationId), B6/B14 (_rbac:false scope), B10 (contacts) and B12 (free text in logs).
Jira context: WOO-586 — CMDB export (TOPdesk, xlsx) via Stackiq into OpenRegister (status: Actief)
CI: required quality / Hydra Gates is red (A1, B1), and Frontend Check (check:schema-l10n) is red (C1); both are green on development. Features Check is red with the same docs/features.json drift that development has (Features Extract red there), so it is not caused by this PR. PHPUnit is red on development too (bootstrap: OCP\EventDispatcher\Event not found).
…licatie Eigenaar (Persoon) After the review with Gemeente Rotterdam the import reads the derived sheets "Onbeh Applicaties CMDB" (applications without arranged maintenance) and "Beheerde Applicaties CMDB" (with maintenance) instead of the raw TOPdesk input sheets. Modules are matched on topdesk:<municipality>:<APPID>; Applicatie Code becomes externalId. Formula cells contribute their cached value; a missing cached value gives an empty field and a row warning. The owner comes from "Applicatie Eigenaar (Persoon)" with its function as role; the technical-owner pack is removed. Applicatiesoort, BNN Classificatie, Classificatie (TIME) and End-of-Life Functioneel are mapped with configurable placeholder values. A test asserts that person data is never anonymously readable. Jira: https://conduction.atlassian.net/browse/WOO-586 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Quality Report — ConductionNL/stackiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue-demi | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ❌ | ||||
| check-schema-l10n | ❌ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 130/130 | |||
| npm | ✅ | ✅ 807/807 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| lockfile sync | ✅ | ||||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it locally with npx playwright test, or from the Actions tab on a branch with no open pull request into development. |
||||
| Hydra gates | ❌ |
Quality workflow — 2026-10-01 16:43 UTC
Download the full PDF report from the workflow artifacts.
…only auth, spec anchors that exist check:schema-l10n: the five external-id properties had no catalogue key. format: prettier on the e2e spec. Hydra gate 5: import() and cancel() are admin-only by having no NoAdminRequired; they now say so with an @auth admin-only declaration. Hydra gate 46: the two test copies of OpenRegister classes pointed at a spec that lives in the openregister repository; they now carry a reason-bearing @SPEC exclude naming it.
…rt (#1209) also declares Two fragments declaring the same version: an instance that already imported one never deploys the other's properties.
…rt-import # Conflicts: # l10n/en.js # l10n/en.json # l10n/nl.js # l10n/nl.json
Quality Report — ConductionNL/stackiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue-demi | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 130/130 | |||
| npm | ✅ | ✅ 807/807 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| lockfile sync | ✅ | ||||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it locally with npx playwright test, or from the Actions tab on a branch with no open pull request into development. |
||||
| Hydra gates | ✅ |
Quality workflow — 2026-10-01 20:36 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/stackiq @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue-demi | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| check-schema-l10n | ✅ | ||||
| check-l10n-js | ✅ | ||||
| composer | ✅ | ✅ 130/130 | |||
| npm | ✅ | ✅ 807/807 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| lockfile sync | ✅ | ||||
| PHPUnit | ❌ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ deferred: E2E runs locally and on the promotion path only. This pull request targets development, so the suite is asked once per promotion into beta and main rather than once per push per open pull request. Run it locally with npx playwright test, or from the Actions tab on a branch with no open pull request into development. |
||||
| Hydra gates | ✅ |
Quality workflow — 2026-10-01 20:39 UTC
Download the full PDF report from the workflow artifacts.
… publication field rules With the bootstrap fixed, CI ran stackiq's unit tests for the first time in a while and found two merged PRs disagreeing: #1209's test said usage may have no public read rule, while #1206 gives usage a public rule conditional on publicationDate. Owners stay private either way: contactPerson has no public rule, the import never sets a usage's publicationDate, and usage.businessOwner, technicalOwner and contactPerson carry authenticated-only property rules. The test now pins exactly that, and fails if any of those person properties is made public (control run: businessOwner set to public fails it).
A test stub extending OCP\EventDispatcher\Event (tests/Stubs/Event/ObjectCreatedEvent.php, added in #1197) sat inside the bootstrap's early stub glob and loaded before Nextcloud booted, so every PHPUnit leg died at tests/bootstrap.php:62 with 'Class OCP\EventDispatcher\Event not found' and no stackiq unit test ran in CI on any PR. The stub now loads after Nextcloud boots, only when the real OpenRegister event is absent. With tests running again (1071 in CI), one revealed conflict between #1209 and #1206 is resolved: the owner-privacy test now pins that contactPerson is never public, a usage is public only once published, and its person properties never are.
Jira: WOO-586 · Plan: readonly-mirror-wilco-claude-plans › issues/jira/WOO-586
What
Gemeente Rotterdam wants its CMDB (a TOPdesk export, xlsx) as a source for OpenCatalogi. This adds a CMDB import to Stackiq's admin settings:
module, the vendor and the municipality asorganization, ausagelinking the municipality to the module, and the owner ascontactPerson, all through OpenRegister's object service.lib/Settings/cmdb-import/, five packs plus a profile) and runs through OpenRegister's migration-packMappingEngine. OpenRegister's own import endpoint cannot apply packs to xlsx and maps one sheet to one schema, while one TOPdesk row becomes several objects.topdesk:<municipality uuid>:<APPID>(the APPID is TOPdesk's ICT Applicatienummer; the Applicatie Code / Middel-ID can change in TOPdesk and is kept asexternalIdfor reference). A re-import updates instead of duplicating. New modules get apublicationDate, so OpenCatalogi lists them. Records missing from a newer export are left alone.Beheer geregeld: ja|nee, the cluster and the owner's department. The column table is indesign.mdanddocs/features/cmdb-import.md.NB(BNN) and 2036-01-01 (end of life) are read as empty.ProgressTracker.OpenSpec change:
openspec/changes/cmdb-export-import/(proposal, specs REQ-CMDB-001..014, design, contract, migration, test-plan, tasks).Security and privacy
NoCSRFRequired).xl/workbook.xmlbefore the reader runs; 10 MB per upload, 10,000 rows per sheet.setReadDataOnly(true); formulas are never evaluated and external connections are never followed.contactPersonandusagehave no public read rule, and a publishedmodulerefers to them by id only.CmdbPersonDataVisibilityTestpins that on the merged register; the e2e suite checks it anonymously against the running stack (OpenRegister objects API and OpenCatalogi search).Schema
modulegoes to 0.3.5 through the register fragmentlib/Settings/register.d/topdesk-cmdb-import.json: five optional propertiesexternalId,externalNumber,externalKey,externalCreatedAt,externalModifiedAt, plus three seed modules (nopublicationDate).Tests
--filter 'Cmdb|TopdeskCmdb',--no-coverage, OpenRegister on the path)tests/e2e/spec-coverage/cmdb-import.spec.tson a local NC 32 riglib/files, phpstan onlib/Service/CmdbExportImportService.php+lib/Service/Cmdb/npm run build,lint,stylelint,test:l10n,check:l10n-jsscripts/run-hydra-gates.sh)Manual check on the rig (data of the earlier import removed first): importing the anonymised export created 2 modules, 2 suppliers, 1 municipality, 2 usages and 2 contact persons, with
cloudDienstverleningsmodel,bbnLevel,timeClassification,startDateOutPhasedand the maintenance note filled where the fixture has values; a second import reported 2 unchanged. An anonymous OpenCatalogi search finds both applications withcontactPersonandusagesempty and no owner name in the hit; anonymous OpenRegister requests for contact persons and usages return 0 rows.Not in this PR
Hosting party ("Hostingpartij") and "Leverancier", the functional administrator as technical owner, connections and suites, the "Gearchiveerde Applicaties" sheet and marking records missing from an export, an occ command or background job, and a live TOPdesk/ServiceNow connection. These are listed as follow-ups in the plan.
🤖 Generated with Claude Code