Skip to content

feat(cmdb-import): import a TOPdesk CMDB export (xlsx) into the catalogue - #1209

Merged
rubenvdlinde merged 5 commits into
developmentfrom
feat/cmdb-export-import
Oct 1, 2026
Merged

rubenvdlinde merged 5 commits into
developmentfrom
feat/cmdb-export-import

Conversation

@WilcoLouwerse

@WilcoLouwerse WilcoLouwerse commented Oct 1, 2026 •

Copy link
Copy Markdown

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:

  • An admin picks the consuming municipality (existing or new) and uploads the TOPdesk export.
  • Stackiq reads the two CMDB sheets the municipality uses as its CMDB: "Onbeh Applicaties CMDB" (applications without arranged maintenance, derived from AIA) and "Beheerde Applicaties CMDB" (with arranged maintenance, derived from APP). The raw "Invoer" sheets are not read. Per row it creates or updates a module, the vendor and the municipality as organization, a usage linking the municipality to the module, and the owner as contactPerson, all through OpenRegister's object service.
  • The column mapping is declarative (lib/Settings/cmdb-import/, five packs plus a profile) and runs through OpenRegister's migration-pack MappingEngine. OpenRegister's own import endpoint cannot apply packs to xlsx and maps one sheet to one schema, while one TOPdesk row becomes several objects.
  • Modules are matched on topdesk:<municipality uuid>:<APPID> (the APPID is TOPdesk's ICT Applicatienummer; the Applicatie Code / Middel-ID can change in TOPdesk and is kept as externalId for reference). A re-import updates instead of duplicating. New modules get a publicationDate, so OpenCatalogi lists them. Records missing from a newer export are left alone.
  • Mapped: name, Roepnaam/Nickname (short description), functional description, Applicatiesoort (hosting model), BNN Classificatie (BBN level), source dates, Vendor (supplier), Applicatie Status, Classificatie (TIME), End-of-Life Functioneel (phase-out date), and an internal note on the usage with Beheer geregeld: ja|nee, the cluster and the owner's department. The column table is in design.md and docs/features/cmdb-import.md.
  • The CMDB sheets are formulas: the reader uses the value Excel cached and never evaluates a formula. A formula without a cached value is an empty cell with a row warning, never a failure. The CMDB placeholders NB (BNN) and 2036-01-01 (end of life) are read as empty.
  • Each row is processed in isolation and reported (created / updated / unchanged / skipped / failed with a reason). Progress and cancel use the existing ProgressTracker.

OpenSpec change: openspec/changes/cmdb-export-import/ (proposal, specs REQ-CMDB-001..014, design, contract, migration, test-plan, tasks).

Security and privacy

  • Both routes are admin-only and require CSRF (unlike the existing SBOM/ArchiMate uploads, which have NoCSRFRequired).
  • xlsx only, checked by extension, ZIP signature and xl/workbook.xml before the reader runs; 10 MB per upload, 10,000 rows per sheet.
  • Values are read with setReadDataOnly(true); formulas are never evaluated and external connections are never followed.
  • Columns are resolved by header name per sheet, against an allowlist. The only person columns read are "Applicatie Eigenaar (Persoon)" and "(Functie)"; the functional administrator is not imported. Owner names are never logged or returned in the report. Imported contacts never become Nextcloud users.
  • Owners are never publicly readable: contactPerson and usage have no public read rule, and a published module refers to them by id only. CmdbPersonDataVisibilityTest pins that on the merged register; the e2e suite checks it anonymously against the running stack (OpenRegister objects API and OpenCatalogi search).
  • Test fixtures are anonymised; a PHPUnit hygiene test fails on document metadata or non-placeholder personal data.

Schema

module goes to 0.3.5 through the register fragment lib/Settings/register.d/topdesk-cmdb-import.json: five optional properties externalId, externalNumber, externalKey, externalCreatedAt, externalModifiedAt, plus three seed modules (no publicationDate).

Tests

Check Result
PHPUnit, CMDB tests (--filter 'Cmdb|TopdeskCmdb', --no-coverage, OpenRegister on the path) 90 tests, 960 assertions, exit 0
Playwright tests/e2e/spec-coverage/cmdb-import.spec.ts on a local NC 32 rig 5 of 5 passed, including the anonymous owner-visibility check
phpcs / phpmd on the changed lib/ files, phpstan on lib/Service/CmdbExportImportService.php + lib/Service/Cmdb/ exit 0
npm run build, lint, stylelint, test:l10n, check:l10n-js exit 0 (lint: warnings only, none in changed files)
Newman, folder "12 - CMDB import" not re-run for this revision
Hydra gates (scripts/run-hydra-gates.sh) not run yet

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, startDateOutPhased and the maintenance note filled where the fixture has values; a second import reported 2 unchanged. An anonymous OpenCatalogi search finds both applications with contactPerson and usages empty 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

WilcoLouwerse and others added 2 commits October 1, 2026 13:28
…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>
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/stackiq @ 69bec95

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 {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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 Gates is 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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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, and Hydra Gates is green
  • a test fails when tests/Unit/Support/OpenRegister/MappingEngine.php is 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);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.xml inflates past the limit and expects CmdbImportException (TOO_MANY_ROWS or a new code), without calling load()
  • a unit test with maxRowsPerSheet = 1 on 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",

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.js exits 0 without a changed l10n/.schema-l10n-baseline.json
  • node scripts/check-schema-l10n.js --list | grep -c topdesk-cmdb-import prints 0
  • npm run check:l10n-js and npm run test:l10n still 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')

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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:created 2022-05-04T12:35:44Z (creation time of the real export) in all four derived fixtures.
  • xl/workbook.xml keeps the xr:revisionPtr documentId GUID and the _xlnm._FilterDatabase ranges, which expose the size of the real CMDB ('Beheerde Applicaties CMDB'!$A$1:$AP$1066, 'Gearchiveerde Applicaties'!$A$1:$I$434); sheet3.xml dimension 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 printerSettings prints 0 for every fixture
  • unzip -p tests/fixtures/cmdb/topdesk-export-anonymised.xlsx docProps/core.xml shows no real creation timestamp
  • a copy of a fixture with xl/printerSettings/printerSettings1.bin re-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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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.',

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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 &amp; 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 &amp; Rodenrijs &lt;b&gt;. 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">

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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'),

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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 WilcoLouwerse left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

🟡 Concerns (29)

🟢 Minor (17)

✅ 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 (basename guard), and a municipality uuid must resolve to an object of type Municipality.
  • A key row cannot match an object in another register or schema, and @self, owner, organisation or id cannot be set from a cell (idStrategy: generate, id unset).
  • 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); no v-html. l10n: 103 keys added to each of en.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.json field names, codes and shapes match CmdbImportReport::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>
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/stackiq @ a13bd3b

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.
rubenvdlinde added a commit that referenced this pull request Oct 1, 2026
…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
@rubenvdlinde
rubenvdlinde merged commit 4dd0fd2 into development Oct 1, 2026
11 checks passed
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/stackiq @ ea784a6

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.

@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/stackiq @ d16e271

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.

rubenvdlinde added a commit that referenced this pull request Oct 2, 2026
… 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).
rubenvdlinde added a commit that referenced this pull request Oct 2, 2026
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.
@WilcoLouwerse
WilcoLouwerse deleted the feat/cmdb-export-import branch October 2, 2026 13:24
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.

2 participants