From 0600012c2ce4284d80c0033e95f1d6e94a1b3b53 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 8 Oct 2026 12:07:06 +0200 Subject: [PATCH 001/124] fix(dashboard): re-point dashboards from the renamed widget id and fix 47 @spec paths Two findings from Thijn's re-review of the beta promotion (#1026), after #1261 restored the frozen widget id: Restoring `softwarecatalog_concept_organisaties_widget` keeps the widget for users from before the rename, but drops it for everyone who added it while stable 0.1.148 to 0.2.0 and beta registered it as `stackiq_concept_organisaties_widget`. The Dashboard app keeps that choice in `oc_preferences` under its own app id `dashboard`, key `layout`, as a comma-separated list of widget ids, which IConfig reaches like this app's own rows. The new post-migration repair step RepointDashboardWidgetId rewrites the renamed id to the frozen one for every seen user, in place and without listing the frozen id twice; a layout without the renamed id is left alone. The widget comment and the app-id-rename spec no longer claim the rows are out of reach. The archive pass (1aa23464, 7339b355) moved four changes without updating their @spec tags: wizard-dataset-card-load (4), portal-contribution (8), publication-field-rules (20) and rename-app-id-to-stackiq (14), 47 tags in 16 files now point at openspec/specs//spec.md. The anchors were checked against the synced specs. Co-Authored-By: Claude Fable 5.1 --- appinfo/info.xml | 9 + .../ModuleVersionPublicationJob.php | 6 +- lib/Controller/SetupController.php | 8 +- lib/Dashboard/ConceptOrganisatiesWidget.php | 11 +- .../ModuleVersionPublicationListener.php | 6 +- lib/Portal/PortalContributionProvider.php | 14 +- .../BackfillModuleVersionPublication.php | 6 +- lib/Repair/MigrateAppConfigKeys.php | 10 +- lib/Repair/MigrateBackgroundJobClasses.php | 8 +- lib/Repair/MigrateUserPreferences.php | 10 +- lib/Repair/RepointDashboardWidgetId.php | 222 ++++++++++++++ .../ModuleVersionPublicationService.php | 16 +- lib/Service/SettingsService.php | 2 +- openspec/specs/app-id-rename/spec.md | 9 +- .../Portal/PortalContributionProviderTest.php | 2 +- .../BackfillModuleVersionPublicationTest.php | 2 +- .../Repair/RepointDashboardWidgetIdTest.php | 270 ++++++++++++++++++ .../ModuleVersionPublicationServiceTest.php | 2 +- .../Settings/PublicationFieldRulesTest.php | 2 +- 19 files changed, 563 insertions(+), 52 deletions(-) create mode 100644 lib/Repair/RepointDashboardWidgetId.php create mode 100644 tests/Unit/Repair/RepointDashboardWidgetIdTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index aa70380b6..da795bd7a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -122,6 +122,15 @@ Vrij en open source onder de EUPL-licentie. --> OCA\Stackiq\Repair\MigrateAppConfigKeys OCA\Stackiq\Repair\MigrateUserPreferences + + OCA\Stackiq\Repair\RepointDashboardWidgetId
@@ -304,7 +338,7 @@ - {{ t('stackiq', 'Mapping (read-only)') }} + {{ t('stackiq', 'Mapping') }}
+

+ {{ + t( + 'stackiq', + 'The packs are kept in OpenRegister, where you can view and edit them. A pack deleted there is restored from the shipped file at the next import.', ) }}

@@ -39,7 +49,7 @@ {{ t('stackiq', 'Loading the mapping…') }}
- +
+ + diff --git a/src/customComponents.js b/src/customComponents.js index f6ec30d97..da401ac5e 100644 --- a/src/customComponents.js +++ b/src/customComponents.js @@ -23,6 +23,7 @@ import OrganisatieCard from './components/cards/OrganisatieCard.vue' import ApplicationContractsPanel from './components/contracts/ApplicationContractsPanel.vue' import ContractApprovalPanel from './components/contracts/ContractApprovalPanel.vue' import ContractSeatsPanel from './components/contracts/ContractSeatsPanel.vue' +import ContactPersonName from './components/organisations/ContactPersonName.vue' import OrganisationMergePanel from './components/organisations/OrganisationMergePanel.vue' import UsageRiskSignals from './components/portfolio/UsageRiskSignals.vue' import ReviewsPanel from './components/reviews/ReviewsPanel.vue' @@ -107,6 +108,11 @@ export default { // and the vulnerabilities, which no built-in widget does. UsageRiskSignals, + // The name of a contact person on its detail page (landscape-usage-registration): + // the name lives in Nextcloud Contacts, read through /api/contact-names, + // which no built-in widget does. + ContactPersonName, + // AI Act evidence per tag on the AI system page (landscape-ai-system-inventory): // it reads the object's files and their tags, which no built-in widget lists per tag. AiActChecklist, diff --git a/src/manifest.json b/src/manifest.json index 22b321684..ec9a337cf 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -423,7 +423,7 @@ { "id": "org-diensten", "type": "object-list", "title": "Services", "icon": "HandshakeOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "catalogService", "filter": { "provider": "@objectId" }, "columns": [ { "key": "type", "label": "Type" } ], "limit": 25, "emptyText": "No services registered for this organisation yet" } }, { "id": "org-modules", "type": "object-list", "title": "Applications", "icon": "Package", "content": { "register": "@resolve:voorzieningen_register", "schema": "module", "filter": { "provider": "@objectId" }, "columns": [ { "key": "name", "label": "Name" }, { "key": "licentietype", "label": "License type" }, { "key": "bbnLevel", "label": "BBN level" } ], "limit": 25, "rowRoute": "ModuleDetail", "viewAllRoute": "Modules", "viewAllQuery": { "provider": "@objectId" }, "emptyText": "No applications registered for this organisation yet" } }, { "id": "org-usages", "type": "object-list", "title": "Applications in use", "icon": "OfficeBuilding", "content": { "register": "@resolve:voorzieningen_register", "schema": "usage", "filter": { "consumer": "@objectId" }, "columns": [ { "key": "module", "label": "Application", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, { "key": "moduleVersion", "label": "Version", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "moduleVersion", "labelField": "version" } }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "GebruikDetail", "viewAllRoute": "Gebruik", "viewAllQuery": { "consumer": "@objectId" }, "allowCreate": false, "emptyText": "No applications in use recorded for this organisation yet" } }, - { "id": "org-contactpersonen", "type": "object-list", "title": "Contact persons", "icon": "AccountGroup", "content": { "register": "@resolve:voorzieningen_register", "schema": "contactPerson", "filter": { "organization": "@objectId" }, "columns": [ { "key": "role", "label": "Function" }, { "key": "roles", "label": "Roles" } ], "limit": 25, "rowRoute": "ContactpersoonDetail", "emptyText": "No contact persons linked to this organisation yet" } } + { "id": "org-contactpersonen", "type": "object-list", "title": "Contact persons", "icon": "AccountGroup", "content": { "register": "@resolve:voorzieningen_register", "schema": "contactPerson", "filter": { "organization": "@objectId" }, "columns": [ { "key": "id", "label": "Name", "widget": "contactName" }, { "key": "role", "label": "Function" }, { "key": "roles", "label": "Roles" } ], "limit": 25, "rowRoute": "ContactpersoonDetail", "emptyText": "No contact persons linked to this organisation yet" } } ], "layout": [ { "id": "1", "widgetId": "org-data", "gridX": 0, "gridY": 0, "gridWidth": 8, "gridHeight": 5 }, @@ -486,6 +486,9 @@ { "id": "1", "widgetId": "cp-data", "gridX": 0, "gridY": 0, "gridWidth": 8, "gridHeight": 4 }, { "id": "2", "widgetId": "cp-related", "gridX": 8, "gridY": 0, "gridWidth": 4, "gridHeight": 4 } ], + "bodyWidgets": [ + { "id": "cp-name", "component": "ContactPersonName", "props": { "objectId": "@objectId" }, "placement": "before-body", "colSpan": 12 } + ], "sidebar": { "enabled": true, "showMetadata": true, From cb48f556b109ca3ede6f80fab4d2a7da6ef285b7 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 18:38:51 +0200 Subject: [PATCH 074/124] fix(usage): keep the risk signal labels clear of their values A page-wide style gives every dt a fixed width of 130px, aligned to the end, so "Known vulnerabilities" and "End of support" ran into their values. The labels now take their own width and start-align. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/components/portfolio/UsageRiskSignals.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/portfolio/UsageRiskSignals.vue b/src/components/portfolio/UsageRiskSignals.vue index de23c8c9e..bad7e81ed 100644 --- a/src/components/portfolio/UsageRiskSignals.vue +++ b/src/components/portfolio/UsageRiskSignals.vue @@ -224,6 +224,10 @@ export default { } .usage-risk-signals__list dt { + /* Undo the page-wide fixed width and right alignment of a dt, which made + a long label run into its value. */ + width: auto; + text-align: start; font-weight: bold; } From ae31ac3867e9af47d02fba8219d185e712fc1a33 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 18:44:13 +0200 Subject: [PATCH 075/124] docs: scope the archive-reconciliation change to the reader slice and document the reader's bounds Closes the review findings: - "design D8 describes UI code not in this PR": D8 now says the UI code lands in #1287. - tasks.md ticked Implement/Test for Tasks 3-7 whose code is not in this PR: unticked, with a status note that they land in #1286 and #1287. - CmdbWorkbookReader sheet count always includes the archive sheet: documented in the read() docblock (limit / 3 is deliberate). - appIds are raw cell values: documented as raw, untrimmed and un-normalised; no consumer exists in this PR, so no code change. Class sweep: docs/features/cmdb-import.md described the referenced-string bound as "once per CMDB sheet"; it now names the archive sheet too. Co-Authored-By: Claude Opus 5.5 --- docs/features/cmdb-import.md | 2 +- lib/Service/Cmdb/CmdbWorkbookReader.php | 10 ++++++++++ .../design.md | 2 ++ .../tasks.md | 20 ++++++++++--------- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md index b9d505abc..32550932b 100644 --- a/docs/features/cmdb-import.md +++ b/docs/features/cmdb-import.md @@ -326,7 +326,7 @@ every import: | `maxUncompressedBytes` | `52428800` (50 MB) | the size of the workbook once unpacked, checked before a sheet is parsed | | `maxPartBytes` | `10485760` (10 MB) | the size of one part the import parses once unpacked, such as a source sheet or the shared-strings table, checked before a sheet is parsed; a sheet the import does not read may be larger, and is read as an empty sheet | | `maxSharedStrings` | `200000` | the number of different texts in the workbook's shared-strings table, each formatted piece of a text with mixed formatting counted as one more, also in text written in a cell itself, counted before a sheet is parsed | -| `maxReferencedStringBytes` | `67108864` (64 MB) | the shared text all cells of the workbook reference together, counted once per cell and once per CMDB sheet before a sheet is parsed; a long text that many cells repeat counts as often as it is repeated | +| `maxReferencedStringBytes` | `67108864` (64 MB) | the shared text all cells of the workbook reference together, counted once per cell and once per sheet the import reads (the two CMDB sheets and the archive sheet, also when the workbook has no archive sheet) before a sheet is parsed; a long text that many cells repeat counts as often as it is repeated | The section's help text shows the defaults; when the server refuses a file, the message shows the limit the server applied. A larger file also has to diff --git a/lib/Service/Cmdb/CmdbWorkbookReader.php b/lib/Service/Cmdb/CmdbWorkbookReader.php index 4723f33fa..3ba065a4d 100644 --- a/lib/Service/Cmdb/CmdbWorkbookReader.php +++ b/lib/Service/Cmdb/CmdbWorkbookReader.php @@ -160,6 +160,16 @@ public function isAvailable(): bool { * and its row number is listed in `archive.uncachedRows`: the sheet then * lists an application the reader could not see, so a caller must not * treat the sheet as complete. + * The `appIds` are the raw key-column values as read, in sheet order: + * not trimmed, not normalised and not de-duplicated, and an empty cell + * is an empty entry. Matching them to the stored applications, and any + * normalisation that needs, is the consumer's job. + * The referenced-string budget (`maxReferencedStringBytes`) is divided + * over every sheet in the profile's `readSheetNames()`, so the archive + * sheet always counts as one of them, also in a workbook that has no + * archive sheet: each sheet's share is limit / sheet count (limit / 3 for + * the Topdesk profile). This is deliberate: the bound does not depend on + * what the workbook contains. * * @param string $path The xlsx file, already checked by assertXlsx(). * @param CmdbImportProfile $profile The import profile. diff --git a/openspec/changes/cmdb-import-archive-reconciliation/design.md b/openspec/changes/cmdb-import-archive-reconciliation/design.md index 51daa0580..53fc943f8 100644 --- a/openspec/changes/cmdb-import-archive-reconciliation/design.md +++ b/openspec/changes/cmdb-import-archive-reconciliation/design.md @@ -115,6 +115,8 @@ Progress: the operation gets the phase `reconciling` (added to `ProgressTracker: ### D8. Stackiq UI +> This section records the decision. The UI code it describes (the quick filters, the `_ids` override and the `src/icons.js` entry) is not part of the reader slice in this PR; it lands in the UI follow-up PR (#1287). + The Applications (`Modules`, a `FacetedCatalogIndexView` that forwards `quickFilters` to `CnIndexPage`) and Applications in use (`Gebruik`) pages get the quick filter `{"label": "Archived", "filter": {"_archived": "true"}}`. `useSelfFetchList` spreads the active tab's filter into the fetch parameters as they are, so `_archived=true` reaches OpenRegister and the list shows the archive alone. The facet narrowing's `_ids` would compose with it (AND) and hide archived applications the facets do not know; the Archived tab of the Applications page therefore overrides `_ids` with an empty value, so it lists the whole archive and ignores the facets and the search. The icon `ArchiveOutline` is registered in `src/icons.js`. `@conduction/nextcloud-vue` 2.65 renders no `@self.archived` on a detail page or in the sidebar metadata, so the detail pages show no archive state yet; that is a library follow-up, not a stackiq widget. ### D9. How the import reads the state of a stored object diff --git a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md index 1ff7af0d9..17bee62a1 100644 --- a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md +++ b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md @@ -4,6 +4,8 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp ## Implementation Tasks +Status in this PR (the archive-sheet reader slice): Tasks 1 and 2 are implemented here. Tasks 3-7 are unticked because their code lands in the follow-up PRs (reconciliation in #1286, UI in #1287). + ### Task 1: Schema opt-in and the import profile - **spec_ref**: `SPEC#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017`, `SPEC#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012` - **files**: `lib/Settings/register.d/topdesk-cmdb-import.json`, `lib/Settings/cmdb-import/topdesk-profile.json`, `lib/Service/Cmdb/CmdbImportProfile.php`, `tests/Unit/Settings/TopdeskCmdbFragmentTest.php`, `tests/Unit/Settings/PublicationFieldRulesTest.php`, `tests/Unit/Service/Cmdb/CmdbImportProfileTest.php` @@ -35,8 +37,8 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp - GIVEN OpenRegister's search results (no archive marker; no soft-deleted row, `_includeDeleted` or not) THEN the state comes from the lens that found the object and from the trash listing, read once per import (design D9) - GIVEN `updateExisting` false THEN the row is skipped as `exists` and nothing is revived - GIVEN a deleted and a working module (and usage) sharing one import key THEN the working one is updated and nothing is restored -- [x] Implement -- [x] Test +- [ ] Implement +- [ ] Test ### Task 4: Reconciliation after the rows - **spec_ref**: `SPEC#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015` @@ -49,8 +51,8 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp - GIVEN a cancel during the last row THEN nothing is archived or deleted; GIVEN a cancel during the reconciliation THEN it stops before the next application and the report is cancelled - GIVEN the archive of one application throws THEN that application is a `failed` row and the others are reconciled; GIVEN no ArchiveHandler THEN 503 `ARCHIVE_UNAVAILABLE` before reading - GIVEN the step runs THEN the operation is in phase `reconciling` and its progress advances per application -- [x] Implement -- [x] Test +- [ ] Implement +- [ ] Test ### Task 5: Controller option - **spec_ref**: `SPEC#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012` @@ -58,8 +60,8 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp - **acceptance_criteria**: - GIVEN no `missingRecords` THEN the service receives `archive`; GIVEN `keep` or `archive` THEN it is passed through; GIVEN `remove` or `mark` THEN 422 `MISSING_RECORDS_UNSUPPORTED` with `details.accepted` `["keep", "archive"]` and no import - GIVEN `ARCHIVE_UNAVAILABLE` from the service THEN 503 with its own translated message -- [x] Implement -- [x] Test +- [ ] Implement +- [ ] Test ### Task 6: Settings section, report words and the archive quick filter - **spec_ref**: `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014`, `SPEC#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017` @@ -69,8 +71,8 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp - GIVEN a report with the new outcomes THEN the summary shows their tiles, the outcome filter offers them, and a reconciliation row shows no row number - GIVEN the Applications and Applications in use pages THEN a quick filter "Archived" sends `_archived=true` - Every new string is in `l10n/en.json` and `l10n/nl.json`, and the generated `l10n/*.js` are current -- [x] Implement -- [x] Test +- [ ] Implement +- [ ] Test ### Task 7: Fixture, docs and e2e - **spec_ref**: `SPEC#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015` @@ -79,7 +81,7 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp - GIVEN `build-fixtures.py` THEN it derives `topdesk-archived-applications.xlsx`: APPID 1234 on the archive sheet only, APPID 2 on no sheet, both CMDB sheets present and empty; the hygiene test passes on it - GIVEN the e2e THEN after the first import, importing the variant archives 1234 and soft-deletes 2, the Applications page lists 1234 under "Archived", and importing the original again reports 1234 `unarchived` and 2 `restored` with the object counts back to the first import's - GIVEN the docs THEN "Repeat imports" describes archive, soft delete, the archive sheet, the option and the return of an application, and the error table has `ARCHIVE_UNAVAILABLE` and the new meaning of `MISSING_RECORDS_UNSUPPORTED` -- [x] Implement +- [ ] Implement - [ ] Test - Status: PHPUnit covers the fixture round trip (testTheArchivedApplicationsFixtureArchivesDeletesAndBringsBack); the Playwright test is written and not run yet, because no Nextcloud was reachable from the worktree. From 8dfa03a1a1cb56ca90b2087f6ed982db6ef99742 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 18:49:30 +0200 Subject: [PATCH 076/124] feat(cmdb-import): shorten the help of the missing-applications choice The help under "Applications missing from the export" was a paragraph of seven lines. It is now one sentence; the full rules, including when applications are only archived, moved to the section's information panel. Co-Authored-By: Claude Opus 5.5 (1M context) --- l10n/en.js | 3 +- l10n/en.json | 3 +- l10n/nl.js | 3 +- l10n/nl.json | 3 +- .../specs/cmdb-export-import/spec.md | 2 +- .../settings/sections/CmdbImport.spec.js | 73 +++++++++++++------ src/views/settings/sections/CmdbImport.vue | 20 +++-- 7 files changed, 74 insertions(+), 33 deletions(-) diff --git a/l10n/en.js b/l10n/en.js index 613cb76f2..fa52e1cda 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1153,7 +1153,8 @@ OC.L10N.register( "{count} unarchived": "{count} unarchived", "{count} deleted": "{count} deleted", "{count} restored": "{count} restored", - "Applications: {changes}.": "Applications: {changes}." + "Applications: {changes}.": "Applications: {changes}.", + "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 29d312f53..a26643b77 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1152,6 +1152,7 @@ "{count} unarchived": "{count} unarchived", "{count} deleted": "{count} deleted", "{count} restored": "{count} restored", - "Applications: {changes}.": "Applications: {changes}." + "Applications: {changes}.": "Applications: {changes}.", + "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when." } } diff --git a/l10n/nl.js b/l10n/nl.js index 1dc8b83d9..d98a24b44 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1221,7 +1221,8 @@ OC.L10N.register( "{count} unarchived": "{count} uit het archief gehaald", "{count} deleted": "{count} verwijderd", "{count} restored": "{count} teruggezet", - "Applications: {changes}.": "Applicaties: {changes}." + "Applications: {changes}.": "Applicaties: {changes}.", + "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd of naar de prullenbak verplaatst. De (i)-knop bovenaan legt uit wanneer." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 7963d0ebc..30c99f4e9 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1220,6 +1220,7 @@ "{count} unarchived": "{count} uit het archief gehaald", "{count} deleted": "{count} verwijderd", "{count} restored": "{count} teruggezet", - "Applications: {changes}.": "Applicaties: {changes}." + "Applications: {changes}.": "Applicaties: {changes}.", + "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd of naar de prullenbak verplaatst. De (i)-knop bovenaan legt uit wanneer." } } diff --git a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md index f6a842422..c65412f52 100644 --- a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md +++ b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md @@ -165,7 +165,7 @@ The import SHALL accept `missingRecords` with the values `keep` and `archive`; ` ### Requirement: The admin settings SHALL offer a CMDB import section (REQ-CMDB-014) -Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, choose what happens to applications missing from the export (archive, the default, or keep) with help text that says what each does, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary, including the archived, unarchived, deleted and restored counts when they are not zero, and a report table that can be filtered by outcome, including the four new ones; a reconciliation row SHALL show no row number. Every control SHALL have a visible label, and every string SHALL be translatable. +Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, choose what happens to applications missing from the export (archive, the default, or keep) with a one-sentence help text that says what each does and the details in the section's information panel, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary, including the archived, unarchived, deleted and restored counts when they are not zero, and a report table that can be filtered by outcome, including the four new ones; a reconciliation row SHALL show no row number. Every control SHALL have a visible label, and every string SHALL be translatable. (Previously: the section had no choice for missing records; the request always sent `keep`.) diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js index 5f43536cc..f30570388 100644 --- a/src/views/settings/sections/CmdbImport.spec.js +++ b/src/views/settings/sections/CmdbImport.spec.js @@ -10,6 +10,7 @@ import axios from '@nextcloud/axios' import { flushPromises, mount, shallowMount } from '@vue/test-utils' +import { h } from 'vue' import CmdbImport from './CmdbImport.vue' // Virtual: the package's `exports` field declares only the `import` condition, which jest does not resolve. @@ -308,13 +309,19 @@ describe('CmdbImport cancelling', () => { await wrapper.setData({ importing: true, operationId: 'cmdb-12345678', - progress: { phase: 'reconciling', processed_items: 400, total_items: 810 }, + progress: { + phase: 'reconciling', + processed_items: 400, + total_items: 810, + }, }) await wrapper.vm.cancelImport() await flushPromises() - const status = wrapper.find('[data-testid="cmdb-import-cancel-status"]').text() + const status = wrapper + .find('[data-testid="cmdb-import-cancel-status"]') + .text() expect(status).toContain('before the next row') expect(status).toContain('before the next application') }) @@ -323,7 +330,11 @@ describe('CmdbImport cancelling', () => { const wrapper = await mountFull() await wrapper.setData({ - report: { cancelled: true, summary: { rowsRead: 400, archived: 3 }, rows: [] }, + report: { + cancelled: true, + summary: { rowsRead: 400, archived: 3 }, + rows: [], + }, }) expect(wrapper.find('[data-testid="cmdb-import-cancelled"]').text()).toMatch( @@ -366,7 +377,13 @@ describe('CmdbImport finished announcement', () => { report: { cancelled: true, municipality: { name: 'Gemeente Bergen' }, - summary: { rowsRead: 5, archived: 2, unarchived: 3, deleted: 4, restored: 5 }, + summary: { + rowsRead: 5, + archived: 2, + unarchived: 3, + deleted: 4, + restored: 5, + }, rows: [], }, }) @@ -435,15 +452,30 @@ describe('CmdbImport the choice for missing applications, as the admin makes it' expect(wrapper.vm.missingRecords).toBe('archive') }) - it('says in the help when applications are only archived, never deleted', async () => { + it('says in short what happens, and in the info panel when applications are only archived', async () => { const wrapper = await mountFull() const help = wrapper.get('#cmdb-import-missing-records-help').text() - - expect(help).toContain('is missing') - expect(help).toContain('has no APPID column') - expect(help).toContain('an APPID without a cached value') - expect(help).toContain('only archived') + expect(help).toContain('archived or moved to the trash') + + // The info panel is the section's `info-content` slot; render it. + const panel = mount(CmdbImport, { + global: { + stubs: { + AlwaysVisibleSection: { + render() { + return h('div', [this.$slots['info-content']?.()]) + }, + }, + }, + }, + }) + await flushPromises() + const text = panel.text() + expect(text).toContain('is missing') + expect(text).toContain('has no APPID column') + expect(text).toContain('an APPID without a cached value') + expect(text).toContain('only archived') }) it('disables both radios while an import runs', async () => { @@ -480,10 +512,7 @@ describe('CmdbImport report rows of archived and deleted applications', () => { await wrapper.setData({ report: { summary: { rowsRead: 0, archived: 1, deleted: 1 }, - rows: [ - reconciliationRow('archived'), - reconciliationRow('deleted'), - ], + rows: [reconciliationRow('archived'), reconciliationRow('deleted')], }, }) @@ -491,11 +520,13 @@ describe('CmdbImport report rows of archived and deleted applications', () => { expect(rows).toHaveLength(2) const [archived, deleted] = rows expect( - archived.find('[data-testid="cmdb-import-module-link"]').attributes('href'), + archived + .find('[data-testid="cmdb-import-module-link"]') + .attributes('href'), ).toBe('/apps/stackiq/modules/module-uuid-archived') - expect(deleted.find('[data-testid="cmdb-import-module-link"]').exists()).toBe( - false, - ) + expect( + deleted.find('[data-testid="cmdb-import-module-link"]').exists(), + ).toBe(false) // The name of the deleted application is still shown, as text. expect(deleted.text()).toContain('Naamtest') }) @@ -524,9 +555,9 @@ describe('CmdbImport report rows of archived and deleted applications', () => { }) const rows = wrapper.findAll('[data-testid="table-row"]') - expect(rows.map((row) => row.get('[data-testid="table-row-number"]').text())).toEqual( - ['', '', '2'], - ) + expect( + rows.map((row) => row.get('[data-testid="table-row-number"]').text()), + ).toEqual(['', '', '2']) expect( rows[0].find('[data-testid="cmdb-import-module-link"]').exists(), ).toBe(true) diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue index 88eaf5d75..801bc9b36 100644 --- a/src/views/settings/sections/CmdbImport.vue +++ b/src/views/settings/sections/CmdbImport.vue @@ -172,7 +172,7 @@ {{ t( 'stackiq', - 'An application of this municipality that is on neither CMDB sheet any more is archived when the sheet "Gearchiveerde Applicaties" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.', + 'Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.', ) }}

@@ -449,6 +449,15 @@ }} +

{{ t('stackiq', 'Applications missing from the export') }}

+

+ {{ + t( + 'stackiq', + 'An application of this municipality that is on neither CMDB sheet any more is archived when the sheet "Gearchiveerde Applicaties" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.', + ) + }} +

@@ -718,8 +727,7 @@ export default { const parts = [ [ 'archived', - (count) => - t('stackiq', '{count} archived', { count }, AS_TEXT), + (count) => t('stackiq', '{count} archived', { count }, AS_TEXT), ], [ 'unarchived', @@ -728,13 +736,11 @@ export default { ], [ 'deleted', - (count) => - t('stackiq', '{count} deleted', { count }, AS_TEXT), + (count) => t('stackiq', '{count} deleted', { count }, AS_TEXT), ], [ 'restored', - (count) => - t('stackiq', '{count} restored', { count }, AS_TEXT), + (count) => t('stackiq', '{count} restored', { count }, AS_TEXT), ], ] .map(([key, text]) => [Number(summary[key]) || 0, text]) From dca821194f2f61b1c769bcfc8e782bfe803b9550 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 18:58:44 +0200 Subject: [PATCH 077/124] fix(cmdb-import): never soft-delete when a CMDB-sheet APPID is unreadable; usages before modules Closes the review findings: - "Unreadable CMDB-sheet APPID still soft-deletes": a CMDB-sheet row whose APPID is a formula without a cached value is skipped as empty, so its application looked absent. The run now notes such rows per sheet and, with any, archives instead of soft-deleting and warns naming the CMDB sheet and the rows (reusing the archive-sheet warning). New test testAnUncachedCmdbRowArchivesInsteadOfDeleting. - "Extras written after the module; no failing-extra test": itemsOf() now orders every usage (own and extra) before every module, as the docblock promises. New test testAFailingExtraUsageFailsTheApplicationBeforeItsModule. - archiveServices() swallowed the container exception: it is now logged (exception class only) and chained as previous. - trash() read for missingRecords=keep: kept on purpose (matching is mode independent, a hand-deleted application must be restored, not duplicated); the reason is now in its docblock. Class sweep: the rule is updated in the service docblocks, openapi.json (missingRecords description), and the change's spec.md (requirement and new scenario), design.md (D3 text, D5 table, risks) and proposal.md. docs/features/cmdb-import.md does not describe archive reconciliation yet, so it holds no copy of the rule. Co-Authored-By: Claude Opus 5.5 --- lib/Service/CmdbExportImportService.php | 81 ++++++++++++++++--- openapi.json | 2 +- .../design.md | 6 +- .../proposal.md | 4 +- .../specs/cmdb-export-import/spec.md | 10 ++- .../Service/CmdbExportImportServiceTest.php | 76 +++++++++++++++++ 6 files changed, 162 insertions(+), 17 deletions(-) diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php index 54c9e4b86..4c236d9ac 100644 --- a/lib/Service/CmdbExportImportService.php +++ b/lib/Service/CmdbExportImportService.php @@ -297,6 +297,13 @@ class CmdbExportImportService { */ private array $winningSheets = []; + /** + * The CMDB-sheet rows of this run whose key column the reader could not see (a formula without a cached value). + * + * @var array> Sheet name => row numbers. + */ + private array $unreadableKeyRows = []; + /** * OpenRegister's archive handler and object mapper, once resolved. * @@ -549,6 +556,7 @@ private function runImport(string $path, array $options, string $startedAt): arr $report->setMunicipality(uuid: $municipality['uuid'], name: $municipality['name'], created: $municipality['created']); $report->addImportWarnings(warnings: $this->translateImportWarnings(warnings: $workbook['importWarnings'])); $archive = $this->archiveSheet(workbook: $workbook); + $this->unreadableKeyRows = $this->unreadableKeyRows(rows: $rows); $this->addRunWarnings(report: $report, municipality: $municipality, archive: $archive, missingRecords: $missingRecords); $this->progressTracker->startOperation( @@ -625,7 +633,10 @@ private function runImport(string $path, array $options, string $startedAt): arr }//end runImport() /** - * Add the run's own warnings: an archive sheet that is missing, has no key column or has unreadable keys, and a created municipality. + * Add the run's own warnings. + * + * An archive sheet that is missing, has no key column or has unreadable keys, a CMDB sheet with + * unreadable keys, and a created municipality. * * @param CmdbImportReport $report The report. * @param array{uuid: string, name: string, created: bool} $municipality The consumer, as resolveMunicipality() found it. @@ -642,6 +653,13 @@ private function addRunWarnings(CmdbImportReport $report, array $municipality, a $report->addImportWarnings(warnings: [['sheet' => $archive['sheet'], 'message' => $this->archiveSheetWarning(archive: $archive)]]); } + if ($missingRecords === 'archive') { + foreach ($this->unreadableKeyRows as $sheet => $rowNumbers) { + $cmdbSheet = ['sheet' => $sheet, 'keyColumnMissing' => false, 'uncachedRows' => $rowNumbers]; + $report->addImportWarnings(warnings: [['sheet' => $sheet, 'message' => $this->archiveSheetWarning(archive: $cmdbSheet)]]); + } + } + if ($municipality['created'] === true) { $report->addImportWarnings( warnings: [ @@ -657,6 +675,32 @@ private function addRunWarnings(CmdbImportReport $report, array $municipality, a } }//end addRunWarnings() + /** + * The CMDB-sheet rows whose key column is a formula without a cached value, per sheet. + * + * Such a row reads as empty and is skipped, so its application may be + * missing from `winningSheets` although it is on a CMDB sheet. A run with + * one is not authoritative for a deletion, as with an archive sheet that + * has an unreadable key. + * + * @param array}> $rows The reader rows. + * + * @return array> Sheet name => row numbers. + * + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015 + */ + private function unreadableKeyRows(array $rows): array { + $key = $this->profile->keyColumn(); + $found = []; + foreach ($rows as $row) { + if (in_array($key, ($row['uncached'] ?? []), true) === true) { + $found[$row['sheet']][] = $row['row']; + } + } + + return $found; + }//end unreadableKeyRows() + /** * Process one row in its own error boundary and add its outcome. * @@ -1968,7 +2012,9 @@ private function archiveSheet(array $workbook): array { /** * The report warning of an archiving import whose archive sheet is absent or has no key column, or has unreadable keys. * - * @param array{sheet: string, keyColumnMissing: bool, uncachedRows: array} $archive The archive sheet. + * Also the warning of a CMDB sheet with unreadable keys: it is handed the sheet's name and rows in the archive sheet's shape. + * + * @param array{sheet: string, keyColumnMissing: bool, uncachedRows: array} $archive The archive sheet, or a CMDB sheet. * * @return string * @@ -2021,10 +2067,13 @@ private function archiveServices(): array { return $this->archiveServices; } + $previous = null; try { $handler = $this->container->get(static::ARCHIVE_HANDLER_CLASS); $mapper = $this->container->get(static::OBJECT_MAPPER_CLASS); } catch (Throwable $e) { + $this->logger->warning('CmdbExportImportService: OpenRegister archive services could not be resolved', ['exception' => get_class($e)]); + $previous = $e; $handler = null; $mapper = null; } @@ -2035,7 +2084,8 @@ private function archiveServices(): array { ) { throw new CmdbImportException( errorCode: CmdbImportException::ARCHIVE_UNAVAILABLE, - message: 'OpenRegister ArchiveHandler or MagicMapper is not available' + message: 'OpenRegister ArchiveHandler or MagicMapper is not available', + previous: $previous ); } @@ -2149,6 +2199,11 @@ private function restore(string $uuid): mixed { * nothing in it is matched, and the log says so. A listing that throws is * not cached, so the next row reads it again. * + * It is read for `keep` as well: matching is not tied to the mode (REQ-CMDB-016), + * and an application a person soft-deleted by hand must be restored, not + * created a second time. The listing cannot be narrowed, so the cost is one + * scan per run, paid on the first row no working or archived object matches. + * * @return array{module: array, usage: array} Schema key => uuid => entity. * * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016 @@ -2545,8 +2600,9 @@ private function reconcileApplication(array $application, array $archive, string if ($onArchiveSheet === true) { $entry['sheet'] = $archive['sheet']; $reason = self::REASON_PREFIX . 'on sheet "' . $archive['sheet'] . '" (' . $operationId . ')'; - } elseif ($archive['present'] === true && $archive['uncachedRows'] === []) { - // A sheet with an unreadable key may list this application, so it is not authoritative for a deletion. + } elseif ($archive['present'] === true && $archive['uncachedRows'] === [] && $this->unreadableKeyRows === []) { + // A sheet with an unreadable key (the archive sheet or a CMDB sheet) may list this application, so the + // workbook is not authoritative for a deletion. $target = CmdbImportReport::DELETED; } @@ -2573,7 +2629,10 @@ private function reconcileApplication(array $application, array $archive, string }//end reconcileApplication() /** - * What one application brings to the state the export asks for: its usage and module, then the other usages and modules of its key. + * What one application brings to the state the export asks for: its usage and the other usages of its key, then its module and the other modules. + * + * Usages come before modules, so a failure part way leaves the + * municipality's view right and a module at worst visible. * * Only the application's own objects are restored from the trash, and only * when `restoreDeleted` says so; the extras never are. @@ -2588,16 +2647,18 @@ private function reconcileApplication(array $application, array $archive, string private static function itemsOf(array $application, bool $restoreDeleted): array { $items = []; foreach (['usage', 'module'] as $schemaKey) { + // Every usage is written before any module, whoever holds it. $items[] = [ 'schemaKey' => $schemaKey, 'uuid' => (string)$application[$schemaKey]->getUuid(), 'state' => $application['states'][$schemaKey], 'restore' => $restoreDeleted, ]; - } - - foreach (($application['extras'] ?? []) as $extra) { - $items[] = $extra + ['restore' => false]; + foreach (($application['extras'] ?? []) as $extra) { + if ($extra['schemaKey'] === $schemaKey) { + $items[] = $extra + ['restore' => false]; + } + } } return $items; diff --git a/openapi.json b/openapi.json index 4d4bb058b..f456fc209 100644 --- a/openapi.json +++ b/openapi.json @@ -66,7 +66,7 @@ "archive" ], "default": "archive", - "description": "What happens to the municipality's applications whose APPID is no longer on either CMDB sheet. archive: after the last row, archive module and usage when the sheet \"Gearchiveerde Applicaties\" lists the APPID, soft-delete them when no sheet does, and archive only when the workbook has no archive sheet, the sheet has no APPID column, or an APPID in it has no cached value; an archived or deleted application that returns is unarchived or restored. keep: leave them as they are. mark and remove are reserved and refused with 422 MISSING_RECORDS_UNSUPPORTED" + "description": "What happens to the municipality's applications whose APPID is no longer on either CMDB sheet. archive: after the last row, archive module and usage when the sheet \"Gearchiveerde Applicaties\" lists the APPID, soft-delete them when no sheet does, and archive only when the workbook has no archive sheet, the sheet has no APPID column, an APPID in it has no cached value, or an APPID on a CMDB sheet has no cached value (that application may be the one the reader could not see); an archived or deleted application that returns is unarchived or restored. keep: leave them as they are. mark and remove are reserved and refused with 422 MISSING_RECORDS_UNSUPPORTED" }, "operationId": { "type": "string", diff --git a/openspec/changes/cmdb-import-archive-reconciliation/design.md b/openspec/changes/cmdb-import-archive-reconciliation/design.md index a1bbbe62e..1fc7058ee 100644 --- a/openspec/changes/cmdb-import-archive-reconciliation/design.md +++ b/openspec/changes/cmdb-import-archive-reconciliation/design.md @@ -71,7 +71,7 @@ With `updateExisting=false`, a matched row is skipped as `exists` before anythin The profile names the sheet (`archiveSheet.name`: "Gearchiveerde Applicaties"). The reader lists the workbook's sheets, and when the archive sheet is present it is loaded with the CMDB sheets under the same bounds: it counts as a read sheet for `CmdbPartReferences` (an oversized archive sheet is refused, not blanked) and for the referenced-string budget, its row span is bounded like a source sheet, and `TOO_MANY_ROWS` names it. Only its APPID column is resolved and read; the allowlist does not grow. The reader returns `archive: {present, keyColumnMissing, appIds, uncachedRows}` next to `rows`. - Sheet absent, or present without an APPID column: `present` false (with `keyColumnMissing`), and the service adds a report warning. The reconciliation then archives every missing application and soft-deletes nothing: a workbook saved without the sheet must not empty the trash-bound set by accident. -- A row whose APPID is a formula without a cached value reads as empty, as on the CMDB sheets (REQ-CMDB-002), so the sheet would silently list one application fewer. The reader therefore lists such rows (their numbers) in `uncachedRows`, and the service treats the sheet like an absent one for the decision between archive and delete: it adds a warning naming the sheet and the rows, archives every missing application and soft-deletes nothing. A tool that saves formulas without cached values must not turn archived applications into trash. +- A row whose APPID is a formula without a cached value reads as empty, as on the CMDB sheets (REQ-CMDB-002), so the sheet would silently list one application fewer. The reader therefore lists such rows (their numbers) in `uncachedRows`, and the service treats the sheet like an absent one for the decision between archive and delete: it adds a warning naming the sheet and the rows, archives every missing application and soft-deletes nothing. A tool that saves formulas without cached values must not turn archived applications into trash. The same holds for a CMDB sheet: a row whose APPID has no cached value is skipped, so that application may be on a CMDB sheet and still look absent. The service notes such rows (`unreadableKeyRows`, per sheet) and, with any of them, archives instead of soft-deleting and warns naming the CMDB sheet and the rows, as for the archive sheet. - The archive sheet's other columns are not read; `Applicatie Status` there is always `Verwijderd` in the municipality's export and adds nothing. ### D4. Matching widens to archived and deleted objects, and revives what it matches @@ -94,7 +94,7 @@ Per application, with `processed` = every APPID match key on the CMDB sheets (wh | on the archive sheet | deleted | restore, then archive | `archived` | | nowhere, sheet present | working or archived | soft-delete usage, then module | `deleted` | | nowhere, sheet present | deleted | nothing | — | -| nowhere, sheet absent or incomplete (no key column, or `uncachedRows` not empty) | working | archive usage, then module | `archived` | +| nowhere, sheet absent or incomplete (no key column, `uncachedRows` not empty, or a CMDB-sheet row with an unreadable APPID) | working | archive usage, then module | `archived` | | nowhere, sheet absent or incomplete | archived or deleted | nothing | — | The table decides on one entry per key. When a municipality has several usages and modules under the same import key (D4 says earlier imports created exactly such duplicates, a module deleted by hand next to the one a later import created), the scope keeps the entry the import would match: the best module state wins (working, then archived, then deleted), the best usage state breaks a tie, the first listed breaks the rest. The other usages of the key follow the application, so a stray usage stays visible nowhere: a working or archived usage of a stale duplicate, a second usage of the same module, and a working or archived duplicate module are archived or soft-deleted as the key asks, once, in the same error boundary and the same report row (usages first, then modules). What is in the trash is left as it is: a stale trashed duplicate is not restored into the archive when the key is on the archive sheet, and is not touched when it is on no sheet. @@ -196,7 +196,7 @@ No seed objects change. The fragment's three seed modules stay as they are; the ## Risks / Trade-offs - [The default archives on the first re-import after the upgrade] → The section shows the choice with help text; the report lists every archived and deleted application; nothing is hard-deleted. -- [A workbook saved without the archive sheet, or whose archive sheet has no APPID column or an APPID without a cached value] → archive only, never delete, plus a warning (D3). +- [A workbook saved without the archive sheet, or whose archive sheet has no APPID column or an APPID without a cached value, or a CMDB sheet with an APPID without a cached value] → archive only, never delete, plus a warning (D3). - [A failure between the usage and the module] → the usage is written first, so the municipality's view is right and the module is at worst still visible (D5). - [Reviving before writing adds calls per returning row] → only for rows whose match is archived or deleted; a row whose module is working costs the same searches as before, a new row one more search (the archive lens). - [The trash listing scans every magic table of the instance] → read once per import, not per row (D9). diff --git a/openspec/changes/cmdb-import-archive-reconciliation/proposal.md b/openspec/changes/cmdb-import-archive-reconciliation/proposal.md index dd4017f30..d9685e710 100644 --- a/openspec/changes/cmdb-import-archive-reconciliation/proposal.md +++ b/openspec/changes/cmdb-import-archive-reconciliation/proposal.md @@ -34,7 +34,7 @@ None. ### In Scope - The sheet "Gearchiveerde Applicaties" (columns Applicatiecomponent, APPID, Applicatie Code, Applicatie Naam, Roepnaam, Applicatie Status, Bron, Datum Interface, Datum wijziging) is read for its APPID column only, when it is present. -- After every row of a completed import: the municipality's usages, with their modules, are compared with the APPIDs on the CMDB sheets and on the archive sheet. Missing and on the archive sheet: module and usage archived. Missing everywhere: module and usage soft-deleted. Without the archive sheet, or when it has no APPID column or an APPID the reader cannot see (a formula without a cached value): archived only, with a warning in the report. +- After every row of a completed import: the municipality's usages, with their modules, are compared with the APPIDs on the CMDB sheets and on the archive sheet. Missing and on the archive sheet: module and usage archived. Missing everywhere: module and usage soft-deleted. Without the archive sheet, or when it has no APPID column or an APPID the reader cannot see (a formula without a cached value, on the archive sheet or on a CMDB sheet): archived only, with a warning in the report. - A returning APPID: an archived module or usage is unarchived, a soft-deleted one is restored from the trash, and the row is then updated as before. - `missingRecords`: `keep` leaves everything as it is (the behaviour so far), `archive` reconciles and is the default; `remove` stays reserved and is refused. - The settings section offers the choice with help text; the report shows the new outcomes in its summary and rows. @@ -78,7 +78,7 @@ None. `ArchiveHandler` and `MagicMapper` come from OpenRegister, which stackiq a **Severity:** Medium — **Mitigation:** only the municipality's own usages and the modules they point at are in scope, never a supplier's or another municipality's module; nothing is hard-deleted; the choice is visible in the section with help text, and the report lists every archived and deleted application. A cancelled or failed import never reconciles. ### Risk 2: A workbook without a usable archive sheet soft-deletes everything that moved there -**Severity:** Medium — **Mitigation:** without the sheet, without its APPID column, or with an APPID the reader cannot see (a formula without a cached value), missing applications are archived, never soft-deleted, and the report warns, naming the sheet (and the rows). +**Severity:** Medium — **Mitigation:** without the sheet, without its APPID column, or with an APPID the reader cannot see on it or on a CMDB sheet (a formula without a cached value), missing applications are archived, never soft-deleted, and the report warns, naming the sheet (and the rows). ### Risk 3: The archive services are OpenRegister internals **Severity:** Low — **Mitigation:** resolved through the container behind a guard; when missing, an import with `missingRecords=archive` is refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read, and `keep` still works. diff --git a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md index d03c04ee7..7f781f895 100644 --- a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md +++ b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md @@ -16,7 +16,7 @@ OpenRegister: `OCA\OpenRegister\Contract\ObjectServiceInterface` (`searchObjects ### Requirement: An application missing from the CMDB sheets SHALL be archived when the archive sheet lists it and soft-deleted when no sheet does (REQ-CMDB-015) -With `missingRecords` `archive`, after every row of an import that was not cancelled and did not fail, the import SHALL reconcile, under the register lock it already holds, every `usage` whose `consumer` is the imported municipality together with the `module` it points at, including archived and soft-deleted ones, and only those whose module carries this municipality's import key (`topdesk::`). It SHALL read the APPID column of the sheet "Gearchiveerde Applicaties" when the workbook has it, under the same size and row bounds as the CMDB sheets and without reading any other column of it. For an application whose APPID is on neither CMDB sheet: when the archive sheet lists the APPID, the import SHALL archive the usage and then the module through OpenRegister's archive state with a reason that names the import, restoring a soft-deleted one from the trash first; when no sheet lists it, the import SHALL soft-delete the usage and then the module through OpenRegister's trash. When the workbook has no archive sheet, or the sheet has no APPID column, or a row of the sheet holds an APPID formula without a cached value (read as empty, as REQ-CMDB-002 has it for the CMDB sheets, and listed by the reader in `archive.uncachedRows`), the import SHALL archive and SHALL NOT soft-delete, and SHALL add a warning to the report; for an uncached row the warning SHALL name the sheet and the row numbers. When a municipality has more than one module or usage under the same import key (a module deleted by hand next to the one a later import created), the import SHALL reconcile as the application the one it would match, a working one before an archived one and an archived one before one in the trash, and SHALL bring every other usage of that key, and every other module of it that is not in the trash, to the same state, so no stray usage stays visible; it SHALL leave what is in the trash as it is, so a stale duplicate is never restored into the archive. An application that is already in the state the export asks for SHALL be left alone and not reported. The import SHALL NOT touch contact persons, organisations, modules that carry another municipality's import key or no import key, or usages of another organisation. Each application SHALL be reconciled in its own error boundary: a failure SHALL be a `failed` report row naming the step and the exception class, and the other applications SHALL still be reconciled. The reconciliation SHALL report its progress as the phase `reconciling` of the import's operation. A cancel SHALL also be honoured after the last row and before each application of the reconciliation: the import SHALL stop there, SHALL keep what it archived or deleted so far, and SHALL report the run as cancelled. The report SHALL count `archived` and `deleted` in its summary and SHALL list each as a row with the APPID, the application name and both uuids, never person data. When OpenRegister's archive handler or object mapper is not available, an import with `missingRecords` `archive` SHALL be refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read. +With `missingRecords` `archive`, after every row of an import that was not cancelled and did not fail, the import SHALL reconcile, under the register lock it already holds, every `usage` whose `consumer` is the imported municipality together with the `module` it points at, including archived and soft-deleted ones, and only those whose module carries this municipality's import key (`topdesk::`). It SHALL read the APPID column of the sheet "Gearchiveerde Applicaties" when the workbook has it, under the same size and row bounds as the CMDB sheets and without reading any other column of it. For an application whose APPID is on neither CMDB sheet: when the archive sheet lists the APPID, the import SHALL archive the usage and then the module through OpenRegister's archive state with a reason that names the import, restoring a soft-deleted one from the trash first; when no sheet lists it, the import SHALL soft-delete the usage and then the module through OpenRegister's trash. When the workbook has no archive sheet, or the sheet has no APPID column, or a row of the sheet holds an APPID formula without a cached value (read as empty, as REQ-CMDB-002 has it for the CMDB sheets, and listed by the reader in `archive.uncachedRows`), the import SHALL archive and SHALL NOT soft-delete, and SHALL add a warning to the report; for an uncached row the warning SHALL name the sheet and the row numbers. The same holds when a row of a CMDB sheet holds an APPID formula without a cached value (the row is skipped as empty, so its application may look absent): the import SHALL archive and SHALL NOT soft-delete, and the warning SHALL name that CMDB sheet and the rows. When a municipality has more than one module or usage under the same import key (a module deleted by hand next to the one a later import created), the import SHALL reconcile as the application the one it would match, a working one before an archived one and an archived one before one in the trash, and SHALL bring every other usage of that key, and every other module of it that is not in the trash, to the same state, so no stray usage stays visible; it SHALL leave what is in the trash as it is, so a stale duplicate is never restored into the archive. An application that is already in the state the export asks for SHALL be left alone and not reported. The import SHALL NOT touch contact persons, organisations, modules that carry another municipality's import key or no import key, or usages of another organisation. Each application SHALL be reconciled in its own error boundary: a failure SHALL be a `failed` report row naming the step and the exception class, and the other applications SHALL still be reconciled. The reconciliation SHALL report its progress as the phase `reconciling` of the import's operation. A cancel SHALL also be honoured after the last row and before each application of the reconciliation: the import SHALL stop there, SHALL keep what it archived or deleted so far, and SHALL report the run as cancelled. The report SHALL count `archived` and `deleted` in its summary and SHALL list each as a row with the APPID, the application name and both uuids, never person data. When OpenRegister's archive handler or object mapper is not available, an import with `missingRecords` `archive` SHALL be refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read. #### Scenario: An application that moved to the archive sheet is archived @e2e tests/e2e/spec-coverage/cmdb-import.spec.ts @@ -53,6 +53,14 @@ With `missingRecords` `archive`, after every row of an import that was not cance - **THEN** the module and the usage of `7` SHALL be archived and SHALL NOT be deleted - **AND** the report SHALL carry a warning naming the sheet "Gearchiveerde Applicaties" and the row +#### Scenario: A CMDB row whose APPID has no cached value never turns an archive into a deletion +@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testAnUncachedCmdbRowArchivesInsteadOfDeleting asserts the archive state, no delete, and the warning naming the CMDB sheet and the row. + +- **GIVEN** the module with APPID `8` and its usage were imported, `8` is on no readable row, and a row of a CMDB sheet holds its APPID in a formula that carries no cached value +- **WHEN** the workbook is imported with `archive` and a readable archive sheet that does not list `8` +- **THEN** the module and the usage of `8` SHALL be archived and SHALL NOT be deleted +- **AND** the report SHALL carry a warning naming the CMDB sheet and the row + #### Scenario: Keeping missing records, a cancelled import and another municipality leave everything as it is @e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testRecordsMissingFromTheExportStayWhenKept, testACancelledImportDoesNotReconcile and testAnotherMunicipalitysUsagesAreNeverReconciled assert no archive, no delete and no reconciliation row. diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php index 6fb99267f..c476b5b1f 100644 --- a/tests/Unit/Service/CmdbExportImportServiceTest.php +++ b/tests/Unit/Service/CmdbExportImportServiceTest.php @@ -2000,6 +2000,82 @@ public function testAnUncachedArchiveRowArchivesInsteadOfDeleting(): void { $this->assertSame([], $kept['importWarnings']); }//end testAnUncachedArchiveRowArchivesInsteadOfDeleting() + /** + * A CMDB-sheet row whose APPID the reader could not see is skipped as empty, so its application looks absent: + * the import archives what it would have deleted, and warns naming the CMDB sheet and the row. + * + * @return void + * + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015 + */ + public function testAnUncachedCmdbRowArchivesInsteadOfDeleting(): void { + $this->importOneSevenAndEight(); + [$seven, $eight] = [$this->moduleOf(appId: '7'), $this->moduleOf(appId: '8')]; + + // APPID 7 is on the readable archive sheet; APPID 8 is on no readable sheet, and row 5 of a CMDB sheet has an APPID formula without a cached value. + $report = $this->service( + reader: $this->rowsReader( + rows: [$this->row(appId: '1'), $this->row(appId: '', cells: ['APPID' => null], row: 5, uncached: ['APPID'])], + archived: ['7'] + ) + )->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertNotContains('delete', array_column($this->transitions, 0), 'nothing is soft-deleted'); + $this->assertSame([true, false], $this->stateOf(schema: self::MODULE, uuid: $seven)); + $this->assertSame([true, false], $this->stateOf(schema: self::MODULE, uuid: $eight), 'APPID 8 may be the unreadable row, so it is archived'); + $this->assertSame([true, false], $this->stateOf(schema: self::USAGE, uuid: $this->usageOf(moduleUuid: $eight))); + $this->assertSame([2, 0], [$report['summary']['archived'], $report['summary']['deleted']]); + $this->assertSame( + [['sheet' => 'Beheerde Applicaties CMDB', 'message' => 'Sheet "Beheerde Applicaties CMDB" has an APPID without a cached value in row 5, so applications missing from the export are archived, not deleted.']], + $report['importWarnings'] + ); + + // Keeping missing records needs no warning, and without the unreadable row the same workbook deletes. + $this->transitions = []; + $this->states = array_replace_recursive($this->states, [self::MODULE => [$eight => ['archived' => null]], self::USAGE => [$this->usageOf(moduleUuid: $eight) => ['archived' => null]]]); + $kept = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1'), $this->row(appId: '', cells: ['APPID' => null], row: 5, uncached: ['APPID'])], archived: ['7'])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1', 'missingRecords' => 'keep']); + $this->assertSame([], $kept['importWarnings']); + $this->assertSame([], $this->transitions); + + $readable = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7'])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + $this->assertSame([false, true], $this->stateOf(schema: self::MODULE, uuid: $eight), 'a readable workbook still soft-deletes what is on no sheet'); + $this->assertSame([], $readable['importWarnings']); + }//end testAnUncachedCmdbRowArchivesInsteadOfDeleting() + + /** + * An extra usage of a key that cannot be archived fails the application's row; its module, written after every usage, stays + * as it was, and the other applications are still reconciled. + * + * @return void + * + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015 + */ + public function testAFailingExtraUsageFailsTheApplicationBeforeItsModule(): void { + $this->importOneSevenAndEight(); + [$seven, $eight] = [$this->moduleOf(appId: '7'), $this->moduleOf(appId: '8')]; + $sevenUsage = $this->usageOf(moduleUuid: $seven); + $this->store[self::USAGE]['usage-second'] = array_merge($this->store[self::USAGE][$sevenUsage], ['id' => 'usage-second']); + $this->beforeTransition = static function (string $verb, string $uuid): void { + if ($uuid === 'usage-second') { + throw new RuntimeException('the extra usage cannot be archived'); + } + }; + + $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7'])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $rows = array_values(array_filter($report['rows'], static fn (array $row): bool => $row['row'] === 0)); + $this->assertSame(['failed', 'deleted'], array_column($rows, 'outcome')); + $this->assertSame(['step "archive" failed (RuntimeException)'], $rows[0]['reasons']); + $this->assertSame([true, false], $this->stateOf(schema: self::USAGE, uuid: $sevenUsage), 'the application\'s own usage went first'); + $this->assertSame([false, false], $this->stateOf(schema: self::USAGE, uuid: 'usage-second'), 'the failing extra is unchanged'); + $this->assertSame([false, false], $this->stateOf(schema: self::MODULE, uuid: $seven), 'the module comes after every usage, so it was not reached'); + $this->assertSame([false, true], $this->stateOf(schema: self::MODULE, uuid: $eight), 'the next application is still reconciled'); + $this->assertSame(1, $report['summary']['failed']); + }//end testAFailingExtraUsageFailsTheApplicationBeforeItsModule() + /** * A cancelled import reconciles nothing. * From 18bd2224df881d3ed09c706d92f67ebc426de752 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 18:58:44 +0200 Subject: [PATCH 078/124] docs(openspec): tick the reconciliation tasks that this PR implements Closes the merge artefact from the base fix, which unticked Tasks 3-7 and said their code lands in #1286/#1287: - Tasks 3, 4 and 5 (matching, reconciliation, controller option) are implemented and tested here: Implement and Test ticked again. - Tasks 6 and 7 stay unticked: the UI is #1287, and the docs and the e2e run of task 7 are not done. - The status line now says what is true for this PR. - Task 4's acceptance criterion also names the CMDB-sheet unreadable APPID case. Co-Authored-By: Claude Opus 5.5 --- .../cmdb-import-archive-reconciliation/tasks.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md index 9e1e85772..b75a1fb11 100644 --- a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md +++ b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md @@ -4,7 +4,7 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp ## Implementation Tasks -Status in this PR (the archive-sheet reader slice): Tasks 1 and 2 are implemented here. Tasks 3-7 are unticked because their code lands in the follow-up PRs (reconciliation in #1286, UI in #1287). +Status in this PR (the reconciliation slice, on top of the reader slice): Tasks 1-5 are implemented here (the reader's tasks 1-2 come from the base PR). Tasks 6-7 stay unticked: the UI lands in #1287, and the docs and the e2e run of task 7 are not done yet. ### Task 1: Schema opt-in and the import profile - **spec_ref**: `SPEC#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017`, `SPEC#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012` @@ -37,23 +37,23 @@ Status in this PR (the archive-sheet reader slice): Tasks 1 and 2 are implemente - GIVEN OpenRegister's search results (no archive marker; no soft-deleted row, `_includeDeleted` or not) THEN the state comes from the lens that found the object and from the trash listing, read once per import (design D9) - GIVEN `updateExisting` false THEN the row is skipped as `exists` and nothing is revived - GIVEN a deleted and a working module (and usage) sharing one import key THEN the working one is updated and nothing is restored -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 4: Reconciliation after the rows - **spec_ref**: `SPEC#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015` - **files**: `lib/Service/CmdbExportImportService.php`, `lib/Service/ProgressTracker.php`, `lib/Exception/CmdbImportException.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php` - **acceptance_criteria**: - GIVEN APPIDs 1 and 7 imported WHEN an export with row 1 and 7 on the archive sheet is imported THEN module and usage of 7 are archived with a reason, the report row is `archived`, and the owner contact person is untouched - - GIVEN APPID 7 on no sheet THEN module and usage are soft-deleted and the row is `deleted`; GIVEN the archive sheet absent, without an APPID column, or with an APPID that has no cached value THEN they are archived instead and the report warns + - GIVEN APPID 7 on no sheet THEN module and usage are soft-deleted and the row is `deleted`; GIVEN the archive sheet absent, without an APPID column, or with an APPID that has no cached value, or a CMDB-sheet row whose APPID has no cached value THEN they are archived instead and the report warns - GIVEN APPID 7 soft-deleted and listed on the archive sheet THEN module and usage are restored and archived; GIVEN it already archived or, when on no sheet, already deleted THEN nothing is written - GIVEN a working pair and a trashed pair under the same import key and the key on the archive sheet THEN the working pair is archived, nothing is restored and the trashed pair stays deleted; GIVEN a further working usage of the key (of the trashed module, or a second one of the working module) THEN it is archived or deleted with the application - GIVEN `missingRecords` `keep`, a cancelled import, or a usage of another municipality THEN nothing is archived or deleted - GIVEN a cancel during the last row THEN nothing is archived or deleted; GIVEN a cancel during the reconciliation THEN it stops before the next application and the report is cancelled - GIVEN the archive of one application throws THEN that application is a `failed` row and the others are reconciled; GIVEN no ArchiveHandler THEN 503 `ARCHIVE_UNAVAILABLE` before reading - GIVEN the step runs THEN the operation is in phase `reconciling` and its progress advances per application -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 5: Controller option - **spec_ref**: `SPEC#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012` @@ -61,8 +61,8 @@ Status in this PR (the archive-sheet reader slice): Tasks 1 and 2 are implemente - **acceptance_criteria**: - GIVEN no `missingRecords` THEN the service receives `archive`; GIVEN `keep` or `archive` THEN it is passed through; GIVEN `remove` or `mark` THEN 422 `MISSING_RECORDS_UNSUPPORTED` with `details.accepted` `["keep", "archive"]` and no import - GIVEN `ARCHIVE_UNAVAILABLE` from the service THEN 503 with its own translated message -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 6: Settings section, report words and the archive quick filter - **spec_ref**: `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014`, `SPEC#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017` From 3d8b7891239fa5b582baa4691587baa8ffe104ad Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 19:02:21 +0200 Subject: [PATCH 079/124] fix(cmdb-import): view the mapping without writing, and never overwrite an edit made in between - Viewing the mapping (the read-only GET and any lazy load) now reads the store only: a pack it does not hold yet is shown from its shipped file, and nothing is seeded, updated or recorded. The import run and the seed step still seed and update (CmdbImportProfile::load() vs loadForView()). - Updating an unedited pack passes the read pack's updatedAt to OpenRegister; when an administrator saved it in between, OpenRegister refuses with PackChangedException and the store keeps that edit. - The store counts as available only when OpenRegister's pack controller keeps every write method admin-only and CSRF-protected (no opening attribute or annotation), checked by reflection in the new CmdbImportPackStoreProbe, next to the slug-lock marker class. - The test fake follows OpenRegister's service: the updatedAt check, the slug lock, DoesNotExist on update, PackSlugExistsException on create, and the real exception hierarchy. - Tests pin that the endpoint without an injected profile uses the import's loader, and that a profile without a usage pack is refused. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/Service/Cmdb/CmdbImportPackStore.php | 203 +++++++++++------ lib/Service/Cmdb/CmdbImportPackStoreProbe.php | 205 ++++++++++++++++++ lib/Service/Cmdb/CmdbImportProfile.php | 51 ++++- ...ettingsControllerCmdbImportMappingTest.php | 54 ++++- .../Service/Cmdb/CmdbImportPackStoreTest.php | 95 ++++++++ .../Service/Cmdb/CmdbImportProfileTest.php | 58 +++++ .../Unit/Support/FakeMigrationPackService.php | 119 ++++++++-- .../AdminOpenedMigrationPacksController.php | 68 ++++++ .../BuiltinPackSlugLockedException.php | 6 +- .../CsrfOpenedMigrationPacksController.php | 70 ++++++ .../InvalidPackDefinitionException.php | 27 +++ .../MigrationPackRefusedException.php | 29 +++ .../OpenRegister/MigrationPacksController.php | 71 ++++++ .../OpenRegister/PackChangedException.php | 27 +++ .../OpenRegister/PackSlugExistsException.php | 27 +++ 15 files changed, 1017 insertions(+), 93 deletions(-) create mode 100644 lib/Service/Cmdb/CmdbImportPackStoreProbe.php create mode 100644 tests/Unit/Support/OpenRegister/AdminOpenedMigrationPacksController.php create mode 100644 tests/Unit/Support/OpenRegister/CsrfOpenedMigrationPacksController.php create mode 100644 tests/Unit/Support/OpenRegister/InvalidPackDefinitionException.php create mode 100644 tests/Unit/Support/OpenRegister/MigrationPackRefusedException.php create mode 100644 tests/Unit/Support/OpenRegister/MigrationPacksController.php create mode 100644 tests/Unit/Support/OpenRegister/PackChangedException.php create mode 100644 tests/Unit/Support/OpenRegister/PackSlugExistsException.php diff --git a/lib/Service/Cmdb/CmdbImportPackStore.php b/lib/Service/Cmdb/CmdbImportPackStore.php index 472226aee..7963d5981 100644 --- a/lib/Service/Cmdb/CmdbImportPackStore.php +++ b/lib/Service/Cmdb/CmdbImportPackStore.php @@ -76,13 +76,6 @@ class CmdbImportPackStore { */ public const UPDATED = 'updated'; - /** - * The methods this store calls on OpenRegister's service. - * - * @var array - */ - private const SERVICE_METHODS = ['findByPackSlug', 'find', 'create', 'update']; - /** * A class only an OpenRegister with the hardened pack store has. * @@ -92,11 +85,22 @@ class CmdbImportPackStore { public const HARDENED_CLASS = 'OCA\OpenRegister\Exception\BuiltinPackSlugLockedException'; /** - * OpenRegister's service, once resolved; false when it is not available. + * OpenRegister's controller for the pack routes; its write methods must be admin-only and CSRF-protected. + */ + public const CONTROLLER_CLASS = 'OCA\OpenRegister\Controller\MigrationPacksController'; + + /** + * The exception OpenRegister throws when a pack was saved after the caller read it. + */ + public const PACK_CHANGED_CLASS = 'OCA\OpenRegister\Exception\PackChangedException'; + + + /** + * Decides whether OpenRegister's store may be used, and hands out its service. * - * @var object|false|null + * @var CmdbImportPackStoreProbe */ - private object|false|null $service = null; + private readonly CmdbImportPackStoreProbe $probe; /** * Reads and writes the seed marker. @@ -111,6 +115,7 @@ class CmdbImportPackStore { * @param ContainerInterface $container Resolves OpenRegister's service, and the app config when none is given. * @param IAppConfig|null $appConfig Keeps the row ids; null resolves it from the container. * @param string $hardenedClass The class that marks an OpenRegister with the hardened pack store. + * @param string $controllerClass The controller whose write methods must be guarded. * * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 */ @@ -118,14 +123,23 @@ public function __construct( private readonly ContainerInterface $container, private ?IAppConfig $appConfig = null, private readonly string $hardenedClass = self::HARDENED_CLASS, + private readonly string $controllerClass = self::CONTROLLER_CLASS, ) { $this->marker = new CmdbImportPackMarker(); + $this->probe = new CmdbImportPackStoreProbe( + container: $this->container, + serviceClass: self::SERVICE_CLASS, + hardenedClass: $this->hardenedClass, + controllerClass: $this->controllerClass + ); }//end __construct() /** * Whether OpenRegister's migration-pack store can be used. * - * Only an OpenRegister with the hardened pack store counts (HARDENED_CLASS). + * Only an OpenRegister with the hardened pack store counts: it has + * HARDENED_CLASS, and no write method of its pack controller carries an + * attribute or annotation that skips the CSRF check or admits non-admins. * * @return bool * @@ -197,6 +211,33 @@ public function assertShippedFallbackAllowed(): void { ); }//end assertShippedFallbackAllowed() + /** + * The stored pack for a shipped pack, read only: nothing is created, updated or recorded. + * + * For a view of the mapping. The import run and the seed step use resolve(), + * which writes. + * + * @param array $shipped The shipped pack, already validated. + * + * @return array|null As resolve(), with outcome found; null when the store holds no such pack. + * + * @throws CmdbImportException MAPPING_UNAVAILABLE when the store is missing or fails, + * or when the pack was renamed in OpenRegister. + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + */ + public function find(array $shipped): ?array { + $service = $this->requireService(); + $slug = (string)($shipped['id'] ?? ''); + $entity = $this->findBySlug(service: $service, slug: $slug); + if ($entity === null) { + $this->assertNotRenamed(service: $service, slug: $slug); + return null; + } + + return $this->describe(entity: $entity, slug: $slug, shipped: $shipped, outcome: self::FOUND); + }//end find() + /** * The stored pack for a shipped pack: found, seeded when missing, or updated when unedited and outdated. * @@ -217,14 +258,7 @@ public function assertShippedFallbackAllowed(): void { * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 */ public function resolve(array $shipped): array { - $service = $this->service(); - if ($service === null) { - throw new CmdbImportException( - errorCode: CmdbImportException::MAPPING_UNAVAILABLE, - message: 'OpenRegister MigrationPackService is not available' - ); - } - + $service = $this->requireService(); $slug = (string)($shipped['id'] ?? ''); $outcome = self::FOUND; $entity = $this->findBySlug(service: $service, slug: $slug); @@ -245,21 +279,71 @@ public function resolve(array $shipped): array { } } } elseif ($this->marker->isOutdated(definition: self::definitionOf(entity: $entity), shipped: $shipped) === true) { - $id = (int)$entity->getId(); - $entity = $this->write( + [$entity, $outcome] = $this->updateUnedited(service: $service, entity: $entity, slug: $slug, shipped: $shipped); + } + + $this->record(slug: $slug, id: (int)$entity->getId()); + return $this->describe(entity: $entity, slug: $slug, shipped: $shipped, outcome: $outcome); + }//end resolve() + + /** + * Replace an unedited stored pack with the newer shipped one, unless someone saved it since it was read. + * + * The update carries the read pack's `updatedAt`, so an administrator's + * edit saved in between is refused by OpenRegister instead of overwritten; + * that edit is then read back and kept. + * + * @param object $service OpenRegister's service. + * @param object $entity The stored pack as read. + * @param string $slug The pack slug. + * @param array $shipped The shipped pack. + * + * @return array{0: object, 1: string} The stored entity and the outcome (updated, or found when an edit came in between). + * + * @throws CmdbImportException MAPPING_UNAVAILABLE when the update fails otherwise. + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + */ + private function updateUnedited(object $service, object $entity, string $slug, array $shipped): array { + $id = (int)$entity->getId(); + $readAt = $entity->getUpdatedAt()?->getTimestamp(); + try { + $updated = $this->write( slug: $slug, action: 'updated', - call: fn (): object => $service->update(id: $id, definition: $this->marker->seed(shipped: $shipped)) + call: fn (): object => $service->update(id: $id, definition: $this->marker->seed(shipped: $shipped), expectedUpdatedAt: $readAt) ); - $outcome = self::UPDATED; - } + return [$updated, self::UPDATED]; + } catch (CmdbImportException $e) { + $current = null; + if (is_a($e->getPrevious(), self::PACK_CHANGED_CLASS) === true) { + $current = $this->findBySlug(service: $service, slug: $slug); + } - $id = (int)$entity->getId(); - $this->record(slug: $slug, id: $id); + if ($current === null) { + throw $e; + } + return [$current, self::FOUND]; + } + }//end updateUnedited() + + /** + * What resolve() and find() answer for a stored pack. + * + * @param object $entity The MigrationPack entity. + * @param string $slug The pack slug. + * @param array $shipped The shipped pack. + * @param string $outcome found, seeded or updated. + * + * @return array + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + */ + private function describe(object $entity, string $slug, array $shipped, string $outcome): array { $definition = self::definitionOf(entity: $entity); return [ - 'id' => $id, + 'id' => (int)$entity->getId(), 'slug' => $slug, 'version' => (string)($definition['version'] ?? ''), 'definition' => $definition, @@ -268,7 +352,28 @@ public function resolve(array $shipped): array { 'newerShipped' => $this->marker->isNewerShipped(definition: $definition, shipped: $shipped), 'outcome' => $outcome, ]; - }//end resolve() + }//end describe() + + /** + * OpenRegister's service, or a refusal when the store is not available. + * + * @return object + * + * @throws CmdbImportException MAPPING_UNAVAILABLE when the store is not available. + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + */ + private function requireService(): object { + $service = $this->service(); + if ($service === null) { + throw new CmdbImportException( + errorCode: CmdbImportException::MAPPING_UNAVAILABLE, + message: 'OpenRegister MigrationPackService is not available' + ); + } + + return $service; + }//end requireService() /** * The decoded definition of a stored pack. @@ -411,54 +516,16 @@ private function record(string $slug, int $id): void { }//end record() /** - * OpenRegister's service, resolved once; null on an OpenRegister without the hardened pack store. + * OpenRegister's service; null on an OpenRegister without the hardened pack store. * * @return object|null * * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 */ private function service(): ?object { - if ($this->service === null) { - $this->service = false; - try { - if (class_exists($this->hardenedClass) === true && $this->container->has(self::SERVICE_CLASS) === true) { - $service = $this->container->get(self::SERVICE_CLASS); - if (is_object($service) === true && self::hasMethods(service: $service) === true) { - $this->service = $service; - } - } - } catch (Throwable $e) { - // OpenRegister is missing or its service cannot be built: the store is not available. - $this->service = false; - } - } - - if ($this->service === false) { - return null; - } - - return $this->service; + return $this->probe->service(); }//end service() - /** - * Whether a resolved service has every method this store calls. - * - * @param object $service The service. - * - * @return bool - * - * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 - */ - private static function hasMethods(object $service): bool { - foreach (self::SERVICE_METHODS as $method) { - if (method_exists($service, $method) === false) { - return false; - } - } - - return true; - }//end hasMethods() - /** * The app config, resolved from the container when it was not given. * diff --git a/lib/Service/Cmdb/CmdbImportPackStoreProbe.php b/lib/Service/Cmdb/CmdbImportPackStoreProbe.php new file mode 100644 index 000000000..4c41b8b66 --- /dev/null +++ b/lib/Service/Cmdb/CmdbImportPackStoreProbe.php @@ -0,0 +1,205 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service\Cmdb; + +use Psr\Container\ContainerInterface; +use ReflectionAttribute; +use ReflectionClass; +use ReflectionMethod; +use Throwable; + +/** + * Resolves OpenRegister's pack service when the store may be used. + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + */ +final class CmdbImportPackStoreProbe { + /** + * The methods the store calls on OpenRegister's service. + * + * @var array + */ + private const SERVICE_METHODS = ['findByPackSlug', 'find', 'create', 'update']; + + /** + * The controller methods that write packs. + * + * @var array + */ + private const WRITE_METHODS = ['create', 'update', 'destroy', 'import', 'preview']; + + /** + * The attributes and annotations that would open a write route to non-admins or skip its CSRF check. + * + * @var array + */ + private const OPENING_MARKERS = ['NoCSRFRequired', 'NoAdminRequired', 'PublicPage', 'AuthorizedAdminSetting']; + + /** + * The service, once resolved; false when the store may not be used. + * + * @var object|false|null + */ + private object|false|null $service = null; + + /** + * Constructor. + * + * @param ContainerInterface $container Resolves OpenRegister's service. + * @param string $serviceClass OpenRegister's pack service. + * @param string $hardenedClass The class that marks an OpenRegister with the hardened pack store. + * @param string $controllerClass The controller whose write methods must be guarded. + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + */ + public function __construct( + private readonly ContainerInterface $container, + private readonly string $serviceClass, + private readonly string $hardenedClass, + private readonly string $controllerClass, + ) { + }//end __construct() + + /** + * OpenRegister's service, resolved once; null when the store may not be used. + * + * @return object|null + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + */ + public function service(): ?object { + if ($this->service === null) { + $this->service = false; + try { + $this->service = $this->resolve(); + } catch (Throwable $e) { + // OpenRegister is missing or its service cannot be built: the store is not available. + $this->service = false; + } + } + + if ($this->service === false) { + return null; + } + + return $this->service; + }//end service() + + /** + * The service when every condition holds, false otherwise. + * + * @return object|false + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + */ + private function resolve(): object|false { + if (class_exists($this->hardenedClass) === false + || self::writesAreGuarded(controllerClass: $this->controllerClass) === false + || $this->container->has($this->serviceClass) === false + ) { + return false; + } + + $service = $this->container->get($this->serviceClass); + if (is_object($service) === false || self::hasMethods(service: $service) === false) { + return false; + } + + return $service; + }//end resolve() + + /** + * Whether every pack write method of the controller is admin-only and CSRF-protected. + * + * Nextcloud opens a route with an attribute or a docblock annotation, so + * a method that carries none of the opening markers keeps the default: + * admins only, with a CSRF token. + * + * @param string $controllerClass The controller class. + * + * @return bool False when the class or a write method is missing, or a write method is opened. + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + */ + private static function writesAreGuarded(string $controllerClass): bool { + if (class_exists($controllerClass) === false) { + return false; + } + + $class = new ReflectionClass($controllerClass); + foreach (self::WRITE_METHODS as $name) { + if ($class->hasMethod($name) === false || self::isOpened(method: $class->getMethod($name)) === true) { + return false; + } + } + + return true; + }//end writesAreGuarded() + + /** + * Whether a controller method carries an attribute or annotation that opens its route. + * + * @param ReflectionMethod $method The method. + * + * @return bool + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + */ + private static function isOpened(ReflectionMethod $method): bool { + $attributes = array_map( + static fn (ReflectionAttribute $attribute): string => substr((string)strrchr('\\' . $attribute->getName(), '\\'), 1), + $method->getAttributes() + ); + $doc = (string)$method->getDocComment(); + foreach (self::OPENING_MARKERS as $marker) { + if (in_array($marker, $attributes, true) === true || str_contains($doc, '@' . $marker) === true) { + return true; + } + } + + return false; + }//end isOpened() + + /** + * Whether a resolved service has every method the store calls. + * + * @param object $service The service. + * + * @return bool + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + */ + private static function hasMethods(object $service): bool { + foreach (self::SERVICE_METHODS as $method) { + if (method_exists($service, $method) === false) { + return false; + } + } + + return true; + }//end hasMethods() +}//end class diff --git a/lib/Service/Cmdb/CmdbImportProfile.php b/lib/Service/Cmdb/CmdbImportProfile.php index 5b3b98273..c945cedfe 100644 --- a/lib/Service/Cmdb/CmdbImportProfile.php +++ b/lib/Service/Cmdb/CmdbImportProfile.php @@ -187,6 +187,9 @@ public function __construct( * OpenRegister, a store that is gone fails closed: the import never falls * back to the files and silently drops an administrator's edit. * + * The import run and the seed step load this way; viewing the mapping + * uses loadForView(), which writes nothing. + * * @return void * * @throws CmdbImportException MAPPING_UNAVAILABLE when the validator is missing, the profile @@ -197,6 +200,39 @@ public function __construct( * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 */ public function load(): void { + $this->loadPacks(storedPack: fn (CmdbImportPackStore $store, array $shipped): ?array => $store->resolve(shipped: $shipped)); + }//end load() + + /** + * Load the profile and validate every pack it names, reading the store only. + * + * A pack the store does not hold yet is taken from its shipped file, and + * nothing is seeded, updated or recorded: viewing the mapping changes + * neither OpenRegister nor stackiq's app config. The next import run + * seeds and updates as load() does. + * + * @return void + * + * @throws CmdbImportException MAPPING_UNAVAILABLE as load(). + * + * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020 + */ + public function loadForView(): void { + $this->loadPacks(storedPack: fn (CmdbImportPackStore $store, array $shipped): ?array => $store->find(shipped: $shipped)); + }//end loadForView() + + /** + * Load the profile and the packs, taking each editable pack from the store as the callback answers it. + * + * @param callable $storedPack (store, shipped pack) => the stored pack, or null to use the shipped file. + * + * @return void + * + * @throws CmdbImportException MAPPING_UNAVAILABLE as load(). + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + */ + private function loadPacks(callable $storedPack): void { $validator = $this->resolveValidator(); $profile = $this->decodeFile(fileName: $this->profileFile); $store = $this->packStore(); @@ -222,8 +258,12 @@ public function load(): void { 'newerShipped' => false, ]; + $stored = null; if ($storeAvailable === true && in_array($target, self::EDITABLE_TARGETS, true) === true) { - $stored = $store->resolve(shipped: $shipped); + $stored = $storedPack($store, $shipped); + } + + if ($stored !== null) { $pack = $stored['pack']; $label = 'OpenRegister migration pack ' . $stored['slug'] . ' (id ' . $stored['id'] . ')'; $details = ['pack' => $stored['slug'], 'packId' => $stored['id']]; @@ -249,7 +289,7 @@ public function load(): void { $this->packs = $packs; $this->sources = $sources; $this->storeAvailable = $storeAvailable; - }//end load() + }//end loadPacks() /** * The shipped packs an administrator edits in OpenRegister, validated, per target. @@ -918,7 +958,10 @@ public function mappingOverview(): array { }//end mappingOverview() /** - * The loaded profile. + * The loaded profile; loaded read only when nothing loaded it yet. + * + * The import run calls load() itself before it reads a row, so a lazy + * load is a read: the limits, the sheet names or the mapping view. * * @return array * @@ -926,7 +969,7 @@ public function mappingOverview(): array { */ private function profile(): array { if ($this->profile === null) { - $this->load(); + $this->loadForView(); } return ($this->profile ?? []); diff --git a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php index fa6062427..46433b1a3 100644 --- a/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php +++ b/tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php @@ -362,6 +362,53 @@ public function mappingOverview(): array { $this->assertSame(500, mb_strlen($reason)); }//end testTheReasonIsOneBoundedLine() + /** + * Without an injected profile the endpoint builds the loader the import uses: the shipped profile and packs. + * + * @return void + * + * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020 + */ + public function testWithoutAProfileTheEndpointUsesTheImportsLoader(): void { + CmdbTestSupport::loadMigrationPack(); + + $data = $this->controller(profile: null)->getCmdbImportMapping()->getData(); + $expected = (new CmdbImportProfile(container: $this->emptyContainer()))->mappingOverview(); + + $this->assertSame($expected['profile'], $data['profile']); + $this->assertSame($expected['openRegisterPacks'], $data['openRegisterPacks']); + foreach ($data['packs'] as $index => $pack) { + unset($pack['editUrl']); + $this->assertSame($expected['packs'][$index], $pack, $pack['target']); + } + }//end testWithoutAProfileTheEndpointUsesTheImportsLoader() + + /** + * The read-only endpoint writes nothing: on an empty store no pack is seeded and no row id is recorded. + * + * @return void + * + * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020 + */ + public function testTheEndpointWritesNothingToOpenRegisterOrTheAppConfig(): void { + CmdbTestSupport::loadMigrationPack(); + $service = new FakeMigrationPackService(); + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getValueString')->willReturn(''); + $appConfig->expects($this->never())->method('setValueString'); + + $data = $this->controller(profile: new CmdbImportProfile(container: $service->container(test: $this, appConfig: $appConfig))) + ->getCmdbImportMapping() + ->getData(); + + $this->assertSame([], $service->writes); + $this->assertTrue($data['openRegisterPacks']); + foreach ($data['packs'] as $pack) { + $this->assertSame('file', $pack['source'], $pack['target']); + $this->assertNull($pack['editUrl'], $pack['target']); + } + }//end testTheEndpointWritesNothingToOpenRegisterOrTheAppConfig() + /** * A pack stored in OpenRegister answers its source, versions and the absolute link to its editor. * @@ -373,9 +420,10 @@ public function testAStoredPackLinksToItsEditorInOpenRegister(): void { CmdbTestSupport::loadMigrationPack(); $service = new FakeMigrationPackService(); $config = []; - $profile = new CmdbImportProfile( - container: $service->container(test: $this, appConfig: FakeMigrationPackService::appConfig(test: $this, values: $config)) - ); + $container = $service->container(test: $this, appConfig: FakeMigrationPackService::appConfig(test: $this, values: $config)); + // The seed step (or an import run) stores the packs; the page then reads them. + (new CmdbImportProfile(container: $container))->load(); + $profile = new CmdbImportProfile(container: $container); $routes = []; $urls = $this->createMock(IURLGenerator::class); $urls->method('linkToRouteAbsolute')->willReturnCallback( diff --git a/tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php b/tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php index d71814be1..aeea3e91d 100644 --- a/tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php +++ b/tests/Unit/Service/Cmdb/CmdbImportPackStoreTest.php @@ -360,6 +360,101 @@ public function testAFailingStoreIsMappingUnavailable(): void { } }//end testAFailingStoreIsMappingUnavailable() + /** + * An administrator who saves the pack between the store's read and its update keeps the edit. + * + * @return void + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + */ + public function testAnEditSavedBetweenTheReadAndTheUpdateIsKept(): void { + $seeded = (new CmdbImportPackMarker())->seed(shipped: $this->shippedUsage()); + $id = $this->service->put(definition: $seeded); + $edited = $seeded; + $edited['description'] = 'Edited by the administrator'; + $this->service->beforeUpdate = function (int $updateId) use ($edited): void { + $this->service->beforeUpdate = null; + $this->service->edit(id: $updateId, definition: $edited); + }; + + $stored = $this->store()->resolve(shipped: $this->newerUsage()); + + $this->assertSame(CmdbImportPackStore::FOUND, $stored['outcome']); + $this->assertSame([], $this->service->writes, 'the update was refused, not applied'); + $this->assertSame('Edited by the administrator', $this->service->definition(id: $id)['description']); + $this->assertSame('Edited by the administrator', $stored['pack']['description']); + $this->assertTrue($stored['edited']); + $this->assertTrue($stored['newerShipped']); + }//end testAnEditSavedBetweenTheReadAndTheUpdateIsKept() + + /** + * A pack deleted between the read and the update cannot be re-read, so the import stops with MAPPING_UNAVAILABLE. + * + * @return void + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + */ + public function testAPackDeletedBetweenTheReadAndTheUpdateIsMappingUnavailable(): void { + $this->service->put(definition: (new CmdbImportPackMarker())->seed(shipped: $this->shippedUsage())); + $this->service->beforeUpdate = function (int $updateId): void { + unset($this->service->rows[$updateId]); + }; + + try { + $this->store()->resolve(shipped: $this->newerUsage()); + $this->fail('MAPPING_UNAVAILABLE expected'); + } catch (CmdbImportException $e) { + $this->assertSame('MAPPING_UNAVAILABLE', $e->getErrorCode()); + $this->assertStringContainsString('could not be updated: OCP\\AppFramework\\Db\\DoesNotExistException', $e->getMessage()); + } + }//end testAPackDeletedBetweenTheReadAndTheUpdateIsMappingUnavailable() + + /** + * The fake refuses an update read at another time, as OpenRegister does; without a time it overwrites. + * + * @return void + */ + public function testTheFakeRefusesAStaleUpdateAsOpenRegisterDoes(): void { + $id = $this->service->put(definition: $this->shippedUsage()); + $readAt = $this->service->find(id: $id)->getUpdatedAt()->getTimestamp(); + $this->service->edit(id: $id, definition: $this->newerUsage()); + + try { + $this->service->update(id: $id, definition: $this->shippedUsage(), expectedUpdatedAt: $readAt); + $this->fail('PackChangedException expected'); + } catch (\OCA\OpenRegister\Exception\PackChangedException $e) { + $this->assertInstanceOf(\InvalidArgumentException::class, $e, 'the same parent as OpenRegister\'s'); + } + + $this->service->update(id: $id, definition: $this->shippedUsage()); + $this->assertSame(['update ' . $id], $this->service->writes); + }//end testTheFakeRefusesAStaleUpdateAsOpenRegisterDoes() + + /** + * An OpenRegister whose pack controller opens a write route is not the hardened store. + * + * @return void + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + */ + public function testAnOpenedWriteRouteMakesTheStoreUnavailable(): void { + $support = __DIR__ . '/../../Support/OpenRegister/'; + require_once $support . 'CsrfOpenedMigrationPacksController.php'; + require_once $support . 'AdminOpenedMigrationPacksController.php'; + $container = $this->service->container(test: $this, appConfig: FakeMigrationPackService::appConfig(test: $this, values: $this->config)); + + foreach (['CsrfOpenedMigrationPacksController', 'AdminOpenedMigrationPacksController', 'NoSuchController'] as $class) { + $store = new CmdbImportPackStore( + container: $container, + controllerClass: 'OCA\\Stackiq\\Tests\\Unit\\Support\\OpenRegister\\' . $class + ); + $this->assertFalse($store->isAvailable(), $class); + } + + $this->assertSame('OCA\\OpenRegister\\Controller\\MigrationPacksController', CmdbImportPackStore::CONTROLLER_CLASS); + $this->assertTrue($this->store()->isAvailable(), 'the guarded controller'); + }//end testAnOpenedWriteRouteMakesTheStoreUnavailable() + /** * The hash ignores the order of an object's keys but not the order of the field mappings. * diff --git a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php index 27543c5fd..d3235f2c9 100644 --- a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php +++ b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php @@ -406,6 +406,29 @@ public function testTheOverviewShowsWhatTheImportRuns(): void { } }//end testTheOverviewShowsWhatTheImportRuns() + /** + * A profile that names no pack for a target cannot be run, so the mapping is unavailable. + * + * @return void + * + * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020 + */ + public function testAProfileWithoutAUsagePackIsMappingUnavailable(): void { + CmdbTestSupport::loadMigrationPack(); + $directory = $this->copyOfShippedDirectory(); + $profile = json_decode((string)file_get_contents($directory . '/topdesk-profile.json'), true); + unset($profile['packs']['usage']); + file_put_contents($directory . '/topdesk-profile.json', json_encode($profile)); + try { + (new CmdbImportProfile(container: $this->emptyContainer(), directory: $directory))->mappingOverview(); + $this->fail('MAPPING_UNAVAILABLE expected'); + } catch (CmdbImportException $e) { + $this->assertSame('MAPPING_UNAVAILABLE', $e->getErrorCode()); + } finally { + $this->remove(directory: $directory); + } + }//end testAProfileWithoutAUsagePackIsMappingUnavailable() + /** * A shipped pack, decoded. * @@ -448,6 +471,8 @@ public function testAStoredPackIsWhatTheImportRuns(): void { $config = []; $profile = $this->storedProfile(service: $service, config: $config); + // The import run loads the packs with writes; the overview then shows what it ran. + $profile->load(); $overview = $profile->mappingOverview(); $this->assertSame(['SaaS'], $profile->pack(target: 'module')['fieldMappings'][7]['transform']['map']['Cloud']); @@ -504,6 +529,7 @@ public function testUneditedPacksFollowTheReleaseAndEditedOnesAreReported(): voi $config = []; $profile = $this->storedProfile(service: $service, config: $config); + $profile->load(); $byTarget = array_column($profile->mappingOverview()['packs'], null, 'target'); $this->assertContains('update ' . $usageId, $service->writes); @@ -520,6 +546,38 @@ public function testUneditedPacksFollowTheReleaseAndEditedOnesAreReported(): voi $this->assertSame('1.0.0', $byTarget['module']['shippedVersion']); }//end testUneditedPacksFollowTheReleaseAndEditedOnesAreReported() + /** + * Viewing the mapping writes nothing: an outdated unedited pack is shown as stored, a missing one from its file. + * + * @return void + * + * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020 + */ + public function testTheMappingOverviewOnlyReadsTheStore(): void { + CmdbTestSupport::loadMigrationPack(); + $service = new FakeMigrationPackService(); + $olderUsage = $this->shipped(file: 'topdesk-usage.json'); + $olderUsage['version'] = '0.9.0'; + unset($olderUsage['fieldMappings'][0]['transform']['map']['Besteld']); + $usageId = $service->put(definition: (new CmdbImportPackMarker())->seed(shipped: $olderUsage)); + $config = []; + + $profile = $this->storedProfile(service: $service, config: $config); + $byTarget = array_column($profile->mappingOverview()['packs'], null, 'target'); + + $this->assertSame([], $service->writes, 'nothing is seeded or updated'); + $this->assertSame([], $config, 'no row id is recorded'); + $this->assertSame('openregister', $byTarget['usage']['source']); + $this->assertSame($usageId, $byTarget['usage']['packId']); + $this->assertSame('0.9.0', $byTarget['usage']['storedVersion'], 'the stored pack, not yet updated'); + $this->assertSame('file', $byTarget['module']['source'], 'not stored yet: shown from the shipped file'); + $this->assertNull($byTarget['module']['packId']); + + $profile->load(); + $this->assertContains('update ' . $usageId, $service->writes, 'the import run still updates it'); + $this->assertContains('create stackiq-topdesk-module', $service->writes, 'and seeds the missing pack'); + }//end testTheMappingOverviewOnlyReadsTheStore() + /** * A stored pack OpenRegister's validator refuses stops the import, naming the pack and its id. * diff --git a/tests/Unit/Support/FakeMigrationPackService.php b/tests/Unit/Support/FakeMigrationPackService.php index 4078ab362..31450fd91 100644 --- a/tests/Unit/Support/FakeMigrationPackService.php +++ b/tests/Unit/Support/FakeMigrationPackService.php @@ -4,9 +4,13 @@ * An in-memory stand-in for OpenRegister's MigrationPackService. * * It has the four methods CmdbImportPackStore calls, with OpenRegister's - * parameter names (the store calls them by name), and answers an unknown - * slug or id with DoesNotExistException as OpenRegister's mapper does. - * Definitions are stored as JSON, as OpenRegister stores them. + * parameter names (the store calls them by name), and answers as + * OpenRegister's MigrationPackService does (openregister#4602): an unknown + * slug or id with DoesNotExistException, a taken slug on create with + * PackSlugExistsException, an update read at another time than the pack's + * `updatedAt` with PackChangedException, and a changed slug of a built-in + * pack with BuiltinPackSlugLockedException. Definitions are stored as JSON, + * as OpenRegister stores them; every write moves `updatedAt` on. * * @category Test * @package OCA\Stackiq\Tests\Unit\Support @@ -25,6 +29,10 @@ namespace OCA\Stackiq\Tests\Unit\Support; +use DateTime; +use OCA\OpenRegister\Exception\BuiltinPackSlugLockedException; +use OCA\OpenRegister\Exception\PackChangedException; +use OCA\OpenRegister\Exception\PackSlugExistsException; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IAppConfig; use PHPUnit\Framework\TestCase; @@ -37,9 +45,9 @@ */ final class FakeMigrationPackService { /** - * Stored rows by id: slug, definition JSON and builtin flag. + * Stored rows by id: slug, definition JSON, builtin flag and the Unix time of the last write. * - * @var array + * @var array */ public array $rows = []; @@ -64,6 +72,20 @@ final class FakeMigrationPackService { */ public ?\Closure $beforeCreate = null; + /** + * When set, called at the start of every update, as an administrator saving in between would act. + * + * @var \Closure|null + */ + public ?\Closure $beforeUpdate = null; + + /** + * The clock: the Unix time the next write records. + * + * @var int + */ + private int $clock = 1760000000; + /** * The next row id. * @@ -88,10 +110,23 @@ public function __construct() { */ public function put(array $definition, ?int $id = null): int { $id = ($id ?? $this->nextId++); - $this->rows[$id] = ['slug' => (string)$definition['id'], 'definition' => (string)json_encode($definition), 'builtin' => false]; + $this->rows[$id] = ['slug' => (string)$definition['id'], 'definition' => (string)json_encode($definition), 'builtin' => false, 'updatedAt' => $this->clock++]; return $id; }//end put() + /** + * Save a row's definition as an administrator would in OpenRegister's editor: a new `updatedAt`, not logged as a store write. + * + * @param int $id The row id. + * @param array $definition The definition. + * + * @return void + */ + public function edit(int $id, array $definition): void { + $this->rows[$id]['definition'] = (string)json_encode($definition); + $this->rows[$id]['updatedAt'] = $this->clock++; + }//end edit() + /** * The stored definition of a row. * @@ -150,7 +185,7 @@ public function find(int $id): object { * * @return object * - * @throws RuntimeException When the slug exists. + * @throws PackSlugExistsException When the slug exists. */ public function create(array $definition, ?string $ownerUid = null, bool $builtin = false): object { if ($this->beforeCreate !== null) { @@ -159,7 +194,7 @@ public function create(array $definition, ?string $ownerUid = null, bool $builti foreach ($this->rows as $row) { if ($row['slug'] === $definition['id']) { - throw new RuntimeException('A migration pack with id "' . $definition['id'] . '" already exists'); + throw new PackSlugExistsException('A migration pack with id "' . $definition['id'] . '" already exists'); } } @@ -174,12 +209,34 @@ public function create(array $definition, ?string $ownerUid = null, bool $builti * * @param int $id The row id. * @param array $definition The definition. + * @param int|null $expectedUpdatedAt The `updatedAt` the caller read, as a Unix time, or null for no check. * * @return object + * + * @throws DoesNotExistException When no row has the id. + * @throws PackChangedException When the pack was saved at another time than `$expectedUpdatedAt`. + * @throws BuiltinPackSlugLockedException When the slug of a built-in pack changes. */ - public function update(int $id, array $definition): object { + public function update(int $id, array $definition, ?int $expectedUpdatedAt = null): object { + if ($this->beforeUpdate !== null) { + ($this->beforeUpdate)($id, $definition); + } + + if (isset($this->rows[$id]) === false) { + throw new DoesNotExistException('No migration pack ' . $id); + } + + if ($expectedUpdatedAt !== null && $this->rows[$id]['updatedAt'] !== $expectedUpdatedAt) { + throw new PackChangedException('Migration pack ' . $id . ' was saved after it was read'); + } + + if ($this->rows[$id]['builtin'] === true && $this->rows[$id]['slug'] !== (string)$definition['id']) { + throw new BuiltinPackSlugLockedException('The slug of built-in pack ' . $id . ' cannot change'); + } + $this->rows[$id]['slug'] = (string)$definition['id']; $this->rows[$id]['definition'] = (string)json_encode($definition); + $this->rows[$id]['updatedAt'] = $this->clock++; $this->writes[] = 'update ' . $id; return $this->entity(id: $id); }//end update() @@ -215,13 +272,21 @@ function (string $id) use ($known) { }//end container() /** - * Make the class that marks OpenRegister's hardened pack store exist, as on such an OpenRegister. + * Make the classes of OpenRegister's hardened pack store exist, as on such an OpenRegister. * * @return void */ public static function loadHardenedOpenRegister(): void { - if (class_exists('OCA\OpenRegister\Exception\BuiltinPackSlugLockedException') === false) { - require_once __DIR__ . '/OpenRegister/BuiltinPackSlugLockedException.php'; + $files = [ + 'MigrationPackRefusedException', + 'InvalidPackDefinitionException', + 'BuiltinPackSlugLockedException', + 'PackSlugExistsException', + 'PackChangedException', + 'MigrationPacksController', + ]; + foreach ($files as $file) { + require_once __DIR__ . '/OpenRegister/' . $file . '.php'; } }//end loadHardenedOpenRegister() @@ -273,15 +338,41 @@ private function failIfAsked(): void { */ private function entity(int $id): object { $row = $this->rows[$id]; - return new class($id, $row['slug'], $row['definition']) { + return new class($id, $row['slug'], $row['definition'], $row['builtin'], $row['updatedAt']) { /** * Constructor. * * @param int $id The row id. * @param string $slug The slug. * @param string $definition The definition JSON. + * @param bool $builtin Whether the pack is built in. + * @param int $updatedAt The Unix time of the last write. + */ + public function __construct( + private int $id, + private string $slug, + private string $definition, + private bool $builtin, + private int $updatedAt, + ) { + } + + /** + * Whether the pack is built in. + * + * @return bool + */ + public function getBuiltin(): bool { + return $this->builtin; + } + + /** + * When the pack was last written. + * + * @return DateTime */ - public function __construct(private int $id, private string $slug, private string $definition) { + public function getUpdatedAt(): DateTime { + return (new DateTime())->setTimestamp($this->updatedAt); } /** diff --git a/tests/Unit/Support/OpenRegister/AdminOpenedMigrationPacksController.php b/tests/Unit/Support/OpenRegister/AdminOpenedMigrationPacksController.php new file mode 100644 index 000000000..7306baf61 --- /dev/null +++ b/tests/Unit/Support/OpenRegister/AdminOpenedMigrationPacksController.php @@ -0,0 +1,68 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Support\OpenRegister; + +/** + * A pack controller whose import admits non-admins with a docblock annotation. + */ +class AdminOpenedMigrationPacksController { + + /** + * The create route. + * + * @return void + */ + public function create(): void { + }//end create() + + /** + * The update route. + * + * @return void + */ + public function update(): void { + }//end update() + + /** + * The destroy route. + * + * @return void + */ + public function destroy(): void { + }//end destroy() + + /** + * The import route. + * + * @NoAdminRequired + * @return void + */ + public function import(): void { + }//end import() + + /** + * The preview route. + * + * @return void + */ + public function preview(): void { + }//end preview() +}//end class diff --git a/tests/Unit/Support/OpenRegister/BuiltinPackSlugLockedException.php b/tests/Unit/Support/OpenRegister/BuiltinPackSlugLockedException.php index 249edd8f4..314077e13 100644 --- a/tests/Unit/Support/OpenRegister/BuiltinPackSlugLockedException.php +++ b/tests/Unit/Support/OpenRegister/BuiltinPackSlugLockedException.php @@ -25,10 +25,8 @@ namespace OCA\OpenRegister\Exception; -use RuntimeException; - /** - * Thrown by OpenRegister when a built-in pack would be renamed. + * A built-in pack's slug cannot change. */ -class BuiltinPackSlugLockedException extends RuntimeException { +class BuiltinPackSlugLockedException extends InvalidPackDefinitionException { }//end class diff --git a/tests/Unit/Support/OpenRegister/CsrfOpenedMigrationPacksController.php b/tests/Unit/Support/OpenRegister/CsrfOpenedMigrationPacksController.php new file mode 100644 index 000000000..54d189bd9 --- /dev/null +++ b/tests/Unit/Support/OpenRegister/CsrfOpenedMigrationPacksController.php @@ -0,0 +1,70 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Support\OpenRegister; + +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; + +/** + * A pack controller whose update skips the CSRF check with an attribute, as OpenRegister's development branch does. + */ +class CsrfOpenedMigrationPacksController { + + /** + * The create route. + * + * @return void + */ + public function create(): void { + }//end create() + + /** + * The update route. + * + * @return void + */ + #[NoCSRFRequired] + public function update(): void { + }//end update() + + /** + * The destroy route. + * + * @return void + */ + public function destroy(): void { + }//end destroy() + + /** + * The import route. + * + * @return void + */ + public function import(): void { + }//end import() + + /** + * The preview route. + * + * @return void + */ + public function preview(): void { + }//end preview() +}//end class diff --git a/tests/Unit/Support/OpenRegister/InvalidPackDefinitionException.php b/tests/Unit/Support/OpenRegister/InvalidPackDefinitionException.php new file mode 100644 index 000000000..52d8d5b4d --- /dev/null +++ b/tests/Unit/Support/OpenRegister/InvalidPackDefinitionException.php @@ -0,0 +1,27 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +/** + * A definition OpenRegister refuses. + */ +class InvalidPackDefinitionException extends MigrationPackRefusedException { +}//end class diff --git a/tests/Unit/Support/OpenRegister/MigrationPackRefusedException.php b/tests/Unit/Support/OpenRegister/MigrationPackRefusedException.php new file mode 100644 index 000000000..b8f2f88e7 --- /dev/null +++ b/tests/Unit/Support/OpenRegister/MigrationPackRefusedException.php @@ -0,0 +1,29 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +use InvalidArgumentException; + +/** + * A refused pack write. + */ +abstract class MigrationPackRefusedException extends InvalidArgumentException { +}//end class diff --git a/tests/Unit/Support/OpenRegister/MigrationPacksController.php b/tests/Unit/Support/OpenRegister/MigrationPacksController.php new file mode 100644 index 000000000..736f63713 --- /dev/null +++ b/tests/Unit/Support/OpenRegister/MigrationPacksController.php @@ -0,0 +1,71 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Controller; + +/** + * The pack routes. + */ +class MigrationPacksController { + + /** + * Create a pack. + * + * @return void + */ + public function create(): void { + }//end create() + + /** + * Update a pack. + * + * @return void + */ + public function update(): void { + }//end update() + + /** + * Delete a pack. + * + * @return void + */ + public function destroy(): void { + }//end destroy() + + /** + * Import a pack. + * + * @return void + */ + public function import(): void { + }//end import() + + /** + * Preview a pack. + * + * @return void + */ + public function preview(): void { + }//end preview() +}//end class diff --git a/tests/Unit/Support/OpenRegister/PackChangedException.php b/tests/Unit/Support/OpenRegister/PackChangedException.php new file mode 100644 index 000000000..b6d3de009 --- /dev/null +++ b/tests/Unit/Support/OpenRegister/PackChangedException.php @@ -0,0 +1,27 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +/** + * The pack was saved after the caller read it. + */ +class PackChangedException extends MigrationPackRefusedException { +}//end class diff --git a/tests/Unit/Support/OpenRegister/PackSlugExistsException.php b/tests/Unit/Support/OpenRegister/PackSlugExistsException.php new file mode 100644 index 000000000..ef9b04171 --- /dev/null +++ b/tests/Unit/Support/OpenRegister/PackSlugExistsException.php @@ -0,0 +1,27 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Exception; + +/** + * Another pack has the slug. + */ +class PackSlugExistsException extends InvalidPackDefinitionException { +}//end class From 8f0792946641087d93106c795f6cdfcfddd8d74b Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 19:02:21 +0200 Subject: [PATCH 080/124] fix(cmdb-import): translate both mapping endpoint errors with literal t() calls The 503 and 500 messages went through a wrapper that called t() with a variable, which Nextcloud's extraction cannot see, and the 500 message had no translation. Both are now literal `->t('...')` calls, and the 500 message has its English and Dutch entries. Co-Authored-By: Claude Opus 5.5 (1M context) --- l10n/en.js | 3 ++- l10n/en.json | 3 ++- l10n/nl.js | 3 ++- l10n/nl.json | 3 ++- lib/Controller/SettingsController.php | 30 +++++++-------------------- 5 files changed, 16 insertions(+), 26 deletions(-) diff --git a/l10n/en.js b/l10n/en.js index afd93e55c..0c34d605b 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1135,7 +1135,8 @@ OC.L10N.register( "The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.", "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.", "In development since": "In development since", - "In use since": "In use since" + "In use since": "In use since", + "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 349882f8f..0ed57538e 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1134,6 +1134,7 @@ "The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.", "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.", "In development since": "In development since", - "In use since": "In use since" + "In use since": "In use since", + "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log." } } diff --git a/l10n/nl.js b/l10n/nl.js index 2cf474396..3f2953500 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1203,7 +1203,8 @@ OC.L10N.register( "The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.", "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.", "In development since": "In ontwikkeling sinds", - "In use since": "In gebruik sinds" + "In use since": "In gebruik sinds", + "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index ff4bd963e..ca9c80731 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1202,6 +1202,7 @@ "The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.", "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.", "In development since": "In ontwikkeling sinds", - "In use since": "In gebruik sinds" + "In use since": "In gebruik sinds", + "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek." } } diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 0451e1718..f4faa4bf7 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -3954,7 +3954,10 @@ public function getCmdbImportMapping(): JSONResponse { [ 'success' => false, 'error' => $e->getErrorCode(), - 'message' => $this->translate(text: 'The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.'), + 'message' => ( + $this->l10n?->t('The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.') + ?? 'The import mapping cannot be shown: OpenRegister is missing, or a mapping pack is invalid or unavailable.' + ), 'details' => (object)$details, ], $e->getHttpStatus() @@ -3965,7 +3968,10 @@ public function getCmdbImportMapping(): JSONResponse { [ 'success' => false, 'error' => 'IMPORT_FAILED', - 'message' => $this->translate(text: 'The import mapping could not be read. The details are in the Nextcloud log.'), + 'message' => ( + $this->l10n?->t('The import mapping could not be read. The details are in the Nextcloud log.') + ?? 'The import mapping could not be read. The details are in the Nextcloud log.' + ), 'details' => (object)[], ], Http::STATUS_INTERNAL_SERVER_ERROR @@ -4029,24 +4035,4 @@ private static function boundedReason(string $message): string { return mb_substr($firstLine, 0, self::REASON_MAX_LENGTH); }//end boundedReason() - - /** - * Translate a message, or hand it back as it is when no translator was injected. - * - * IL10N is an optional constructor argument (cmdb-import-mapping-view, design D1), - * so a test that builds the controller without it gets the English text. - * - * @param string $text The English text. - * - * @return string - * - * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020 - */ - private function translate(string $text): string { - if ($this->l10n === null) { - return $text; - } - - return $this->l10n->t($text); - }//end translate() }//end class From 27a2110891ec39922e7e3c02a37e794e50bb3579 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 19:05:06 +0200 Subject: [PATCH 081/124] fix(cmdb-import): see a fully empty CMDB row with an uncached APPID; describe what a failed application leaves Closes the pre-push findings: - "Fully-empty CMDB row with an uncached APPID stays invisible": the reader dropped a CMDB row whose only content is an APPID formula without a cached value before the service could see it, so its application could still be soft-deleted. read() now returns `uncachedKeyRows` (source sheet => dropped row numbers, collected only when the key column is the uncached one), and the service merges them with the rows that carry `uncached` into the same unreadable-key bookkeeping, so the run archives instead of deleting and warns. New tests: testAnEmptyCmdbRowWithAnUncachedKeyIsListed (reader) and testAFullyEmptyCmdbRowWithAnUncachedKeyArchivesInsteadOfDeleting (service). - "Failure-branch docblock overstates": the itemsOf() and reconcileApplication() docblocks and design.md D5 and Risks now state the order (own usage, extra usages, module, extra modules) and what a failure leaves: a failing extra usage leaves the own usage changed, that extra visible and the module unchanged, and the row only says failed. Class sweep: other callers of readRows() are the archive sheet (unchanged, no key filter) and readSourceRows(); the read() docblock names the new key. The spec and proposal speak of "a row of a CMDB sheet" in general and need no change. Co-Authored-By: Claude Opus 5.5 --- lib/Service/Cmdb/CmdbWorkbookReader.php | 59 ++++++++++++++++--- lib/Service/CmdbExportImportService.php | 32 ++++++++-- .../design.md | 4 +- .../Service/Cmdb/CmdbWorkbookReaderTest.php | 28 +++++++++ .../Service/CmdbExportImportServiceTest.php | 32 +++++++++- 5 files changed, 136 insertions(+), 19 deletions(-) diff --git a/lib/Service/Cmdb/CmdbWorkbookReader.php b/lib/Service/Cmdb/CmdbWorkbookReader.php index 3ba065a4d..4b80393b8 100644 --- a/lib/Service/Cmdb/CmdbWorkbookReader.php +++ b/lib/Service/Cmdb/CmdbWorkbookReader.php @@ -175,8 +175,11 @@ public function isAvailable(): bool { * @param CmdbImportProfile $profile The import profile. * * @return array `rows` (list of {sheet, row, cells, uncached}), `importWarnings` - * (list of {sheet, message}), `date1904` (bool) and `archive` - * ({sheet, present, keyColumnMissing, appIds, uncachedRows}). + * (list of {sheet, message}), `date1904` (bool), `archive` + * ({sheet, present, keyColumnMissing, appIds, uncachedRows}) and `uncachedKeyRows` + * (source sheet => numbers of the rows dropped as empty because their key is a + * formula without a cached value; a row with other content is in `rows`, its + * `uncached` lists the key). * * @throws CmdbImportException WORKBOOK_TOO_LARGE, READER_UNAVAILABLE, NOT_XLSX, NO_SOURCE_SHEET, * MISSING_COLUMN or TOO_MANY_ROWS. @@ -369,7 +372,15 @@ private function readBounded(string $path, CmdbImportProfile $profile, object $s $spreadsheet = $this->load(path: $path, sheetNames: $loaded, filter: new CmdbReadFilter(lastRow: $lastRow, columns: $letters)); try { - $rows = $this->readSourceRows(spreadsheet: $spreadsheet, sheetNames: $present, columnsPerSheet: $resolved['columns'], limit: $limit); + $uncachedKeyRows = []; + $rows = $this->readSourceRows( + spreadsheet: $spreadsheet, + sheetNames: $present, + columnsPerSheet: $resolved['columns'], + limit: $limit, + keyColumn: $profile->keyColumn(), + uncachedKeyRows: $uncachedKeyRows + ); if ($archiveColumns !== []) { $unseen = []; $archiveRows = $this->readRows( @@ -389,7 +400,13 @@ private function readBounded(string $path, CmdbImportProfile $profile, object $s $spreadsheet->disconnectWorksheets(); } - return ['rows' => $rows, 'importWarnings' => $resolved['warnings'], 'date1904' => $date1904, 'archive' => $archive]; + return [ + 'rows' => $rows, + 'importWarnings' => $resolved['warnings'], + 'date1904' => $date1904, + 'archive' => $archive, + 'uncachedKeyRows' => $uncachedKeyRows, + ]; }//end readBounded() /** @@ -434,20 +451,38 @@ private function sourceSheets(string $path, CmdbImportProfile $profile): array { * @param array $sheetNames The source sheets the workbook has. * @param array> $columnsPerSheet Sheet name => column letter => column name. * @param int $limit The row limit per sheet. + * @param string $keyColumn The key column's name. + * @param array> $uncachedKeyRows Filled, per sheet, with the number of each row that was + * dropped as empty only because its key is a formula + * without a cached value. * * @return array> See read(). * * @throws CmdbImportException TOO_MANY_ROWS. */ - private function readSourceRows(object $spreadsheet, array $sheetNames, array $columnsPerSheet, int $limit): array { + private function readSourceRows( + object $spreadsheet, + array $sheetNames, + array $columnsPerSheet, + int $limit, + string $keyColumn, + array &$uncachedKeyRows, + ): array { $rows = []; foreach ($sheetNames as $sheetName) { + $unseen = []; $sheetRows = $this->readRows( worksheet: $spreadsheet->getSheetByName($sheetName), columns: $columnsPerSheet[$sheetName], sheetName: $sheetName, - limit: $limit + limit: $limit, + unseen: $unseen, + keyColumn: $keyColumn ); + if ($unseen !== []) { + $uncachedKeyRows[$sheetName] = array_values((array)$unseen); + } + array_push($rows, ...$sheetRows); } @@ -732,12 +767,20 @@ private function resolveColumns(object $worksheet, array $referenced): array { * @param int $limit The maximum number of non-empty rows. * @param array|null $unseen Filled with the number of each row that read as empty only because * a formula in it has no cached value; null to not collect them. + * @param string|null $keyColumn When set, only a row whose uncached columns include this one is collected. * * @return array, uncached: array}> * * @throws CmdbImportException TOO_MANY_ROWS. */ - private function readRows(object $worksheet, array $columns, string $sheetName, int $limit, ?array &$unseen = null): array { + private function readRows( + object $worksheet, + array $columns, + string $sheetName, + int $limit, + ?array &$unseen = null, + ?string $keyColumn = null, + ): array { $rows = []; $lastRow = (int)$worksheet->getHighestDataRow(); for ($rowNumber = 2; $rowNumber <= $lastRow; $rowNumber++) { @@ -747,7 +790,7 @@ private function readRows(object $worksheet, array $columns, string $sheetName, rowNumber: $rowNumber ); if ($empty === true) { - if ($uncached !== [] && $unseen !== null) { + if ($uncached !== [] && $unseen !== null && ($keyColumn === null || in_array($keyColumn, $uncached, true) === true)) { $unseen[] = $rowNumber; } diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php index 4c236d9ac..1cce85f47 100644 --- a/lib/Service/CmdbExportImportService.php +++ b/lib/Service/CmdbExportImportService.php @@ -556,7 +556,7 @@ private function runImport(string $path, array $options, string $startedAt): arr $report->setMunicipality(uuid: $municipality['uuid'], name: $municipality['name'], created: $municipality['created']); $report->addImportWarnings(warnings: $this->translateImportWarnings(warnings: $workbook['importWarnings'])); $archive = $this->archiveSheet(workbook: $workbook); - $this->unreadableKeyRows = $this->unreadableKeyRows(rows: $rows); + $this->unreadableKeyRows = $this->unreadableKeyRows(rows: $rows, droppedRows: (array)($workbook['uncachedKeyRows'] ?? [])); $this->addRunWarnings(report: $report, municipality: $municipality, archive: $archive, missingRecords: $missingRecords); $this->progressTracker->startOperation( @@ -684,20 +684,31 @@ private function addRunWarnings(CmdbImportReport $report, array $municipality, a * has an unreadable key. * * @param array}> $rows The reader rows. + * @param array> $droppedRows The rows the reader dropped as empty because their key + * has no cached value (they are not in `$rows`), per sheet. * * @return array> Sheet name => row numbers. * * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015 */ - private function unreadableKeyRows(array $rows): array { + private function unreadableKeyRows(array $rows, array $droppedRows): array { $key = $this->profile->keyColumn(); $found = []; + foreach ($droppedRows as $sheet => $rowNumbers) { + $found[(string)$sheet] = array_map('intval', array_values((array)$rowNumbers)); + } + foreach ($rows as $row) { if (in_array($key, ($row['uncached'] ?? []), true) === true) { $found[$row['sheet']][] = $row['row']; } } + foreach ($found as $sheet => $rowNumbers) { + sort($rowNumbers); + $found[$sheet] = $rowNumbers; + } + return $found; }//end unreadableKeyRows() @@ -2572,8 +2583,15 @@ private function modulesInChunk(array $chunk, array $coordinates): array { /** * Archive or soft-delete one application that left the CMDB sheets, usage first, in its own error boundary. * - * The usage is written before the module, so a failure between the two - * leaves the municipality's view right and the module at worst visible. + * The writes go in this order: the application's own usage, the other + * usages of its key, its module, the other modules. A failure stops the + * application there and reports one `failed` row (the step and the + * exception class, no list of what was already written). What it leaves: + * a failing own usage leaves everything as it was; a failing extra usage + * leaves the own usage in the new state, that extra and the module as + * they were (so the extra stays visible and the module stays working); + * a failing module leaves every usage in the new state and the module + * as it was. The next import reconciles the application again. * An application already in the state the export asks for is not reported. * * @param ScopeEntry $application The application. @@ -2631,8 +2649,10 @@ private function reconcileApplication(array $application, array $archive, string /** * What one application brings to the state the export asks for: its usage and the other usages of its key, then its module and the other modules. * - * Usages come before modules, so a failure part way leaves the - * municipality's view right and a module at worst visible. + * Own usage, extra usages, own module, extra modules. Usages come before + * modules, so a failure leaves usages in the new state and a module at + * worst unchanged; it does not leave a module changed under a usage that + * still shows. An extra usage that fails does hold back the module. * * Only the application's own objects are restored from the trash, and only * when `restoreDeleted` says so; the extras never are. diff --git a/openspec/changes/cmdb-import-archive-reconciliation/design.md b/openspec/changes/cmdb-import-archive-reconciliation/design.md index 1fc7058ee..5329cc153 100644 --- a/openspec/changes/cmdb-import-archive-reconciliation/design.md +++ b/openspec/changes/cmdb-import-archive-reconciliation/design.md @@ -99,7 +99,7 @@ Per application, with `processed` = every APPID match key on the CMDB sheets (wh The table decides on one entry per key. When a municipality has several usages and modules under the same import key (D4 says earlier imports created exactly such duplicates, a module deleted by hand next to the one a later import created), the scope keeps the entry the import would match: the best module state wins (working, then archived, then deleted), the best usage state breaks a tie, the first listed breaks the rest. The other usages of the key follow the application, so a stray usage stays visible nowhere: a working or archived usage of a stale duplicate, a second usage of the same module, and a working or archived duplicate module are archived or soft-deleted as the key asks, once, in the same error boundary and the same report row (usages first, then modules). What is in the trash is left as it is: a stale trashed duplicate is not restored into the archive when the key is on the archive sheet, and is not touched when it is on no sheet. -The usage is written before the module, so a failure between the two leaves the municipality's usage in the new state and the module visible, never the reverse. A module another municipality still uses is archived or deleted all the same when this municipality's usage says so: the module carries this municipality's import key, so it is this municipality's record (REQ-CMDB-006 allows a shared module only when the other organisation's usage was added by hand). The archive reason is `cmdb-import: on sheet "Gearchiveerde Applicaties"` or `cmdb-import: not in the export`, plus the operation id, so the audit trail says why. +The writes go in this order: the application's own usage, the other usages of its key, its module, the other modules. A failure stops that application and gives one `failed` report row (step and exception class only). A failing own usage leaves everything as it was; a failing extra usage leaves the own usage in the new state and that extra and the module as they were (the extra stays visible, the module stays working); a failing module leaves every usage in the new state and the module as it was. Never is a module changed while a usage of the key still shows. The next import reconciles the application again. A module another municipality still uses is archived or deleted all the same when this municipality's usage says so: the module carries this municipality's import key, so it is this municipality's record (REQ-CMDB-006 allows a shared module only when the other organisation's usage was added by hand). The archive reason is `cmdb-import: on sheet "Gearchiveerde Applicaties"` or `cmdb-import: not in the export`, plus the operation id, so the audit trail says why. Each application runs in its own boundary: a failure is a `failed` row naming the step (`archive`, `restore`, `delete`) and the exception class, as row failures do, and the other applications continue. Contact persons are never touched. @@ -197,7 +197,7 @@ No seed objects change. The fragment's three seed modules stay as they are; the - [The default archives on the first re-import after the upgrade] → The section shows the choice with help text; the report lists every archived and deleted application; nothing is hard-deleted. - [A workbook saved without the archive sheet, or whose archive sheet has no APPID column or an APPID without a cached value, or a CMDB sheet with an APPID without a cached value] → archive only, never delete, plus a warning (D3). -- [A failure between the usage and the module] → the usage is written first, so the municipality's view is right and the module is at worst still visible (D5). +- [A failure part way through an application] → usages are written before modules, so a module is never changed under a usage that still shows; but a failing extra usage leaves the own usage changed, that extra visible and the module unchanged, and the row only says `failed`. The next import retries (D5). - [Reviving before writing adds calls per returning row] → only for rows whose match is archived or deleted; a row whose module is working costs the same searches as before, a new row one more search (the archive lens). - [The trash listing scans every magic table of the instance] → read once per import, not per row (D9). - [OpenRegister internals change shape] → guarded resolution, 503 before the file is read (D7). diff --git a/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php b/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php index b4d561809..fa5754636 100644 --- a/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php +++ b/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php @@ -1055,6 +1055,34 @@ public function testAnUncachedArchiveKeyIsListedNotDropped(): void { $this->assertSame([2, 5], $archive['uncachedRows'], 'the rows whose APPID formula has no cached value'); }//end testAnUncachedArchiveKeyIsListedNotDropped() + /** + * A CMDB row whose only content is an APPID formula without a cached value is dropped as empty, but its row + * number is listed per sheet, so a caller knows the sheet holds an application it could not see. + * + * @return void + * + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015 + */ + public function testAnEmptyCmdbRowWithAnUncachedKeyIsListed(): void { + $this->requireSpreadsheet(); + $path = CmdbTestSupport::buildWorkbook( + sheets: [ + 'Beheerde Applicaties CMDB' => [['APPID', 'Applicatie Naam'], [1, 'Een'], [['f' => 'Invoer!A2']], [2, 'Twee']], + 'Onbeh Applicaties CMDB' => [['APPID', 'Applicatie Naam'], [3, 'Drie']], + ] + ); + + try { + $result = (new CmdbWorkbookReader())->read(path: $path, profile: $this->profile()); + } finally { + unlink($path); + } + + $beheerd = array_filter($result['rows'], static fn (array $row): bool => $row['sheet'] === 'Beheerde Applicaties CMDB'); + $this->assertSame([2, 4], array_column($beheerd, 'row'), 'the empty row is not a row'); + $this->assertSame(['Beheerde Applicaties CMDB' => [3]], $result['uncachedKeyRows'], 'the dropped row is listed, only on its sheet'); + }//end testAnEmptyCmdbRowWithAnUncachedKeyIsListed() + /** * An archive sheet whose last used row lies beyond twice the row limit stops before any sheet is loaded. * diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php index c476b5b1f..2cf2e04c8 100644 --- a/tests/Unit/Service/CmdbExportImportServiceTest.php +++ b/tests/Unit/Service/CmdbExportImportServiceTest.php @@ -994,19 +994,21 @@ function (&$value): void { * * @return CmdbWorkbookReader */ - private function rowsReader(array $rows, ?array $archived = [], array $uncachedRows = []): CmdbWorkbookReader { - return new class($rows, $archived, $uncachedRows) extends CmdbWorkbookReader { + private function rowsReader(array $rows, ?array $archived = [], array $uncachedRows = [], array $uncachedKeyRows = []): CmdbWorkbookReader { + return new class($rows, $archived, $uncachedRows, $uncachedKeyRows) extends CmdbWorkbookReader { /** * Constructor. * * @param array> $rows The rows. * @param array|null $archived The archive sheet's APPIDs, or null. * @param array $uncachedRows The archive sheet's rows without a cached APPID. + * @param array> $uncachedKeyRows The CMDB rows dropped as empty, per sheet. */ public function __construct( private array $rows, private ?array $archived, private array $uncachedRows, + private array $uncachedKeyRows = [], ) { } @@ -1020,7 +1022,7 @@ public function __construct( */ public function read(string $path, CmdbImportProfile $profile): array { $archive = ['sheet' => 'Gearchiveerde Applicaties', 'present' => ($this->archived !== null), 'keyColumnMissing' => false, 'appIds' => ($this->archived ?? []), 'uncachedRows' => $this->uncachedRows]; - return ['rows' => $this->rows, 'importWarnings' => [], 'date1904' => false, 'archive' => $archive]; + return ['rows' => $this->rows, 'importWarnings' => [], 'date1904' => false, 'archive' => $archive, 'uncachedKeyRows' => $this->uncachedKeyRows]; } }; }//end rowsReader() @@ -2044,6 +2046,30 @@ public function testAnUncachedCmdbRowArchivesInsteadOfDeleting(): void { $this->assertSame([], $readable['importWarnings']); }//end testAnUncachedCmdbRowArchivesInsteadOfDeleting() + /** + * A CMDB row whose only content is an uncached APPID is dropped by the reader before the service sees a row, and + * is handed over as a list of rows: the import still archives instead of deleting, and warns. + * + * @return void + * + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015 + */ + public function testAFullyEmptyCmdbRowWithAnUncachedKeyArchivesInsteadOfDeleting(): void { + $this->importOneSevenAndEight(); + $eight = $this->moduleOf(appId: '8'); + + $report = $this->service( + reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7'], uncachedKeyRows: ['Beheerde Applicaties CMDB' => [6]]) + )->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertNotContains('delete', array_column($this->transitions, 0)); + $this->assertSame([true, false], $this->stateOf(schema: self::MODULE, uuid: $eight)); + $this->assertSame( + [['sheet' => 'Beheerde Applicaties CMDB', 'message' => 'Sheet "Beheerde Applicaties CMDB" has an APPID without a cached value in row 6, so applications missing from the export are archived, not deleted.']], + $report['importWarnings'] + ); + }//end testAFullyEmptyCmdbRowWithAnUncachedKeyArchivesInsteadOfDeleting() + /** * An extra usage of a key that cannot be archived fails the application's row; its module, written after every usage, stays * as it was, and the other applications are still reconciled. From 3c81e239f934de40cb0fbbee37d77b0b7686a976 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 19:07:28 +0200 Subject: [PATCH 082/124] docs(cmdb-import): describe only what this part ships, and what editing needs - The docs and the spec delta of this part describe the endpoint, the import and the seed step, and no longer the settings section, which arrives with the UI part (#1289, Task 4). The REQ-CMDB-014 change moves there with its scenarios. - "Viewing and editing the mapping" opens with what editing needs: an OpenRegister with the migration-pack editor (openregister#4602), and what an administrator gets without it. The claims that the import always runs what OpenRegister holds are qualified, and the stale "fixed list of plain fields" now says any plain field of the target schema. - REQ-CMDB-020 says reading the mapping writes nothing; the deleted-pack scenario and its e2e test now expect the next import, not a read, to seed the pack again, and the first e2e test checks a read stores nothing. - The edit e2e test names the "Beheerde" usage as the evidence and the "Onbeh" one as unchanged by the edit. - Task 6 is split into the API coverage (done here) and the section's coverage (Task 7); Task 8 records that archiving returns the canonical spec's status to done. - CHANGELOG: the migration pack store and the repair step. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 7 ++ docs/features/cmdb-import.md | 80 +++++++++++-------- .../cmdb-import-mapping-view/design.md | 4 +- .../cmdb-import-mapping-view/proposal.md | 2 +- .../specs/cmdb-export-import/spec.md | 29 ++----- .../changes/cmdb-import-mapping-view/tasks.md | 25 ++++-- openspec/specs/cmdb-export-import/spec.md | 2 +- .../spec-coverage/cmdb-import-packs.spec.ts | 59 +++++++++++--- 8 files changed, 132 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87505881a..3de8ca873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ sheets becomes or updates a module, its vendor, a usage for the municipality and a contact person for its owner; a repeat import matches on APPID, so it updates instead of duplicating. See `docs/features/cmdb-import.md`. +- CMDB import: on an OpenRegister with the migration-pack editor + (openregister#4602), the import runs the mapping packs kept in + OpenRegister's migration pack store, where an administrator edits them. + The `SeedCmdbImportPacks` repair step stores them on install and upgrade; + a pack nobody edited follows newer releases, an edited one is kept. + Viewing the mapping writes nothing. An older OpenRegister keeps the packs + that ship with stackiq. ### Changed - EOL feed: the register and schema slugs the feature reads from are corrected. diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md index fa101274f..ed2b17e16 100644 --- a/docs/features/cmdb-import.md +++ b/docs/features/cmdb-import.md @@ -150,8 +150,9 @@ date stored there, including one set by hand. ### Columns and where they go -This is the mapping as it ships; an administrator can change it in -OpenRegister (see [Viewing and editing the mapping](#viewing-and-editing-the-mapping)). +This is the mapping as it ships. On an OpenRegister with the migration-pack +editor an administrator can change it there (see +[Viewing and editing the mapping](#viewing-and-editing-the-mapping)). | Column | Goes to | Rule | |---|---|---| @@ -339,10 +340,22 @@ server is overwritten by the next app update. ## Viewing and editing the mapping +**What you need.** Editing the mapping needs an OpenRegister with the +migration-pack editor, whose pack writes are admin-only and need a CSRF +token (added in [openregister#4602](https://github.com/ConductionNL/openregister/pull/4602); +the first OpenRegister release that contains it). On an older OpenRegister +nothing is stored in OpenRegister and there is nothing to edit: the import +runs the packs that ship with stackiq, and the import result says so. +Changing those shipped files on the server does not last, because the next +app update replaces them. Once the packs have been stored in OpenRegister, +the import does not fall back to the shipped copies: when OpenRegister's +pack store is gone, it refuses with `MAPPING_UNAVAILABLE` until OpenRegister +is back. + The mapping from export columns to stackiq fields is not in code. It is a set of mapping packs that OpenRegister's mapping engine runs, one per kind of -record the import writes. Four of them are kept in OpenRegister, where you -can view and change them: +record the import writes. With the editor, four of them are kept in +OpenRegister, where you can view and change them: | Pack (in OpenRegister) | What it maps | |---|---| @@ -351,11 +364,14 @@ can view and change them: | `stackiq-topdesk-usage` | a row to the usage (status and TIME lookups, phase-out date, internal note) | | `stackiq-topdesk-business-owner` | the owner columns | -Stackiq puts these four packs into OpenRegister when it is installed or -updated. The **CMDB import** section shows, per pack, where the import takes -it from (OpenRegister, or the copy that ships with stackiq), its version, and -an **Edit in OpenRegister** link that opens the pack. The import always runs -what is saved in OpenRegister. +When OpenRegister has the editor, stackiq puts these four packs into +OpenRegister when it is installed or updated, and an import stores any that +is missing. From then on the import runs what is saved in OpenRegister. The +mapping endpoint (`GET /index.php/apps/stackiq/api/settings/cmdb-import/mapping`, +Nextcloud admins only) answers, per pack, where the import takes it from +(OpenRegister, or the copy that ships with stackiq), its version and the +link to its editor in OpenRegister. Reading it changes nothing: it does not +store or update a pack. Each pack has a list of field mappings, one per column: the column name in the export, the field it goes to, whether it is required, and a @@ -374,27 +390,27 @@ rules, so it checks every pack before it reads a row. It refuses a pack, with - OpenRegister finds the pack invalid; - the application name, the APPID (to the source number), the vendor name or the owner name is no longer mapped, or no longer marked required; -- a mapping or a default writes a field the pack may not write. Each pack - may write only a fixed list of plain fields: the fields the shipped pack - maps, the licence type of an application, and the start dates of a usage's - phases. Refused are the fields the import sets itself (the import key, the - provider, the consumer, the application of a usage, the business owner, the - type of an organisation: a Supplier stays a Supplier), every link to - another record, the publication dates, and the fields only an - administrator or the catalogue sets, such as an organisation's status and - who registered it. The status and registration defaults of the vendor and - municipality packs stay as shipped. - -The section shows the same reason with an **Edit in OpenRegister** link to -the pack, so you can correct it there and try again. +- a mapping or a default writes a field the pack may not write. A pack may + write any plain field of the record it writes (the application, the + organisation or the usage), as the register defines it; the owner pack + writes the name and the role only. Refused are the fields the import sets + itself (the import key, the provider, the consumer, the application of a + usage, the business owner, the type of an organisation: a Supplier stays a + Supplier), every link to another record, files, the publication dates, and + the fields only an administrator or the catalogue sets, such as an + organisation's status and who registered it. The status and registration + defaults of the vendor and municipality packs stay as shipped. + +The mapping endpoint answers the same reason, with the link to the pack's +editor in `details.editUrl`, so you can correct it there and try again. **What a pack does not change.** Which sheets are read, the APPID and "Applicatie Naam" columns, the values that mean empty (`NB`), the constant each sheet adds (`Beheer geregeld: ja` or `nee`), the limits, and which fields are written only on create or never on an existing record are part of the app -(`lib/Settings/cmdb-import/topdesk-profile.json`), and the section shows them -read-only. The municipality is mapped from the name you type in the section, -not from the export; that pack is not edited in OpenRegister either. +(`lib/Settings/cmdb-import/topdesk-profile.json`) and cannot be changed. The +municipality is mapped from the name you choose for the import, not from the +export; that pack is not stored in OpenRegister. **Reading more columns.** The import reads only the columns the mapping names. When you map a new column in a pack, the import reads that column from @@ -405,18 +421,12 @@ import on this Nextcloud, whatever the municipality. **App updates.** A pack you have not changed follows the app: when an update ships a newer version, the stored pack is replaced by it. A pack you changed -is never overwritten. When an update ships a newer version of a pack you -changed, the section says so; compare the two and copy what you need into -your pack in OpenRegister. +is never overwritten, also not when you save it while an import is updating +it. When an update ships a newer version of a pack you changed, the mapping +endpoint reports it (`newerShipped`); compare the two and copy what you need +into your pack in OpenRegister. **Back to the shipped pack.** Delete the pack in OpenRegister. The next import, or the next app update, puts the shipped pack back. Do not rename a pack: the import finds it by its id (such as `stackiq-topdesk-usage`), and refuses to run while a pack it stored has another id. - -**Without OpenRegister's pack store.** On an OpenRegister version without -migration packs, or one whose pack store does not yet protect its writes -with a CSRF token and lock the id of a built-in pack, the import uses the -packs that ship with stackiq and the result says so. Once the packs have been stored in OpenRegister, the import -does not fall back to those copies: when OpenRegister's pack store is gone, -it refuses with `MAPPING_UNAVAILABLE` until OpenRegister is back. diff --git a/openspec/changes/cmdb-import-mapping-view/design.md b/openspec/changes/cmdb-import-mapping-view/design.md index 21f19580e..8aef9796c 100644 --- a/openspec/changes/cmdb-import-mapping-view/design.md +++ b/openspec/changes/cmdb-import-mapping-view/design.md @@ -71,7 +71,7 @@ OpenRegister's pack store has no organisation column: an instance has one TOPdes A stored pack carries `x-stackiq: {shippedVersion, shippedHash}`: the version of the shipped pack it was seeded from and the sha256 of that pack's canonical JSON (keys of every object sorted recursively, list order kept, because the order of `fieldMappings` decides which mapping wins a target). OpenRegister's validator ignores keys it does not know, so the marker travels with the definition. A stored definition that, without the marker, still hashes to `shippedHash` is unedited. -`CmdbImportPackStore::resolve()` is the one place that writes: it creates a missing pack as built-in with the marker (never through OpenRegister's import, which upserts and would overwrite an edit), replaces an unedited pack whose marker hash differs from the current shipped pack, and leaves an edited pack alone, reporting `newerShipped`. A pack without a marker (created by hand under the same slug) counts as edited with `edited: null`, and is never replaced. The repair step `SeedCmdbImportPacks` runs it on install and upgrade, after `InitializeSettings`; the loader runs it too, so a pack deleted in OpenRegister is seeded again on the next import, and an instance whose OpenRegister gained the store after stackiq's upgrade is seeded lazily. +`CmdbImportPackStore::resolve()` is the one place that writes: it creates a missing pack as built-in with the marker (never through OpenRegister's import, which upserts and would overwrite an edit), replaces an unedited pack whose marker hash differs from the current shipped pack, and leaves an edited pack alone, reporting `newerShipped`. A pack without a marker (created by hand under the same slug) counts as edited with `edited: null`, and is never replaced. The repair step `SeedCmdbImportPacks` runs it on install and upgrade, after `InitializeSettings`; the import's `CmdbImportProfile::load()` runs it too, so a pack deleted in OpenRegister is seeded again on the next import, and an instance whose OpenRegister gained the store after stackiq's upgrade is seeded by its next import. Viewing the mapping uses `loadForView()`, which calls `CmdbImportPackStore::find()`: it only reads, and a pack the store does not hold yet is shown from its shipped file. The update of an unedited pack passes the read pack's `updatedAt`, so OpenRegister refuses it (`PackChangedException`) when an administrator saved the pack in between; the store then keeps that edit. `CmdbImportPackStoreProbe` decides whether the store may be used at all: the slug-lock class exists and no write method of OpenRegister's pack controller carries an attribute or annotation that skips the CSRF check or admits non-admins. Version numbers alone cannot tell an edit from a release: OpenRegister's editor bumps the version on save, independently of stackiq's releases. The hash can. @@ -163,5 +163,5 @@ l10n/en.json, en.js, nl.json, nl.js - PHPUnit on the import: an edit in OpenRegister changes the mapped usage, the separator and a new date column; without a store the report carries the fallback warning. - PHPUnit on the endpoint and the repair step: the extended answer, `editUrl` from the page route and null when it cannot be built, 503 naming the pack with the link to its editor; seeding four packs, updating and keeping, no store, a failing store, the registration order. - Playwright, through the API (`cmdb-import-packs.spec.ts`): the four packs stored as built-in packs with the marker, the mapping with source, `packId` and `editUrl`, a broken stored pack refused by the mapping and by the import, a module pack without its required name refused, an edit in OpenRegister run by the import, a deleted pack seeded again, the 403 for a non-admin. Each test that edits a pack puts back the definition it read. -- Playwright, on the settings page: the section shows the source and the editor link per pack (UI). +- Playwright, on the settings page (Task 7, with the section in Task 4): the section shows the source and the editor link per pack. - Install, upgrade and a missing or older store are not driven in e2e: they need another OpenRegister or a reinstall. The requirements state them, and the PHPUnit tests above prove them. diff --git a/openspec/changes/cmdb-import-mapping-view/proposal.md b/openspec/changes/cmdb-import-mapping-view/proposal.md index 5a12aa5f3..f8172ddaf 100644 --- a/openspec/changes/cmdb-import-mapping-view/proposal.md +++ b/openspec/changes/cmdb-import-mapping-view/proposal.md @@ -39,7 +39,7 @@ None. - The `x-stackiq` marker in a stored definition: the shipped version and the hash of the shipped pack it was seeded from. - Excel date conversion for every column a pack maps with a date transform; the note cleanup on the pack's own concat separator. - `GET /api/settings/cmdb-import/mapping` extended per pack with `source`, `packId`, `storedVersion`, `shippedVersion`, `edited`, `newerShipped`, `editable` and `editUrl`, and `openRegisterPacks` at the top level. -- The section's mapping block: per pack its source, version, a hint when a newer shipped pack was not applied, and an "Edit in OpenRegister" link; the sheet names in the help text come from the endpoint. +- The section's mapping block: per pack its source, version, a hint when a newer shipped pack was not applied, and a link to its editor in OpenRegister (Task 4); the sheet names in the help text come from the endpoint. - `docs/features/cmdb-import.md`: viewing and editing the mapping in OpenRegister. ### Out of Scope diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md index 793c3bdfa..991044edf 100644 --- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md +++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md @@ -22,7 +22,7 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages), `OCP\IURLGenerator` (the - `packs`: one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings`, the write rules `defaults`, `createOnlyDefaults`, `createOnlyFields` and `neverWritten`, and where the pack comes from: `source` (`openregister` or `file`), `packId` (the OpenRegister row id, or null), `storedVersion` (or null), `shippedVersion`, `edited` (whether the stored pack differs from what stackiq seeded; null when unknown or for a file), `newerShipped` (an edited pack kept a newer shipped pack out), `editable` (false for the municipality pack) and `editUrl` (the absolute link to the pack's editor in OpenRegister, built by Nextcloud's URL generator so an instance that needs `/index.php` gets it, or null when the pack is not stored there or the link cannot be built); - `openRegisterPacks`: whether OpenRegister's migration-pack store is available. -Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it. `createOnlyDefaults` holds values the import sets on create only, `createOnlyFields` the mapped fields written on create or, on an existing object, only when the stored value is empty (a key of `createOnlyDefaults` only when it is also a mapped field), and `neverWritten` the fields the import never writes on an existing object. When the validator is missing, a pack is unreadable, invalid or breaks a rule of the import, or the store that holds the packs is gone, the endpoint SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason` (its first line, at most 500 characters), and for a pack stored in OpenRegister its slug in `details.pack`, its row id in `details.packId` and the link to its editor in `details.editUrl` (null when the link cannot be built). The "CMDB import" section SHALL show, per pack, its source, its version, a hint when a newer shipped pack was not applied, and a link "Edit in OpenRegister" when the pack has an `editUrl`; when the endpoint answers 503 for a stored pack, the error SHALL carry the same link from `details.editUrl`. It SHALL offer no editing itself. +Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it. `createOnlyDefaults` holds values the import sets on create only, `createOnlyFields` the mapped fields written on create or, on an existing object, only when the stored value is empty (a key of `createOnlyDefaults` only when it is also a mapped field), and `neverWritten` the fields the import never writes on an existing object. When the validator is missing, a pack is unreadable, invalid or breaks a rule of the import, or the store that holds the packs is gone, the endpoint SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason` (its first line, at most 500 characters), and for a pack stored in OpenRegister its slug in `details.pack`, its row id in `details.packId` and the link to its editor in `details.editUrl` (null when the link cannot be built). Reading the mapping SHALL change nothing: it SHALL NOT seed, update or delete a pack in OpenRegister and SHALL NOT write stackiq's app config; a pack the store does not hold yet SHALL be answered from its shipped file (`source` `file`), and an unedited stored pack that a newer shipped pack would replace SHALL be answered as stored until the next import run or upgrade replaces it. #### Scenario: An admin reads the mapping the import uses @e2e tests/e2e/spec-coverage/cmdb-import-packs.spec.ts @@ -92,8 +92,10 @@ OpenRegister's store SHALL count as available only when its pack writes require - **GIVEN** an administrator deleted the business-owner pack in OpenRegister - **WHEN** a Nextcloud admin requests the mapping -- **THEN** the business-owner pack SHALL have `source` `openregister`, a new `packId` and `edited` `false` -- **AND** OpenRegister SHALL hold `stackiq-topdesk-business-owner` again as a built-in pack +- **THEN** the business-owner pack SHALL have `source` `file`, and OpenRegister SHALL still hold no `stackiq-topdesk-business-owner` +- **WHEN** the next import runs +- **THEN** OpenRegister SHALL hold `stackiq-topdesk-business-owner` again as a built-in pack +- **AND** the mapping SHALL show it with `source` `openregister`, a new `packId` and `edited` `false` ### Requirement: Stackiq SHALL seed its mapping packs into OpenRegister and keep unedited ones current (REQ-CMDB-022) @@ -152,27 +154,12 @@ The service SHALL map each normalised row with OpenRegister's `MigrationPack\Map - **AND** the "Onbeh" row's "Applicatiesoort" `Webapplicatie`, which is not a hosting model, SHALL be stored as `applicationType` and SHALL leave `cloudDienstverleningsmodel` empty without a warning - **AND** "BNN Classificatie" `1`, `2`, `2+` SHALL be `BBN1`, `BBN2`, `BBN2+` -### Requirement: The admin settings SHALL offer a CMDB import section (REQ-CMDB-014) - -Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary and a report table that can be filtered by outcome. The section SHALL also show the mapping the import uses (REQ-CMDB-020): per pack its source, its version and a link to its editor in OpenRegister. The sheet names in its help text SHALL come from that mapping, with the shipped names as the fallback until the mapping has loaded or when it cannot be loaded. Every control SHALL have a visible label, and every string SHALL be translatable. - -(Previously: the section had no mapping block, and the sheet names in its help text were a constant in the page, which could drift from the profile on the server.) - -#### Scenario: The admin runs an import from the settings page -@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts - -- **GIVEN** a Nextcloud admin on stackiq's admin settings page -- **WHEN** they choose "Gemeente Voorbeeldstad", choose the anonymised export and press "Import" -- **THEN** a progress bar SHALL appear while the import runs -- **AND** afterwards the summary and the report table SHALL be shown -- **AND** filtering the table on `created` SHALL show the two imported rows - ## Non-Functional Requirements -- **Performance:** loading the mapping reads seven small JSON files and the register definition, and makes at most one OpenRegister lookup per sheet pack; it SHALL answer within the time an admin settings page loads. Writes happen only to seed or update a pack. +- **Performance:** loading the mapping reads seven small JSON files and the register definition, and makes at most one OpenRegister lookup per sheet pack; it SHALL answer within the time an admin settings page loads. Reading the mapping writes nothing; only the import run and the seed step seed or update a pack. - **Security:** admin-only with CSRF, as the import routes; OpenRegister's pack writes are admin-only with CSRF, and stackiq uses no store without those guards. The import writes with OpenRegister's RBAC and multitenancy off, so every pack SHALL follow the import's rules (REQ-CMDB-021). An edit that maps a new column makes the import read that column. The answer and the reasons hold column names, field names, lookup tables, pack names and ids; no object data and no person data. A store failure is named by its exception class, never its message. - **Reliability:** an edit is never overwritten by an upgrade and never silently replaced by the shipped files (REQ-CMDB-021, REQ-CMDB-022). -- **Accessibility:** Target WCAG 2.2 AA. The mapping block's link has a visible, descriptive label (SC 2.4.4, 4.1.2; gate `link-name`); a status hint is text, not colour alone (SC 1.4.1). New in 2.2: 2.4.11 Focus Not Obscured applies (the block is in the page flow); 2.5.7 Dragging Movements does not apply; 2.5.8 Target Size applies to the link (Nextcloud default); 3.2.6 Consistent Help does not apply; 3.3.7 Redundant Entry does not apply (nothing is entered); 3.3.8 Accessible Authentication does not apply. +- **Accessibility:** not applicable to this part: the endpoint, the import rules and the seed step have no user interface. The section that shows the mapping is part of Task 4 and targets WCAG 2.2 AA. - **Internationalization:** Dutch and English MUST be supported (ADR-005) for the block, the messages and the import warning. ## Acceptance Criteria @@ -181,7 +168,7 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th - [ ] A Nextcloud admin edits a lookup value of the usage pack in OpenRegister, and the next CMDB import uses it. - [ ] A pack edited to map a field the import manages makes the import answer 503 naming the pack; the mapping block shows the same. - [ ] Deleting the stored pack in OpenRegister makes the next import seed the shipped pack again. -- [ ] The "CMDB import" section shows per pack its source and an "Edit in OpenRegister" link that opens the pack's editor. +- [ ] The "CMDB import" section shows per pack its source and a link that opens the pack's editor (Task 4). - [ ] A signed-in user who is not a Nextcloud admin gets 403 from the endpoint. ## Notes diff --git a/openspec/changes/cmdb-import-mapping-view/tasks.md b/openspec/changes/cmdb-import-mapping-view/tasks.md index 9c6df1ed4..67e261fd8 100644 --- a/openspec/changes/cmdb-import-mapping-view/tasks.md +++ b/openspec/changes/cmdb-import-mapping-view/tasks.md @@ -43,7 +43,7 @@ Spec: `openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.m - **spec_ref**: `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014` (cmdb-export-import#REQ-CMDB-014) and `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020) - **files**: `src/views/settings/sections/CmdbImportMapping.vue`, `src/views/settings/sections/CmdbImport.vue`, `src/utils/cmdbImport.js`, `tests/vitest/cmdbImportMapping.spec.js` - **acceptance_criteria**: - - GIVEN the endpoint's answer WHEN the block renders THEN each pack shows its source, its version, a hint when `newerShipped`, and an "Edit in OpenRegister" link to `editUrl` when there is one + - GIVEN the endpoint's answer WHEN the block renders THEN each pack shows its source, its version, a hint when `newerShipped`, and a link to its editor (`editUrl`) when there is one - GIVEN a 503 THEN the block shows the error, the code and the reason - GIVEN the answer THEN the help text names the endpoint's sheets; before and on failure `PROFILE_DEFAULTS.sheets` - [ ] Implement @@ -58,16 +58,31 @@ Spec: `openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.m - [x] Implement (backend strings and documentation; the section's strings come with Task 4) - [x] Test -### Task 6: End-to-end coverage +### Task 6: End-to-end coverage of the endpoint, the import and the seed - **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020), `SPEC#requirement-the-import-shall-run-the-mapping-packs-stored-in-openregister-req-cmdb-021` (cmdb-export-import#REQ-CMDB-021) and `SPEC#requirement-stackiq-shall-seed-its-mapping-packs-into-openregister-and-keep-unedited-ones-current-req-cmdb-022` (cmdb-export-import#REQ-CMDB-022) -- **files**: `tests/e2e/spec-coverage/cmdb-import-packs.spec.ts`, `tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts` +- **files**: `tests/e2e/spec-coverage/cmdb-import-packs.spec.ts` - **acceptance_criteria**: - GIVEN the rig with the hardened OpenRegister WHEN an admin lists OpenRegister's packs THEN the four sheet packs are built-in packs with the marker, and no municipality pack is stored - GIVEN an admin WHEN they read the mapping THEN every sheet pack answers `source: openregister`, its `packId` and an `editUrl` to its editor; the municipality pack `source: file` and no link - GIVEN the usage pack edited to map a column to `provider` THEN the mapping and the import answer 503 naming the pack, and nothing is written; GIVEN the module pack's name no longer required THEN the same with the rule named - GIVEN an edited lookup in the usage pack WHEN the export is imported THEN the usages carry the edited value - - GIVEN the business-owner pack deleted in OpenRegister WHEN the mapping is read THEN it is seeded again + - GIVEN the business-owner pack deleted in OpenRegister WHEN the mapping is read THEN it is shown from the shipped file and not stored; WHEN the next import runs THEN it is seeded again - GIVEN a signed-in non-admin WHEN they call the endpoint THEN 403 - - GIVEN an admin on the settings page WHEN the mapping has loaded THEN every sheet pack shows its source and an "Edit in OpenRegister" link +- [x] Implement +- [x] Test + +### Task 7: End-to-end coverage of the section +- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014` (cmdb-export-import#REQ-CMDB-014) +- **files**: `tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts` +- **acceptance_criteria**: + - GIVEN an admin on the settings page WHEN the mapping has loaded THEN every sheet pack shows its source and a link to its editor +- [ ] Implement +- [ ] Test + +### Task 8: Archive +- **spec_ref**: `SPEC#requirement-the-import-shall-run-the-mapping-packs-stored-in-openregister-req-cmdb-021` (cmdb-export-import#REQ-CMDB-021) +- **files**: `openspec/specs/cmdb-export-import/spec.md` +- **acceptance_criteria**: + - GIVEN the change is archived WHEN this was the last open change of `cmdb-export-import` THEN the canonical spec's front matter `status:` and its `**Status**` line read `done` again, and the change's entry links the archive - [ ] Implement - [ ] Test diff --git a/openspec/specs/cmdb-export-import/spec.md b/openspec/specs/cmdb-export-import/spec.md index d285d7409..796158845 100644 --- a/openspec/specs/cmdb-export-import/spec.md +++ b/openspec/specs/cmdb-export-import/spec.md @@ -10,7 +10,7 @@ built_by: openspec/changes/archive/2026-10-05-cmdb-export-import **Scope**: stackiq **OpenSpec changes**: - [cmdb-export-import](../../changes/archive/2026-10-05-cmdb-export-import/) _(archived 2026-10-05)_ — admin uploads a TOPdesk CMDB export (xlsx); stackiq upserts modules, vendor organisations, usages and owner contact persons for one municipality from the two CMDB sheets, matched on APPID, mapped by OpenRegister migration packs (kind: code) -- [cmdb-import-mapping-view](../../changes/cmdb-import-mapping-view/) — the four sheet packs are seeded into OpenRegister's migration-pack store, where an administrator edits them, and the import runs what is stored there (shipped files as seed and fallback, fail-closed on an invalid pack); the "CMDB import" section shows per pack its source and a link to its editor, served by `GET /api/settings/cmdb-import/mapping` (kind: code) +- [cmdb-import-mapping-view](../../changes/cmdb-import-mapping-view/) — the four sheet packs are seeded into OpenRegister's migration-pack store, where an administrator edits them, and the import runs what is stored there (shipped files as seed and fallback, fail-closed on an invalid pack); `GET /api/settings/cmdb-import/mapping` answers, without writing, per pack its source and a link to its editor (kind: code) ## Purpose diff --git a/tests/e2e/spec-coverage/cmdb-import-packs.spec.ts b/tests/e2e/spec-coverage/cmdb-import-packs.spec.ts index 6fd9f8cda..ae9bb5ab1 100644 --- a/tests/e2e/spec-coverage/cmdb-import-packs.spec.ts +++ b/tests/e2e/spec-coverage/cmdb-import-packs.spec.ts @@ -246,8 +246,18 @@ test.describe.serial('CMDB import packs in OpenRegister', () => { test('the four sheet packs are stored in OpenRegister as built-in packs', async () => { const ctx = await newApiContext() try { - // The mapping endpoint seeds a pack that is still missing. + // Reading the mapping stores nothing; the seed step (install or + // upgrade) or an import run stores a pack that is still missing. + const before = await storedPacks(ctx) expect((await readMapping(ctx)).status()).toBe(200) + expect( + await storedPacks(ctx), + 'reading the mapping writes nothing', + ).toEqual(before) + if (Object.keys(before).length < STORED_SLUGS.length) { + const answer = await importExport(ctx) + expect(answer.status(), await answer.text()).toBe(200) + } const packs = await storedPacks(ctx) expect(Object.keys(packs).sort()).toEqual([...STORED_SLUGS].sort()) @@ -427,10 +437,24 @@ test.describe.serial('CMDB import packs in OpenRegister', () => { const usages = await usagesOfMunicipality(ctx) expect(usages).toHaveLength(2) - // The "Beheerde" row is "In productie" in the export. - for (const written of usages) { - expect(written.status).toBe('Planned') - } + const fromSheet = (constant: string) => + usages.find((usage) => + String(usage.interneAnnotation ?? '').startsWith( + constant, + ), + ) + // The "Beheerde" row is "In productie" in the export: only the + // edit makes it Planned. This is the evidence. + expect( + fromSheet('Beheer geregeld: ja')?.status, + 'the usage of the "Beheerde" row', + ).toBe('Planned') + // The "Onbeh" row is "In voorraad", which the shipped lookup + // already maps to Planned: unchanged by the edit, no evidence. + expect( + fromSheet('Beheer geregeld: nee')?.status, + 'the usage of the "Onbeh" row', + ).toBe('Planned') }, ) @@ -447,7 +471,7 @@ test.describe.serial('CMDB import packs in OpenRegister', () => { }) // @e2e cmdb-export-import::a-deleted-pack-is-seeded-again - test('a pack deleted in OpenRegister is seeded again', async () => { + test('a pack deleted in OpenRegister is seeded again by the next import', async () => { const ctx = await newApiContext() try { const before = (await storedPacks(ctx))['stackiq-topdesk-business-owner'] @@ -455,11 +479,24 @@ test.describe.serial('CMDB import packs in OpenRegister', () => { const deleted = await ctx.delete(`${PACKS_PATH}/${before.id}`) expect(deleted.status(), await deleted.text()).toBe(204) - const res = await readMapping(ctx) - expect(res.status(), await res.text()).toBe(200) - const owner = ((await res.json()).packs as MappingPack[]).find( - (pack) => pack.target === 'businessOwner', - ) as MappingPack + const ownerIn = async () => + ( + (await (await readMapping(ctx)).json()).packs as MappingPack[] + ).find((pack) => pack.target === 'businessOwner') as MappingPack + const shown = await ownerIn() + expect( + shown.source, + 'until the next import, from the shipped file', + ).toBe('file') + expect( + (await storedPacks(ctx))['stackiq-topdesk-business-owner'], + 'reading the mapping does not seed it', + ).toBeUndefined() + + const answer = await importExport(ctx) + expect(answer.status(), await answer.text()).toBe(200) + + const owner = await ownerIn() expect(owner.source).toBe('openregister') expect(owner.packId).not.toBe(before.id) expect(owner.edited).toBe(false) From c55a96d8810cd12c7e804406e0a81537649ea92b Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 19:07:37 +0200 Subject: [PATCH 083/124] test(cmdb-import): wait on a rendered list, not networkidle, and fail loudly on a bad lens read Closes "e2e waits for networkidle, which never settles on Nextcloud (gate-58)": the archive test now waits for a working application row to be visible before asserting that the archived application is absent, so the negative assertion cannot pass on a list that has not rendered. No other networkidle wait remains in the touched spec (git grep over tests/e2e shows only comments explaining why it is avoided). Closes thread 4248187506 (e2e cleanup comment): the header and the helper doc no longer claim that an interrupted run leaves nothing behind or that _includeDeleted adds the trash (a soft-deleted row cannot be listed, design D9); objectsInLens now fails the test on a non-OK response instead of returning an empty list that made cleanup skip silently. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/spec-coverage/cmdb-import.spec.ts | 31 +++++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/tests/e2e/spec-coverage/cmdb-import.spec.ts b/tests/e2e/spec-coverage/cmdb-import.spec.ts index c0bd8d3e3..e86047e41 100644 --- a/tests/e2e/spec-coverage/cmdb-import.spec.ts +++ b/tests/e2e/spec-coverage/cmdb-import.spec.ts @@ -33,8 +33,10 @@ * moved APPID 1234 to "Gearchiveerde Applicaties" and dropped APPID 2 archives * the one and moves the other to the trash, the Applications page lists 1234 * under "Archived", and the original export brings both back. The cleanup - * looks through OpenRegister's archive and trash lenses too, so an - * interrupted run leaves nothing archived behind. + * looks through OpenRegister's archive lens too, so an interrupted run + * leaves no archived application of the run's municipality behind; a + * soft-deleted one cannot be listed and stays in the trash until its purge + * date (design.md, D9). */ import type { APIRequestContext, Locator, Page, Response } from '@playwright/test' @@ -140,7 +142,8 @@ async function objectsOfMunicipality( /** * The objects of a schema that mention a municipality, through OpenRegister's * archive and trash lenses: `_archived` `true` lists the archive alone, `any` - * the archive and the working set; `_includeDeleted` adds the trash. + * the archive and the working set. `_includeDeleted` is sent for completeness: + * the list cannot return a soft-deleted row even then (design.md, D9). * * @param ctx The API context * @param schema The schema id @@ -158,9 +161,10 @@ async function objectsInLens( const res = await ctx.get( `/index.php/apps/openregister/api/objects/${config.register}/${schema}?_limit=5000&_archived=${lens.archived}&_includeDeleted=${lens.includeDeleted}`, ) - if (!res.ok()) { - return [] - } + expect( + res.ok(), + `Listing ${schema} with _archived=${lens.archived} failed with ${res.status()}`, + ).toBe(true) const body = await res.json() const list = body?.results ?? body ?? [] return (Array.isArray(list) ? list : []).filter((row) => @@ -358,7 +362,8 @@ test.describe.serial('CMDB import section', () => { config.contactpersoon_schema, config.module_schema, ]) { - // Archived and deleted objects too: the archive test may have stopped half-way. + // Archived objects too: the archive test may have stopped half-way. A + // soft-deleted one is not listable and stays in the trash until it is purged. for (const row of await objectsInLens( ctx, schema, @@ -560,8 +565,16 @@ test.describe.serial('CMDB import section', () => { // 2. The Applications page lists 1234 only under the quick filter "Archived". await gotoAppRoute(page, '/modules') await dismissSupportDialog(page) - // Not under the default tab: wait for the list to settle, then look. - await page.waitForLoadState('networkidle') + // Not under the default tab. Nextcloud keeps polls open, so no network + // idle wait: wait until the list shows a working application (the + // export brought more than the two this test took out), then look. + expect( + working.modules, + 'the working set must still hold an application the list can show', + ).toBeGreaterThan(0) + await expect(page.locator('tbody tr').first()).toBeVisible({ + timeout: 30000, + }) await expect(page.getByText(ARCHIVED_APPLICATION_NAME)).toHaveCount(0) await page.getByRole('tab', { name: 'Archived', exact: true }).click() await expect(page.getByText(ARCHIVED_APPLICATION_NAME).first()).toBeVisible({ From 4b70494f196b0827470980ea242d6df8c4a9db9a Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 19:07:37 +0200 Subject: [PATCH 084/124] fix(applications): say on the Archived tab that facets and search are ignored Closes "Archived tab keeps facet sidebar and search active but ignores them": FacetedCatalogIndexView tracks the active quick-filter tab and shows a note under the toolbar while a tab whose filter carries the empty _ids override is active; en and nl strings and the generated l10n js are added. A mounted view test asserts the note appears on the Archived tab, is gone on another tab, and goes red with the note removed. Closes "Archived-tab facet override is tested against a local merge, not the view": the facet-override test now calls the library's own useSelfFetchList and reads the fixedFilters getter it hands the list composable, instead of restating the spread; only the list composable and store are replaced. It goes red when the _ids override is removed from the manifest. The source grep test that pinned the merge order is dropped as redundant. Class sweep: the Services page uses the same view but has no quick filter with the override, so it shows no note. Co-Authored-By: Claude Opus 5.5 --- l10n/en.js | 3 +- l10n/en.json | 3 +- l10n/nl.js | 3 +- l10n/nl.json | 3 +- src/views/FacetedCatalogIndexView.vue | 49 +++++++++- tests/vitest/cmdbArchiveQuickFilter.spec.js | 86 +++++++++++----- .../vitest/facetedCatalogArchiveNote.spec.js | 98 +++++++++++++++++++ 7 files changed, 214 insertions(+), 31 deletions(-) create mode 100644 tests/vitest/facetedCatalogArchiveNote.spec.js diff --git a/l10n/en.js b/l10n/en.js index 613cb76f2..c4ee74c0a 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1153,7 +1153,8 @@ OC.L10N.register( "{count} unarchived": "{count} unarchived", "{count} deleted": "{count} deleted", "{count} restored": "{count} restored", - "Applications: {changes}.": "Applications: {changes}." + "Applications: {changes}.": "Applications: {changes}.", + "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "This tab lists the whole archive. It ignores the GEMMA filters and the search term." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 29d312f53..22795a655 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1152,6 +1152,7 @@ "{count} unarchived": "{count} unarchived", "{count} deleted": "{count} deleted", "{count} restored": "{count} restored", - "Applications: {changes}.": "Applications: {changes}." + "Applications: {changes}.": "Applications: {changes}.", + "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "This tab lists the whole archive. It ignores the GEMMA filters and the search term." } } diff --git a/l10n/nl.js b/l10n/nl.js index 1dc8b83d9..7407af21b 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1221,7 +1221,8 @@ OC.L10N.register( "{count} unarchived": "{count} uit het archief gehaald", "{count} deleted": "{count} verwijderd", "{count} restored": "{count} teruggezet", - "Applications: {changes}.": "Applicaties: {changes}." + "Applications: {changes}.": "Applicaties: {changes}.", + "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "Dit tabblad toont het hele archief. De GEMMA-filters en de zoekterm worden genegeerd." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 7963d0ebc..26705400c 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1220,6 +1220,7 @@ "{count} unarchived": "{count} uit het archief gehaald", "{count} deleted": "{count} verwijderd", "{count} restored": "{count} teruggezet", - "Applications: {changes}.": "Applicaties: {changes}." + "Applications: {changes}.": "Applicaties: {changes}.", + "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "Dit tabblad toont het hele archief. De GEMMA-filters en de zoekterm worden genegeerd." } } diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue index 427245c47..cfb2737b2 100644 --- a/src/views/FacetedCatalogIndexView.vue +++ b/src/views/FacetedCatalogIndexView.vue @@ -92,6 +92,18 @@ generic route-query-to-filter passthrough never sees it (see the +

+ {{ + t( + 'stackiq', + 'This tab lists the whole archive. It ignores the GEMMA filters and the search term.', + ) + }} +

+
+ @view="openDetail" + @quickFilterChange="onQuickFilterChange" />
@@ -281,6 +294,7 @@ export default { showSaveViewModal: false, savingView: false, searchDebounceTimer: null, + activeQuickFilterIndex: null, } }, @@ -340,6 +354,20 @@ export default { ) }, + /** + * Whether the active quick-filter tab drops the facet id set and the + * search term: a tab whose filter carries the empty-string `_ids` + * override (the Archived tab, see the `quickFilters` prop). The facet + * sidebar and the search box stay on screen, so the page says so. + * + * @return {boolean} True while such a tab is active. + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017 + */ + archivedTabIgnoresFilters() { + const tab = (this.quickFilters ?? [])[this.activeQuickFilterIndex] + return tab?.filter?._ids === '' + }, + /** * Remount key for `CnIndexPage`: its self-fetch mode has no reactive * watcher on the `filter` prop (only route/quick-filter/tab changes @@ -375,6 +403,18 @@ export default { }, methods: { + /** + * Remember which quick-filter tab is active (single-select mode emits the + * tab index; multi-select emits an array, which never selects the + * Archived tab here). + * + * @param {number|number[]} index The tab index, or the selected indices + * @return {void} + */ + onQuickFilterChange(index) { + this.activeQuickFilterIndex = Number.isInteger(index) ? index : null + }, + /** * Open the detail page of a clicked row, or of the row whose View action was used. * @@ -538,6 +578,13 @@ export default { flex: 1; } +.faceted-catalog-index__archive-note { + color: var(--color-text-maxcontrast); + margin: 0; + padding: 0 calc(var(--default-grid-baseline, 8px) * 2) + calc(var(--default-grid-baseline, 8px) * 2); +} + .faceted-catalog-index__body { display: flex; align-items: flex-start; diff --git a/tests/vitest/cmdbArchiveQuickFilter.spec.js b/tests/vitest/cmdbArchiveQuickFilter.spec.js index ff4df73ee..f93a42ba1 100644 --- a/tests/vitest/cmdbArchiveQuickFilter.spec.js +++ b/tests/vitest/cmdbArchiveQuickFilter.spec.js @@ -18,12 +18,34 @@ import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' import { describe, expect, it, vi } from 'vitest' +import { effectScope } from 'vue' // resolveFilterTokens reads the current user at import time, which needs a // browser; the tokens this spec merges (`_archived`, `_ids`) resolve no user. vi.mock('@nextcloud/auth', () => ({ getCurrentUser: () => null })) +// useSelfFetchList is the real function; only the list composable it feeds its +// `fixedFilters` getter to is replaced, so the test can read the options it got. +const captured = vi.hoisted(() => ({ options: null })) +vi.mock( + '../../node_modules/@conduction/nextcloud-vue/src/composables/index.js', + () => ({ + useListView: (objectType, options) => { + captured.options = options + return { refresh: () => {}, searchTerm: { value: '' } } + }, + }), +) +vi.mock( + '../../node_modules/@conduction/nextcloud-vue/src/composables/useObjectSubscription.js', + () => ({ + useObjectSubscription: () => ({}), + }), +) +vi.mock('../../node_modules/@conduction/nextcloud-vue/src/store/index.js', () => ({ + useObjectStore: () => ({ registerObjectType: () => {} }), +})) +import { useSelfFetchList } from '../../node_modules/@conduction/nextcloud-vue/src/components/CnIndexPage/useSelfFetchList.js' // The REAL implementations from the installed package — not a local restatement. import { buildQueryString } from '../../node_modules/@conduction/nextcloud-vue/src/utils/headers.js' -import { resolveFilterMap } from '../../node_modules/@conduction/nextcloud-vue/src/utils/routeFilters.js' import { facetNarrowingFilter } from '../../src/utils/facetNarrowing.js' const here = path.dirname(fileURLToPath(import.meta.url)) @@ -74,41 +96,53 @@ describe('the archive quick filter', () => { // matched) and merges the active tab's filter over it. OpenRegister leaves // archived objects out of that id set, so `_ids` AND `_archived=true` lists // nothing: with a facet selected, the Archived tab must not send `_ids`. + // The merge is the library's own: useSelfFetchList hands useListView a + // `fixedFilters` getter, which is called here exactly as a fetch calls it. it('sends no _ids with the Archived tab when a facet narrowed the list', () => { const page = pages().find((candidate) => candidate.id === 'Modules') - const archived = page.config.quickFilters.find( + const quickFilters = page.config.quickFilters + const archivedIndex = quickFilters.findIndex( (tab) => tab.label === 'Archived', ) const facetFilter = facetNarrowingFilter(['live-1', 'live-2']) - // The merge useSelfFetchList#fixedFilters does in single-tab mode: - // the page's filter, then the active tab's filter spread last. - const archivedQuery = buildQueryString({ - ...facetFilter, - ...resolveFilterMap(archived.filter, {}, {}), - }) - const allQuery = buildQueryString({ - ...facetFilter, - ...resolveFilterMap({}, {}, {}), - }) + /** + * The query string the library builds for the list with one tab active. + * + * @param {number|null} tabIndex The active quick-filter tab + * @return {string} The query string + */ + function queryWithTab(tabIndex) { + const scope = effectScope() + let fixedFilters + captured.options = null + scope.run(() => { + const result = useSelfFetchList( + { + register: 'voorzieningen', + schema: 'module', + filter: facetFilter, + quickFilters, + columns: [], + }, + null, + () => null, + ) + result.activeQuickFilterIndex.value = tabIndex + fixedFilters = captured.options.fixedFilters + }) + const query = buildQueryString(fixedFilters()) + scope.stop() + return query + } + + const archivedQuery = queryWithTab(archivedIndex) + const otherQuery = queryWithTab(archivedIndex === 0 ? 1 : 0) expect(new URLSearchParams(archivedQuery).get('_archived')).toBe('true') expect(archivedQuery).not.toContain('_ids') // A tab without the override keeps the facet narrowing. - expect(allQuery).toContain('_ids') - }) - - it('still merges the active tab last, which the override above relies on', () => { - const source = fs.readFileSync( - path.join( - repoRoot, - 'node_modules/@conduction/nextcloud-vue/src/components/CnIndexPage/useSelfFetchList.js', - ), - 'utf8', - ) - expect(source).toMatch( - /\.\.\.base, \.\.\.resolveFilterMap\(tabFilter, params, ctx\)/, - ) + expect(otherQuery).toContain('_ids') }) it('the Modules page note names the Archived tab as the exception to the additive merge', () => { diff --git a/tests/vitest/facetedCatalogArchiveNote.spec.js b/tests/vitest/facetedCatalogArchiveNote.spec.js new file mode 100644 index 000000000..53f397737 --- /dev/null +++ b/tests/vitest/facetedCatalogArchiveNote.spec.js @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +/** + * The Archived tab of the Applications page says it ignores the facets and + * the search term. + * + * The facet sidebar and the search box stay on screen on that tab, but its + * filter carries an empty `_ids` that drops the facet id set. Without a note + * an admin who picks a facet sees an unchanged list and no reason. + * + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' + +// CnIndexPage stands in as a component that emits what the real one emits +// when a quick-filter tab is chosen. +vi.mock('@conduction/nextcloud-vue', () => ({ + CnFacetSidebar: defineComponent({ + name: 'CnFacetSidebar', + render: () => h('div'), + }), + CnIndexPage: defineComponent({ + name: 'CnIndexPage', + emits: ['quick-filter-change'], + render() { + return h('div', { class: 'index-page-stub' }) + }, + }), +})) +vi.mock('../../src/modals/SaveFacetViewModal.vue', () => ({ + default: defineComponent({ name: 'SaveFacetViewModal', render: () => h('div') }), +})) + +import FacetedCatalogIndexView from '../../src/views/FacetedCatalogIndexView.vue' + +const NOTE = '.faceted-catalog-index__archive-note' +const QUICK_FILTERS = [ + { label: 'All', filter: {}, default: true }, + { label: 'Archived', filter: { _archived: 'true', _ids: '' } }, +] + +/** + * Mount the view with the facet store's network calls silenced. + * + * @return {object} The wrapper + */ +function mountView() { + setActivePinia(createPinia()) + return mount(FacetedCatalogIndexView, { + props: { + schema: 'module', + register: 'voorzieningen', + title: 'Applications', + quickFilters: QUICK_FILTERS, + }, + global: { + mocks: { + $route: { query: {} }, + $router: { push: vi.fn() }, + t: (app, text) => text, + }, + stubs: { + NcTextField: true, + NcButton: true, + NcActions: true, + NcActionButton: true, + NcActionCaption: true, + }, + plugins: [ + (app) => { + app.config.globalProperties.t = (a, text) => text + }, + ], + }, + }) +} + +describe('FacetedCatalogIndexView on the Archived tab', () => { + it('shows the note only while the Archived tab is active', async () => { + const wrapper = mountView() + const index = wrapper.findComponent({ name: 'CnIndexPage' }) + + expect(wrapper.find(NOTE).exists()).toBe(false) + + await index.vm.$emit('quick-filter-change', 1) + expect(wrapper.find(NOTE).exists()).toBe(true) + expect(wrapper.find(NOTE).text()).toContain('ignores the GEMMA filters') + + await index.vm.$emit('quick-filter-change', 0) + expect(wrapper.find(NOTE).exists()).toBe(false) + }) +}) From 3c17b477431b75c366a2ffc4b5665494817eac5f Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 19:07:37 +0200 Subject: [PATCH 085/124] docs(cmdb-import): tick the UI tasks and document the archive tab note and unreadable CMDB APPIDs Makes tasks.md true for this PR after the merge of the reconcile base: the status line names #1286 for tasks 1-5 and this PR for task 6 (ticked, with the new Archived-tab note as an acceptance line); task 7 has its implementation ticked and its Playwright run still open. docs/features/cmdb-import.md already described archive and soft delete (the old "left as they are" sentence only remains for the Keep choice); it now also says that an APPID without a cached value on a CMDB sheet archives instead of deleting, and that the Archived tab shows a note about the ignored facets and search. Co-Authored-By: Claude Opus 5.5 --- docs/features/cmdb-import.md | 4 ++-- .../changes/cmdb-import-archive-reconciliation/tasks.md | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md index c4d839903..e71756d73 100644 --- a/docs/features/cmdb-import.md +++ b/docs/features/cmdb-import.md @@ -277,13 +277,13 @@ APPID is on neither CMDB sheet: |---|---| | on the sheet `Gearchiveerde Applicaties` | are **archived**: OpenRegister's archive state, with the reason `cmdb-import: on sheet "Gearchiveerde Applicaties"`. Reported as *archived*. | | on no sheet at all | are **deleted**: moved to OpenRegister's trash, where they stay restorable for 30 days. Reported as *deleted*. | -| on no sheet, but the archive sheet cannot be relied on: the workbook has no sheet `Gearchiveerde Applicaties`, the sheet has no `APPID` column, or an `APPID` in it has no cached value (a formula whose result the file does not store) | are **archived**, never deleted. Each case has its own warning in the result: *sheet not found*, *no column APPID*, or *an APPID without a cached value in row …* (for more rows: *in N rows (…)*, naming the first ten). Such a workbook therefore never moves anything to the trash. | +| on no sheet, but the archive sheet cannot be relied on: the workbook has no sheet `Gearchiveerde Applicaties`, the sheet has no `APPID` column, or an `APPID` in it has no cached value (a formula whose result the file does not store), or an `APPID` on one of the two CMDB sheets has no cached value (the import cannot tell which application that row stands for) | are **archived**, never deleted. Each case has its own warning in the result: *sheet not found*, *no column APPID*, or *an APPID without a cached value in row …* (for more rows: *in N rows (…)*, naming the first ten; for a CMDB sheet the warning names that sheet). Such a workbook therefore never moves anything to the trash. | An archived or deleted application disappears from the stackiq lists, from OpenCatalogi and from Portaliq's **Software we use**. In stackiq, the quick filter **Archived** on the **Applications** and **Applications in use** pages lists the archive. On the **Applications** page the **Archived** tab lists -the whole archive: it ignores the GEMMA filters and the search box, because +the whole archive: it ignores the GEMMA filters and the search box (the page says so in a note under the search box while that tab is active), because their counts and matches describe the working set, which leaves archived applications out. An OpenCatalogi caller finds archived applications by adding `_archived=true` to its request. Contact persons are never archived diff --git a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md index b75a1fb11..76d347861 100644 --- a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md +++ b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md @@ -4,7 +4,7 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp ## Implementation Tasks -Status in this PR (the reconciliation slice, on top of the reader slice): Tasks 1-5 are implemented here (the reader's tasks 1-2 come from the base PR). Tasks 6-7 stay unticked: the UI lands in #1287, and the docs and the e2e run of task 7 are not done yet. +Status in this PR (the reconciliation slice, on top of the reader slice): Tasks 1-5 are implemented in #1286 (the reader's tasks 1-2 come from the base PR). Task 6 (the UI) is implemented in #1287; task 7 has its fixture, docs and e2e spec written there, and the Playwright run is the one thing not done yet. ### Task 1: Schema opt-in and the import profile - **spec_ref**: `SPEC#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017`, `SPEC#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012` @@ -71,9 +71,10 @@ Status in this PR (the reconciliation slice, on top of the reader slice): Tasks - GIVEN the section THEN a labelled choice "Applications missing from the export" offers Archive (default) and Keep with help text, and `buildImportForm()` sends the choice as `missingRecords` - GIVEN a report with the new outcomes THEN the summary shows their tiles, the outcome filter offers them, and a reconciliation row shows no row number - GIVEN the Applications and Applications in use pages THEN a quick filter "Archived" sends `_archived=true` + - GIVEN the Applications page with the "Archived" tab active THEN a note under the toolbar says the tab ignores the GEMMA filters and the search term, and it is gone on any other tab - Every new string is in `l10n/en.json` and `l10n/nl.json`, and the generated `l10n/*.js` are current -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 7: Fixture, docs and e2e - **spec_ref**: `SPEC#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015` @@ -82,7 +83,7 @@ Status in this PR (the reconciliation slice, on top of the reader slice): Tasks - GIVEN `build-fixtures.py` THEN it derives `topdesk-archived-applications.xlsx`: APPID 1234 on the archive sheet only, APPID 2 on no sheet, both CMDB sheets present and empty; the hygiene test passes on it - GIVEN the e2e THEN after the first import, importing the variant archives 1234 and soft-deletes 2, the Applications page lists 1234 under "Archived", and importing the original again reports 1234 `unarchived` and 2 `restored` with the object counts back to the first import's - GIVEN the docs THEN "Repeat imports" describes archive, soft delete, the archive sheet, the option and the return of an application, and the error table has `ARCHIVE_UNAVAILABLE` and the new meaning of `MISSING_RECORDS_UNSUPPORTED` -- [ ] Implement +- [x] Implement - [ ] Test - Status: PHPUnit covers the fixture round trip (testTheArchivedApplicationsFixtureArchivesDeletesAndBringsBack); the Playwright test is written and not run yet, because no Nextcloud was reachable from the worktree. From 49bc84b933964784cbeff11b1e8e93a4576a3d14 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 19:11:45 +0200 Subject: [PATCH 086/124] fix(cmdb-import): announce the mapping block's loading state, and tidy its tests - The loading line of the Mapping block is a status region, so a screen reader hears that the mapping is loading. - The block styles its help and code lines with its own classes instead of copies of the parent section's. - The e2e help check for the shipped sheet names runs only after the error card shows, so the loading state cannot satisfy it. - The first-run-wizard helper is shared from spec-coverage/_helpers.ts. - The e2e header says why nothing is written. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../settings/sections/CmdbImportMapping.vue | 13 ++--- tests/e2e/spec-coverage/_helpers.ts | 35 ++++++++++++++ .../spec-coverage/cmdb-import-mapping.spec.ts | 47 +++++-------------- tests/e2e/spec-coverage/cmdb-import.spec.ts | 36 +------------- 4 files changed, 54 insertions(+), 77 deletions(-) diff --git a/src/views/settings/sections/CmdbImportMapping.vue b/src/views/settings/sections/CmdbImportMapping.vue index 9140a58bc..b9c089b18 100644 --- a/src/views/settings/sections/CmdbImportMapping.vue +++ b/src/views/settings/sections/CmdbImportMapping.vue @@ -22,7 +22,7 @@ v-show="expanded" id="cmdb-import-mapping-content" class="cmdb-import-mapping__content"> -

+

{{ t( 'stackiq', @@ -34,6 +34,7 @@

{{ t('stackiq', 'Loading the mapping…') }} @@ -70,7 +71,7 @@ {{ t('stackiq', 'Edit in OpenRegister') }} -

+

{{ t( 'stackiq', @@ -183,7 +184,7 @@ data-testid="cmdb-import-mapping-edited" />

{{ t( @@ -225,7 +226,7 @@

+ class="cmdb-import-mapping__help"> {{ t( 'stackiq', @@ -425,13 +426,13 @@ export default { margin-top: 0.5rem; } -.cmdb-import__help { +.cmdb-import-mapping__help { margin: 0.25rem 0 0; font-size: 0.875rem; color: var(--color-text-maxcontrast); } -.cmdb-import__code { +.cmdb-import-mapping__code { font-size: 0.875rem; color: var(--color-text-maxcontrast); } diff --git a/tests/e2e/spec-coverage/_helpers.ts b/tests/e2e/spec-coverage/_helpers.ts index 97210bfe8..cb11d1140 100644 --- a/tests/e2e/spec-coverage/_helpers.ts +++ b/tests/e2e/spec-coverage/_helpers.ts @@ -285,3 +285,38 @@ export async function expectIndexSurface( const populated = main.getByText(/Showing\s+\d+\s+of\s+\d+/i).first() await expect(emptyState.or(populated)).toBeVisible({ timeout: 30000 }) } + +/** + * Get Nextcloud's own first-run wizard out of the way. + * + * It opens on an admin's first visit to a fresh instance, and its modal mask + * intercepts every click, so the chooser below would time out on a click + * that reads like a broken select. It has no close button and ignores + * Escape until its last slide, so it is marked as seen through its own + * route (what finishing it does) and the page is loaded again. A bounded + * wait, because the wizard mounts after the page. + * + * @param page The page + * @return True when the page was reloaded + */ +export async function dismissFirstRunWizard(page: Page): Promise { + const wizard = page.locator('.first-run-wizard[role="dialog"]') + try { + await wizard.waitFor({ state: 'visible', timeout: 3000 }) + } catch { + return false + } + await page.evaluate(async () => { + const oc = ( + window as unknown as { + OC: { requestToken: string; generateUrl: (u: string) => string } + } + ).OC + await fetch(oc.generateUrl('/apps/firstrunwizard/wizard'), { + method: 'DELETE', + headers: { requesttoken: oc.requestToken }, + }) + }) + await page.reload({ waitUntil: 'domcontentloaded' }) + return true +} diff --git a/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts b/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts index 05a66bd4d..cc8c2d3f8 100644 --- a/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts +++ b/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts @@ -6,20 +6,23 @@ * * Every scenario the delta spec tags * `@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts` is driven here - * on the real settings page: one line per pack with its source, its version - * and the link to its editor in OpenRegister, the link on the error of a - * broken stored pack, and the sheet names in the help text. The error states + * on the real settings page: the tables of the export and of the packs, each + * pack with its source, its version and the link to its editor in + * OpenRegister, the link on the error of a broken stored pack, and the sheet + * names in the help text. The error states * come from a route intercept, so no pack is broken on a shared instance. * The endpoint itself, the import and the 403 for a non-admin are driven * through the API in cmdb-import-packs.spec.ts. * - * Nothing is written. + * Nothing is written: the page reads the mapping, and reading it does not + * seed or update a pack (cmdb-import-packs.spec.ts checks that). */ import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' import { newApiContext, RUN_ID } from '../workflows/_fixtures.ts' +import { dismissFirstRunWizard } from './_helpers.ts' const MAPPING_PATH = '/index.php/apps/stackiq/api/settings/cmdb-import/mapping' // The page builds its URL with generateUrl(), which drops `/index.php` on an @@ -28,35 +31,6 @@ const MAPPING_ROUTE = '**/apps/stackiq/api/settings/cmdb-import/mapping' const SHEET_PACKS = ['module', 'manufacturer', 'usage', 'businessOwner'] const SHIPPED_SHEETS = ['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB'] -/** - * Close Nextcloud's first-run wizard when it shows, the way - * cmdb-import.spec.ts does: mark it as seen and load the page again. - * - * @param page The page - * @return True when the page was reloaded - */ -async function dismissFirstRunWizard(page: Page): Promise { - const wizard = page.locator('.first-run-wizard[role="dialog"]') - try { - await wizard.waitFor({ state: 'visible', timeout: 3000 }) - } catch { - return false - } - await page.evaluate(async () => { - const oc = ( - window as unknown as { - OC: { requestToken: string; generateUrl: (u: string) => string } - } - ).OC - await fetch(oc.generateUrl('/apps/firstrunwizard/wizard'), { - method: 'DELETE', - headers: { requesttoken: oc.requestToken }, - }) - }) - await page.reload({ waitUntil: 'domcontentloaded' }) - return true -} - /** * Open stackiq's admin settings and return the CMDB import section. * @@ -193,12 +167,13 @@ test.describe('CMDB import mapping block', () => { }), ) const section = await gotoCmdbSection(page) - for (const sheet of SHIPPED_SHEETS) { - await expect(help).toContainText(sheet) - } await section.locator('[data-testid="cmdb-import-mapping-toggle"]').click() const error = section.locator('[data-testid="cmdb-import-mapping-error"]') await expect(error).toBeVisible() + // Only now: the shipped names also show while the request is in flight. + for (const sheet of SHIPPED_SHEETS) { + await expect(help).toContainText(sheet) + } await expect(error).toContainText('MAPPING_UNAVAILABLE') await expect(error).toContainText('topdesk-usage.json: refused in this test') await expect( diff --git a/tests/e2e/spec-coverage/cmdb-import.spec.ts b/tests/e2e/spec-coverage/cmdb-import.spec.ts index 6251f2d8d..e59c7b928 100644 --- a/tests/e2e/spec-coverage/cmdb-import.spec.ts +++ b/tests/e2e/spec-coverage/cmdb-import.spec.ts @@ -45,6 +45,7 @@ import { resolveConfig, RUN_ID, } from '../workflows/_fixtures.ts' +import { dismissFirstRunWizard } from './_helpers.ts' const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/cmdb') const EXPORT_FIXTURE = path.join(FIXTURES_DIR, 'topdesk-export-anonymised.xlsx') @@ -161,41 +162,6 @@ async function countWritten(ctx: APIRequestContext): Promise<{ } } -/** - * Get Nextcloud's own first-run wizard out of the way. - * - * It opens on an admin's first visit to a fresh instance, and its modal mask - * intercepts every click, so the chooser below would time out on a click - * that reads like a broken select. It has no close button and ignores - * Escape until its last slide, so it is marked as seen through its own - * route (what finishing it does) and the page is loaded again. A bounded - * wait, because the wizard mounts after the page. - * - * @param page The page - * @return True when the page was reloaded - */ -async function dismissFirstRunWizard(page: Page): Promise { - const wizard = page.locator('.first-run-wizard[role="dialog"]') - try { - await wizard.waitFor({ state: 'visible', timeout: 3000 }) - } catch { - return false - } - await page.evaluate(async () => { - const oc = ( - window as unknown as { - OC: { requestToken: string; generateUrl: (u: string) => string } - } - ).OC - await fetch(oc.generateUrl('/apps/firstrunwizard/wizard'), { - method: 'DELETE', - headers: { requesttoken: oc.requestToken }, - }) - }) - await page.reload({ waitUntil: 'domcontentloaded' }) - return true -} - /** * Open stackiq's admin settings and return the CMDB import section. * From 1719b19ee8def2633ba9fc35e7fa4f8df10f2ad5 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 19:17:39 +0200 Subject: [PATCH 087/124] fix(applications): keep the Archived-tab note in step with the list's tab The view started with no known tab and only learned of a change from the tab's click, but it remounts CnIndexPage whenever the facet or search key changes, which puts the page back on its default tab; the note then showed over the default list. The view now starts from the tab the library starts on (first quick filter marked default, else the first) and returns to it whenever the remount key changes. Tests: the note follows the default tab at mount, and disappears when a search change remounts the list while the Archived tab is active; the remount test goes red without the reset. The e2e wait for a listed application is scoped to the Applications list container, so a row of another table cannot satisfy it. Co-Authored-By: Claude Opus 5.5 --- src/views/FacetedCatalogIndexView.vue | 29 ++++++++++++++++++- tests/e2e/spec-coverage/cmdb-import.spec.ts | 4 ++- .../vitest/facetedCatalogArchiveNote.spec.js | 29 +++++++++++++++++-- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue index cfb2737b2..6cb3f8aec 100644 --- a/src/views/FacetedCatalogIndexView.vue +++ b/src/views/FacetedCatalogIndexView.vue @@ -171,6 +171,21 @@ const DIMENSION_LABELS = { domain: () => t('stackiq', 'Domain'), } +/** + * The tab CnIndexPage starts on: the first quick filter marked `default`, + * else the first one (the library's own rule in `useSelfFetchList.js`). + * + * @param {Array|null} quickFilters The quick filters + * @return {number|null} The index, or null without quick filters + */ +function initialQuickFilterIndex(quickFilters) { + if (!Array.isArray(quickFilters) || quickFilters.length === 0) { + return null + } + const index = quickFilters.findIndex((tab) => tab && tab.default === true) + return index >= 0 ? index : 0 +} + /** Debounce delay (ms) between search keystrokes and the facets/list refetch. */ const SEARCH_DEBOUNCE_MS = 400 @@ -294,7 +309,7 @@ export default { showSaveViewModal: false, savingView: false, searchDebounceTimer: null, - activeQuickFilterIndex: null, + activeQuickFilterIndex: initialQuickFilterIndex(this.quickFilters), } }, @@ -383,6 +398,18 @@ export default { }, }, + watch: { + /** + * CnIndexPage is remounted when the key changes and starts on its + * default tab again, so the tab this view remembers goes back to it. + * + * @return {void} + */ + indexPageKey() { + this.activeQuickFilterIndex = initialQuickFilterIndex(this.quickFilters) + }, + }, + created() { this.facetStore.setFiltersFromQuery(this.schema, this.$route.query) }, diff --git a/tests/e2e/spec-coverage/cmdb-import.spec.ts b/tests/e2e/spec-coverage/cmdb-import.spec.ts index e86047e41..b10deda29 100644 --- a/tests/e2e/spec-coverage/cmdb-import.spec.ts +++ b/tests/e2e/spec-coverage/cmdb-import.spec.ts @@ -572,7 +572,9 @@ test.describe.serial('CMDB import section', () => { working.modules, 'the working set must still hold an application the list can show', ).toBeGreaterThan(0) - await expect(page.locator('tbody tr').first()).toBeVisible({ + await expect( + page.locator('.faceted-catalog-index__list tbody tr').first(), + ).toBeVisible({ timeout: 30000, }) await expect(page.getByText(ARCHIVED_APPLICATION_NAME)).toHaveCount(0) diff --git a/tests/vitest/facetedCatalogArchiveNote.spec.js b/tests/vitest/facetedCatalogArchiveNote.spec.js index 53f397737..23060dc1e 100644 --- a/tests/vitest/facetedCatalogArchiveNote.spec.js +++ b/tests/vitest/facetedCatalogArchiveNote.spec.js @@ -50,14 +50,14 @@ const QUICK_FILTERS = [ * * @return {object} The wrapper */ -function mountView() { +function mountView(quickFilters = QUICK_FILTERS) { setActivePinia(createPinia()) return mount(FacetedCatalogIndexView, { props: { schema: 'module', register: 'voorzieningen', title: 'Applications', - quickFilters: QUICK_FILTERS, + quickFilters, }, global: { mocks: { @@ -95,4 +95,29 @@ describe('FacetedCatalogIndexView on the Archived tab', () => { await index.vm.$emit('quick-filter-change', 0) expect(wrapper.find(NOTE).exists()).toBe(false) }) + + it('follows the default tab at mount, before any tab event', () => { + expect(mountView().find(NOTE).exists()).toBe(false) + + const archivedFirst = [ + { label: 'Archived', filter: { _archived: 'true', _ids: '' } }, + { label: 'All', filter: {} }, + ] + // No default marked: CnIndexPage starts on the first tab. + expect(mountView(archivedFirst).find(NOTE).exists()).toBe(true) + }) + + it('drops the note when a facet or search change remounts the list on its default tab', async () => { + const wrapper = mountView() + const index = wrapper.findComponent({ name: 'CnIndexPage' }) + await index.vm.$emit('quick-filter-change', 1) + expect(wrapper.find(NOTE).exists()).toBe(true) + + // A search term changes the key CnIndexPage is mounted under. + wrapper.vm.facetStore.module.search = 'mail' + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + expect(wrapper.find(NOTE).exists()).toBe(false) + }) }) From e2081bba37f35622a051ba91ce55dc288f517a5d Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 19:20:22 +0200 Subject: [PATCH 088/124] fix(cmdb-import): type the import's pack callback as never null resolve() always answers a pack, so the closure load() hands to loadPacks() returns array, not ?array (phpstan return.unusedType). Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/Service/Cmdb/CmdbImportProfile.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Service/Cmdb/CmdbImportProfile.php b/lib/Service/Cmdb/CmdbImportProfile.php index c945cedfe..f097ea7c6 100644 --- a/lib/Service/Cmdb/CmdbImportProfile.php +++ b/lib/Service/Cmdb/CmdbImportProfile.php @@ -200,7 +200,7 @@ public function __construct( * @spec openspec/changes/cmdb-import-mapping-view/tasks.md#task-1 */ public function load(): void { - $this->loadPacks(storedPack: fn (CmdbImportPackStore $store, array $shipped): ?array => $store->resolve(shipped: $shipped)); + $this->loadPacks(storedPack: fn (CmdbImportPackStore $store, array $shipped): array => $store->resolve(shipped: $shipped)); }//end load() /** From 4fd91944972ec373b69611dd0a82b4431d8cfbeb Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 20:42:08 +0200 Subject: [PATCH 089/124] docs(spec): cover the contact person page and the list Name column in REQ-UAP-005 Closes review finding f2: the two new name surfaces had no scenario and their @spec tags pointed at REQ-UAP-003 (usage owners). REQ-UAP-005 states who sees the name and the empty states; ContactNameCell is retagged. Class sweep: every @spec tag of ContactPersonName, contactNames.js (new export) and ContactNameCell now targets REQ-UAP-005 or REQ-UAP-003 by surface. Co-Authored-By: Claude Opus 5.5 --- .../specs/application-usage-pages/spec.md | 22 +++++++++++++++++++ src/components/cells/ContactNameCell.vue | 6 ++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/openspec/specs/application-usage-pages/spec.md b/openspec/specs/application-usage-pages/spec.md index e022b84f7..36dee9624 100644 --- a/openspec/specs/application-usage-pages/spec.md +++ b/openspec/specs/application-usage-pages/spec.md @@ -65,3 +65,25 @@ The usage schema SHALL declare its lifecycle on the status values its rows hold, - **GIVEN** a usage with status Planned - **WHEN** the information manager opens it and clicks Go live - **THEN** its status reads In production + +### Requirement: REQ-UAP-005 A contact person's name shows only to who can read the contact + +The contact person page and the Name column of an organisation's contact person list SHALL show the name of the linked Nextcloud contact only to a user who can read that contact in their own or shared address books, and SHALL say why there is no name when there is none. + +#### Scenario: A reader of the contact sees the name +@e2e exclude Needs a Contacts address book per user; tests/vitest/contactNames.spec.js asserts the name on the contact person page and in the list column. + +- **GIVEN** a contact person linked to a contact in the information manager's own address book +- **WHEN** the information manager opens the contact person, or the organisation it belongs to +- **THEN** the contact person page opens with the contact's name +- **AND** the Name column of the organisation's contact person list shows it + +#### Scenario: Without a name the page says why +@e2e exclude Needs a Contacts address book per user; tests/vitest/contactNames.spec.js asserts each empty state. + +- **GIVEN** a contact person whose name the signed-in user does not get +- **WHEN** the user opens the contact person page +- **THEN** a contact person without a contactsUid reads that it is not linked to a Nextcloud contact +- **AND** a contact in an address book the user cannot read reads that the name is kept in Nextcloud Contacts, in an address book the user cannot read +- **AND** a request that failed (for example rate limited) reads that the name could not be loaded, to reload the page +- **AND** the list column keeps the contact person's id in each of these cases diff --git a/src/components/cells/ContactNameCell.vue b/src/components/cells/ContactNameCell.vue index e8ef534dc..5bd2f487a 100644 --- a/src/components/cells/ContactNameCell.vue +++ b/src/components/cells/ContactNameCell.vue @@ -9,7 +9,7 @@ Nextcloud contact instead of its uuid. The name comes from the signed-in user's own address books (see `utils/contactNames.js`); when there is none, the uuid stays, as before. -@spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner +@spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-005-a-contact-persons-name-shows-only-to-who-can-read-the-contact --> @@ -37,7 +35,7 @@ import { CnIcon, CnWidgetWrapper } from '@conduction/nextcloud-vue' import { translate as t } from '@nextcloud/l10n' import { NcLoadingIcon } from '@nextcloud/vue' -import { contactName } from '../../utils/contactNames.js' +import { contactNameResult } from '../../utils/contactNames.js' /** * The name of a contact person, on its detail page. @@ -47,7 +45,7 @@ import { contactName } from '../../utils/contactNames.js' * signed-in user's own and shared address books, so the name is shown to whoever * can already read the contact, and to nobody else. * - * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner + * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-005-a-contact-persons-name-shows-only-to-who-can-read-the-contact */ export default { name: 'ContactPersonName', @@ -64,15 +62,49 @@ export default { type: String, required: true, }, + + /** The Nextcloud contact the person is linked to; empty when not linked. */ + contactsUid: { + type: String, + default: '', + }, }, data() { return { loading: true, name: null, + failed: false, } }, + computed: { + /** + * Why there is no name to show. + * + * @return {string} The hint. + * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-005-a-contact-persons-name-shows-only-to-who-can-read-the-contact + */ + hint() { + if (this.failed) { + return t( + 'stackiq', + 'The name could not be loaded. Reload the page to try again.', + ) + } + if (!this.contactsUid) { + return t( + 'stackiq', + 'This contact person is not linked to a Nextcloud contact.', + ) + } + return t( + 'stackiq', + 'The name is kept in Nextcloud Contacts, in an address book you cannot read.', + ) + }, + }, + watch: { objectId: { immediate: true, @@ -81,14 +113,16 @@ export default { * * @param {string} id The contact person uuid. * @return {Promise} - * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner + * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-005-a-contact-persons-name-shows-only-to-who-can-read-the-contact */ async handler(id) { this.loading = true this.name = null - const name = await contactName(id) + this.failed = false + const result = await contactNameResult(id) if (id === this.objectId) { - this.name = name + this.name = result.name + this.failed = result.failed this.loading = false } }, diff --git a/src/manifest.json b/src/manifest.json index ec9a337cf..a51036bfb 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -487,7 +487,7 @@ { "id": "2", "widgetId": "cp-related", "gridX": 8, "gridY": 0, "gridWidth": 4, "gridHeight": 4 } ], "bodyWidgets": [ - { "id": "cp-name", "component": "ContactPersonName", "props": { "objectId": "@objectId" }, "placement": "before-body", "colSpan": 12 } + { "id": "cp-name", "component": "ContactPersonName", "props": { "objectId": "@objectId", "contactsUid": "@object.contactsUid" }, "placement": "before-body", "colSpan": 12 } ], "sidebar": { "enabled": true, diff --git a/src/utils/contactNames.js b/src/utils/contactNames.js index ed9513e46..125e2880a 100644 --- a/src/utils/contactNames.js +++ b/src/utils/contactNames.js @@ -19,7 +19,7 @@ import { generateUrl } from '@nextcloud/router' /** The endpoint's own limit (`ContactNamesController::MAX_IDS`). */ export const MAX_IDS_PER_REQUEST = 100 -/** uuid -> Promise; null when no name could be read. */ +/** uuid -> Promise<{name: string|null, failed: boolean}>. */ const cache = new Map() /** uuid -> { resolve } for the ids of the batch being collected. */ @@ -74,23 +74,24 @@ async function flush() { if (failed) { cache.delete(id) } - batch.get(id).resolve(name !== '' ? name : null) + batch.get(id).resolve({ name: name !== '' ? name : null, failed }) }) } } /** - * The display name of a contact person, or null when the signed-in user - * cannot read one. + * What the endpoint made of a contact person: its name, and whether the + * request failed (rate limit, network, server error) as opposed to answering + * without a name. * * @param {string} id The contact person uuid. - * @return {Promise} The name. - * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner + * @return {Promise<{name: string|null, failed: boolean}>} The name, or why there is none. + * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-005-a-contact-persons-name-shows-only-to-who-can-read-the-contact */ -export function contactName(id) { +export function contactNameResult(id) { const key = String(id ?? '').toLowerCase() if (key === '') { - return Promise.resolve(null) + return Promise.resolve({ name: null, failed: false }) } if (!cache.has(key)) { cache.set( @@ -106,6 +107,18 @@ export function contactName(id) { return cache.get(key) } +/** + * The display name of a contact person, or null when the signed-in user + * cannot read one (or the request failed; see contactNameResult). + * + * @param {string} id The contact person uuid. + * @return {Promise} The name. + * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-003-a-usage-names-a-business-owner-and-a-technical-owner + */ +export async function contactName(id) { + return (await contactNameResult(id)).name +} + /** * Forget every name read so far (tests). * From 7ca89b332664bbbcb36306a1ce712188e21e36b2 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 20:42:08 +0200 Subject: [PATCH 091/124] test(contacts): cover the contact person empty states and the risk signals settings wait Closes review finding f3: ContactPersonName (name, not linked, no readable address book, failed request), contactNameResult, and UsageRiskSignals reading the settings before its first object read. Co-Authored-By: Claude Opus 5.5 --- tests/vitest/contactNames.spec.js | 17 +++++- tests/vitest/contactPersonName.spec.js | 79 +++++++++++++++++++++++++ tests/vitest/usageRiskSignals.spec.js | 80 ++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 tests/vitest/contactPersonName.spec.js create mode 100644 tests/vitest/usageRiskSignals.spec.js diff --git a/tests/vitest/contactNames.spec.js b/tests/vitest/contactNames.spec.js index e303e85db..7a1012844 100644 --- a/tests/vitest/contactNames.spec.js +++ b/tests/vitest/contactNames.spec.js @@ -19,8 +19,13 @@ const http = vi.hoisted(() => ({ get: vi.fn() })) vi.mock('@nextcloud/axios', () => ({ default: http })) vi.mock('@nextcloud/router', () => ({ generateUrl: (url) => url })) -const { clearContactNames, contactName, contactPersonId, MAX_IDS_PER_REQUEST } = - await import('../../src/utils/contactNames.js') +const { + clearContactNames, + contactName, + contactNameResult, + contactPersonId, + MAX_IDS_PER_REQUEST, +} = await import('../../src/utils/contactNames.js') const ContactNameCell = ( await import('../../src/components/cells/ContactNameCell.vue') ).default @@ -81,6 +86,14 @@ describe('contactNames', () => { expect(http.get).toHaveBeenCalledTimes(2) }) + it('tells a failed request from an answer without a name', async () => { + http.get.mockRejectedValueOnce(new Error('429')) + expect(await contactNameResult(A)).toEqual({ name: null, failed: true }) + + http.get.mockResolvedValueOnce({ data: { names: {} } }) + expect(await contactNameResult(B)).toEqual({ name: null, failed: false }) + }) + it('reads the uuid from a string or a related object', () => { expect(contactPersonId(` ${A} `)).toBe(A) expect(contactPersonId({ id: A })).toBe(A) diff --git a/tests/vitest/contactPersonName.spec.js b/tests/vitest/contactPersonName.spec.js new file mode 100644 index 000000000..b956421b6 --- /dev/null +++ b/tests/vitest/contactPersonName.spec.js @@ -0,0 +1,79 @@ +// @vitest-environment jsdom + +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The name on a contact person's page, and what it says when there is none: + * not linked, in an address book the user cannot read, or a failed request. + * + * @spec openspec/specs/application-usage-pages/spec.md#requirement-req-uap-005-a-contact-persons-name-shows-only-to-who-can-read-the-contact + */ + +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const http = vi.hoisted(() => ({ get: vi.fn() })) + +vi.mock('@nextcloud/axios', () => ({ default: http })) +vi.mock('@nextcloud/router', () => ({ generateUrl: (url) => url })) +vi.mock('@nextcloud/l10n', () => ({ translate: (app, text) => text })) +vi.mock('@nextcloud/vue', () => ({ + NcLoadingIcon: { template: '' }, +})) +vi.mock('@conduction/nextcloud-vue', () => ({ + CnIcon: { template: '' }, + CnWidgetWrapper: { template: '
' }, +})) + +const { clearContactNames } = await import('../../src/utils/contactNames.js') +const ContactPersonName = ( + await import('../../src/components/organisations/ContactPersonName.vue') +).default + +const A = '00000000-0000-0000-0000-00000000000a' +async function mountIt(props) { + const wrapper = mount(ContactPersonName, { props: { objectId: A, ...props } }) + await flushPromises() + return wrapper +} + +describe('ContactPersonName', () => { + beforeEach(() => { + clearContactNames() + http.get.mockReset() + }) + + it('shows the name', async () => { + http.get.mockResolvedValue({ data: { names: { [A]: 'Anna' } } }) + const wrapper = await mountIt({ contactsUid: 'uid-1' }) + expect(wrapper.text()).toContain('Anna') + expect( + wrapper.find('[data-testid="contact-person-name-hint"]').exists(), + ).toBe(false) + }) + + it('says the contact person is not linked when there is no contactsUid', async () => { + http.get.mockResolvedValue({ data: { names: {} } }) + const wrapper = await mountIt({}) + const hint = wrapper.get('[data-testid="contact-person-name-hint"]').text() + expect(hint).toBe( + 'This contact person is not linked to a Nextcloud contact.', + ) + }) + + it('names the address book only after an answer without a name', async () => { + http.get.mockResolvedValue({ data: { names: {} } }) + const wrapper = await mountIt({ contactsUid: 'uid-1' }) + const hint = wrapper.get('[data-testid="contact-person-name-hint"]').text() + expect(hint).toContain('in an address book you cannot read') + }) + + it('says the name could not be loaded when the request failed', async () => { + http.get.mockRejectedValue(new Error('429')) + const wrapper = await mountIt({ contactsUid: 'uid-1' }) + const hint = wrapper.get('[data-testid="contact-person-name-hint"]').text() + expect(hint).toContain('could not be loaded') + expect(hint).not.toContain('address book') + }) +}) diff --git a/tests/vitest/usageRiskSignals.spec.js b/tests/vitest/usageRiskSignals.spec.js new file mode 100644 index 000000000..6ba69e658 --- /dev/null +++ b/tests/vitest/usageRiskSignals.spec.js @@ -0,0 +1,80 @@ +// @vitest-environment jsdom + +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The risk signals widget, opened straight from a link: it reads the app + * settings first, so the object types resolve before the first read. + * + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-002-the-usage-page-shows-the-risk-signals-next-to-the-risk-score + */ + +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const order = vi.hoisted(() => []) +const store = vi.hoisted(() => ({ + settings: null, + objectTypeRegistry: {}, + fetchSettings: null, + registerObjectType: null, + getSchemaConfig: null, + fetchObject: null, + fetchCollectionForOptions: null, +})) + +vi.mock('../../src/store/store.js', () => ({ objectStore: store })) +vi.mock('@nextcloud/l10n', () => ({ + translate: (app, text) => text, + translatePlural: (app, singular) => singular, +})) +vi.mock('@nextcloud/vue', () => ({ + NcLoadingIcon: { template: '' }, + NcNoteCard: { template: '
' }, +})) +vi.mock('@conduction/nextcloud-vue', () => ({ + CnIcon: { template: '' }, + CnWidgetWrapper: { template: '
' }, +})) + +const UsageRiskSignals = ( + await import('../../src/components/portfolio/UsageRiskSignals.vue') +).default + +describe('UsageRiskSignals', () => { + beforeEach(() => { + order.length = 0 + store.settings = null + store.objectTypeRegistry = {} + store.fetchSettings = vi.fn(async () => { + order.push('fetchSettings') + store.settings = { loaded: true } + }) + store.getSchemaConfig = vi.fn(() => ({ register: 1, schema: 2 })) + store.registerObjectType = vi.fn() + store.fetchObject = vi.fn(async () => { + order.push('fetchObject') + return { riskScore: 3 } + }) + store.fetchCollectionForOptions = vi.fn(async () => []) + }) + + it('reads the settings before its first object read', async () => { + const wrapper = mount(UsageRiskSignals, { props: { objectId: 'u1' } }) + await flushPromises() + + expect(store.fetchSettings).toHaveBeenCalledTimes(1) + expect(order).toEqual(['fetchSettings', 'fetchObject']) + expect(wrapper.find('[data-testid="usage-risk-score"]').text()).toBe('3 / 5') + }) + + it('does not read the settings again when the app already has them', async () => { + store.settings = { loaded: true } + mount(UsageRiskSignals, { props: { objectId: 'u1' } }) + await flushPromises() + + expect(store.fetchSettings).not.toHaveBeenCalled() + expect(store.fetchObject).toHaveBeenCalled() + }) +}) From afd1c6fbf201e1e0eed8d34a4c16340743b6dc7d Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 20:45:23 +0200 Subject: [PATCH 092/124] fix(contacts): keep the spinner until the contact person is loaded CnDetailPage passes no object while it loads and CnBodySections drops the unresolved @object.contactsUid, so the 'not linked' hint flashed before the object arrived. The page now also takes @object.id (loadedId) and shows the spinner until it is there. Also names the right test files in the REQ-UAP-005 scenario exclude reasons. Co-Authored-By: Claude Opus 5.5 --- .../specs/application-usage-pages/spec.md | 4 ++-- .../organisations/ContactPersonName.vue | 22 ++++++++++++++++++- src/manifest.json | 2 +- tests/vitest/contactPersonName.spec.js | 18 ++++++++++++++- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/openspec/specs/application-usage-pages/spec.md b/openspec/specs/application-usage-pages/spec.md index 36dee9624..5e34fe5df 100644 --- a/openspec/specs/application-usage-pages/spec.md +++ b/openspec/specs/application-usage-pages/spec.md @@ -71,7 +71,7 @@ The usage schema SHALL declare its lifecycle on the status values its rows hold, The contact person page and the Name column of an organisation's contact person list SHALL show the name of the linked Nextcloud contact only to a user who can read that contact in their own or shared address books, and SHALL say why there is no name when there is none. #### Scenario: A reader of the contact sees the name -@e2e exclude Needs a Contacts address book per user; tests/vitest/contactNames.spec.js asserts the name on the contact person page and in the list column. +@e2e exclude Needs a Contacts address book per user; tests/vitest/contactPersonName.spec.js asserts the name on the contact person page and tests/vitest/contactNames.spec.js the list column cell. - **GIVEN** a contact person linked to a contact in the information manager's own address book - **WHEN** the information manager opens the contact person, or the organisation it belongs to @@ -79,7 +79,7 @@ The contact person page and the Name column of an organisation's contact person - **AND** the Name column of the organisation's contact person list shows it #### Scenario: Without a name the page says why -@e2e exclude Needs a Contacts address book per user; tests/vitest/contactNames.spec.js asserts each empty state. +@e2e exclude Needs a Contacts address book per user; tests/vitest/contactPersonName.spec.js asserts each empty state, and that none shows before the object has loaded; tests/vitest/contactNames.spec.js asserts a failed request apart from an empty answer. - **GIVEN** a contact person whose name the signed-in user does not get - **WHEN** the user opens the contact person page diff --git a/src/components/organisations/ContactPersonName.vue b/src/components/organisations/ContactPersonName.vue index ab7ead2c6..4170a25f9 100644 --- a/src/components/organisations/ContactPersonName.vue +++ b/src/components/organisations/ContactPersonName.vue @@ -13,7 +13,7 @@
@@ -503,7 +498,9 @@ import { interruptedImportError, isKnownError, makeCmdbOperationId, + mappingArchiveSheet, mappingSheetNames, + missingApplicationsText, MISSING_RECORDS_MODES, moduleUrl, municipalityOptions, @@ -611,6 +608,19 @@ export default { return mappingSheetNames(this.mapping) }, + /** + * The archive sheet, of which the import reads only the APPID column: + * the profile's once the mapping block has loaded it (null when the + * profile names none), the shipped name until then or when it could + * not be loaded. + * + * @return {string|null} The sheet name, or null + * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014 + */ + archiveSheet() { + return mappingArchiveSheet(this.mapping) + }, + /** * The help text under the file chooser, naming every sheet the import reads. * @@ -628,7 +638,18 @@ export default { * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014 */ sheetsReadInfo() { - return sheetsReadText(this.sheetNames) + return sheetsReadText(this.sheetNames, this.archiveSheet) + }, + + /** + * The info-panel paragraph on applications missing from the export, + * naming the profile's archive sheet. + * + * @return {string} The text + * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014 + */ + missingApplicationsInfo() { + return missingApplicationsText(this.archiveSheet) }, /** @@ -667,7 +688,7 @@ export default { if (!this.error) { return null } - const words = errorText(this.error, this.sheetNames) + const words = errorText(this.error, this.sheetNames, this.archiveSheet) return { ...words, code: this.error.error, diff --git a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php index 4541e38eb..9d7fc0c68 100644 --- a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php +++ b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php @@ -364,6 +364,7 @@ public function testTheOverviewShowsWhatTheImportRuns(): void { $this->assertFalse($overview['openRegisterPacks'], 'no OpenRegister pack store in this container'); $this->assertSame('topdesk-cmdb', $overview['profile']['id']); $this->assertSame($profile->sheets(), $overview['profile']['sheets']); + $this->assertSame('Gearchiveerde Applicaties', $overview['profile']['archiveSheet']); $this->assertSame(['Beheerde Applicaties CMDB', 'Onbeh Applicaties CMDB'], $overview['profile']['sheetPrecedence']); $this->assertSame($profile->keyColumn(), $overview['profile']['keyColumn']); $this->assertSame($profile->requiredColumns(), $overview['profile']['requiredColumns']); From 8e027d2c1914851abe5fdc57f381729b72f4d0cf Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 13:53:34 +0200 Subject: [PATCH 110/124] docs(openspec): give both changes the same REQ-CMDB-014 block cmdb-import-mapping-view and cmdb-import-archive-reconciliation each modified REQ-CMDB-014 in full, each without what the other adds, so archiving the second would have dropped the first one's text. Both now carry one block with the missing-applications choice, the four counts, the mapping block and the archive sheet from the profile, plus a note that a change to it goes into both. The payload list, design D4 and the tasks name the archive sheet. Co-Authored-By: Claude Opus 5.5 --- .../specs/cmdb-export-import/spec.md | 40 ++++++++++++++++++- .../cmdb-import-mapping-view/design.md | 2 +- .../specs/cmdb-export-import/spec.md | 23 +++++++++-- .../changes/cmdb-import-mapping-view/tasks.md | 1 + 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md index d6e445624..a3b5cd2e8 100644 --- a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md +++ b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md @@ -173,9 +173,9 @@ The import SHALL accept `missingRecords` with the values `keep` and `archive`; ` ### Requirement: The admin settings SHALL offer a CMDB import section (REQ-CMDB-014) -Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, choose what happens to applications missing from the export (archive, the default, or keep) with a one-sentence help text that says what each does and the details in the section's information panel, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary, including the archived, unarchived, deleted and restored counts when they are not zero, and a report table that can be filtered by outcome, including the four new ones; a reconciliation row SHALL show no row number. Every control SHALL have a visible label, and every string SHALL be translatable. +Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, choose what happens to applications missing from the export (archive, the default, or keep) with a one-sentence help text that says what each does and the details in the section's information panel, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary, including the archived, unarchived, deleted and restored counts when they are not zero, and a report table that can be filtered by outcome, including those four; a reconciliation row SHALL show no row number. The section SHALL also show the mapping the import uses (REQ-CMDB-020), as tables: the export's sheets and columns, and per sheet pack its source, its version, a hint when a newer shipped pack was not applied, and a link "Edit in OpenRegister" when the pack has an `editUrl`; the municipality pack, which maps the import options and no column, SHALL NOT be listed. When the endpoint answers 503 for a stored pack, the error SHALL carry the same link from `details.editUrl`. The section SHALL offer no editing of the mapping itself. The sheet names in its help text SHALL come from that mapping, every sheet it names and in its order, whatever their number, and so SHALL the archive sheet its texts name, with no archive sheet when the profile names none; the shipped names are the fallback until the mapping has loaded or when it cannot be loaded. Every control SHALL have a visible label, and every string SHALL be translatable. -(Previously: the section had no choice for missing records; the request always sent `keep`.) +(Previously: the section had no mapping block, and the sheet names in its help text were a constant in the page, which could drift from the profile on the server. This block also carries the missing-applications choice, the four counts and the reconciliation rows of cmdb-import-archive-reconciliation, which modifies REQ-CMDB-014 too (before it, the request always sent `keep`); both changes carry this same block, so archiving them in either order leaves the whole requirement.) #### Scenario: The admin runs an import from the settings page @e2e tests/e2e/spec-coverage/cmdb-import.spec.ts @@ -194,6 +194,41 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th - **THEN** the request SHALL carry `missingRecords=keep` - **AND** without a choice it SHALL carry `missingRecords=archive` +#### Scenario: The sheet names in the help come from the server +@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts + +- **GIVEN** a Nextcloud admin on stackiq's admin settings page +- **WHEN** the mapping endpoint has answered +- **THEN** the help text under the file control SHALL name the sheets the endpoint returned +- **AND** a profile with one, two or three sheets SHALL give a help text and an info text that name exactly those sheets, with no sheet the profile does not name and no description tied to a sheet's position +- **AND** when the endpoint fails, it SHALL name the shipped sheets and the mapping block SHALL show the error + +#### Scenario: The archive sheet in the help comes from the server +@e2e exclude Not driven in e2e yet: the texts are derived in the page from the endpoint's answer, and src/views/settings/sections/CmdbImport.spec.js emits mappings with the archive sheet "Archief" and with none and asserts the info text, the missing-applications paragraph and the too-large hint; the route intercept in tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts could drive it as well. + +- **GIVEN** the mapping endpoint answers `profile.archiveSheet` `Archief` +- **WHEN** the section renders its information panel and a too-large error +- **THEN** the info text, the paragraph on applications missing from the export and the hint SHALL name the sheet `Archief` +- **AND** when it answers `null`, none of them SHALL name an archive sheet, and the paragraph SHALL say that the import only archives + +#### Scenario: The section lists the mapping and links each pack to its editor +@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts + +- **GIVEN** a Nextcloud admin on stackiq's admin settings page +- **WHEN** the mapping block of the "CMDB import" section has loaded +- **THEN** a table of the export SHALL list the sheets read and the match, name, required and date columns, and a table of the packs SHALL show each of the four sheet packs with its source and version and a link "Edit in OpenRegister" to its `editUrl` +- **AND** the municipality pack, which maps the import options and no column, SHALL NOT be listed +- **AND** when the endpoint answers 503 for a stored pack, the error SHALL show a link "Edit in OpenRegister" to its `details.editUrl` +- **AND** the block SHALL hold no input field + +#### Scenario: An edited pack whose newer shipped version was not applied says so +@e2e exclude Needs a stackiq release that ships a newer pack than the one installed; tests/vitest/cmdbImportMapping.spec.js mounts the block with an edited usage pack whose `newerShipped` is true and asserts the hint, and its absence for the unedited module pack. + +- **GIVEN** the mapping endpoint answers an edited usage pack with `newerShipped` `true` and `shippedVersion` `1.1.0` +- **WHEN** the mapping block renders +- **THEN** the usage row SHALL say that stackiq ships version `1.1.0`, that it was not applied because the pack was edited, and how to use the shipped version +- **AND** a row whose `newerShipped` is `false` SHALL show no such hint + ## Non-Functional Requirements - **Performance:** the reconciliation SHALL page the municipality's usages with the import's page size and load their modules in one search per page, so an export of 1,100 rows adds a handful of searches, and SHALL make no write for an application that is already in the state the export asks for. @@ -214,3 +249,4 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th - The decision table per application is in the change's [design.md](../../design.md), D5. - The library does not render `@self.archived` on detail pages yet; a follow-up for `@conduction/nextcloud-vue`. +- REQ-CMDB-014 is modified by this change and by cmdb-import-mapping-view; both carry the same block, which holds what each adds, so archiving them in either order leaves the whole requirement. A change to that block goes into both. diff --git a/openspec/changes/cmdb-import-mapping-view/design.md b/openspec/changes/cmdb-import-mapping-view/design.md index dfe9e40ab..f5cab267f 100644 --- a/openspec/changes/cmdb-import-mapping-view/design.md +++ b/openspec/changes/cmdb-import-mapping-view/design.md @@ -54,7 +54,7 @@ The method carries no auth attribute (Nextcloud's default: admins with a CSRF to ### D4. The section takes the sheet names from the endpoint, with the constant as fallback -Unchanged from the first version: the help text computes the sheet names from the endpoint's answer, or from `PROFILE_DEFAULTS.sheets` until it arrives or when it fails. The help text, the info text and the missing-sheet and too-large hints name every sheet the profile lists, whatever their number: they are built from the list (`quotedSheetList`), not from its first two entries, and say nothing that depends on a sheet's position. +Unchanged from the first version: the help text computes the sheet names from the endpoint's answer, or from `PROFILE_DEFAULTS.sheets` until it arrives or when it fails. The help text, the info text and the missing-sheet and too-large hints name every sheet the profile lists, whatever their number: they are built from the list (`quotedSheetList`), not from its first two entries, and say nothing that depends on a sheet's position. The archive sheet those texts name comes from the endpoint's `profile.archiveSheet` the same way, with `PROFILE_DEFAULTS.archiveSheet` as the fallback; when the profile names none, the texts leave it out and the information panel says the import only archives. ### D5. The packs live in OpenRegister's migration-pack store, found by slug diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md index 527c2802c..2d8414c5c 100644 --- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md +++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md @@ -18,7 +18,7 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages), `OCP\IURLGenerator` (the `GET /api/settings/cmdb-import/mapping` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token; like the import routes (REQ-CMDB-001) it SHALL NOT carry `#[AuthorizedAdminSetting]`, `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL load the import profile and every pack through the same loader, store and validator the import uses (REQ-CMDB-021), and SHALL answer 200 with a flat envelope of three keys: -- `profile`: the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns` (the columns the import converts from Excel dates, REQ-CMDB-005), `idColumns`, `emptyValues`, `externalKeyPrefix`, `missingRecords` and `profileFile`; +- `profile`: the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `archiveSheet` (the sheet of which the import reads only the APPID column, REQ-CMDB-015, or null when the profile names none or names one of its source sheets), `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns` (the columns the import converts from Excel dates, REQ-CMDB-005), `idColumns`, `emptyValues`, `externalKeyPrefix`, `missingRecords` and `profileFile`; - `packs`: one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings`, the write rules `defaults`, `createOnlyDefaults`, `createOnlyFields` and `neverWritten`, and where the pack comes from: `source` (`openregister` or `file`), `packId` (the OpenRegister row id, or null), `storedVersion` (or null), `shippedVersion`, `edited` (whether the stored pack differs from what stackiq seeded; null when unknown or for a file), `newerShipped` (an edited pack kept a newer shipped pack out), `editable` (false for the municipality pack), `updatePending` (an unedited stored pack that the next import run replaces with the shipped one; then `version`, `fieldMappings`, `defaults` and the other pack fields show the shipped pack and `storedVersion` what OpenRegister holds) and `editUrl` (the absolute link to the pack's editor in OpenRegister, built by Nextcloud's URL generator so an instance that needs `/index.php` gets it, or null when the pack is not stored there or the link cannot be built); - `openRegisterPacks`: whether OpenRegister's migration-pack store is available. @@ -154,9 +154,9 @@ The service SHALL map each normalised row with OpenRegister's `MigrationPack\Map ### Requirement: The admin settings SHALL offer a CMDB import section (REQ-CMDB-014) -Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary and a report table that can be filtered by outcome. The section SHALL also show the mapping the import uses (REQ-CMDB-020), as tables: the export's sheets and columns, and per sheet pack its source, its version, a hint when a newer shipped pack was not applied, and a link "Edit in OpenRegister" when the pack has an `editUrl`; the municipality pack, which maps the import options and no column, SHALL NOT be listed. When the endpoint answers 503 for a stored pack, the error SHALL carry the same link from `details.editUrl`. The section SHALL offer no editing of the mapping itself. The sheet names in its help text SHALL come from that mapping, every sheet it names and in its order, whatever their number, with the shipped names as the fallback until the mapping has loaded or when it cannot be loaded. Every control SHALL have a visible label, and every string SHALL be translatable. +Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, choose what happens to applications missing from the export (archive, the default, or keep) with a one-sentence help text that says what each does and the details in the section's information panel, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary, including the archived, unarchived, deleted and restored counts when they are not zero, and a report table that can be filtered by outcome, including those four; a reconciliation row SHALL show no row number. The section SHALL also show the mapping the import uses (REQ-CMDB-020), as tables: the export's sheets and columns, and per sheet pack its source, its version, a hint when a newer shipped pack was not applied, and a link "Edit in OpenRegister" when the pack has an `editUrl`; the municipality pack, which maps the import options and no column, SHALL NOT be listed. When the endpoint answers 503 for a stored pack, the error SHALL carry the same link from `details.editUrl`. The section SHALL offer no editing of the mapping itself. The sheet names in its help text SHALL come from that mapping, every sheet it names and in its order, whatever their number, and so SHALL the archive sheet its texts name, with no archive sheet when the profile names none; the shipped names are the fallback until the mapping has loaded or when it cannot be loaded. Every control SHALL have a visible label, and every string SHALL be translatable. -(Previously: the section had no mapping block, and the sheet names in its help text were a constant in the page, which could drift from the profile on the server.) +(Previously: the section had no mapping block, and the sheet names in its help text were a constant in the page, which could drift from the profile on the server. This block also carries the missing-applications choice, the four counts and the reconciliation rows of cmdb-import-archive-reconciliation, which modifies REQ-CMDB-014 too (before it, the request always sent `keep`); both changes carry this same block, so archiving them in either order leaves the whole requirement.) #### Scenario: The admin runs an import from the settings page @e2e tests/e2e/spec-coverage/cmdb-import.spec.ts @@ -167,6 +167,14 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th - **AND** afterwards the summary and the report table SHALL be shown - **AND** filtering the table on `created` SHALL show the two imported rows +#### Scenario: The admin chooses to keep missing applications +@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts + +- **GIVEN** the section +- **WHEN** the admin selects "Keep them as they are" and starts the import +- **THEN** the request SHALL carry `missingRecords=keep` +- **AND** without a choice it SHALL carry `missingRecords=archive` + #### Scenario: The sheet names in the help come from the server @e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts @@ -176,6 +184,14 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th - **AND** a profile with one, two or three sheets SHALL give a help text and an info text that name exactly those sheets, with no sheet the profile does not name and no description tied to a sheet's position - **AND** when the endpoint fails, it SHALL name the shipped sheets and the mapping block SHALL show the error +#### Scenario: The archive sheet in the help comes from the server +@e2e exclude Not driven in e2e yet: the texts are derived in the page from the endpoint's answer, and src/views/settings/sections/CmdbImport.spec.js emits mappings with the archive sheet "Archief" and with none and asserts the info text, the missing-applications paragraph and the too-large hint; the route intercept in tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts could drive it as well. + +- **GIVEN** the mapping endpoint answers `profile.archiveSheet` `Archief` +- **WHEN** the section renders its information panel and a too-large error +- **THEN** the info text, the paragraph on applications missing from the export and the hint SHALL name the sheet `Archief` +- **AND** when it answers `null`, none of them SHALL name an archive sheet, and the paragraph SHALL say that the import only archives + #### Scenario: The section lists the mapping and links each pack to its editor @e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts @@ -217,3 +233,4 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th - One mapping per instance: OpenRegister's pack store has no organisation scope. An instance that imports for more than one municipality shares the mapping between them. - `MigrationPackService`, `PackDefinitionValidator` and `MappingEngine` are not part of OpenRegister's `Contract` namespace; stackiq resolves them defensively. - Deleting a stored pack means "back to the shipped pack": the next import or upgrade seeds it again. +- REQ-CMDB-014 is modified by this change and by cmdb-import-archive-reconciliation; both carry the same block, which holds what each adds, so archiving them in either order leaves the whole requirement. A change to that block goes into both. diff --git a/openspec/changes/cmdb-import-mapping-view/tasks.md b/openspec/changes/cmdb-import-mapping-view/tasks.md index f5cb4abf1..f0988cb0a 100644 --- a/openspec/changes/cmdb-import-mapping-view/tasks.md +++ b/openspec/changes/cmdb-import-mapping-view/tasks.md @@ -50,6 +50,7 @@ Spec: `openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.m - GIVEN a 503 THEN the block shows the error, the code and the reason, and for a stored pack an "Edit in OpenRegister" link to `details.editUrl` - GIVEN the answer THEN the help text names the endpoint's sheets; before and on failure `PROFILE_DEFAULTS.sheets` - GIVEN a profile with one, two or three sheets WHEN the section renders its help text and its info text THEN both name exactly those sheets in the profile's order + - GIVEN the answer names an archive sheet, or none THEN the info text, the missing-applications paragraph and the too-large hint name that sheet, or no archive sheet - [x] Implement - [x] Test From 727793f0c07047f6ec0390b68ad4e9b449533551 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 15 Oct 2026 13:56:56 +0200 Subject: [PATCH 111/124] style(cmdb-import): sort the named imports of the CMDB import section perfectionist/sort-named-imports wants MISSING_RECORDS_MODES before missingApplicationsText. Co-Authored-By: Claude Opus 5.5 --- src/views/settings/sections/CmdbImport.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue index a972e2160..b552150fd 100644 --- a/src/views/settings/sections/CmdbImport.vue +++ b/src/views/settings/sections/CmdbImport.vue @@ -500,8 +500,8 @@ import { makeCmdbOperationId, mappingArchiveSheet, mappingSheetNames, - missingApplicationsText, MISSING_RECORDS_MODES, + missingApplicationsText, moduleUrl, municipalityOptions, normaliseError, From af446eb8cc46917bc7151b9e7fb7ce4f58748013 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:18:28 +0200 Subject: [PATCH 112/124] chore(deps-dev): bump conduction/hydra-gates from 1.16.1 to 1.18.0 (#1039) Bumps [conduction/hydra-gates](https://github.com/ConductionNL/.github) from 1.16.1 to 1.18.0. - [Release notes](https://github.com/ConductionNL/.github/releases) - [Commits](https://github.com/ConductionNL/.github/compare/v1.16.1...v1.18.0) --- updated-dependencies: - dependency-name: conduction/hydra-gates dependency-version: 1.18.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 0ad6814c4..469b12f75 100644 --- a/composer.lock +++ b/composer.lock @@ -2792,16 +2792,16 @@ }, { "name": "conduction/hydra-gates", - "version": "v1.16.1", + "version": "v1.18.0", "source": { "type": "git", "url": "https://github.com/ConductionNL/.github.git", - "reference": "bfb34cc6caa9762f6aa3da66f9a2b573f8442358" + "reference": "477f930e84f1b3e709d2fb645d7c303d8d39a994" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ConductionNL/.github/zipball/bfb34cc6caa9762f6aa3da66f9a2b573f8442358", - "reference": "bfb34cc6caa9762f6aa3da66f9a2b573f8442358", + "url": "https://api.github.com/repos/ConductionNL/.github/zipball/477f930e84f1b3e709d2fb645d7c303d8d39a994", + "reference": "477f930e84f1b3e709d2fb645d7c303d8d39a994", "shasum": "" }, "require": { @@ -2840,9 +2840,9 @@ "support": { "docs": "https://github.com/ConductionNL/.github/blob/main/hydra-gates/README.md", "issues": "https://github.com/ConductionNL/.github/issues", - "source": "https://github.com/ConductionNL/.github/tree/v1.16.1" + "source": "https://github.com/ConductionNL/.github/tree/v1.18.0" }, - "time": "2026-09-07T08:01:54+00:00" + "time": "2026-09-10T10:15:53+00:00" }, { "name": "consolidation/annotated-command", From 4800637a0df347fd6bd5bac8808c591f822059da Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Fri, 16 Oct 2026 13:41:47 +0200 Subject: [PATCH 113/124] fix(cmdb-import): a workbook without one of the CMDB sheets archives instead of deleting The reader refused a workbook only when none of the profile's source sheets was present, and the reconciliation treated the workbook as authoritative for a deletion whenever the archive sheet was readable. A workbook that had "Onbeh Applicaties CMDB" but not "Beheerde Applicaties CMDB" (a renamed sheet, a partial export) therefore soft-deleted every application an earlier import took from the missing sheet, on a plain re-import with the default missingRecords=archive, and the report gave no reason. The reader now returns the expected source sheets the workbook lacks (absentSheets). An import with such a sheet is not authoritative for a deletion, like one with an unreadable key: what is on no sheet is archived, never deleted, and the report warns that the sheet was not found. The run caches for unreadable keys and absent sheets are cleared with the others. Fixes #1304 Co-Authored-By: Claude Opus 5.5 --- docs/features/cmdb-import.md | 2 +- lib/Service/Cmdb/CmdbWorkbookReader.php | 14 ++++--- lib/Service/CmdbExportImportService.php | 32 +++++++++++---- .../specs/cmdb-export-import/spec.md | 10 ++++- .../Service/Cmdb/CmdbWorkbookReaderTest.php | 35 ++++++++++++++++ .../Service/CmdbExportImportServiceTest.php | 41 +++++++++++++++++-- 6 files changed, 117 insertions(+), 17 deletions(-) diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md index b54fad71e..d1e67fe93 100644 --- a/docs/features/cmdb-import.md +++ b/docs/features/cmdb-import.md @@ -281,7 +281,7 @@ APPID is on neither CMDB sheet: |---|---| | on the sheet `Gearchiveerde Applicaties` | are **archived**: OpenRegister's archive state, with the reason `cmdb-import: on sheet "Gearchiveerde Applicaties"`. Reported as *archived*. | | on no sheet at all | are **deleted**: moved to OpenRegister's trash, where they stay restorable for 30 days. Reported as *deleted*. | -| on no sheet, but the archive sheet cannot be relied on: the workbook has no sheet `Gearchiveerde Applicaties`, the sheet has no `APPID` column, or an `APPID` in it has no cached value (a formula whose result the file does not store), or an `APPID` on one of the two CMDB sheets has no cached value (the import cannot tell which application that row stands for) | are **archived**, never deleted. Each case has its own warning in the result: *sheet not found*, *no column APPID*, or *an APPID without a cached value in row …* (for more rows: *in N rows (…)*, naming the first ten; for a CMDB sheet the warning names that sheet). Such a workbook therefore never moves anything to the trash. | +| on no sheet, but the archive sheet cannot be relied on: the workbook has no sheet `Gearchiveerde Applicaties`, the sheet has no `APPID` column, or an `APPID` in it has no cached value (a formula whose result the file does not store), or an `APPID` on one of the two CMDB sheets has no cached value (the import cannot tell which application that row stands for), or the workbook has only one of the two CMDB sheets (the applications of the other may be among those missing) | are **archived**, never deleted. Each case has its own warning in the result: *sheet not found* (for the archive sheet or the missing CMDB sheet), *no column APPID*, or *an APPID without a cached value in row …* (for more rows: *in N rows (…)*, naming the first ten; for a CMDB sheet the warning names that sheet). Such a workbook therefore never moves anything to the trash. | An archived or deleted application disappears from the stackiq lists, from OpenCatalogi and from Portaliq's **Software we use**. In stackiq, the quick diff --git a/lib/Service/Cmdb/CmdbWorkbookReader.php b/lib/Service/Cmdb/CmdbWorkbookReader.php index 4b80393b8..674179925 100644 --- a/lib/Service/Cmdb/CmdbWorkbookReader.php +++ b/lib/Service/Cmdb/CmdbWorkbookReader.php @@ -179,7 +179,8 @@ public function isAvailable(): bool { * ({sheet, present, keyColumnMissing, appIds, uncachedRows}) and `uncachedKeyRows` * (source sheet => numbers of the rows dropped as empty because their key is a * formula without a cached value; a row with other content is in `rows`, its - * `uncached` lists the key). + * `uncached` lists the key) and `absentSheets` (the profile's source sheets the + * workbook does not have, in profile order). * * @throws CmdbImportException WORKBOOK_TOO_LARGE, READER_UNAVAILABLE, NOT_XLSX, NO_SOURCE_SHEET, * MISSING_COLUMN or TOO_MANY_ROWS. @@ -331,7 +332,7 @@ private function readBounded(string $path, CmdbImportProfile $profile, object $s scanner: $scanner ); - ['present' => $present, 'available' => $available] = $this->sourceSheets(path: $path, profile: $profile); + ['present' => $present, 'absent' => $absent, 'available' => $available] = $this->sourceSheets(path: $path, profile: $profile); $archiveSheet = $profile->archiveSheetName(); $loaded = $present; @@ -406,16 +407,19 @@ private function readBounded(string $path, CmdbImportProfile $profile, object $s 'date1904' => $date1904, 'archive' => $archive, 'uncachedKeyRows' => $uncachedKeyRows, + 'absentSheets' => $absent, ]; }//end readBounded() /** - * The profile's source sheets that the workbook has, and every sheet it has. + * The profile's source sheets that the workbook has, the ones it lacks, and every sheet it has. + * + * A workbook with only some of the source sheets is read; the import must not take it as the whole CMDB. * * @param string $path The xlsx file. * @param CmdbImportProfile $profile The import profile. * - * @return array{present: array, available: array} + * @return array{present: array, absent: array, available: array} * * @throws CmdbImportException NOT_XLSX when the sheet list cannot be read, NO_SOURCE_SHEET when no source sheet is there. */ @@ -441,7 +445,7 @@ private function sourceSheets(string $path, CmdbImportProfile $profile): array { ); } - return ['present' => $present, 'available' => $available]; + return ['present' => $present, 'absent' => array_values(array_diff($expected, $available)), 'available' => $available]; }//end sourceSheets() /** diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php index a96f5f2ca..d9d037cfb 100644 --- a/lib/Service/CmdbExportImportService.php +++ b/lib/Service/CmdbExportImportService.php @@ -299,6 +299,16 @@ class CmdbExportImportService { */ private array $unreadableKeyRows = []; + /** + * The profile's source sheets this run's workbook does not have. + * + * Applications an earlier import took from such a sheet are missing from + * `winningSheets`, so a run with one is not authoritative for a deletion. + * + * @var array + */ + private array $absentSheets = []; + /** * OpenRegister's archive handler and object mapper, once resolved. * @@ -553,6 +563,7 @@ private function runImport(string $path, array $options, string $startedAt): arr $report->addImportWarnings(warnings: $this->translateImportWarnings(warnings: $workbook['importWarnings'])); $archive = $this->archiveSheet(workbook: $workbook); $this->unreadableKeyRows = $this->unreadableKeyRows(rows: $rows, droppedRows: (array)($workbook['uncachedKeyRows'] ?? [])); + $this->absentSheets = array_values(array_map('strval', (array)($workbook['absentSheets'] ?? []))); $this->addRunWarnings(report: $report, municipality: $municipality, archive: $archive, missingRecords: $missingRecords); $this->progressTracker->startOperation( @@ -632,8 +643,8 @@ private function runImport(string $path, array $options, string $startedAt): arr * Add the run's own warnings. * * A mapping that fell back to the packs shipped with stackiq, an archive sheet that is missing, - * has no key column or has unreadable keys, a CMDB sheet with unreadable keys, and a created - * municipality. + * has no key column or has unreadable keys, a CMDB sheet that is missing or has unreadable keys, + * and a created municipality. * * @param CmdbImportReport $report The report. * @param array{uuid: string, name: string, created: bool} $municipality The consumer, as resolveMunicipality() found it. @@ -663,6 +674,11 @@ private function addRunWarnings(CmdbImportReport $report, array $municipality, a } if ($missingRecords === 'archive') { + foreach ($this->absentSheets as $sheet) { + $absent = ['sheet' => $sheet, 'keyColumnMissing' => false, 'uncachedRows' => []]; + $report->addImportWarnings(warnings: [['sheet' => $sheet, 'message' => $this->archiveSheetWarning(archive: $absent)]]); + } + foreach ($this->unreadableKeyRows as $sheet => $rowNumbers) { $cmdbSheet = ['sheet' => $sheet, 'keyColumnMissing' => false, 'uncachedRows' => $rowNumbers]; $report->addImportWarnings(warnings: [['sheet' => $sheet, 'message' => $this->archiveSheetWarning(archive: $cmdbSheet)]]); @@ -2656,8 +2672,8 @@ private function reconcileApplication(array $application, array $archive, string * * Listed on the archive sheet: archived, with that sheet as the reason. * Otherwise soft-deleted, but only when the workbook is authoritative for - * a deletion: it has the archive sheet and no sheet has a row whose key - * could not be read. In every other case it is archived. + * a deletion: it has the archive sheet and every source sheet, and no sheet + * has a row whose key could not be read. In every other case it is archived. * * @param string $key The application key. * @param ArchiveSheet $archive The archive sheet. @@ -2678,9 +2694,9 @@ private function reconcileTarget(string $key, array $archive, string $operationI } $target = CmdbImportReport::ARCHIVED; - if ($archive['present'] === true && $archive['uncachedRows'] === [] && $this->unreadableKeyRows === []) { - // A sheet with an unreadable key (the archive sheet or a CMDB sheet) may list this application, so the - // workbook is not authoritative for a deletion. + if ($archive['present'] === true && $archive['uncachedRows'] === [] && $this->unreadableKeyRows === [] && $this->absentSheets === []) { + // A sheet with an unreadable key (the archive sheet or a CMDB sheet), or a source sheet the workbook + // lacks, may list this application, so the workbook is not authoritative for a deletion. $target = CmdbImportReport::DELETED; } @@ -3023,6 +3039,8 @@ private function resetRun(): void { $this->suppliers = null; $this->seenKeys = []; $this->winningSheets = []; + $this->unreadableKeyRows = []; + $this->absentSheets = []; $this->contactUids = []; $this->contactPersons = []; $this->archiveServices = null; diff --git a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md index a3b5cd2e8..7e941afb2 100644 --- a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md +++ b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md @@ -16,7 +16,7 @@ OpenRegister: `OCA\OpenRegister\Contract\ObjectServiceInterface` (`searchObjects ### Requirement: An application missing from the CMDB sheets SHALL be archived when the archive sheet lists it and soft-deleted when no sheet does (REQ-CMDB-015) -With `missingRecords` `archive`, after every row of an import that was not cancelled and did not fail, the import SHALL reconcile, under the register lock it already holds, every `usage` whose `consumer` is the imported municipality together with the `module` it points at, including archived and soft-deleted ones, and only those whose module carries this municipality's import key (`topdesk::`). It SHALL read the APPID column of the sheet "Gearchiveerde Applicaties" when the workbook has it, under the same size and row bounds as the CMDB sheets and without reading any other column of it. For an application whose APPID is on neither CMDB sheet: when the archive sheet lists the APPID, the import SHALL archive the usage and then the module through OpenRegister's archive state with a reason that names the import, restoring a soft-deleted one from the trash first; when no sheet lists it, the import SHALL soft-delete the usage and then the module through OpenRegister's trash. When the workbook has no archive sheet, or the sheet has no APPID column, or a row of the sheet holds an APPID formula without a cached value (read as empty, as REQ-CMDB-002 has it for the CMDB sheets, and listed by the reader in `archive.uncachedRows`), the import SHALL archive and SHALL NOT soft-delete, and SHALL add a warning to the report; for an uncached row the warning SHALL name the sheet and the row numbers. The same holds when a row of a CMDB sheet holds an APPID formula without a cached value (the row is skipped as empty, so its application may look absent): the import SHALL archive and SHALL NOT soft-delete, and the warning SHALL name that CMDB sheet and the rows. When a municipality has more than one module or usage under the same import key (a module deleted by hand next to the one a later import created), the import SHALL reconcile as the application the one it would match, a working one before an archived one and an archived one before one in the trash, and SHALL bring every other usage of that key, and every other module of it that is not in the trash, to the same state, so no stray usage stays visible; it SHALL leave what is in the trash as it is, so a stale duplicate is never restored into the archive. An application that is already in the state the export asks for SHALL be left alone and not reported. The import SHALL NOT touch contact persons, organisations, modules that carry another municipality's import key or no import key, or usages of another organisation. Each application SHALL be reconciled in its own error boundary: a failure SHALL be a `failed` report row naming the step and the exception class, and the other applications SHALL still be reconciled. The reconciliation SHALL report its progress as the phase `reconciling` of the import's operation. A cancel SHALL also be honoured after the last row and before each application of the reconciliation: the import SHALL stop there, SHALL keep what it archived or deleted so far, and SHALL report the run as cancelled. The report SHALL count `archived` and `deleted` in its summary and SHALL list each as a row with the APPID, the application name and both uuids, never person data. When OpenRegister's archive handler or object mapper is not available, an import with `missingRecords` `archive` SHALL be refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read. +With `missingRecords` `archive`, after every row of an import that was not cancelled and did not fail, the import SHALL reconcile, under the register lock it already holds, every `usage` whose `consumer` is the imported municipality together with the `module` it points at, including archived and soft-deleted ones, and only those whose module carries this municipality's import key (`topdesk::`). It SHALL read the APPID column of the sheet "Gearchiveerde Applicaties" when the workbook has it, under the same size and row bounds as the CMDB sheets and without reading any other column of it. For an application whose APPID is on neither CMDB sheet: when the archive sheet lists the APPID, the import SHALL archive the usage and then the module through OpenRegister's archive state with a reason that names the import, restoring a soft-deleted one from the trash first; when no sheet lists it, the import SHALL soft-delete the usage and then the module through OpenRegister's trash. When the workbook has no archive sheet, or the sheet has no APPID column, or a row of the sheet holds an APPID formula without a cached value (read as empty, as REQ-CMDB-002 has it for the CMDB sheets, and listed by the reader in `archive.uncachedRows`), the import SHALL archive and SHALL NOT soft-delete, and SHALL add a warning to the report; for an uncached row the warning SHALL name the sheet and the row numbers. The same holds when a row of a CMDB sheet holds an APPID formula without a cached value (the row is skipped as empty, so its application may look absent): the import SHALL archive and SHALL NOT soft-delete, and the warning SHALL name that CMDB sheet and the rows. The same holds when the workbook lacks one of the profile's CMDB sheets while it has another (the reader lists the absent ones in `absentSheets`; a workbook with none of them is refused with `NO_SOURCE_SHEET`): the import SHALL archive and SHALL NOT soft-delete, and the warning SHALL name the missing sheet. When a municipality has more than one module or usage under the same import key (a module deleted by hand next to the one a later import created), the import SHALL reconcile as the application the one it would match, a working one before an archived one and an archived one before one in the trash, and SHALL bring every other usage of that key, and every other module of it that is not in the trash, to the same state, so no stray usage stays visible; it SHALL leave what is in the trash as it is, so a stale duplicate is never restored into the archive. An application that is already in the state the export asks for SHALL be left alone and not reported. The import SHALL NOT touch contact persons, organisations, modules that carry another municipality's import key or no import key, or usages of another organisation. Each application SHALL be reconciled in its own error boundary: a failure SHALL be a `failed` report row naming the step and the exception class, and the other applications SHALL still be reconciled. The reconciliation SHALL report its progress as the phase `reconciling` of the import's operation. A cancel SHALL also be honoured after the last row and before each application of the reconciliation: the import SHALL stop there, SHALL keep what it archived or deleted so far, and SHALL report the run as cancelled. The report SHALL count `archived` and `deleted` in its summary and SHALL list each as a row with the APPID, the application name and both uuids, never person data. When OpenRegister's archive handler or object mapper is not available, an import with `missingRecords` `archive` SHALL be refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read. #### Scenario: An application that moved to the archive sheet is archived @e2e tests/e2e/spec-coverage/cmdb-import.spec.ts @@ -61,6 +61,14 @@ With `missingRecords` `archive`, after every row of an import that was not cance - **THEN** the module and the usage of `8` SHALL be archived and SHALL NOT be deleted - **AND** the report SHALL carry a warning naming the CMDB sheet and the row +#### Scenario: A workbook that lacks one of the CMDB sheets never turns an archive into a deletion +@e2e exclude Covered by the service and reader tests; tests/Unit/Service/CmdbExportImportServiceTest.php testAMissingSourceSheetArchivesInsteadOfDeleting asserts the archive state, no delete, and the warning naming the missing sheet; tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php testAWorkbookWithSomeSourceSheetsNamesTheAbsentOnes asserts the reader lists the absent sheet. + +- **GIVEN** the module with APPID `8` and its usage were imported from the sheet "Beheerde Applicaties CMDB" +- **WHEN** a workbook with "Onbeh Applicaties CMDB" and a readable archive sheet, but without "Beheerde Applicaties CMDB", is imported with `archive` +- **THEN** the module and the usage of `8` SHALL be archived and SHALL NOT be deleted +- **AND** the report SHALL carry a warning naming "Beheerde Applicaties CMDB" + #### Scenario: Keeping missing records, a cancelled import and another municipality leave everything as it is @e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testRecordsMissingFromTheExportStayWhenKept, testACancelledImportDoesNotReconcile and testAnotherMunicipalitysUsagesAreNeverReconciled assert no archive, no delete and no reconciliation row. diff --git a/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php b/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php index fa5754636..39554e1c1 100644 --- a/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php +++ b/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php @@ -995,6 +995,41 @@ public function testTheArchiveSheetIsOptionalAndReadForItsKeyOnly(): void { } }//end testTheArchiveSheetIsOptionalAndReadForItsKeyOnly() + /** + * A workbook with only some of the source sheets is read, and names the ones it lacks; with all of them it names none. + * + * @return void + * + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015 + */ + public function testAWorkbookWithSomeSourceSheetsNamesTheAbsentOnes(): void { + $this->requireSpreadsheet(); + $cmdb = [['APPID', 'Applicatie Naam'], [1, 'Een']]; + $archive = [['APPID'], [7]]; + $cases = [ + 'one source sheet missing' => [ + ['Onbeh Applicaties CMDB' => $cmdb, 'Gearchiveerde Applicaties' => $archive], + ['Beheerde Applicaties CMDB'], + ], + 'every source sheet there' => [ + ['Onbeh Applicaties CMDB' => $cmdb, 'Beheerde Applicaties CMDB' => $cmdb, 'Gearchiveerde Applicaties' => $archive], + [], + ], + ]; + + foreach ($cases as $case => [$sheets, $absent]) { + $path = CmdbTestSupport::buildWorkbook(sheets: $sheets); + try { + $result = (new CmdbWorkbookReader())->read(path: $path, profile: $this->profile()); + } finally { + unlink($path); + } + + $this->assertSame($absent, $result['absentSheets'], $case); + $this->assertTrue($result['archive']['present'], $case); + } + }//end testAWorkbookWithSomeSourceSheetsNamesTheAbsentOnes() + /** * An archive sheet with more rows than the profile allows is refused and named, like a source sheet. * diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php index dffbf6182..c8e6a583d 100644 --- a/tests/Unit/Service/CmdbExportImportServiceTest.php +++ b/tests/Unit/Service/CmdbExportImportServiceTest.php @@ -998,8 +998,8 @@ function (&$value): void { * * @return CmdbWorkbookReader */ - private function rowsReader(array $rows, ?array $archived = [], array $uncachedRows = [], array $uncachedKeyRows = []): CmdbWorkbookReader { - return new class($rows, $archived, $uncachedRows, $uncachedKeyRows) extends CmdbWorkbookReader { + private function rowsReader(array $rows, ?array $archived = [], array $uncachedRows = [], array $uncachedKeyRows = [], array $absentSheets = []): CmdbWorkbookReader { + return new class($rows, $archived, $uncachedRows, $uncachedKeyRows, $absentSheets) extends CmdbWorkbookReader { /** * Constructor. * @@ -1007,12 +1007,14 @@ private function rowsReader(array $rows, ?array $archived = [], array $uncachedR * @param array|null $archived The archive sheet's APPIDs, or null. * @param array $uncachedRows The archive sheet's rows without a cached APPID. * @param array> $uncachedKeyRows The CMDB rows dropped as empty, per sheet. + * @param array $absentSheets The source sheets the workbook lacks. */ public function __construct( private array $rows, private ?array $archived, private array $uncachedRows, private array $uncachedKeyRows = [], + private array $absentSheets = [], ) { } @@ -1026,7 +1028,14 @@ public function __construct( */ public function read(string $path, CmdbImportProfile $profile): array { $archive = ['sheet' => 'Gearchiveerde Applicaties', 'present' => ($this->archived !== null), 'keyColumnMissing' => false, 'appIds' => ($this->archived ?? []), 'uncachedRows' => $this->uncachedRows]; - return ['rows' => $this->rows, 'importWarnings' => [], 'date1904' => false, 'archive' => $archive, 'uncachedKeyRows' => $this->uncachedKeyRows]; + return [ + 'rows' => $this->rows, + 'importWarnings' => [], + 'date1904' => false, + 'archive' => $archive, + 'uncachedKeyRows' => $this->uncachedKeyRows, + 'absentSheets' => $this->absentSheets, + ]; } }; }//end rowsReader() @@ -2176,6 +2185,32 @@ public function testAFullyEmptyCmdbRowWithAnUncachedKeyArchivesInsteadOfDeleting ); }//end testAFullyEmptyCmdbRowWithAnUncachedKeyArchivesInsteadOfDeleting() + /** + * A workbook that lacks one of the source sheets is not the whole CMDB: what is on no sheet is archived, not deleted, + * and the import names the missing sheet. + * + * @return void + * + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015 + */ + public function testAMissingSourceSheetArchivesInsteadOfDeleting(): void { + $this->importOneSevenAndEight(); + $eight = $this->moduleOf(appId: '8'); + + $report = $this->service( + reader: $this->rowsReader(rows: [$this->row(appId: '1')], archived: ['7'], absentSheets: ['Beheerde Applicaties CMDB']) + )->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertNotContains('delete', array_column($this->transitions, 0), 'nothing is soft-deleted'); + $this->assertSame([true, false], $this->stateOf(schema: self::MODULE, uuid: $eight), 'APPID 8 may be on the missing sheet, so it is archived'); + $this->assertSame([true, false], $this->stateOf(schema: self::USAGE, uuid: $this->usageOf(moduleUuid: $eight))); + $this->assertSame(0, $report['summary']['deleted']); + $this->assertSame( + [['sheet' => 'Beheerde Applicaties CMDB', 'message' => 'Sheet "Beheerde Applicaties CMDB" was not found, so applications missing from the export are archived, not deleted.']], + $report['importWarnings'] + ); + }//end testAMissingSourceSheetArchivesInsteadOfDeleting() + /** * An extra usage of a key that cannot be archived fails the application's row; its module, written after every usage, stays * as it was, and the other applications are still reconciled. From 04f8d60e1239e71f392f10aa3853ea761adb37c3 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Fri, 16 Oct 2026 13:41:47 +0200 Subject: [PATCH 114/124] docs(cmdb-import): six limits are read from the profile, as the table lists Co-Authored-By: Claude Opus 5.5 --- docs/features/cmdb-import.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md index d1e67fe93..31df3bc27 100644 --- a/docs/features/cmdb-import.md +++ b/docs/features/cmdb-import.md @@ -397,7 +397,7 @@ page. ## Limits -Five limits are read from `lib/Settings/cmdb-import/topdesk-profile.json` on +Six limits are read from `lib/Settings/cmdb-import/topdesk-profile.json` on every import: | Setting | Default | What it limits | From 2607f77e2d8daabf3906c15720bb4f125fd540a9 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Fri, 16 Oct 2026 13:43:38 +0200 Subject: [PATCH 115/124] fix(usages): show the technical owner's name, not the contact person uuid technicalOwner is the same $ref contactPerson relation as businessOwner, whose column #1292 gave the contactName widget; the technical owner column still printed the key. Co-Authored-By: Claude Opus 5.5 --- src/manifest.d/usages.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/manifest.d/usages.json b/src/manifest.d/usages.json index ec21a35fe..928e0b5b9 100644 --- a/src/manifest.d/usages.json +++ b/src/manifest.d/usages.json @@ -24,7 +24,7 @@ { "key": "moduleVersion", "label": "Version", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "moduleVersion", "labelField": "version" } }, "status", { "key": "businessOwner", "label": "Business owner", "widget": "contactName" }, - "technicalOwner", + { "key": "technicalOwner", "label": "Technical owner", "widget": "contactName" }, "timeClassification", "serviceDeskRecordId" ], From 9fea9c0ef27d5883942ed0f02b5f7bcd1f795b63 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Fri, 16 Oct 2026 13:48:29 +0200 Subject: [PATCH 116/124] fix(cmdb-import): promise the trash only when the profile names an archive sheet The info paragraph already switched on the archive sheet, but the choice "Archive or delete them", its help ("archived or moved to the trash") and the re-import line ("archived or deleted") did not. Without an archive sheet the import only archives, so these now read "Archive them" and "archived" for such a profile. Co-Authored-By: Claude Opus 5.5 --- l10n/en.js | 5 +++- l10n/en.json | 5 +++- l10n/nl.js | 5 +++- l10n/nl.json | 5 +++- src/utils/cmdbImport.js | 30 +++++++++++++++++++ .../settings/sections/CmdbImport.spec.js | 5 ++++ src/views/settings/sections/CmdbImport.vue | 28 +++++++++-------- 7 files changed, 66 insertions(+), 17 deletions(-) diff --git a/l10n/en.js b/l10n/en.js index 108dd98c4..53bb38c6d 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1212,7 +1212,10 @@ OC.L10N.register( "Loading the name": "Loading the name", "The name could not be loaded. Reload the page to try again.": "The name could not be loaded. Reload the page to try again.", "This contact person is not linked to a Nextcloud contact.": "This contact person is not linked to a Nextcloud contact.", - "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "The name is kept in Nextcloud Contacts, in an address book you cannot read." + "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "The name is kept in Nextcloud Contacts, in an address book you cannot read.", + "Archive them": "Archive them", + "Applications no longer in the export are archived. The (i) button above explains when.": "Applications no longer in the export are archived. The (i) button above explains when.", + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 95a57c106..6f996706e 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1211,6 +1211,9 @@ "Loading the name": "Loading the name", "The name could not be loaded. Reload the page to try again.": "The name could not be loaded. Reload the page to try again.", "This contact person is not linked to a Nextcloud contact.": "This contact person is not linked to a Nextcloud contact.", - "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "The name is kept in Nextcloud Contacts, in an address book you cannot read." + "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "The name is kept in Nextcloud Contacts, in an address book you cannot read.", + "Archive them": "Archive them", + "Applications no longer in the export are archived. The (i) button above explains when.": "Applications no longer in the export are archived. The (i) button above explains when.", + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them." } } diff --git a/l10n/nl.js b/l10n/nl.js index 7ed0533e2..64de11c79 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1280,7 +1280,10 @@ OC.L10N.register( "Loading the name": "De naam wordt geladen", "The name could not be loaded. Reload the page to try again.": "De naam kon niet worden geladen. Laad de pagina opnieuw om het nog eens te proberen.", "This contact person is not linked to a Nextcloud contact.": "Deze contactpersoon is niet gekoppeld aan een Nextcloud-contact.", - "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "De naam staat in Nextcloud Contacten, in een adresboek dat je niet kunt lezen." + "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "De naam staat in Nextcloud Contacten, in een adresboek dat je niet kunt lezen.", + "Archive them": "Archiveren", + "Applications no longer in the export are archived. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd. De (i)-knop bovenaan legt uit wanneer.", + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd, tenzij je kiest ze te laten zoals ze zijn." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 20e6df066..d1a0dd8e2 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1279,6 +1279,9 @@ "Loading the name": "De naam wordt geladen", "The name could not be loaded. Reload the page to try again.": "De naam kon niet worden geladen. Laad de pagina opnieuw om het nog eens te proberen.", "This contact person is not linked to a Nextcloud contact.": "Deze contactpersoon is niet gekoppeld aan een Nextcloud-contact.", - "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "De naam staat in Nextcloud Contacten, in een adresboek dat je niet kunt lezen." + "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "De naam staat in Nextcloud Contacten, in een adresboek dat je niet kunt lezen.", + "Archive them": "Archiveren", + "Applications no longer in the export are archived. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd. De (i)-knop bovenaan legt uit wanneer.", + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd, tenzij je kiest ze te laten zoals ze zijn." } } diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js index bafa6d8b4..225567040 100644 --- a/src/utils/cmdbImport.js +++ b/src/utils/cmdbImport.js @@ -1245,6 +1245,36 @@ export function missingApplicationsText(archive = mappingArchiveSheet(null)) { ) } +/** + * The words of the "Applications missing from the export" choice and of the + * info-panel line on re-imports: they promise the trash only when the profile + * names an archive sheet, because without one the import only archives. + * + * @param {string|null} [archive] The archive sheet, from mappingArchiveSheet(); the shipped name when left out, null when the profile names none + * @return {{archiveOption: string, archiveHelp: string, reimportInfo: string}} The texts + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015 + */ +export function missingRecordsTexts(archive = mappingArchiveSheet(null)) { + if (!archive) { + return { + archiveOption: t('stackiq', 'Archive them'), + archiveHelp: t('stackiq', 'Applications no longer in the export are archived. The (i) button above explains when.'), + reimportInfo: t( + 'stackiq', + 'Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.', + ), + } + } + return { + archiveOption: t('stackiq', 'Archive or delete them'), + archiveHelp: t('stackiq', 'Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.'), + reimportInfo: t( + 'stackiq', + 'Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.', + ), + } +} + /** * The words for a pack's target: what the pack's rows become. * diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js index a7a64a7c7..e422d8eda 100644 --- a/src/views/settings/sections/CmdbImport.spec.js +++ b/src/views/settings/sections/CmdbImport.spec.js @@ -381,6 +381,11 @@ describe('CmdbImport sheet names in the help texts', () => { const panel = wrapper.find('.cmdb-import-info').text() expect(panel).not.toContain('Gearchiveerde Applicaties') expect(panel).not.toContain('moved to the trash') + expect(panel).not.toContain('archived or deleted') + const choice = wrapper.find('[data-testid="cmdb-import-missing-records"]').text() + expect(wrapper.vm.missingRecordsWords.archiveOption).toBe('Archive them') + expect(choice).not.toContain('delete') + expect(choice).not.toContain('trash') await wrapper.setData({ error: tooLarge }) expect(wrapper.vm.errorView.hint).toContain( '(it reads only "Alpha", "Beta")', diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue index b552150fd..b0dd806a0 100644 --- a/src/views/settings/sections/CmdbImport.vue +++ b/src/views/settings/sections/CmdbImport.vue @@ -138,7 +138,7 @@ type="radio" :disabled="importing" data-testid="cmdb-import-missing-records-archive"> - {{ t('stackiq', 'Archive or delete them') }} + {{ missingRecordsWords.archiveOption }}

- {{ - t( - 'stackiq', - 'Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.', - ) - }} + {{ missingRecordsWords.archiveHelp }}

@@ -414,12 +409,7 @@ }}
  • - {{ - t( - 'stackiq', - 'Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.', - ) - }} + {{ missingRecordsWords.reimportInfo }}
  • {{ t('stackiq', 'Update existing records') }}

    @@ -502,6 +492,7 @@ import { mappingSheetNames, MISSING_RECORDS_MODES, missingApplicationsText, + missingRecordsTexts, moduleUrl, municipalityOptions, normaliseError, @@ -652,6 +643,17 @@ export default { return missingApplicationsText(this.archiveSheet) }, + /** + * The words of the missing-records choice and of the re-import line, + * which mention deleting only when the profile names an archive sheet. + * + * @return {{archiveOption: string, archiveHelp: string, reimportInfo: string}} The texts + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015 + */ + missingRecordsWords() { + return missingRecordsTexts(this.archiveSheet) + }, + /** * Whether everything the import needs has been chosen. * From 0b50b62fae1167cab255c782de0e7187d87f3d86 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Fri, 16 Oct 2026 13:50:19 +0200 Subject: [PATCH 117/124] chore(parity): screen.board is always a list, and decided-no rows say why they have no screen 118 capabilities named their board as a string and 44 as a list, so a reader iterating board walked a string character by character. Every board is a list now, as in specScreens. The 41 decided-no rows had no screen at all; they get board null with the reason that nothing was built. The file still validates against the parity-verify capabilities schema. Co-Authored-By: Claude Opus 5.5 --- openspec/parity/capabilities.json | 668 ++++++++++++++++++++++++------ 1 file changed, 534 insertions(+), 134 deletions(-) diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index 362945e88..b19412d83 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -936,7 +936,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkModuleversie" + "board": [ + "SkModuleversie" + ] } }, { @@ -972,7 +974,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkSuiteWizard" + "board": [ + "SkSuiteWizard" + ] } }, { @@ -1008,7 +1012,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicaties" + "board": [ + "SkApplicaties" + ] } }, { @@ -1039,6 +1045,10 @@ "bluedolphin": "https://help.bluedolphin.io/en/articles/11967603-manage-object-questionnaires: 'A questionnaire is a group of fields that add more details to objects' configured by admins, which can hold a sector field; no sector list is shipped (read 2026-09-26). Reached on: Admin > Object questionnaires.", "sap-leanix": "https://help.sap.com/docs/leanix/ea/tags-and-custom-fields: tags and tag groups for 'quick, high-level classification', filterable and usable as reporting views; no predefined government sector list is documented (read 2026-09-26). Reached on: Fact sheet > Tags.", "glpi": "source read at 11.0.9: no government sector concept, grep -i 'sector' over src/*.php and locales/glpi.pot hits only menu sectorization (src/Html.php:1019); the nearest holder is the single-valued Appliance type dropdown install/mysql/glpi-empty.sql:8941 appliancetypes_id, or an admin custom dropdown (install/mysql/glpi-empty.sql:10113 glpi_dropdowns_dropdowndefinitions) used as a field of a custom asset. Multi-valued tagging needs the separate tag plugin (github.com/pluginsGLPI/tag, not read). Reached on: Management > Appliances, Appliance type field." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -1113,7 +1123,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -1148,7 +1160,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicaties" + "board": [ + "SkApplicaties" + ] } }, { @@ -1184,7 +1198,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -1220,7 +1236,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicaties" + "board": [ + "SkApplicaties" + ] } }, { @@ -1293,7 +1311,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkDatakwaliteit" + "board": [ + "SkDatakwaliteit" + ] } }, { @@ -1367,7 +1387,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkInstellingenGegevens" + "board": [ + "SkInstellingenGegevens" + ] } }, { @@ -1402,7 +1424,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkSuiteWizard" + "board": [ + "SkSuiteWizard" + ] } }, { @@ -1437,7 +1461,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkModuleversie" + "board": [ + "SkModuleversie" + ] } }, { @@ -1473,7 +1499,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -1509,7 +1537,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkKoppelingen" + "board": [ + "SkKoppelingen" + ] } }, { @@ -1545,7 +1575,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkKoppelingen" + "board": [ + "SkKoppelingen" + ] } }, { @@ -1581,7 +1613,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -1617,7 +1651,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkKoppelingen" + "board": [ + "SkKoppelingen" + ] } }, { @@ -1652,7 +1688,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -1687,7 +1725,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkKoppelingen" + "board": [ + "SkKoppelingen" + ] } }, { @@ -1722,7 +1762,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -1758,7 +1800,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkKoppelingen" + "board": [ + "SkKoppelingen" + ] } }, { @@ -1793,7 +1837,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkInstellingenUitwisseling" + "board": [ + "SkInstellingenUitwisseling" + ] } }, { @@ -1829,7 +1875,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkInstellingenGegevens" + "board": [ + "SkInstellingenGegevens" + ] } }, { @@ -1865,7 +1913,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkInstellingenGegevens" + "board": [ + "SkInstellingenGegevens" + ] } }, { @@ -1901,7 +1951,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkInstellingenGegevens" + "board": [ + "SkInstellingenGegevens" + ] } }, { @@ -1937,7 +1989,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkInstellingenGegevens" + "board": [ + "SkInstellingenGegevens" + ] } }, { @@ -1973,7 +2027,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkInstellingenGegevens" + "board": [ + "SkInstellingenGegevens" + ] } }, { @@ -2049,7 +2105,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkGemmaPlaat" + "board": [ + "SkGemmaPlaat" + ] } }, { @@ -2084,7 +2142,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkModelleren" + "board": [ + "SkModelleren" + ] } }, { @@ -2119,7 +2179,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkModelleren" + "board": [ + "SkModelleren" + ] } }, { @@ -2154,7 +2216,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkModelleren" + "board": [ + "SkModelleren" + ] } }, { @@ -2189,7 +2253,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkGemmaPlaat" + "board": [ + "SkGemmaPlaat" + ] } }, { @@ -2224,7 +2290,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkModelleren" + "board": [ + "SkModelleren" + ] } }, { @@ -2260,7 +2328,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkStandaarden" + "board": [ + "SkStandaarden" + ] } }, { @@ -2296,7 +2366,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkComplianceMatrix" + "board": [ + "SkComplianceMatrix" + ] } }, { @@ -2332,7 +2404,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkComplianceMatrix" + "board": [ + "SkComplianceMatrix" + ] } }, { @@ -2368,7 +2442,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkComplianceMatrix" + "board": [ + "SkComplianceMatrix" + ] } }, { @@ -2404,7 +2480,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkInstellingenGegevens" + "board": [ + "SkInstellingenGegevens" + ] } }, { @@ -2440,7 +2518,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkStandaarden" + "board": [ + "SkStandaarden" + ] } }, { @@ -2476,7 +2556,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkComplianceMatrix" + "board": [ + "SkComplianceMatrix" + ] } }, { @@ -2546,6 +2628,10 @@ "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for Forum Standaardisatie, no hits (read 2026-09-26)", "sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea for Forum Standaardisatie and comply or explain, no hits (read 2026-09-26)", "glpi": "source read at 11.0.9: grep -rli 'standaard\\|forum standaardisatie\\|comply' over src/ locales/glpi.pot returns nothing relevant; no comply or explain list in core (Setup menu src/Html.php:1330 onwards)." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -2614,6 +2700,10 @@ "vng-softwarecatalogus": "unknown: no questionnaire to suppliers is described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)", "sap-leanix": "https://help.sap.com/docs/leanix/ea/reviewing-responses: survey answers are reviewed and saved to the fact sheet; recipients are the users 'assigned to the fact sheet'. Sending to an outside supplier is not documented (read 2026-09-26). Reached on: Surveys.", "glpi": "source read at 11.0.9: native forms (src/Glpi/Form/Form.php:92) are filled by logged in or helpdesk users and only produce tickets, changes or problems (src/Glpi/Form/Destination/FormDestinationTicket.php:47); nothing sends a questionnaire to a supplier or stores answers on an appliance." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -2649,7 +2739,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicaties" + "board": [ + "SkApplicaties" + ] } }, { @@ -2685,7 +2777,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicaties" + "board": [ + "SkApplicaties" + ] } }, { @@ -2720,7 +2814,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkOrganisaties" + "board": [ + "SkOrganisaties" + ] } }, { @@ -2751,6 +2847,10 @@ "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; a vendor community is referenced in the docs (https://help.bluedolphin.io/en/articles/11967582-iso27001-management-system-template 'a post on the community'), but finding peers who use the same product is not documented (read 2026-09-26)", "sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; a vendor community exists (https://community.leanix.net/) but no in product way to find organisations using the same product is documented (read 2026-09-26)", "glpi": "source read at 11.0.9: each GLPI instance holds one organisation's data (entities are internal subdivisions, src/Entity.php), so there is no view of other organisations using the same product; grep -rli 'peer' src/*.php matches only relation and network code such as src/CommonDBConnexity.php, no peer organisation feature." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -2825,7 +2925,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -2861,7 +2963,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -2896,7 +3000,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -2970,7 +3076,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkRoadmap" + "board": [ + "SkRoadmap" + ] } }, { @@ -3006,7 +3114,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkRoadmap" + "board": [ + "SkRoadmap" + ] } }, { @@ -3079,7 +3189,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkRapportages" + "board": [ + "SkRapportages" + ] } }, { @@ -3226,6 +3338,10 @@ "vng-softwarecatalogus": "unknown: no strategic goals are described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)", "sap-leanix": "https://help.sap.com/docs/leanix/ea/objective-modeling-guidelines: 'Objectives link to business capabilities and initiatives so progress can be tracked over time'. Applications reach objectives through capabilities and initiatives; a direct application to objective relation is not described (read 2026-09-26). Reached on: Objective fact sheet.", "glpi": "source read at 11.0.9: grep -i 'strategic\\|goal\\|objective' over src/Appliance.php and src/Project.php returns nothing; no goal itemtype exists and the Appliance tabs (src/Appliance.php:98 onwards) link items, contracts, documents, tickets and projects only." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -3340,7 +3456,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkContract" + "board": [ + "SkContract" + ] } }, { @@ -3492,7 +3610,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkLicenties" + "board": [ + "SkLicenties" + ] } }, { @@ -3562,6 +3682,10 @@ "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; no entitlement against consumption computation is documented (read 2026-09-26)", "sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no entitlement against consumption computation is documented (read 2026-09-26)", "glpi": "source read at 11.0.9: entitlement is compared with licence assignments (src/SoftwareLicense.php:158 computeValidityIndicator), while measured installations are counted separately (src/Item_SoftwareVersion.php:39); core has no computed effective licence position report reconciling the two for an audit. Reached on: Assets > Software > Licenses and Installations tabs." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -3592,6 +3716,10 @@ "vng-softwarecatalogus": "unknown: contracts, licences and costs are not described anywhere in the public docs (the 2021 user survey asks which other tool municipalities use for contract management); searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/gebruikersonderzoek%202021 (read 2026-09-26)", "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for budget, no hits (read 2026-09-26)", "sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; the contract has a 'Contract Cost Center' string (https://help.sap.com/docs/leanix/ea/contract-extension-to-meta-model) but charging costs against a budget with a period is not documented (read 2026-09-26)" + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -3622,6 +3750,10 @@ "vng-softwarecatalogus": "unknown: contracts, licences and costs are not described anywhere in the public docs (the 2021 user survey asks which other tool municipalities use for contract management); searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/gebruikersonderzoek%202021 (read 2026-09-26)", "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for depreciation, no hits (read 2026-09-26)", "sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea for depreciation and amortisation, no hits (read 2026-09-26)" + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -3658,7 +3790,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkContract" + "board": [ + "SkContract" + ] } }, { @@ -3731,7 +3865,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkKwetsbaarheden" + "board": [ + "SkKwetsbaarheden" + ] } }, { @@ -3804,7 +3940,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkModuleversie" + "board": [ + "SkModuleversie" + ] } }, { @@ -3835,6 +3973,10 @@ "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for CVE, no hits (read 2026-09-26)", "sap-leanix": "https://help.sap.com/docs/leanix/ea/api-updates-sbom-async-processing: 'Switching to asynchronous processing will allow us to add additional post-processing mechanisms in the future, such as vulnerability checks. Though there's no established timeline for these enhancements' (read 2026-09-26)", "glpi": "source read at 11.0.9: grep -rli 'cve' over src/ templates/ finds no feed matching (only src/Glpi/System/Requirement/PhpSupportedVersion.php:74 text); no pluginsGLPI cve repository exists (git ls-remote https://github.com/pluginsGLPI/cve: repository not found)." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -3865,6 +4007,10 @@ "vng-softwarecatalogus": "unknown: no vulnerability, SBOM or risk functions are described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)", "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; risk can be captured in questionnaires ('Risk Assessment' example in https://help.bluedolphin.io/en/articles/11967710-using-the-odata-feed), but a risk score from vulnerabilities and support status is not documented (read 2026-09-26)", "glpi": "source read at 11.0.9: with no vulnerability data (only hit src/Glpi/System/Requirement/PhpSupportedVersion.php:74) and no support status field on software (install/mysql/glpi-empty.sql:6856 glpi_softwares), no risk score is computed; grep -i 'risk' over src/Appliance.php src/Software.php returns nothing." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -3895,6 +4041,10 @@ "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for patch and vulnerability, no hits (read 2026-09-26)", "sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no per vulnerability patch status is documented, and vulnerability checks are a future item (https://help.sap.com/docs/leanix/ea/api-updates-sbom-async-processing) (read 2026-09-26)", "glpi": "source read at 11.0.9: installed versions are known (src/Item_SoftwareVersion.php:39) but no vulnerability or fixed in version exists to compare against (grep -rli 'cve' src/ hits only src/Glpi/System/Requirement/PhpSupportedVersion.php:74)." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -3929,7 +4079,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkKwetsbaarheden" + "board": [ + "SkKwetsbaarheden" + ] } }, { @@ -3964,7 +4116,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkOrganisaties" + "board": [ + "SkOrganisaties" + ] } }, { @@ -4000,7 +4154,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkBeheerToegang" + "board": [ + "SkBeheerToegang" + ] } }, { @@ -4031,6 +4187,10 @@ "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; concept, active and inactive states for organisations are not documented (read 2026-09-26)", "sap-leanix": "unknown: https://help.sap.com/docs/leanix/ea/organization-modeling-guidelines does not describe concept, active and inactive states for organizations; only archiving of fact sheets in general (https://help.sap.com/docs/leanix/ea/archiving-deleting-and-recovering-fact-sheets) (read 2026-09-26)", "glpi": "source read at 11.0.9: a supplier is active or inactive, src/Supplier.php:365 is_active search option (install/mysql/glpi-empty.sql:7087 glpi_suppliers.is_active); there is no concept state. Reached on: Management > Suppliers, Active field." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -4065,7 +4225,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkOrganisatie" + "board": [ + "SkOrganisatie" + ] } }, { @@ -4101,7 +4263,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkOrganisatie" + "board": [ + "SkOrganisatie" + ] } }, { @@ -4137,7 +4301,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkOrganisatie" + "board": [ + "SkOrganisatie" + ] } }, { @@ -4172,7 +4338,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkOrganisatie" + "board": [ + "SkOrganisatie" + ] } }, { @@ -4207,7 +4375,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkOrganisatie" + "board": [ + "SkOrganisatie" + ] } }, { @@ -4242,7 +4412,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkBeheerToegang" + "board": [ + "SkBeheerToegang" + ] } }, { @@ -4273,6 +4445,10 @@ "vng-softwarecatalogus": "unknown: login is by username and password (\"Vul uw GEMMA Softwarecatalogus-gebruikersnaam in\"); no identity provider sign-in is described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/user/login (read 2026-09-26)", "bluedolphin": "https://help.bluedolphin.io/en/articles/11967579-configure-single-sign-on-sso-with-okta-saml and https://help.bluedolphin.io/en/articles/11967575-configure-single-sign-on-sso-with-office-365-openid: SSO setup guides for Okta SAML, ADFS and Office 365 OpenID (read 2026-09-26). Reached on: Admin > Identity providers.", "sap-leanix": "https://help.sap.com/docs/leanix/ea/managing-users: 'SSO : You manage access through your identity provider (IdP) system. Users sign in through your IdP'; guides for Entra ID, Okta, OneLogin (read 2026-09-26). Reached on: Administration > Single Sign-On." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -4303,6 +4479,10 @@ "vng-softwarecatalogus": "unknown: no directory sync is described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)", "bluedolphin": "https://help.bluedolphin.io/en/articles/11967618-automatic-user-provisioning-with-scim-2-0: 'BlueDolphin supports automatic user provisioning ... manage BlueDolphin users and their role assignments', with guides for Entra ID, Okta and SailPoint (read 2026-09-26). Reached on: Admin > SCIM provisioning.", "sap-leanix": "https://help.sap.com/docs/leanix/ea/scim-provisioning: 'SCIM facilitates the transfer of user information from a source system, such as an external identity provider (IdP), to a target system, such as SAP LeanIX'; setup guides for Entra ID and Okta (read 2026-09-26). Reached on: Administration > SCIM provisioning." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -4333,6 +4513,10 @@ "bluedolphin": "https://help.bluedolphin.io/en/articles/11967481-user-profile: users see 'Profile > Personal information'; https://help.bluedolphin.io/en/articles/11967487-forgotten-password: 'To have your password reset, you need to contact your BlueDolphin administrator' and change the temporary password after login (read 2026-09-26). Reached on: Account > Profile.", "sap-leanix": "https://help.sap.com/docs/leanix/ea/user-profile: 'Manage your user profile settings. Change or reset your password if needed' on the My Settings profile page (read 2026-09-26). Reached on: User menu > My Settings.", "glpi": "source read at 11.0.9: front/preference.php renders the user's own form through src/User.php:3105 showMyForm (template pages/admin/user/user.html.twig), whose preference variant shows a 'Change password' button to front/updatepassword.php at templates/pages/admin/user/user.html.twig:277 to :279; the new password form is templates/password_form.html.twig:79." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -4367,7 +4551,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkBeheerToegang" + "board": [ + "SkBeheerToegang" + ] } }, { @@ -4403,7 +4589,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicaties" + "board": [ + "SkApplicaties" + ] } }, { @@ -4435,6 +4623,10 @@ "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; no open data publication is documented (read 2026-09-26)", "sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; no open data publication is documented (read 2026-09-26)", "glpi": "source read at 11.0.9: no open data publication exists (grep -rli 'open data' src/ returns nothing); anonymisation settings cover ticket actors only (install/mysql/glpi-empty.sql:2824 anonymize_support_agents on entities)." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -4470,7 +4662,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkInstellingenUitwisseling" + "board": [ + "SkInstellingenUitwisseling" + ] } }, { @@ -4506,7 +4700,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkInstellingenUitwisseling" + "board": [ + "SkInstellingenUitwisseling" + ] } }, { @@ -4542,7 +4738,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkInstellingenUitwisseling" + "board": [ + "SkInstellingenUitwisseling" + ] } }, { @@ -4687,7 +4885,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkInstellingenUitwisseling" + "board": [ + "SkInstellingenUitwisseling" + ] } }, { @@ -4758,7 +4958,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicaties" + "board": [ + "SkApplicaties" + ] } }, { @@ -4793,7 +4995,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicaties" + "board": [ + "SkApplicaties" + ] } }, { @@ -4828,7 +5032,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkDashboard" + "board": [ + "SkDashboard" + ] } }, { @@ -4863,7 +5069,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkDashboard" + "board": [ + "SkDashboard" + ] } }, { @@ -4898,7 +5106,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkRapportages" + "board": [ + "SkRapportages" + ] } }, { @@ -4934,7 +5144,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkRapportages" + "board": [ + "SkRapportages" + ] } }, { @@ -5004,6 +5216,10 @@ "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; surveys can be scheduled (https://bluedolphin.io/application-portfolio-management-application-rationalization/ 'Keep data current by scheduling surveys'), but sending reports on a schedule is not documented (read 2026-09-26)", "sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; scheduled notification digests (https://help.sap.com/docs/leanix/ea/notifications-center) and scheduled snapshot exports by API (https://help.sap.com/docs/leanix/ea/export) exist, but mailing a report to named people on a schedule is not documented (read 2026-09-26)", "glpi": "source read at 11.0.9: saved search alerts, src/SavedSearch_Alert.php:45 with count conditions (src/SavedSearch_Alert.php:53 to :58) and a frequency, run by src/SavedSearch_Alert.php:307 cronSavedSearchesAlerts and sent through the notification system to configured recipients; they notify on a result count rather than sending the report itself. Reached on: Tools > Saved searches > Alerts tab." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -5077,7 +5293,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkFunctiesRoadmap" + "board": [ + "SkFunctiesRoadmap" + ] } }, { @@ -5150,7 +5368,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -5181,6 +5401,10 @@ "vng-softwarecatalogus": "unknown: operational IT management functions are not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)", "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; the DataCollector uploads data from databases and files (https://help.bluedolphin.io/en/articles/11967629-import-and-export-options-with-bluedolphin), an installation discovery agent is not documented (read 2026-09-26)", "sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; LeanIX has no own installation agent, software assets come in through the ServiceNow integration (https://help.sap.com/docs/leanix/ea/obsolescence-risk-management-import-software-assets) (read 2026-09-26)" + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -5211,6 +5435,10 @@ "vng-softwarecatalogus": "unknown: operational IT management functions are not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)", "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; network discovery is not documented (read 2026-09-26)", "sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; network device discovery is not documented (read 2026-09-26)" + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -5241,6 +5469,10 @@ "vng-softwarecatalogus": "unknown: operational IT management functions are not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)", "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for SaaS discovery, no hits (read 2026-09-26)", "glpi": "source read at 11.0.9: inventory handlers under src/Glpi/Inventory/Asset/ (Software.php, Process.php, VirtualMachine.php and others) read what the agent sees on devices; grep -i 'saas' over src/ returns nothing, so cloud subscriptions nobody registered are not discovered. The software handler is src/Glpi/Inventory/Asset/Software.php:56." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -5275,7 +5507,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkCmdb" + "board": [ + "SkCmdb" + ] } }, { @@ -5382,6 +5616,10 @@ "vng-softwarecatalogus": "unknown: operational IT management functions are not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)", "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; incidents stay in the ITSM tool, logging them against an application in BlueDolphin is not documented (read 2026-09-26)", "sap-leanix": "https://help.sap.com/docs/leanix/ea/portals: an Application Portal 'accessible to everyone to order software, issue support tickets' through configurable links to the helpdesk; incidents themselves stay in the ITSM tool (read 2026-09-26). Reached on: Portals > links to helpdesk." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -5412,6 +5650,10 @@ "vng-softwarecatalogus": "unknown: operational IT management functions are not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)", "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; a change approval workflow on an application is not documented (read 2026-09-26)", "sap-leanix": "unknown: docs searched at https://help.sap.com/docs/leanix/ea; architecture decisions have a review process (https://help.sap.com/docs/leanix/ea/architecture-decisions) but a change approval workflow on an application is not documented (read 2026-09-26)" + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -5442,6 +5684,10 @@ "vng-softwarecatalogus": "unknown: operational IT management functions are not described; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)", "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/ for SLA and service level, no hits (read 2026-09-26)", "sap-leanix": "https://help.sap.com/docs/leanix/ea/contract-extension-to-meta-model: 'Service Level Agreement (SLA) Single Select Corporate-Level SLA, Customer-Level SLA, Service-Level SLA' and an SLA description on the contract. No tracking of targets against results (read 2026-09-26). Reached on: Contract fact sheet > Governance and Regulations." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -5472,6 +5718,10 @@ "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; the process portal publishes processes (https://help.bluedolphin.io/en/articles/11967500-getting-started-with-process-publication-portal), but requesting software is not documented (read 2026-09-26)", "sap-leanix": "https://help.sap.com/docs/leanix/ea/portals: 'create an Application Portal that is accessible to everyone to order software ... Action button: Perform an action, in this case \"Request new Application\"' (read 2026-09-26). Reached on: Portals > Application Portal.", "glpi": "source read at 11.0.9: the 11.0 service catalog src/Glpi/Form/ServiceCatalog/ServiceCatalog.php:45 lists native forms for self service users (menu src/Html.php:1283), and a form question can offer software items to pick, src/Glpi/Form/QuestionType/QuestionTypeItem.php:180 Software::class, creating a ticket from the answer. Reached on: Self service > Service catalog. Driven on the lab at 11.0.9 (2026-09-26): /ServiceCatalog shows the service catalog with \"Report an issue\" and \"Request a service\"." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -5502,6 +5752,10 @@ "bluedolphin": "unknown: https://help.bluedolphin.io/en/articles/11967477-minimum-system-requirements lists desktop browsers (Chrome, Firefox, Edge) and says Safari 'is not supported'; a native mobile app is not documented (read 2026-09-26)", "sap-leanix": "https://updates.leanix.net/announcements/explore-portals-on-mobile-devices (2025-09-18): 'Since the mobile app was decommissioned in June 2023, we planned to make portals mobile responsive'; only portals are mobile friendly (read 2026-09-26)", "glpi": "source read at 11.0.9: grep -rli 'android\\|ios app\\|mobile app' over src/ templates/ returns nothing; the repository ships only the responsive web interface (templates/) and APIs (src/Glpi/Api/HL/Controller/CoreController.php:322 docs), no native mobile client." + }, + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." } }, { @@ -5537,7 +5791,9 @@ }, "screen": { "canvas": "design-system", - "board": "SkInstellingenGegevens" + "board": [ + "SkInstellingenGegevens" + ] } }, { @@ -5573,7 +5829,9 @@ "topdesk": "unknown", "screen": { "canvas": "design-system", - "board": "SkGemmaPlaat" + "board": [ + "SkGemmaPlaat" + ] } }, { @@ -5609,7 +5867,9 @@ "topdesk": "unknown", "screen": { "canvas": "design-system", - "board": "SkGemmaPlaat" + "board": [ + "SkGemmaPlaat" + ] } }, { @@ -5645,7 +5905,9 @@ "topdesk": "unknown", "screen": { "canvas": "design-system", - "board": "SkModelleren" + "board": [ + "SkModelleren" + ] } }, { @@ -5681,7 +5943,9 @@ "topdesk": "partial", "screen": { "canvas": "design-system", - "board": "SkBeheerToegang" + "board": [ + "SkBeheerToegang" + ] } }, { @@ -5717,7 +5981,9 @@ "topdesk": "unknown", "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -5749,7 +6015,11 @@ "sap-leanix": "unknown", "bluedolphin": "unknown", "glpi": "no", - "topdesk": "unknown" + "topdesk": "unknown", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "life-value-assessment", @@ -5819,7 +6089,11 @@ "sap-leanix": "partial", "bluedolphin": "unknown", "glpi": "no", - "topdesk": "unknown" + "topdesk": "unknown", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "comp-processing-register-generate", @@ -5850,7 +6124,11 @@ "sap-leanix": "unknown", "bluedolphin": "unknown", "glpi": "no", - "topdesk": "unknown" + "topdesk": "unknown", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "ctr-collective-agreements", @@ -5881,7 +6159,11 @@ "sap-leanix": "unknown", "bluedolphin": "unknown", "glpi": "no", - "topdesk": "unknown" + "topdesk": "unknown", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "share-compliance-documents", @@ -5990,7 +6272,11 @@ "sap-leanix": "unknown", "bluedolphin": "unknown", "glpi": "partial", - "topdesk": "unknown" + "topdesk": "unknown", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "org-act-as-user", @@ -6021,7 +6307,11 @@ "sap-leanix": "unknown", "bluedolphin": "unknown", "glpi": "yes", - "topdesk": "unknown" + "topdesk": "unknown", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "comp-common-ground-fit", @@ -6052,7 +6342,11 @@ "sap-leanix": "unknown", "bluedolphin": "unknown", "glpi": "no", - "topdesk": "unknown" + "topdesk": "unknown", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "land-version-carry-connections", @@ -6122,7 +6416,11 @@ "vng-softwarecatalogus": "unknown", "sap-leanix": "partial", "bluedolphin": "unknown", - "glpi": "partial" + "glpi": "partial", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "land-dependent-fields", @@ -6157,7 +6455,9 @@ "glpi": "partial", "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -6193,7 +6493,9 @@ "glpi": "partial", "screen": { "canvas": "design-system", - "board": "SkApplicaties" + "board": [ + "SkApplicaties" + ] } }, { @@ -6229,7 +6531,9 @@ "glpi": "no", "screen": { "canvas": "design-system", - "board": "SkDashboard" + "board": [ + "SkDashboard" + ] } }, { @@ -6261,7 +6565,11 @@ "glpi": "yes", "vng-softwarecatalogus": "unknown", "sap-leanix": "unknown", - "bluedolphin": "unknown" + "bluedolphin": "unknown", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "sec-password-policy", @@ -6292,7 +6600,11 @@ "vng-softwarecatalogus": "unknown", "sap-leanix": "yes", "bluedolphin": "unknown", - "glpi": "yes" + "glpi": "yes", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "ops-change-risk-score", @@ -6323,7 +6635,11 @@ "vng-softwarecatalogus": "unknown", "sap-leanix": "unknown", "bluedolphin": "unknown", - "glpi": "no" + "glpi": "no", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "conn-auto-populate-dependencies", @@ -6358,7 +6674,9 @@ "topdesk": "unknown", "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -6390,7 +6708,11 @@ "vng-softwarecatalogus": "unknown", "sap-leanix": "unknown", "bluedolphin": "unknown", - "topdesk": "partial" + "topdesk": "partial", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "ctr-contract-owner", @@ -6460,7 +6782,11 @@ "vng-softwarecatalogus": "unknown", "sap-leanix": "unknown", "bluedolphin": "unknown", - "topdesk": "unknown" + "topdesk": "unknown", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "org-assigned-only-rights", @@ -6491,7 +6817,11 @@ "vng-softwarecatalogus": "unknown", "sap-leanix": "partial", "bluedolphin": "unknown", - "topdesk": "partial" + "topdesk": "partial", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "ops-own-house-style", @@ -6562,7 +6892,9 @@ "topdesk": "partial", "screen": { "canvas": "design-system", - "board": "SkApplicaties" + "board": [ + "SkApplicaties" + ] } }, { @@ -6599,7 +6931,9 @@ "topdesk": "unknown", "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -6635,7 +6969,9 @@ "topdesk": "unknown", "screen": { "canvas": "design-system", - "board": "SkModelleren" + "board": [ + "SkModelleren" + ] } }, { @@ -6671,7 +7007,9 @@ "topdesk": "partial", "screen": { "canvas": "design-system", - "board": "SkDashboard" + "board": [ + "SkDashboard" + ] } }, { @@ -6708,7 +7046,9 @@ "topdesk": "unknown", "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -6740,7 +7080,11 @@ "vng-softwarecatalogus": "unknown", "bluedolphin": "unknown", "glpi": "no", - "topdesk": "unknown" + "topdesk": "unknown", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "arch-diagram-version-compare", @@ -6775,7 +7119,9 @@ "topdesk": "unknown", "screen": { "canvas": "design-system", - "board": "SkModelleren" + "board": [ + "SkModelleren" + ] } }, { @@ -6811,7 +7157,9 @@ "topdesk": "partial", "screen": { "canvas": "design-system", - "board": "SkInstellingenUitwisseling" + "board": [ + "SkInstellingenUitwisseling" + ] } }, { @@ -6843,7 +7191,11 @@ "vng-softwarecatalogus": "partial", "sap-leanix": "unknown", "glpi": "no", - "topdesk": "partial" + "topdesk": "partial", + "screen": { + "board": null, + "reason": "Besloten dit niet te bouwen (decided-no), dus er is geen scherm." + } }, { "id": "land-move-between-workspaces", @@ -6878,7 +7230,9 @@ "topdesk": "unknown", "screen": { "canvas": "design-system", - "board": "SkApplicaties" + "board": [ + "SkApplicaties" + ] } }, { @@ -6940,7 +7294,9 @@ "topdesk": "unknown", "screen": { "canvas": "design-system", - "board": "SkModelleren" + "board": [ + "SkModelleren" + ] } }, { @@ -6976,7 +7332,9 @@ "topdesk": "unknown", "screen": { "canvas": "design-system", - "board": "SkModelleren" + "board": [ + "SkModelleren" + ] } }, { @@ -7057,7 +7415,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkApplicaties" + "board": [ + "SkApplicaties" + ] } }, { @@ -7096,7 +7456,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkKoppelingen" + "board": [ + "SkKoppelingen" + ] } }, { @@ -7135,7 +7497,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkGebruikDetail" + "board": [ + "SkGebruikDetail" + ] } }, { @@ -7216,7 +7580,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkGebruikDetail" + "board": [ + "SkGebruikDetail" + ] } }, { @@ -7255,7 +7621,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkGemmaPlaat" + "board": [ + "SkGemmaPlaat" + ] } }, { @@ -7294,7 +7662,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkGemmaPlaat" + "board": [ + "SkGemmaPlaat" + ] } }, { @@ -7372,7 +7742,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkComplianceMatrix" + "board": [ + "SkComplianceMatrix" + ] } }, { @@ -7411,7 +7783,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkOrganisatie" + "board": [ + "SkOrganisatie" + ] } }, { @@ -7450,7 +7824,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkGemmaPlaat" + "board": [ + "SkGemmaPlaat" + ] } }, { @@ -7489,7 +7865,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -7528,7 +7906,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkBeheerToegang" + "board": [ + "SkBeheerToegang" + ] } }, { @@ -7687,7 +8067,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkInstellingenGegevens" + "board": [ + "SkInstellingenGegevens" + ] } }, { @@ -7810,7 +8192,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkContracten" + "board": [ + "SkContracten" + ] } }, { @@ -7893,7 +8277,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkContract" + "board": [ + "SkContract" + ] } }, { @@ -8015,7 +8401,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkPubliek" + "board": [ + "SkPubliek" + ] } }, { @@ -8054,7 +8442,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkOrganisatie" + "board": [ + "SkOrganisatie" + ] } }, { @@ -8214,7 +8604,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkPubliek" + "board": [ + "SkPubliek" + ] } }, { @@ -8366,7 +8758,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkInstellingenUitwisseling" + "board": [ + "SkInstellingenUitwisseling" + ] } }, { @@ -8405,7 +8799,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkApplicatie" + "board": [ + "SkApplicatie" + ] } }, { @@ -8487,7 +8883,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkDashboard" + "board": [ + "SkDashboard" + ] } }, { @@ -8525,7 +8923,9 @@ "source": "pending-moved-2026-10-07", "screen": { "canvas": "design-system", - "board": "SkFunctiesRoadmap" + "board": [ + "SkFunctiesRoadmap" + ] } }, { From bc472af00a261fc5cb19c22f98c6928b8befd480 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 19 Oct 2026 10:16:50 +0200 Subject: [PATCH 118/124] style(cmdb-import): Prettier on the archive-sheet wording helpers and their spec Co-Authored-By: Claude Opus 5.5 --- src/utils/cmdbImport.js | 10 ++++++++-- src/views/settings/sections/CmdbImport.spec.js | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js index 225567040..2f5a47b9b 100644 --- a/src/utils/cmdbImport.js +++ b/src/utils/cmdbImport.js @@ -1258,7 +1258,10 @@ export function missingRecordsTexts(archive = mappingArchiveSheet(null)) { if (!archive) { return { archiveOption: t('stackiq', 'Archive them'), - archiveHelp: t('stackiq', 'Applications no longer in the export are archived. The (i) button above explains when.'), + archiveHelp: t( + 'stackiq', + 'Applications no longer in the export are archived. The (i) button above explains when.', + ), reimportInfo: t( 'stackiq', 'Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.', @@ -1267,7 +1270,10 @@ export function missingRecordsTexts(archive = mappingArchiveSheet(null)) { } return { archiveOption: t('stackiq', 'Archive or delete them'), - archiveHelp: t('stackiq', 'Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.'), + archiveHelp: t( + 'stackiq', + 'Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.', + ), reimportInfo: t( 'stackiq', 'Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.', diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js index e422d8eda..852cb1627 100644 --- a/src/views/settings/sections/CmdbImport.spec.js +++ b/src/views/settings/sections/CmdbImport.spec.js @@ -382,7 +382,9 @@ describe('CmdbImport sheet names in the help texts', () => { expect(panel).not.toContain('Gearchiveerde Applicaties') expect(panel).not.toContain('moved to the trash') expect(panel).not.toContain('archived or deleted') - const choice = wrapper.find('[data-testid="cmdb-import-missing-records"]').text() + const choice = wrapper + .find('[data-testid="cmdb-import-missing-records"]') + .text() expect(wrapper.vm.missingRecordsWords.archiveOption).toBe('Archive them') expect(choice).not.toContain('delete') expect(choice).not.toContain('trash') From 789cb571da6cf06e0a020c4ad294d0efeb99c51a Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 19 Oct 2026 10:17:46 +0200 Subject: [PATCH 119/124] =?UTF-8?q?fix(review):=20#1305=20sq-f1=20?= =?UTF-8?q?=E2=80=94=20the=20info=20text=20and=20the=20change's=20design,?= =?UTF-8?q?=20proposal=20and=20tasks=20name=20the=20missing=20CMDB=20sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workbook without one of the CMDB sheets archives instead of deleting since 4800637a, but the info panel, the D5 decision table and D3 in design.md, the proposal and the tasks still listed only the archive-sheet and unreadable-key cases. Co-Authored-By: Claude Opus 5.5 --- l10n/en.js | 4 ++-- l10n/en.json | 4 ++-- l10n/nl.js | 4 ++-- l10n/nl.json | 4 ++-- openspec/changes/cmdb-import-archive-reconciliation/design.md | 4 ++-- .../changes/cmdb-import-archive-reconciliation/proposal.md | 4 ++-- openspec/changes/cmdb-import-archive-reconciliation/tasks.md | 2 +- src/utils/cmdbImport.js | 2 +- src/utils/cmdbImport.spec.js | 1 + 9 files changed, 15 insertions(+), 14 deletions(-) diff --git a/l10n/en.js b/l10n/en.js index 53bb38c6d..f398bcc42 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1047,7 +1047,6 @@ OC.L10N.register( "Applications missing from the export": "Applications missing from the export", "Archive or delete them": "Archive or delete them", "Keep them as they are": "Keep them as they are", - "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.", "An application of this municipality that is on no CMDB sheet any more is archived. It disappears from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Choose Keep to leave such applications as they are.": "An application of this municipality that is on no CMDB sheet any more is archived. It disappears from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Choose Keep to leave such applications as they are.", "Applications missing from the export cannot be archived.": "Applications missing from the export cannot be archived.", "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.", @@ -1215,7 +1214,8 @@ OC.L10N.register( "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "The name is kept in Nextcloud Contacts, in an address book you cannot read.", "Archive them": "Archive them", "Applications no longer in the export are archived. The (i) button above explains when.": "Applications no longer in the export are archived. The (i) button above explains when.", - "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them." + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.", + "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column or has an APPID without a cached value, or the workbook lacks one of the CMDB sheets, applications are only archived and the result says so. Choose Keep to leave them as they are.": "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column or has an APPID without a cached value, or the workbook lacks one of the CMDB sheets, applications are only archived and the result says so. Choose Keep to leave them as they are." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 6f996706e..3ca802f32 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1046,7 +1046,6 @@ "Applications missing from the export": "Applications missing from the export", "Archive or delete them": "Archive or delete them", "Keep them as they are": "Keep them as they are", - "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.", "An application of this municipality that is on no CMDB sheet any more is archived. It disappears from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Choose Keep to leave such applications as they are.": "An application of this municipality that is on no CMDB sheet any more is archived. It disappears from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Choose Keep to leave such applications as they are.", "Applications missing from the export cannot be archived.": "Applications missing from the export cannot be archived.", "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.", @@ -1214,6 +1213,7 @@ "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "The name is kept in Nextcloud Contacts, in an address book you cannot read.", "Archive them": "Archive them", "Applications no longer in the export are archived. The (i) button above explains when.": "Applications no longer in the export are archived. The (i) button above explains when.", - "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them." + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.", + "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column or has an APPID without a cached value, or the workbook lacks one of the CMDB sheets, applications are only archived and the result says so. Choose Keep to leave them as they are.": "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column or has an APPID without a cached value, or the workbook lacks one of the CMDB sheets, applications are only archived and the result says so. Choose Keep to leave them as they are." } } diff --git a/l10n/nl.js b/l10n/nl.js index 64de11c79..6044f4669 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1115,7 +1115,6 @@ OC.L10N.register( "Applications missing from the export": "Applicaties die in de export ontbreken", "Archive or delete them": "Archiveren of verwijderen", "Keep them as they are": "Laat ze zoals ze zijn", - "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen enkel CMDB-tabblad meer staat, wordt gearchiveerd als het tabblad \"{archive}\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Ontbreekt dat tabblad, heeft het geen kolom APPID of heeft een APPID geen opgeslagen waarde, dan worden applicaties alleen gearchiveerd en zegt het resultaat dat. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.", "An application of this municipality that is on no CMDB sheet any more is archived. It disappears from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Choose Keep to leave such applications as they are.": "Een applicatie van deze gemeente die op geen enkel CMDB-tabblad meer staat, wordt gearchiveerd. Ze verdwijnt uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Kies Laat ze zoals ze zijn om zulke applicaties ongemoeid te laten.", "Applications missing from the export cannot be archived.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd.", "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister ontbreekt of is te oud om objecten te archiveren. Kies \"Laat ze zoals ze zijn\" en importeer opnieuw, of werk OpenRegister bij. Er is niets geïmporteerd.", @@ -1283,7 +1282,8 @@ OC.L10N.register( "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "De naam staat in Nextcloud Contacten, in een adresboek dat je niet kunt lezen.", "Archive them": "Archiveren", "Applications no longer in the export are archived. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd. De (i)-knop bovenaan legt uit wanneer.", - "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd, tenzij je kiest ze te laten zoals ze zijn." + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd, tenzij je kiest ze te laten zoals ze zijn.", + "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column or has an APPID without a cached value, or the workbook lacks one of the CMDB sheets, applications are only archived and the result says so. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen enkel CMDB-tabblad meer staat, wordt gearchiveerd als het tabblad \"{archive}\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Ontbreekt dat tabblad, heeft het geen kolom APPID of heeft een APPID geen opgeslagen waarde, of mist de werkmap een van de CMDB-tabbladen, dan worden applicaties alleen gearchiveerd en zegt het resultaat dat. Kies Laat ze zoals ze zijn om ze ongemoeid te laten." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index d1a0dd8e2..d1ecd4eec 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1114,7 +1114,6 @@ "Applications missing from the export": "Applicaties die in de export ontbreken", "Archive or delete them": "Archiveren of verwijderen", "Keep them as they are": "Laat ze zoals ze zijn", - "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen enkel CMDB-tabblad meer staat, wordt gearchiveerd als het tabblad \"{archive}\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Ontbreekt dat tabblad, heeft het geen kolom APPID of heeft een APPID geen opgeslagen waarde, dan worden applicaties alleen gearchiveerd en zegt het resultaat dat. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.", "An application of this municipality that is on no CMDB sheet any more is archived. It disappears from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Choose Keep to leave such applications as they are.": "Een applicatie van deze gemeente die op geen enkel CMDB-tabblad meer staat, wordt gearchiveerd. Ze verdwijnt uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Kies Laat ze zoals ze zijn om zulke applicaties ongemoeid te laten.", "Applications missing from the export cannot be archived.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd.", "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister ontbreekt of is te oud om objecten te archiveren. Kies \"Laat ze zoals ze zijn\" en importeer opnieuw, of werk OpenRegister bij. Er is niets geïmporteerd.", @@ -1282,6 +1281,7 @@ "The name is kept in Nextcloud Contacts, in an address book you cannot read.": "De naam staat in Nextcloud Contacten, in een adresboek dat je niet kunt lezen.", "Archive them": "Archiveren", "Applications no longer in the export are archived. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd. De (i)-knop bovenaan legt uit wanneer.", - "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd, tenzij je kiest ze te laten zoals ze zijn." + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd, tenzij je kiest ze te laten zoals ze zijn.", + "An application of this municipality that is on no CMDB sheet any more is archived when the sheet \"{archive}\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column or has an APPID without a cached value, or the workbook lacks one of the CMDB sheets, applications are only archived and the result says so. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen enkel CMDB-tabblad meer staat, wordt gearchiveerd als het tabblad \"{archive}\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Ontbreekt dat tabblad, heeft het geen kolom APPID of heeft een APPID geen opgeslagen waarde, of mist de werkmap een van de CMDB-tabbladen, dan worden applicaties alleen gearchiveerd en zegt het resultaat dat. Kies Laat ze zoals ze zijn om ze ongemoeid te laten." } } diff --git a/openspec/changes/cmdb-import-archive-reconciliation/design.md b/openspec/changes/cmdb-import-archive-reconciliation/design.md index 5329cc153..dfb88b9a0 100644 --- a/openspec/changes/cmdb-import-archive-reconciliation/design.md +++ b/openspec/changes/cmdb-import-archive-reconciliation/design.md @@ -94,7 +94,7 @@ Per application, with `processed` = every APPID match key on the CMDB sheets (wh | on the archive sheet | deleted | restore, then archive | `archived` | | nowhere, sheet present | working or archived | soft-delete usage, then module | `deleted` | | nowhere, sheet present | deleted | nothing | — | -| nowhere, sheet absent or incomplete (no key column, `uncachedRows` not empty, or a CMDB-sheet row with an unreadable APPID) | working | archive usage, then module | `archived` | +| nowhere, sheet absent or incomplete (no key column, `uncachedRows` not empty, a CMDB-sheet row with an unreadable APPID, or a CMDB sheet absent from the workbook: `absentSheets` not empty) | working | archive usage, then module | `archived` | | nowhere, sheet absent or incomplete | archived or deleted | nothing | — | The table decides on one entry per key. When a municipality has several usages and modules under the same import key (D4 says earlier imports created exactly such duplicates, a module deleted by hand next to the one a later import created), the scope keeps the entry the import would match: the best module state wins (working, then archived, then deleted), the best usage state breaks a tie, the first listed breaks the rest. The other usages of the key follow the application, so a stray usage stays visible nowhere: a working or archived usage of a stale duplicate, a second usage of the same module, and a working or archived duplicate module are archived or soft-deleted as the key asks, once, in the same error boundary and the same report row (usages first, then modules). What is in the trash is left as it is: a stale trashed duplicate is not restored into the archive when the key is on the archive sheet, and is not touched when it is on no sheet. @@ -196,7 +196,7 @@ No seed objects change. The fragment's three seed modules stay as they are; the ## Risks / Trade-offs - [The default archives on the first re-import after the upgrade] → The section shows the choice with help text; the report lists every archived and deleted application; nothing is hard-deleted. -- [A workbook saved without the archive sheet, or whose archive sheet has no APPID column or an APPID without a cached value, or a CMDB sheet with an APPID without a cached value] → archive only, never delete, plus a warning (D3). +- [A workbook saved without the archive sheet, or whose archive sheet has no APPID column or an APPID without a cached value, or a CMDB sheet with an APPID without a cached value, or a workbook without one of the CMDB sheets] → archive only, never delete, plus a warning (D3). - [A failure part way through an application] → usages are written before modules, so a module is never changed under a usage that still shows; but a failing extra usage leaves the own usage changed, that extra visible and the module unchanged, and the row only says `failed`. The next import retries (D5). - [Reviving before writing adds calls per returning row] → only for rows whose match is archived or deleted; a row whose module is working costs the same searches as before, a new row one more search (the archive lens). - [The trash listing scans every magic table of the instance] → read once per import, not per row (D9). diff --git a/openspec/changes/cmdb-import-archive-reconciliation/proposal.md b/openspec/changes/cmdb-import-archive-reconciliation/proposal.md index d9685e710..0531293d2 100644 --- a/openspec/changes/cmdb-import-archive-reconciliation/proposal.md +++ b/openspec/changes/cmdb-import-archive-reconciliation/proposal.md @@ -34,7 +34,7 @@ None. ### In Scope - The sheet "Gearchiveerde Applicaties" (columns Applicatiecomponent, APPID, Applicatie Code, Applicatie Naam, Roepnaam, Applicatie Status, Bron, Datum Interface, Datum wijziging) is read for its APPID column only, when it is present. -- After every row of a completed import: the municipality's usages, with their modules, are compared with the APPIDs on the CMDB sheets and on the archive sheet. Missing and on the archive sheet: module and usage archived. Missing everywhere: module and usage soft-deleted. Without the archive sheet, or when it has no APPID column or an APPID the reader cannot see (a formula without a cached value, on the archive sheet or on a CMDB sheet): archived only, with a warning in the report. +- After every row of a completed import: the municipality's usages, with their modules, are compared with the APPIDs on the CMDB sheets and on the archive sheet. Missing and on the archive sheet: module and usage archived. Missing everywhere: module and usage soft-deleted. Without the archive sheet, or when it has no APPID column or an APPID the reader cannot see (a formula without a cached value, on the archive sheet or on a CMDB sheet), or when the workbook lacks one of the CMDB sheets: archived only, with a warning in the report. - A returning APPID: an archived module or usage is unarchived, a soft-deleted one is restored from the trash, and the row is then updated as before. - `missingRecords`: `keep` leaves everything as it is (the behaviour so far), `archive` reconciles and is the default; `remove` stays reserved and is refused. - The settings section offers the choice with help text; the report shows the new outcomes in its summary and rows. @@ -78,7 +78,7 @@ None. `ArchiveHandler` and `MagicMapper` come from OpenRegister, which stackiq a **Severity:** Medium — **Mitigation:** only the municipality's own usages and the modules they point at are in scope, never a supplier's or another municipality's module; nothing is hard-deleted; the choice is visible in the section with help text, and the report lists every archived and deleted application. A cancelled or failed import never reconciles. ### Risk 2: A workbook without a usable archive sheet soft-deletes everything that moved there -**Severity:** Medium — **Mitigation:** without the sheet, without its APPID column, or with an APPID the reader cannot see on it or on a CMDB sheet (a formula without a cached value), missing applications are archived, never soft-deleted, and the report warns, naming the sheet (and the rows). +**Severity:** Medium — **Mitigation:** without the sheet, without its APPID column, or with an APPID the reader cannot see on it or on a CMDB sheet (a formula without a cached value), or with a CMDB sheet absent from the workbook, missing applications are archived, never soft-deleted, and the report warns, naming the sheet (and the rows). ### Risk 3: The archive services are OpenRegister internals **Severity:** Low — **Mitigation:** resolved through the container behind a guard; when missing, an import with `missingRecords=archive` is refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read, and `keep` still works. diff --git a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md index cf59a6d3d..be258b8b3 100644 --- a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md +++ b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md @@ -45,7 +45,7 @@ Status in this PR (the reconciliation slice, on top of the reader slice): Tasks - **files**: `lib/Service/CmdbExportImportService.php`, `lib/Service/ProgressTracker.php`, `lib/Exception/CmdbImportException.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php` - **acceptance_criteria**: - GIVEN APPIDs 1 and 7 imported WHEN an export with row 1 and 7 on the archive sheet is imported THEN module and usage of 7 are archived with a reason, the report row is `archived`, and the owner contact person is untouched - - GIVEN APPID 7 on no sheet THEN module and usage are soft-deleted and the row is `deleted`; GIVEN the archive sheet absent, without an APPID column, or with an APPID that has no cached value, or a CMDB-sheet row whose APPID has no cached value THEN they are archived instead and the report warns + - GIVEN APPID 7 on no sheet THEN module and usage are soft-deleted and the row is `deleted`; GIVEN the archive sheet absent, without an APPID column, or with an APPID that has no cached value, or a CMDB-sheet row whose APPID has no cached value, or a workbook without one of the CMDB sheets, THEN they are archived instead and the report warns - GIVEN APPID 7 soft-deleted and listed on the archive sheet THEN module and usage are restored and archived; GIVEN it already archived or, when on no sheet, already deleted THEN nothing is written - GIVEN a working pair and a trashed pair under the same import key and the key on the archive sheet THEN the working pair is archived, nothing is restored and the trashed pair stays deleted; GIVEN a further working usage of the key (of the trashed module, or a second one of the working module) THEN it is archived or deleted with the application - GIVEN `missingRecords` `keep`, a cancelled import, or a usage of another municipality THEN nothing is archived or deleted diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js index 2f5a47b9b..fe979eee0 100644 --- a/src/utils/cmdbImport.js +++ b/src/utils/cmdbImport.js @@ -1239,7 +1239,7 @@ export function missingApplicationsText(archive = mappingArchiveSheet(null)) { } return t( 'stackiq', - 'An application of this municipality that is on no CMDB sheet any more is archived when the sheet "{archive}" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.', + 'An application of this municipality that is on no CMDB sheet any more is archived when the sheet "{archive}" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column or has an APPID without a cached value, or the workbook lacks one of the CMDB sheets, applications are only archived and the result says so. Choose Keep to leave them as they are.', { archive }, AS_TEXT, ) diff --git a/src/utils/cmdbImport.spec.js b/src/utils/cmdbImport.spec.js index 52f0a32a6..f06217de2 100644 --- a/src/utils/cmdbImport.spec.js +++ b/src/utils/cmdbImport.spec.js @@ -600,6 +600,7 @@ describe('the archive sheet in the help texts', () => { const withArchive = missingApplicationsText('Archief') expect(withArchive).toContain('archived when the sheet "Archief" lists it') expect(withArchive).toContain('moved to the trash') + expect(withArchive).toContain('or the workbook lacks one of the CMDB sheets') const without = missingApplicationsText(null) expect(without).not.toContain('trash') expect(without).not.toContain('sheet "') From 76699ce98180cbd9c691cd56e817ffce5f1965d1 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 19 Oct 2026 10:18:15 +0200 Subject: [PATCH 120/124] chore(parity): place the four specs that have capability rows on their boards in specScreens ai-system-inventory, contract-administration, maintenance-and-supplier-roadmap and open-data-publishing had rows on boards but no specScreens entry; each entry is the union of the boards of the rows of the spec's own feature, so every spec under openspec/specs is now a key of specScreens. Thread part 3 of the parity finding on #1269 (sq-f2). Co-Authored-By: Claude Opus 5.5 --- openspec/parity/capabilities.json | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index b19412d83..60f0880bc 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -8981,6 +8981,11 @@ "SkGebruikDetail" ] }, + "ai-system-inventory": { + "board": [ + "SkApplicatie" + ] + }, "app-id-rename": { "board": null, "reason": "Interne migratie van de app-id, geen scherm." @@ -9071,6 +9076,15 @@ "SkInstellingenGegevens" ] }, + "contract-administration": { + "board": [ + "SkApplicatie", + "SkContract", + "SkContracten", + "SkLicenties", + "SkRapportages" + ] + }, "contract-decision-delegation": { "board": [ "SkContract" @@ -9164,6 +9178,13 @@ "SkLicenties" ] }, + "maintenance-and-supplier-roadmap": { + "board": [ + "SkApplicatie", + "SkDashboard", + "SkModuleversie" + ] + }, "method-decomposition": { "board": null, "reason": "Refactoring van controllers en services, geen scherm." @@ -9189,6 +9210,12 @@ "SkOrganisatie" ] }, + "open-data-publishing": { + "board": [ + "SkApplicaties", + "SkPubliek" + ] + }, "org-archimate-export": { "board": [ "SkInstellingenGegevens" From 9da207d8d4bbfc567554595cd6cca1cf0fd35fd4 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 19 Oct 2026 10:19:34 +0200 Subject: [PATCH 121/124] test(usages): both owner columns resolve the contact name (sq-f3) Pins the contactName widget on businessOwner and technicalOwner of the Applications in use list; with technicalOwner back to a plain key the test fails. Co-Authored-By: Claude Opus 5.5 --- tests/vitest/usages.spec.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/vitest/usages.spec.js b/tests/vitest/usages.spec.js index 3bbc33a9f..fe5e876c4 100644 --- a/tests/vitest/usages.spec.js +++ b/tests/vitest/usages.spec.js @@ -99,6 +99,18 @@ describe('the usage pages', () => { expect(statuses).toEqual(usage.properties.status.enum) }) + it('resolves both owner columns to the contact name, not the contact person uuid', () => { + // A contact person carries only `contactsUid`; the name lives in + // Nextcloud Contacts, so a plain column would print the uuid. + const columns = page('Gebruik').config.columns + for (const owner of ['businessOwner', 'technicalOwner']) { + const column = columns.find( + (c) => (typeof c === 'string' ? c : c.key) === owner, + ) + expect(column, owner).toMatchObject({ widget: 'contactName' }) + } + }) + it('shows version, status and owners on the detail page and offers the lifecycle', () => { const detail = page('GebruikDetail') expect(detail.route).toBe('/gebruik/:id') From dc84d6b1832e585da91c9973071b3a7f019e409e Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 19 Oct 2026 12:28:46 +0200 Subject: [PATCH 122/124] feat(cmdb-import): match owners on employee number or e-mail from renameable placeholder columns The business-owner pack maps two optional columns, "Applicatie Eigenaar (E-mail)" and "Applicatie Eigenaar (Personeelsnummer)". Their names are placeholders: the administrator renames the source in OpenRegister's pack editor once the export's columns are named, without a stackiq release. Both are listed as absent on the CMDB sheets, so an export without them raises no warning. An owner is now matched on the employee number first (a contact UID derived from the number and the municipality, the number itself is not stored), then on the e-mail address, then on the exact name, so two namesakes stay two owners. OpenSpec change cmdb-import-owner-identity (MODIFIED REQ-CMDB-010). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/features/cmdb-import.md | 30 +++++- lib/Service/Cmdb/CmdbImportPackRules.php | 7 +- lib/Service/CmdbExportImportService.php | 83 +++++++++++++--- lib/Service/StackiqContactSyncService.php | 12 ++- .../cmdb-import/topdesk-business-owner.json | 8 +- lib/Settings/cmdb-import/topdesk-profile.json | 5 +- .../specs/cmdb-export-import/spec.md | 2 +- .../cmdb-import-owner-identity/.openspec.yaml | 2 + .../cmdb-import-owner-identity/proposal.md | 47 ++++++++++ .../specs/cmdb-export-import/spec.md | 91 ++++++++++++++++++ .../cmdb-import-owner-identity/tasks.md | 28 ++++++ tests/Unit/Repair/SeedCmdbImportPacksTest.php | 2 +- .../Service/Cmdb/CmdbImportPackRulesTest.php | 21 ++++- .../Service/Cmdb/CmdbImportProfileTest.php | 17 +++- .../Service/CmdbExportImportServiceTest.php | 94 ++++++++++++++++++- .../Service/StackiqContactSyncServiceTest.php | 25 +++++ 16 files changed, 436 insertions(+), 38 deletions(-) create mode 100644 openspec/changes/cmdb-import-owner-identity/.openspec.yaml create mode 100644 openspec/changes/cmdb-import-owner-identity/proposal.md create mode 100644 openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec.md create mode 100644 openspec/changes/cmdb-import-owner-identity/tasks.md diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md index b54fad71e..94f64928e 100644 --- a/docs/features/cmdb-import.md +++ b/docs/features/cmdb-import.md @@ -192,6 +192,7 @@ editor an administrator can change it there (see | End-of-Life Functioneel | usage phase-out date | Excel date, stored as is | | (the sheet), Cluster, Applicatie Eigenaar (Afdeling) | usage internal annotation | `Beheer geregeld: ja` or `nee`, the cluster and the department, joined with ` / `; written only when the usage is new or the note is empty | | Applicatie Eigenaar (Persoon), Applicatie Eigenaar (Functie) | usage business owner (contact person) | see [Owners](#owners) | +| Applicatie Eigenaar (E-mail), Applicatie Eigenaar (Personeelsnummer) | how the business owner is recognised | optional, placeholder names; see [Owners](#owners) | Columns not in this table are not read at all. That includes Hostingpartij and Leverancier (not mapped yet), the BIV and value columns (Beschikbaarheid, @@ -339,11 +340,30 @@ The identity is kept in **Nextcloud Contacts**, in a dedicated address book, creates that address book the first time it needs it. The import looks for an existing contact only in that address book, never in the administrator's own address books: a personal contact who happens to have the owner's name -is not linked to the application. The CMDB sheets have no e-mail address, so -a contact is found by an exact match on the name in **Stackiq CMDB owners**, -and created there when there is none. The -stackiq contact person object only holds the link to that contact, the role -and the municipality. The same owner on several rows is one contact person. +is not linked to the application. Within **Stackiq CMDB owners** the import +recognises an owner, in this order: + +1. by the **employee number**, when the row has one. Two people with the same + name stay two owners, and an owner whose name changes keeps their contact. + The number is not stored on the contact; the contact's id is derived from + it and the municipality. +2. by the **e-mail address**, when the row has one and no employee number. +3. by an exact match on the **name** otherwise. + +When nothing matches, the contact is created there, with the e-mail address +when the row has one. The stackiq contact person object only holds the link +to that contact, the role and the municipality. The same owner on several +rows is one contact person. + +The e-mail address and employee number come from the optional columns +`Applicatie Eigenaar (E-mail)` and `Applicatie Eigenaar (Personeelsnummer)`. +Those names are placeholders: the export does not have these columns yet. +When your export names them differently, change the source column of the +business-owner pack in OpenRegister (**Edit in OpenRegister** in the +**CMDB import** section); no stackiq update is needed. An export without the +columns imports as before, without a warning. Once you add the employee +number, an owner who was found by name until then gets a new contact; the +old contact stays in **Stackiq CMDB owners**. When the Contacts app is disabled, applications and usages are still imported; the owners are skipped and each affected row carries a warning. diff --git a/lib/Service/Cmdb/CmdbImportPackRules.php b/lib/Service/Cmdb/CmdbImportPackRules.php index 9da72dbce..a8ca512f1 100644 --- a/lib/Service/Cmdb/CmdbImportPackRules.php +++ b/lib/Service/Cmdb/CmdbImportPackRules.php @@ -70,13 +70,14 @@ class CmdbImportPackRules { /** * The fields a pack may map or default for a target that writes into no schema. * - * The business owner is resolved in Nextcloud Contacts by name, with the - * role on the contact person, so it has no schema to read its fields from. + * The business owner is resolved in Nextcloud Contacts by employee number, + * e-mail address or name, with the role on the contact person, so it has + * no schema to read its fields from. * * @var array> */ private const FIELDS_WITHOUT_SCHEMA = [ - 'businessOwner' => ['name', 'role'], + 'businessOwner' => ['name', 'role', 'email', 'employeeNumber'], ]; /** diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php index a96f5f2ca..05b585d7e 100644 --- a/lib/Service/CmdbExportImportService.php +++ b/lib/Service/CmdbExportImportService.php @@ -1515,7 +1515,7 @@ private function resolveOwners(array $values, int $rowNumber, string $municipali foreach ($identities as $target => $identity) { $column = $this->ownerColumn(target: $target); try { - $contactsUid = $this->resolveContactUid(identity: $identity); + $contactsUid = $this->resolveContactUid(identity: $identity, municipalityUuid: $municipalityUuid); if ($contactsUid === null) { $warnings[] = $this->l10n->t('Owner from column "%s" could not be resolved in Nextcloud Contacts', [$column]); continue; @@ -1543,25 +1543,36 @@ private function resolveOwners(array $values, int $rowNumber, string $municipali * * Contacts are matched, and created, only in the importing admin's * dedicated "Stackiq CMDB owners" address book, never in the admin's - * other address books. With an e-mail address, StackiqContactSyncService - * matches on it there or creates the contact. Without one, only a contact - * there whose display name is exactly the owner's name (case-insensitive) - * is reused, so an owner known by name alone is not created again on - * every import. - * - * @param array $identity name, email and role from the owner pack. + * other address books. With an employee number, the contact is the one + * whose UID derives from that number and the municipality, so two + * namesakes stay two contacts and a renamed owner keeps the same one; + * the number itself is never stored. Without a number but with an + * e-mail address, StackiqContactSyncService matches on it there or + * creates the contact. Without either, only a contact there whose + * display name is exactly the owner's name (case-insensitive) is reused, + * so an owner known by name alone is not created again on every import. + * + * @param array $identity name, email, employeeNumber and role from the owner pack. + * @param string $municipalityUuid The municipality whose employee numbers these are. * * @return string|null The contact UID, or null. * * @spec openspec/changes/cmdb-export-import/tasks.md#task-6 + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 */ - private function resolveContactUid(array $identity): ?string { + private function resolveContactUid(array $identity, string $municipalityUuid): ?string { $parts = self::splitPersonName(name: (string)$identity['name']); $displayName = trim($parts['voornaam'] . ' ' . $parts['achternaam']); $email = trim((string)($identity['email'] ?? '')); + $numberUid = self::ownerUidForEmployeeNumber( + employeeNumber: (string)($identity['employeeNumber'] ?? ''), + municipalityUuid: $municipalityUuid + ); $cacheKey = 'name:' . mb_strtolower($displayName); - if ($email !== '') { + if ($numberUid !== null) { + $cacheKey = 'uid:' . $numberUid; + } elseif ($email !== '') { $cacheKey = 'email:' . mb_strtolower($email); } @@ -1570,7 +1581,9 @@ private function resolveContactUid(array $identity): ?string { } $uid = null; - if ($email === '') { + if ($numberUid !== null) { + $uid = $this->contactByUid(uid: $numberUid); + } elseif ($email === '') { $uid = $this->contactByDisplayName(displayName: $displayName); } @@ -1589,7 +1602,8 @@ private function resolveContactUid(array $identity): ?string { objectType: 'contactPerson', record: $record, addressBookUri: self::OWNER_ADDRESS_BOOK_URI, - displayName: $this->l10n->t('Stackiq CMDB owners') + displayName: $this->l10n->t('Stackiq CMDB owners'), + fixedUid: $numberUid ); if ($uid === '') { $uid = null; @@ -1600,6 +1614,51 @@ private function resolveContactUid(array $identity): ?string { return $uid; }//end resolveContactUid() + /** + * The contact UID of an owner with this employee number, or null without one. + * + * A hash, so the employee number is not readable from the contact, and + * scoped to the municipality, so the same number at two municipalities + * is two owners. A number Excel stored as a decimal ("1234.0") is the + * same number as "1234". + * + * @param string $employeeNumber The employee number from the owner pack. + * @param string $municipalityUuid The municipality. + * + * @return string|null + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + public static function ownerUidForEmployeeNumber(string $employeeNumber, string $municipalityUuid): ?string { + $number = mb_strtolower(trim($employeeNumber)); + $number = (string)preg_replace('/^(\d+)\.0+$/', '$1', $number); + if ($number === '') { + return null; + } + + return 'stackiq-cmdb-owner-' . hash('sha256', $municipalityUuid . '|' . $number); + }//end ownerUidForEmployeeNumber() + + /** + * The contact in the owners' address book with exactly this UID. + * + * @param string $uid The contact UID. + * + * @return string|null The contact UID, or null. + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + private function contactByUid(string $uid): ?string { + $contacts = $this->contactSync->searchNamedAddressBook(query: $uid, addressBookUri: self::OWNER_ADDRESS_BOOK_URI, properties: ['UID']); + foreach ($contacts as $contact) { + if ((string)($contact['uid'] ?? '') === $uid) { + return $uid; + } + } + + return null; + }//end contactByUid() + /** * The contact in the owners' address book whose display name is exactly this one, case-insensitive. * diff --git a/lib/Service/StackiqContactSyncService.php b/lib/Service/StackiqContactSyncService.php index 7ebebdbca..256e9866e 100644 --- a/lib/Service/StackiqContactSyncService.php +++ b/lib/Service/StackiqContactSyncService.php @@ -191,12 +191,20 @@ public function syncToContacts(string $objectType, array $record): ?string { * @param array $record The relationship record. * @param string $addressBookUri The address book's URI, stable across languages. * @param string $displayName The display name for a new address book. + * @param string|null $fixedUid The UID for a new contact, so the caller can find it again by UID; a random one when null. * * @return ?string The contacts UID, or null when it could not be resolved or created. * * @spec openspec/specs/softwarecatalog-contacts-to-nc/spec.md + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 */ - public function syncToNamedAddressBook(string $objectType, array $record, string $addressBookUri, string $displayName): ?string { + public function syncToNamedAddressBook( + string $objectType, + array $record, + string $addressBookUri, + string $displayName, + ?string $fixedUid = null, + ): ?string { if ($this->isAvailable() === false) { return null; } @@ -231,7 +239,7 @@ public function syncToNamedAddressBook(string $objectType, array $record, string uri: $addressBookUri, displayName: $displayName ); - $contactUid = self::newUid(); + $contactUid = ($fixedUid ?? self::newUid()); $backend->createCard($addressBookId, $contactUid . '.vcf', self::serialiseVCard(uid: $contactUid, properties: $properties)); } catch (Throwable $e) { $this->logger->warning( diff --git a/lib/Settings/cmdb-import/topdesk-business-owner.json b/lib/Settings/cmdb-import/topdesk-business-owner.json index 3bc7e1921..a2b09d728 100644 --- a/lib/Settings/cmdb-import/topdesk-business-owner.json +++ b/lib/Settings/cmdb-import/topdesk-business-owner.json @@ -1,12 +1,14 @@ { "id": "stackiq-topdesk-business-owner", "name": "TOPdesk CMDB export to business owner identity", - "description": "The application owner (Applicatie Eigenaar (Persoon)); the value may be a function instead of a name and is used as the display name either way. The import resolves it in Nextcloud Contacts by exact display name and links a contactPerson of the municipality as usage.businessOwner, with the owner's function as its role. No other person column is read.", + "description": "The application owner (Applicatie Eigenaar (Persoon)); the value may be a function instead of a name and is used as the display name either way. The import resolves it in Nextcloud Contacts and links a contactPerson of the municipality as usage.businessOwner, with the owner's function as its role. It matches on the employee number when the row has one, else on the e-mail address, else on the exact display name. The e-mail and employee number columns are optional; rename their source here when the export names them differently. No other person column is read.", "sourceFormat": "excel", - "version": "1.0.0", + "version": "1.1.0", "fieldMappings": [ { "source": "Applicatie Eigenaar (Persoon)", "target": "name", "required": true, "transform": { "type": "trim" } }, - { "source": "Applicatie Eigenaar (Functie)", "target": "role", "transform": { "type": "trim" } } + { "source": "Applicatie Eigenaar (Functie)", "target": "role", "transform": { "type": "trim" } }, + { "source": "Applicatie Eigenaar (E-mail)", "target": "email", "transform": { "type": "trim" } }, + { "source": "Applicatie Eigenaar (Personeelsnummer)", "target": "employeeNumber", "transform": { "type": "trim" } } ], "idStrategy": { "type": "generate" } } diff --git a/lib/Settings/cmdb-import/topdesk-profile.json b/lib/Settings/cmdb-import/topdesk-profile.json index a21cf3c15..df2323931 100644 --- a/lib/Settings/cmdb-import/topdesk-profile.json +++ b/lib/Settings/cmdb-import/topdesk-profile.json @@ -13,11 +13,12 @@ { "name": "Onbeh Applicaties CMDB", "constants": { "Beheer": "Beheer geregeld: nee" }, - "absentColumns": ["Nickname"] + "absentColumns": ["Nickname", "Applicatie Eigenaar (E-mail)", "Applicatie Eigenaar (Personeelsnummer)"] }, { "name": "Beheerde Applicaties CMDB", - "constants": { "Beheer": "Beheer geregeld: ja" } + "constants": { "Beheer": "Beheer geregeld: ja" }, + "absentColumns": ["Applicatie Eigenaar (E-mail)", "Applicatie Eigenaar (Personeelsnummer)"] } ], "sheetPrecedence": ["Beheerde Applicaties CMDB", "Onbeh Applicaties CMDB"], diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md index 2d8414c5c..2560fc008 100644 --- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md +++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md @@ -57,7 +57,7 @@ For the module, manufacturer, usage and business-owner targets the import SHALL Every pack, stored or shipped, SHALL pass OpenRegister's `PackDefinitionValidator` and the import's own rules, or the import SHALL stop with 503 `MAPPING_UNAVAILABLE` before any row is read, naming the pack (its file, or its OpenRegister slug and row id) and every rule it breaks; it SHALL NOT fall back to the shipped file. The rules are: - the module pack SHALL map `name`, and SHALL map the profile's key column (`APPID`) to `externalNumber`, both with `required: true`; the manufacturer, municipality and business-owner packs SHALL map `name` with `required: true`; -- a pack SHALL map, and set as a default, only properties of its target schema in the register stackiq ships, fragments included (module; organisation for manufacturer and municipality; usage); every such property is allowed unless a rule below refuses it, so no list of fields is kept in code; the business-owner pack, which writes into no schema, only `name` and `role`; +- a pack SHALL map, and set as a default, only properties of its target schema in the register stackiq ships, fragments included (module; organisation for manufacturer and municipality; usage); every such property is allowed unless a rule below refuses it, so no list of fields is kept in code; the business-owner pack, which writes into no schema, only `name`, `role`, `email` and `employeeNumber` (since cmdb-import-owner-identity); - no pack SHALL map or default `@self`, `id`, `externalKey`, `provider`, `consumer`, `module`, `businessOwner`, a field the profile lists as never written, a relation or a file (a property whose definition or items carry a `$ref`, an `inversedBy`, a `writeBack`, `format: uuid`, `type: file` or a related object), a property the register hides from forms (`hideOnForm: true`, in the base register or a fragment) or calculates (an `x-openregister-calculations` entry), which other subsystems own (the service desk exchange's `serviceDeskSystem`, `serviceDeskRecordId`, `serviceDeskUrl` and `serviceDeskSyncedAt`, the calculated `suggestedTimeClassification`), a usage's `installedVersion`, `publicationDate`, `depublicationDate`, `registeredBy`, `registrationStatus`, `mergedInto`, `contactsUid`, `pki`, `xml`, an organisation's `status` or a module's `type`; - the manufacturer and municipality packs SHALL NOT map `type` and SHALL keep the defaults as shipped: `type` `Supplier` and `Municipality`, `status` `Active`, and the manufacturer's `registeredBy` `Supplier`. diff --git a/openspec/changes/cmdb-import-owner-identity/.openspec.yaml b/openspec/changes/cmdb-import-owner-identity/.openspec.yaml new file mode 100644 index 000000000..943df390c --- /dev/null +++ b/openspec/changes/cmdb-import-owner-identity/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-10-19 diff --git a/openspec/changes/cmdb-import-owner-identity/proposal.md b/openspec/changes/cmdb-import-owner-identity/proposal.md new file mode 100644 index 000000000..e3c511e71 --- /dev/null +++ b/openspec/changes/cmdb-import-owner-identity/proposal.md @@ -0,0 +1,47 @@ +--- +kind: code +depends_on: [cmdb-import-mapping-view] +--- + +# Proposal: cmdb-import-owner-identity + +## Summary + +The CMDB import recognises an application owner by employee number first, then by e-mail address, then by name. The business-owner pack maps two optional columns, `Applicatie Eigenaar (E-mail)` and `Applicatie Eigenaar (Personeelsnummer)`, whose names are placeholders: the administrator renames their source in OpenRegister's pack editor once the export's real column names are known, and no stackiq release is needed for that. + +## Motivation + +Owners are matched on their display name, so two people with the same name become one contact (Jira WOO-586, F29). Gemeente Rotterdam answered on 2026-10-15 that it can add the owner's e-mail address and employee number to the CMDB sheets, and that the employee number never changes; on 2026-10-19 it said it knows the column names but first wants to align them across its teams. Shipping placeholders now, editable in OpenRegister (cmdb-import-mapping-view), lets the municipality pick the names later without waiting for a release. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cmdb-export-import`: MODIFIED REQ-CMDB-010 (owner matching on employee number and e-mail address, the two optional columns as placeholders, the business-owner pack's allowed fields). + +## Affected Projects + +- [ ] Project: `stackiq`: the pack rules, the business-owner pack and the profile under `lib/Settings/cmdb-import/`, the owner resolution in `CmdbExportImportService`, `StackiqContactSyncService`, documentation, tests. + +## Scope + +### In Scope + +- `email` and `employeeNumber` as fields the business-owner pack may map. +- The shipped business-owner pack 1.1.0 with the two placeholder columns; both listed as absent on the CMDB sheets, so an export without them raises no warning. +- Matching an owner on a contact UID derived from the employee number and the municipality, in the "Stackiq CMDB owners" address book; creating the contact with that UID. + +### Out of Scope + +- Storing the employee number itself anywhere. +- Merging the contact an owner had before the employee number arrived into the new one. +- Changing the profile's column lists in OpenRegister: the profile is not an editable pack. + +## Risks + +- An owner first imported by name gets a second contact once the employee number arrives; the old contact stays in the address book. Documented. +- An unedited stored business-owner pack is replaced by 1.1.0 on update (REQ-CMDB-022); an edited one is kept and the new columns are then not added, with the existing "newer shipped version not applied" warning. diff --git a/openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec.md new file mode 100644 index 000000000..b2f4c6ae7 --- /dev/null +++ b/openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec.md @@ -0,0 +1,91 @@ +# cmdb-export-import Specification (delta: cmdb-import-owner-identity) + +**Status**: in-progress +**Scope**: stackiq +**OpenSpec changes**: +- [cmdb-export-import](../../../archive/2026-10-05-cmdb-export-import/) _(archived 2026-10-05)_ +- [cmdb-import-owner-identity](../../) + +## Purpose + +The CMDB import recognises an owner by display name, so two people with the same name become one contact (Jira WOO-586, finding F29/F51). The municipality will add the owner's e-mail address and employee number to its export, but has not named the columns yet. This delta lets the business-owner pack map both, matches an owner on the employee number first, and ships placeholder column names that the administrator renames in OpenRegister's pack editor once the export's names are known, without a stackiq release. + +Nextcloud OCP interfaces used: `OCP\Contacts\IManager` (through `StackiqContactSyncService`), `OCP\IL10N` (messages). + +## MODIFIED Requirements + +### Requirement: The owner SHALL become a contact person of the municipality through Nextcloud Contacts, never a user account, and SHALL never be publicly readable (REQ-CMDB-010) + +The business owner pack SHALL map "Applicatie Eigenaar (Persoon)" (the display name, which may be a function instead of a person's name) to `name` and "Applicatie Eigenaar (Functie)" (the role) to `role`, and SHALL map the optional columns "Applicatie Eigenaar (E-mail)" to `email` and "Applicatie Eigenaar (Personeelsnummer)" to `employeeNumber`. Those two column names are placeholders until the municipality names the columns of its export; an administrator renames their `source` in OpenRegister's pack editor (REQ-CMDB-021), and the import then reads the renamed columns without a stackiq release. The profile SHALL list the two placeholder columns as absent on both CMDB sheets, so an export without them raises no "optional column not found" warning. The business-owner pack SHALL map, and set as a default, only `name`, `role`, `email` and `employeeNumber` (extending the list in REQ-CMDB-021). No technical owner SHALL be imported; the functional administrator columns SHALL NOT be read. For the owner the service SHALL resolve a Nextcloud contact through `StackiqContactSyncService`, all in a dedicated address book "Stackiq CMDB owners" of the admin who runs the import (`StackiqContactSyncService::syncToNamedAddressBook()` and `searchNamedAddressBook()`), which is created on first use: when the row has an employee number, by the contact whose UID is `stackiq-cmdb-owner-` followed by the SHA-256 of the municipality's uuid, `|` and the lower-cased, trimmed number (a number Excel stored as `1234.0` counts as `1234`), and otherwise by creating the contact with that UID, so two namesakes with their own number stay two owners and a renamed owner keeps the contact; the number itself SHALL NOT be stored on the contact. Without an employee number it SHALL match on the e-mail address when the row has one, and otherwise on an exact match on the display name, and otherwise create one. A new contact SHALL carry the e-mail address when the row has one. It SHALL NOT match a contact in any other address book of the admin, and SHALL NOT add an owner to them. It SHALL then reuse or create one `contactPerson` with that `contactsUid`, `organization` = the municipality and `role` = "Applicatie Eigenaar (Functie)" when given, and SHALL set `usage.businessOwner` to it. An owner first imported by name or e-mail address and later with an employee number gets the employee-number contact and its own `contactPerson`; the usage follows the export (REQ-CMDB-009) and the earlier contact stays in the address book. The import SHALL NOT create Nextcloud user accounts. When Nextcloud Contacts is unavailable, the row SHALL be imported without an owner and SHALL carry a warning. `contactPerson` and `usage` SHALL have no public read rule, so the owner is never readable by an anonymous visitor; a published module SHALL refer to them by relation only. + +#### Scenario: The owner becomes the business owner +@e2e exclude Needs a Contacts address book; tests/Unit/Service/CmdbExportImportServiceTest.php asserts the calls to a StackiqContactSyncService test double and the saved contactPerson. + +- **GIVEN** the "Onbeh" row with "Applicatie Eigenaar (Persoon)" `Achternaam, Voornaam` and "Applicatie Eigenaar (Functie)" `Afdelingshoofd`, and the "Beheerde" row whose person column holds the function `Teamleider Applicatiebeheer` +- **WHEN** they are imported for "Gemeente Voorbeeldstad" +- **THEN** one `contactPerson` SHALL exist per owner with the resolved `contactsUid`, `organization` = "Gemeente Voorbeeldstad" and the function as `role` +- **AND** each usage SHALL have `businessOwner` = its owner's contact person and no `technicalOwner` +- **AND** no Nextcloud user account SHALL be created + +#### Scenario: Imported owners are never readable anonymously +@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts + +- **GIVEN** the anonymised export was imported, creating contact persons for its owners +- **WHEN** a visitor who is not signed in lists the `contactPerson` and `usage` objects through OpenRegister, or searches OpenCatalogi for an imported application +- **THEN** OpenRegister SHALL return no contact person and no usage +- **AND** the OpenCatalogi search hit SHALL carry no owner name, and its `contactPerson` and `usages` SHALL be empty or ids only + +#### Scenario: The same owner on two rows is one contact person +@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testTheSameOwnerOnTwoRowsIsOneContactPerson. + +- **GIVEN** two rows with the same "Applicatie Eigenaar (Persoon)" +- **WHEN** they are imported +- **THEN** exactly one `contactPerson` for that contact SHALL exist for the municipality, referenced by both usages + +#### Scenario: A namesake in the admin's own address book is not linked +@e2e exclude Needs a CardDAV backend with two address books; tests/Unit/Service/CmdbExportImportServiceTest.php testOwnersAreMatchedOnlyInTheOwnersAddressBook asserts a personal contact with the owner's exact name is not linked while a contact in the owners' address book is reused, and tests/Unit/Service/StackiqContactSyncServiceTest.php testAnExistingContactIsMatchedOnlyInTheNamedAddressBook asserts the same for an e-mail address against the contacts manager's address book keys. + +- **GIVEN** an admin whose personal address book holds a contact "Voornaam Achternaam", and whose "Stackiq CMDB owners" address book holds "Teamleider Applicatiebeheer" +- **WHEN** they import a row with owner `Achternaam, Voornaam` and a row with owner `Teamleider Applicatiebeheer` +- **THEN** the first owner SHALL get a new contact in "Stackiq CMDB owners", and the personal contact SHALL NOT be linked or changed +- **AND** the second owner SHALL reuse the contact already in "Stackiq CMDB owners" + +#### Scenario: A new owner contact goes into the dedicated address book +@e2e exclude Needs a CardDAV backend; tests/Unit/Service/StackiqContactSyncServiceTest.php testNewContactsGoIntoTheNamedAddressBook asserts the "Stackiq CMDB owners" address book is created once and holds every new card, with no other address book written, and tests/Unit/Service/CmdbExportImportServiceTest.php testTheOwnerBecomesTheBusinessOwner asserts the import asks for that address book. + +- **GIVEN** an admin with a personal address book and no "Stackiq CMDB owners" address book +- **WHEN** they import a row whose owner has no Nextcloud contact yet +- **THEN** an address book "Stackiq CMDB owners" SHALL be created for that admin and SHALL hold the new contact +- **AND** the admin's personal address book SHALL NOT receive a contact +- **AND** when the address book cannot be created, the row SHALL be imported without an owner and SHALL carry a warning + +#### Scenario: Contacts disabled does not block the import +@e2e exclude Environment condition; tests/Unit/Service/CmdbExportImportServiceTest.php sets isAvailable() to false. + +- **GIVEN** the Nextcloud Contacts app is disabled +- **WHEN** the anonymised export is imported +- **THEN** both modules and usages SHALL be saved without owners +- **AND** each row with an owner SHALL carry the warning that owners were skipped because Contacts is unavailable + +#### Scenario: An owner with an employee number is matched on it, not on the name +@e2e exclude Needs a CardDAV backend; tests/Unit/Service/CmdbExportImportServiceTest.php testAnOwnerWithAnEmployeeNumberIsMatchedOnItNotOnTheName asserts the contacts and contact persons through a StackiqContactSyncService test double, and tests/Unit/Service/StackiqContactSyncServiceTest.php testANewContactTakesTheGivenUid asserts the card is created with the given UID. + +- **GIVEN** two rows whose owner is `Achternaam, Voornaam`, with employee numbers `1001` and `2002` +- **WHEN** they are imported for "Gemeente Voorbeeldstad" +- **THEN** two contacts and two `contactPerson` objects SHALL exist, and the UID of the first SHALL be the one derived from `1001` and the municipality, without the number in it +- **AND** when a later export names the owner of `1001` `Nieuwenaam, Voornaam` with the number stored as `1001.0`, the usage SHALL keep that same contact person + +#### Scenario: Without an employee number the e-mail address matches the owner +@e2e exclude Needs a CardDAV backend; tests/Unit/Service/CmdbExportImportServiceTest.php testAnOwnerWithOnlyAnEmailAddressIsMatchedOnIt. + +- **GIVEN** the owners' address book holds a contact "Voornaam Achternaam" without an e-mail address +- **WHEN** a row with owner `Achternaam, Voornaam` and e-mail address `v.achternaam@example.org` is imported twice +- **THEN** one new contact with that e-mail address SHALL exist next to the namesake, and one `contactPerson` + +#### Scenario: Renamed owner columns are read without a stackiq release +@e2e exclude Needs OpenRegister's pack store; tests/Unit/Service/CmdbExportImportServiceTest.php testRenamedOwnerColumnsInOpenRegisterAreRead uses the fake migration-pack service, and tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php testTheOwnerPackMayMapAnEmailAddressAndAnEmployeeNumber asserts the renamed pack passes the import's rules. + +- **GIVEN** the business-owner pack in OpenRegister maps "E-mailadres eigenaar" to `email` and "Pers.nr. eigenaar" to `employeeNumber` +- **WHEN** a row with those columns is imported +- **THEN** the owner SHALL be matched on the employee number and the new contact SHALL carry the e-mail address +- **AND** the row SHALL carry no warning diff --git a/openspec/changes/cmdb-import-owner-identity/tasks.md b/openspec/changes/cmdb-import-owner-identity/tasks.md new file mode 100644 index 000000000..5020ec5ee --- /dev/null +++ b/openspec/changes/cmdb-import-owner-identity/tasks.md @@ -0,0 +1,28 @@ +# Tasks: cmdb-import-owner-identity + +Spec: `openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec.md` (`SPEC` below). + +## Implementation Tasks + +### Task 1: The business-owner pack maps an e-mail address and an employee number from placeholder columns +- **spec_ref**: `SPEC#requirement-the-owner-shall-become-a-contact-person-of-the-municipality-through-nextcloud-contacts-never-a-user-account-and-shall-never-be-publicly-readable-req-cmdb-010` (cmdb-export-import#REQ-CMDB-010) +- **files**: `lib/Service/Cmdb/CmdbImportPackRules.php`, `lib/Settings/cmdb-import/topdesk-business-owner.json`, `lib/Settings/cmdb-import/topdesk-profile.json`, `docs/features/cmdb-import.md`, `tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php`, `tests/Unit/Service/Cmdb/CmdbImportProfileTest.php`, `tests/Unit/Repair/SeedCmdbImportPacksTest.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php` +- **acceptance_criteria**: + - GIVEN the business-owner pack with its sources renamed WHEN the import's rules check it THEN it passes; GIVEN it maps `telephone` THEN it is refused + - GIVEN the shipped pack THEN it maps `Applicatie Eigenaar (E-mail)` to `email` and `Applicatie Eigenaar (Personeelsnummer)` to `employeeNumber`, version 1.1.0 + - GIVEN an export without those columns THEN no "optional column not found" warning is raised + - GIVEN the pack in OpenRegister maps renamed columns WHEN a row with them is imported THEN they are read +- [x] Implement +- [x] Test + +### Task 2: The owner is matched on the employee number, then the e-mail address, then the name +- **spec_ref**: `SPEC#requirement-the-owner-shall-become-a-contact-person-of-the-municipality-through-nextcloud-contacts-never-a-user-account-and-shall-never-be-publicly-readable-req-cmdb-010` (cmdb-export-import#REQ-CMDB-010) +- **files**: `lib/Service/CmdbExportImportService.php`, `lib/Service/StackiqContactSyncService.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php`, `tests/Unit/Service/StackiqContactSyncServiceTest.php` +- **acceptance_criteria**: + - GIVEN two namesakes with their own employee number THEN two contacts and two contact persons + - GIVEN a renamed owner with the same number (also as `1001.0`) THEN the same contact person + - GIVEN the same number at another municipality THEN another contact UID; the number is not readable from the UID + - GIVEN only an e-mail address THEN the owner is matched on it, not on a namesake without one + - GIVEN a fixed UID WHEN a new contact is created THEN the card has that UID +- [x] Implement +- [x] Test diff --git a/tests/Unit/Repair/SeedCmdbImportPacksTest.php b/tests/Unit/Repair/SeedCmdbImportPacksTest.php index fb4aed5f7..0657f34cc 100644 --- a/tests/Unit/Repair/SeedCmdbImportPacksTest.php +++ b/tests/Unit/Repair/SeedCmdbImportPacksTest.php @@ -152,7 +152,7 @@ public function testUneditedPacksAreUpdatedAndEditedOnesKept(): void { ); $this->assertContains( 'warning: CMDB import pack stackiq-topdesk-business-owner (id ' . $ownerId . ') was edited in OpenRegister and is kept; ' - . 'this release ships version 1.0.0, which is not applied', + . 'this release ships version 1.1.0, which is not applied', $this->lines ); }//end testUneditedPacksAreUpdatedAndEditedOnesKept() diff --git a/tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php b/tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php index 45f05d78d..4523031a8 100644 --- a/tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php +++ b/tests/Unit/Service/Cmdb/CmdbImportPackRulesTest.php @@ -112,7 +112,7 @@ public static function refusedMappings(): array { 'the organisation image, a file' => ['manufacturer', 'image', '"image" is a relation or a file and cannot be mapped'], 'a field the module schema does not have' => ['module', 'noSuchField', '"noSuchField" is not a field the module pack may map'], 'a field the usage schema does not have' => ['usage', 'noSuchField', '"noSuchField" is not a field the usage pack may map'], - 'a contact field the business owner does not have' => ['businessOwner', 'email', '"email" is not a field the businessOwner pack may map'], + 'a contact field the business owner does not have' => ['businessOwner', 'telephone', '"telephone" is not a field the businessOwner pack may map'], ]; }//end refusedMappings() @@ -135,6 +135,25 @@ public function testARefusedFieldCannotBeMapped(string $target, string $field, s $this->assertSame([$rule], (new CmdbImportPackRules())->violations(target: $target, pack: $pack, profile: $profile)); }//end testARefusedFieldCannotBeMapped() + /** + * The owner pack may map an e-mail address and an employee number, from any column. + * + * @return void + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-1 + */ + public function testTheOwnerPackMayMapAnEmailAddressAndAnEmployeeNumber(): void { + $profile = $this->shipped(file: 'topdesk-profile.json'); + $pack = $this->shipped(file: 'topdesk-business-owner.json'); + $pack['fieldMappings'] = array_map( + fn (array $mapping): array => array_merge($mapping, ['source' => 'Kolom ' . $mapping['target']]), + $pack['fieldMappings'] + ); + + $this->assertSame(['name', 'role', 'email', 'employeeNumber'], array_column($pack['fieldMappings'], 'target')); + $this->assertSame([], (new CmdbImportPackRules())->violations(target: 'businessOwner', pack: $pack, profile: $profile)); + }//end testTheOwnerPackMayMapAnEmailAddressAndAnEmployeeNumber() + /** * A refused field cannot be set as a default either, and the admin defaults keep their shipped value. * diff --git a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php index 9d7fc0c68..b3dd34995 100644 --- a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php +++ b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php @@ -135,7 +135,15 @@ public function testThePacksImplementTheColumnTable(): void { ['Applicatie Status' => 'status', 'Classificatie' => 'timeClassification', 'End-of-Life Functioneel' => 'startDateOutPhased', 'Beheer' => 'interneAnnotation'], $targets('usage') ); - $this->assertSame(['Applicatie Eigenaar (Persoon)' => 'name', 'Applicatie Eigenaar (Functie)' => 'role'], $targets('businessOwner')); + $this->assertSame( + [ + 'Applicatie Eigenaar (Persoon)' => 'name', + 'Applicatie Eigenaar (Functie)' => 'role', + 'Applicatie Eigenaar (E-mail)' => 'email', + 'Applicatie Eigenaar (Personeelsnummer)' => 'employeeNumber', + ], + $targets('businessOwner') + ); $this->assertSame(['module', 'manufacturer', 'municipality', 'usage', 'businessOwner'], CmdbImportProfile::TARGETS, 'no technical owner'); $this->assertSame(['type' => 'Supplier', 'status' => 'Active', 'registeredBy' => 'Supplier'], $profile->pack(target: 'manufacturer')['defaults']); @@ -148,8 +156,9 @@ public function testThePacksImplementTheColumnTable(): void { $this->assertSame(['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB'], $profile->sheetNames()); $this->assertSame(['Beheer' => 'Beheer geregeld: nee'], $profile->sheetConstants(sheetName: 'Onbeh Applicaties CMDB')); $this->assertSame(['Beheer' => 'Beheer geregeld: ja'], $profile->sheetConstants(sheetName: 'Beheerde Applicaties CMDB')); - $this->assertSame(['Nickname'], $profile->absentColumns(sheetName: 'Onbeh Applicaties CMDB')); - $this->assertSame([], $profile->absentColumns(sheetName: 'Beheerde Applicaties CMDB')); + $ownerColumns = ['Applicatie Eigenaar (E-mail)', 'Applicatie Eigenaar (Personeelsnummer)']; + $this->assertSame(['Nickname', ...$ownerColumns], $profile->absentColumns(sheetName: 'Onbeh Applicaties CMDB')); + $this->assertSame($ownerColumns, $profile->absentColumns(sheetName: 'Beheerde Applicaties CMDB'), 'the export has no owner e-mail or employee number column yet'); $this->assertSame(['BNN Classificatie' => ['NB']], $profile->emptyValues()); $this->assertSame(10485760, $profile->maxFileBytes()); $this->assertSame(10000, $profile->maxRowsPerSheet()); @@ -648,7 +657,7 @@ public static function brokenRules(): array { $pack['defaults']['type'] = 'Municipality'; return $pack; }, 'default "type" must stay "Supplier"'], - 'an owner field that is no contact field' => ['topdesk-business-owner.json', $target(1, 'email'), '"email" is not a field the businessOwner pack may map'], + 'an owner field that is no contact field' => ['topdesk-business-owner.json', $target(1, 'telephone'), '"telephone" is not a field the businessOwner pack may map'], ]; }//end brokenRules() diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php index dffbf6182..63246ceec 100644 --- a/tests/Unit/Service/CmdbExportImportServiceTest.php +++ b/tests/Unit/Service/CmdbExportImportServiceTest.php @@ -858,11 +858,16 @@ private function contactSync(): StackiqContactSyncService { $sync->method('isAvailable')->willReturnCallback(fn (): bool => $this->contactsEnabled); $sync->method('searchContacts')->willThrowException(new \LogicException('owners are only searched in the named address book')); $sync->method('searchNamedAddressBook')->willReturnCallback( - function (string $query, string $addressBookUri): array { + function (string $query, string $addressBookUri, array $properties): array { $found = []; foreach ($this->contacts as $uid => $contact) { + $haystack = $contact['name']; + if ($properties === ['UID']) { + $haystack = (string)$uid; + } + if (($contact['book'] ?? 'personal') === $addressBookUri - && str_contains(mb_strtolower($contact['name']), mb_strtolower($query)) === true + && str_contains(mb_strtolower($haystack), mb_strtolower($query)) === true ) { $found[] = ['uid' => $uid, 'name' => $contact['name'], 'email' => $contact['email']]; } @@ -873,7 +878,7 @@ function (string $query, string $addressBookUri): array { ); $sync->method('syncToContacts')->willThrowException(new \LogicException('owners go into the named address book, not the first writable one')); $sync->method('syncToNamedAddressBook')->willReturnCallback( - function (string $objectType, array $record, string $addressBookUri, string $displayName): ?string { + function (string $objectType, array $record, string $addressBookUri, string $displayName, ?string $fixedUid = null): ?string { $this->addressBooks[$addressBookUri] = $displayName; $email = (string)($record['email'] ?? ''); foreach ($this->contacts as $uid => $contact) { @@ -882,7 +887,7 @@ function (string $objectType, array $record, string $addressBookUri, string $dis } } - $uid = 'contact-' . (count($this->contacts) + 1); + $uid = ($fixedUid ?? 'contact-' . (count($this->contacts) + 1)); $this->contacts[$uid] = [ 'name' => trim(($record['voornaam'] ?? '') . ' ' . ($record['achternaam'] ?? '')), 'email' => $email, @@ -3197,6 +3202,87 @@ public function testOwnersAreMatchedOnlyInTheOwnersAddressBook(): void { $this->assertSame(['stackiq-cmdb-owners'], array_values(array_unique(array_column($created, 'book')))); }//end testOwnersAreMatchedOnlyInTheOwnersAddressBook() + /** + * With an employee number the owner is matched on it: two namesakes stay two owners, and a renamed owner keeps its contact. + * + * @return void + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + public function testAnOwnerWithAnEmployeeNumberIsMatchedOnItNotOnTheName(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $first = ['Applicatie Eigenaar (Persoon)' => 'Achternaam, Voornaam', 'Applicatie Eigenaar (Personeelsnummer)' => '1001']; + $namesake = ['Applicatie Eigenaar (Persoon)' => 'Achternaam, Voornaam', 'Applicatie Eigenaar (Personeelsnummer)' => '2002']; + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: $first, row: 2), $this->row(appId: '2', cells: $namesake, row: 3)])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertCount(2, $this->contacts, 'two namesakes with their own number are two contacts'); + $this->assertCount(2, $this->objects(self::CONTACT_PERSON)); + $uid = CmdbExportImportService::ownerUidForEmployeeNumber(employeeNumber: '1001', municipalityUuid: 'muni-1'); + $this->assertArrayHasKey($uid, $this->contacts); + $this->assertStringNotContainsString('1001', (string)$uid, 'the number is not readable from the contact'); + + // The owner changed their name; Excel stored the number as a decimal. + $renamed = ['Applicatie Eigenaar (Persoon)' => 'Nieuwenaam, Voornaam', 'Applicatie Eigenaar (Personeelsnummer)' => '1001.0']; + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: $renamed, row: 2)])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertCount(2, $this->contacts, 'the renamed owner is the same contact'); + $people = array_column($this->objects(self::CONTACT_PERSON), null, 'contactsUid'); + $this->assertSame($people[$uid]['id'], $this->objects(self::USAGE)[0]['businessOwner']); + $this->assertNull(CmdbExportImportService::ownerUidForEmployeeNumber(employeeNumber: ' ', municipalityUuid: 'muni-1')); + $this->assertNotSame($uid, CmdbExportImportService::ownerUidForEmployeeNumber(employeeNumber: '1001', municipalityUuid: 'muni-2'), 'scoped to the municipality'); + }//end testAnOwnerWithAnEmployeeNumberIsMatchedOnItNotOnTheName() + + /** + * Without an employee number an e-mail address matches the owner, and is stored on the new contact. + * + * @return void + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + public function testAnOwnerWithOnlyAnEmailAddressIsMatchedOnIt(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $this->contacts['contact-namesake'] = ['name' => 'Voornaam Achternaam', 'email' => '', 'book' => 'stackiq-cmdb-owners']; + $owner = ['Applicatie Eigenaar (Persoon)' => 'Achternaam, Voornaam', 'Applicatie Eigenaar (E-mail)' => 'v.achternaam@example.org']; + $rows = [$this->row(appId: '1', cells: $owner)]; + + $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1']); + $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertCount(2, $this->contacts, 'one new contact next to the namesake without an e-mail address'); + $created = array_diff_key($this->contacts, ['contact-namesake' => true]); + $this->assertSame(['v.achternaam@example.org'], array_column($created, 'email')); + $this->assertCount(1, $this->objects(self::CONTACT_PERSON)); + }//end testAnOwnerWithOnlyAnEmailAddressIsMatchedOnIt() + + /** + * Renaming the e-mail and employee number columns in OpenRegister is all an export with other column names needs. + * + * @return void + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-1 + */ + public function testRenamedOwnerColumnsInOpenRegisterAreRead(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Rotterdam', type: 'Municipality'); + $packs = new FakeMigrationPackService(); + $owner = (new CmdbImportPackMarker())->seed( + shipped: json_decode((string)file_get_contents(CmdbTestSupport::appRoot() . '/lib/Settings/cmdb-import/topdesk-business-owner.json'), true) + ); + $owner['fieldMappings'][2]['source'] = 'E-mailadres eigenaar'; + $owner['fieldMappings'][3]['source'] = 'Pers.nr. eigenaar'; + $packs->put(definition: $owner); + + $cells = ['Applicatie Eigenaar (Persoon)' => 'Achternaam, Voornaam', 'E-mailadres eigenaar' => 'v.achternaam@example.org', 'Pers.nr. eigenaar' => '1001']; + $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: $cells)]), packs: $packs) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertSame([], $report['rows'][0]['warnings']); + $uid = CmdbExportImportService::ownerUidForEmployeeNumber(employeeNumber: '1001', municipalityUuid: 'muni-1'); + $this->assertSame([$uid], array_keys($this->contacts), 'matched on the renamed employee number column'); + $this->assertSame('v.achternaam@example.org', $this->contacts[$uid]['email'], 'the renamed e-mail column'); + }//end testRenamedOwnerColumnsInOpenRegisterAreRead() + /** * No technical owner is written, whatever the row holds. * diff --git a/tests/Unit/Service/StackiqContactSyncServiceTest.php b/tests/Unit/Service/StackiqContactSyncServiceTest.php index 670fe6cfa..96c2731dd 100644 --- a/tests/Unit/Service/StackiqContactSyncServiceTest.php +++ b/tests/Unit/Service/StackiqContactSyncServiceTest.php @@ -196,6 +196,31 @@ public function testNewContactsGoIntoTheNamedAddressBook(): void { $this->assertStringEndsWith("END:VCARD\r\n", $card); }//end testNewContactsGoIntoTheNamedAddressBook() + /** + * A caller that gives a UID gets a new contact with exactly that UID, so it can find it again by UID. + * + * @return void + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + public function testANewContactTakesTheGivenUid(): void { + $backend = $this->backend(); + $service = $this->service(backend: $backend); + + $uid = $service->syncToNamedAddressBook( + objectType: 'contactPerson', + record: ['voornaam' => 'Voornaam', 'achternaam' => 'Achternaam', 'email' => 'owner@example.org'], + addressBookUri: 'stackiq-cmdb-owners', + displayName: 'Stackiq CMDB owners', + fixedUid: 'stackiq-cmdb-owner-abc' + ); + + $this->assertSame('stackiq-cmdb-owner-abc', $uid); + $this->assertSame('stackiq-cmdb-owner-abc.vcf', $backend->cards[0][1]); + $this->assertStringStartsWith("BEGIN:VCARD\r\nVERSION:3.0\r\nUID:stackiq-cmdb-owner-abc\r\n", $backend->cards[0][2]); + $this->assertStringContainsString("\r\nEMAIL:owner@example.org\r\n", $backend->cards[0][2]); + }//end testANewContactTakesTheGivenUid() + /** * An e-mail address matches only a contact in the named address book; a personal contact with it is not linked. * From 7cd631c565975e40233cd474842a90fb35e49ec9 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 19 Oct 2026 12:41:22 +0200 Subject: [PATCH 123/124] refactor(cmdb-import): move the owner's contact UID and cache key into CmdbOwnerIdentity PHPMD flagged an eleventh public method on CmdbExportImportService and a cyclomatic complexity of 10 in resolveContactUid(). The UID derivation and the per-run cache key now live in their own class; the lookup of an existing contact is its own method. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/Service/Cmdb/CmdbOwnerIdentity.php | 85 +++++++++++++++++++ lib/Service/CmdbExportImportService.php | 50 +++++------ .../cmdb-import-owner-identity/tasks.md | 2 +- .../Service/Cmdb/CmdbOwnerIdentityTest.php | 58 +++++++++++++ .../Service/CmdbExportImportServiceTest.php | 9 +- 5 files changed, 170 insertions(+), 34 deletions(-) create mode 100644 lib/Service/Cmdb/CmdbOwnerIdentity.php create mode 100644 tests/Unit/Service/Cmdb/CmdbOwnerIdentityTest.php diff --git a/lib/Service/Cmdb/CmdbOwnerIdentity.php b/lib/Service/Cmdb/CmdbOwnerIdentity.php new file mode 100644 index 000000000..ec1db29b0 --- /dev/null +++ b/lib/Service/Cmdb/CmdbOwnerIdentity.php @@ -0,0 +1,85 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service\Cmdb; + +/** + * Derives the contact UID and the per-run cache key of an owner. + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ +class CmdbOwnerIdentity { + /** + * The prefix of a contact UID derived from an employee number. + */ + public const UID_PREFIX = 'stackiq-cmdb-owner-'; + + /** + * The contact UID of an owner with this employee number, or null without one. + * + * A hash, so the employee number is not readable from the contact, and + * scoped to the municipality, so the same number at two municipalities + * is two owners. A number Excel stored as a decimal ("1234.0") is the + * same number as "1234". + * + * @param string $employeeNumber The employee number from the owner pack. + * @param string $municipalityUuid The municipality. + * + * @return string|null + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + public function contactUid(string $employeeNumber, string $municipalityUuid): ?string { + $number = mb_strtolower(trim($employeeNumber)); + $number = (string)preg_replace('/^(\d+)\.0+$/', '$1', $number); + if ($number === '') { + return null; + } + + return self::UID_PREFIX . hash('sha256', $municipalityUuid . '|' . $number); + }//end contactUid() + + /** + * The key under which one import run remembers the owner's contact. + * + * @param string|null $contactUid The UID from the employee number, or null. + * @param string $email The e-mail address, or ''. + * @param string $displayName The display name. + * + * @return string + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + public function cacheKey(?string $contactUid, string $email, string $displayName): string { + if ($contactUid !== null) { + return 'uid:' . $contactUid; + } + + if ($email !== '') { + return 'email:' . mb_strtolower($email); + } + + return 'name:' . mb_strtolower($displayName); + }//end cacheKey() +}//end class diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php index 05b585d7e..5d6ca6386 100644 --- a/lib/Service/CmdbExportImportService.php +++ b/lib/Service/CmdbExportImportService.php @@ -68,6 +68,7 @@ use OCA\Stackiq\Exception\CmdbImportException; use OCA\Stackiq\Service\Cmdb\CmdbImportProfile; use OCA\Stackiq\Service\Cmdb\CmdbImportReport; +use OCA\Stackiq\Service\Cmdb\CmdbOwnerIdentity; use OCA\Stackiq\Service\Cmdb\CmdbRowNormaliser; use OCA\Stackiq\Service\Cmdb\CmdbWorkbookReader; use OCP\AppFramework\Db\DoesNotExistException; @@ -1564,28 +1565,18 @@ private function resolveContactUid(array $identity, string $municipalityUuid): ? $parts = self::splitPersonName(name: (string)$identity['name']); $displayName = trim($parts['voornaam'] . ' ' . $parts['achternaam']); $email = trim((string)($identity['email'] ?? '')); - $numberUid = self::ownerUidForEmployeeNumber( + $ownerIdentity = new CmdbOwnerIdentity(); + $numberUid = $ownerIdentity->contactUid( employeeNumber: (string)($identity['employeeNumber'] ?? ''), municipalityUuid: $municipalityUuid ); - $cacheKey = 'name:' . mb_strtolower($displayName); - if ($numberUid !== null) { - $cacheKey = 'uid:' . $numberUid; - } elseif ($email !== '') { - $cacheKey = 'email:' . mb_strtolower($email); - } - + $cacheKey = $ownerIdentity->cacheKey(contactUid: $numberUid, email: $email, displayName: $displayName); if (array_key_exists($cacheKey, $this->contactUids) === true) { return $this->contactUids[$cacheKey]; } - $uid = null; - if ($numberUid !== null) { - $uid = $this->contactByUid(uid: $numberUid); - } elseif ($email === '') { - $uid = $this->contactByDisplayName(displayName: $displayName); - } + $uid = $this->existingOwnerContact(numberUid: $numberUid, email: $email, displayName: $displayName); if ($uid === null) { $record = ['voornaam' => $parts['voornaam'], 'achternaam' => $parts['achternaam']]; @@ -1615,29 +1606,30 @@ private function resolveContactUid(array $identity, string $municipalityUuid): ? }//end resolveContactUid() /** - * The contact UID of an owner with this employee number, or null without one. + * The contact an owner already has: by the UID from the employee number, else by exact name when there is no e-mail address. * - * A hash, so the employee number is not readable from the contact, and - * scoped to the municipality, so the same number at two municipalities - * is two owners. A number Excel stored as a decimal ("1234.0") is the - * same number as "1234". + * With an e-mail address and no employee number, StackiqContactSyncService + * matches on the address itself when it resolves the contact. * - * @param string $employeeNumber The employee number from the owner pack. - * @param string $municipalityUuid The municipality. + * @param string|null $numberUid The UID from the employee number, or null. + * @param string $email The e-mail address, or ''. + * @param string $displayName The display name. * - * @return string|null + * @return string|null The contact UID, or null. * * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 */ - public static function ownerUidForEmployeeNumber(string $employeeNumber, string $municipalityUuid): ?string { - $number = mb_strtolower(trim($employeeNumber)); - $number = (string)preg_replace('/^(\d+)\.0+$/', '$1', $number); - if ($number === '') { - return null; + private function existingOwnerContact(?string $numberUid, string $email, string $displayName): ?string { + if ($numberUid !== null) { + return $this->contactByUid(uid: $numberUid); } - return 'stackiq-cmdb-owner-' . hash('sha256', $municipalityUuid . '|' . $number); - }//end ownerUidForEmployeeNumber() + if ($email === '') { + return $this->contactByDisplayName(displayName: $displayName); + } + + return null; + }//end existingOwnerContact() /** * The contact in the owners' address book with exactly this UID. diff --git a/openspec/changes/cmdb-import-owner-identity/tasks.md b/openspec/changes/cmdb-import-owner-identity/tasks.md index 5020ec5ee..339b1d45f 100644 --- a/openspec/changes/cmdb-import-owner-identity/tasks.md +++ b/openspec/changes/cmdb-import-owner-identity/tasks.md @@ -17,7 +17,7 @@ Spec: `openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec ### Task 2: The owner is matched on the employee number, then the e-mail address, then the name - **spec_ref**: `SPEC#requirement-the-owner-shall-become-a-contact-person-of-the-municipality-through-nextcloud-contacts-never-a-user-account-and-shall-never-be-publicly-readable-req-cmdb-010` (cmdb-export-import#REQ-CMDB-010) -- **files**: `lib/Service/CmdbExportImportService.php`, `lib/Service/StackiqContactSyncService.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php`, `tests/Unit/Service/StackiqContactSyncServiceTest.php` +- **files**: `lib/Service/CmdbExportImportService.php`, `lib/Service/Cmdb/CmdbOwnerIdentity.php`, `lib/Service/StackiqContactSyncService.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php`, `tests/Unit/Service/Cmdb/CmdbOwnerIdentityTest.php`, `tests/Unit/Service/StackiqContactSyncServiceTest.php` - **acceptance_criteria**: - GIVEN two namesakes with their own employee number THEN two contacts and two contact persons - GIVEN a renamed owner with the same number (also as `1001.0`) THEN the same contact person diff --git a/tests/Unit/Service/Cmdb/CmdbOwnerIdentityTest.php b/tests/Unit/Service/Cmdb/CmdbOwnerIdentityTest.php new file mode 100644 index 000000000..4a165d482 --- /dev/null +++ b/tests/Unit/Service/Cmdb/CmdbOwnerIdentityTest.php @@ -0,0 +1,58 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service\Cmdb; + +use OCA\Stackiq\Service\Cmdb\CmdbOwnerIdentity; +use PHPUnit\Framework\TestCase; + +/** + * How an owner is identified across imports. + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ +class CmdbOwnerIdentityTest extends TestCase { + /** + * The UID hides the number, is scoped to the municipality and ignores Excel's decimal form. + * + * @return void + */ + public function testTheContactUidDerivesFromTheNumberAndTheMunicipality(): void { + $identity = new CmdbOwnerIdentity(); + $uid = $identity->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-1'); + + $this->assertStringStartsWith(CmdbOwnerIdentity::UID_PREFIX, (string)$uid); + $this->assertStringNotContainsString('1001', (string)$uid); + $this->assertSame($uid, $identity->contactUid(employeeNumber: ' 1001.0 ', municipalityUuid: 'muni-1')); + $this->assertNotSame($uid, $identity->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-2')); + $this->assertNotSame($uid, $identity->contactUid(employeeNumber: '10010', municipalityUuid: 'muni-1')); + $this->assertNull($identity->contactUid(employeeNumber: ' ', municipalityUuid: 'muni-1')); + }//end testTheContactUidDerivesFromTheNumberAndTheMunicipality() + + /** + * The employee number wins over the e-mail address, which wins over the name. + * + * @return void + */ + public function testTheCacheKeyFollowsTheMatchOrder(): void { + $identity = new CmdbOwnerIdentity(); + $this->assertSame('uid:x', $identity->cacheKey(contactUid: 'x', email: 'A@example.org', displayName: 'Naam')); + $this->assertSame('email:a@example.org', $identity->cacheKey(contactUid: null, email: 'A@example.org', displayName: 'Naam')); + $this->assertSame('name:naam', $identity->cacheKey(contactUid: null, email: '', displayName: 'Naam')); + }//end testTheCacheKeyFollowsTheMatchOrder() +}//end class diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php index 63246ceec..f8480f8c6 100644 --- a/tests/Unit/Service/CmdbExportImportServiceTest.php +++ b/tests/Unit/Service/CmdbExportImportServiceTest.php @@ -40,6 +40,7 @@ use OCA\Stackiq\Service\Cmdb\CmdbImportPackMarker; use OCA\Stackiq\Service\Cmdb\CmdbImportPackStore; use OCA\Stackiq\Service\Cmdb\CmdbImportProfile; +use OCA\Stackiq\Service\Cmdb\CmdbOwnerIdentity; use OCA\Stackiq\Service\Cmdb\CmdbRowNormaliser; use OCA\Stackiq\Service\Cmdb\CmdbWorkbookReader; use OCA\Stackiq\Service\CmdbExportImportService; @@ -3218,7 +3219,7 @@ public function testAnOwnerWithAnEmployeeNumberIsMatchedOnItNotOnTheName(): void $this->assertCount(2, $this->contacts, 'two namesakes with their own number are two contacts'); $this->assertCount(2, $this->objects(self::CONTACT_PERSON)); - $uid = CmdbExportImportService::ownerUidForEmployeeNumber(employeeNumber: '1001', municipalityUuid: 'muni-1'); + $uid = (new CmdbOwnerIdentity())->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-1'); $this->assertArrayHasKey($uid, $this->contacts); $this->assertStringNotContainsString('1001', (string)$uid, 'the number is not readable from the contact'); @@ -3230,8 +3231,8 @@ public function testAnOwnerWithAnEmployeeNumberIsMatchedOnItNotOnTheName(): void $this->assertCount(2, $this->contacts, 'the renamed owner is the same contact'); $people = array_column($this->objects(self::CONTACT_PERSON), null, 'contactsUid'); $this->assertSame($people[$uid]['id'], $this->objects(self::USAGE)[0]['businessOwner']); - $this->assertNull(CmdbExportImportService::ownerUidForEmployeeNumber(employeeNumber: ' ', municipalityUuid: 'muni-1')); - $this->assertNotSame($uid, CmdbExportImportService::ownerUidForEmployeeNumber(employeeNumber: '1001', municipalityUuid: 'muni-2'), 'scoped to the municipality'); + $this->assertNull((new CmdbOwnerIdentity())->contactUid(employeeNumber: ' ', municipalityUuid: 'muni-1')); + $this->assertNotSame($uid, (new CmdbOwnerIdentity())->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-2'), 'scoped to the municipality'); }//end testAnOwnerWithAnEmployeeNumberIsMatchedOnItNotOnTheName() /** @@ -3278,7 +3279,7 @@ public function testRenamedOwnerColumnsInOpenRegisterAreRead(): void { ->import(path: '', options: ['municipalityUuid' => 'muni-1']); $this->assertSame([], $report['rows'][0]['warnings']); - $uid = CmdbExportImportService::ownerUidForEmployeeNumber(employeeNumber: '1001', municipalityUuid: 'muni-1'); + $uid = (new CmdbOwnerIdentity())->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-1'); $this->assertSame([$uid], array_keys($this->contacts), 'matched on the renamed employee number column'); $this->assertSame('v.achternaam@example.org', $this->contacts[$uid]['email'], 'the renamed e-mail column'); }//end testRenamedOwnerColumnsInOpenRegisterAreRead() From 154e31f170f616c316b92882e0991f909d89f3e9 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 19 Oct 2026 13:26:18 +0200 Subject: [PATCH 124/124] fix(cmdb-import): keep the employee number decisive and the owner UID unrecoverable Closes the review findings on #1306: - f1: with an employee number, the contacts bridge e-mail-matched before creating the contact with the number UID, so namesakes sharing a mailbox merged and an owner known by e-mail never got the number contact. The import now does all owner matching itself and always passes the UID of a new contact; the bridge's e-mail match is skipped when a UID is given. - Class sweep of f1: a row without a number no longer takes a contact created for an employee number, by e-mail address or by name; the e-mail match compares every address of a contact, not only the first. - f2: a number cell holding "-", "0", "n.v.t." or another value without a digit 1-9 is no employee number; leading zeros, a decimal ".0" and surrounding (non-breaking) spaces are ignored. - f3: the UID is an HMAC-SHA256 with a key derived from the instance secret instead of a plain SHA-256, so the number cannot be recovered by hashing candidates. Without a secret the number is not used and a warning is logged once (fail-closed, ADR-102). - f4: the docs say the absent-column list covers the placeholder names only, and that a changed secret gives number owners new contacts. Spec delta, tasks, proposal and docs follow the code; new scenarios for the shared mailbox, placeholders and owners without a number. Co-Authored-By: Claude Opus 5.5 --- docs/features/cmdb-import.md | 26 +++- lib/Service/Cmdb/CmdbOwnerIdentity.php | 109 +++++++++++++-- lib/Service/CmdbExportImportService.php | 61 +++++---- lib/Service/StackiqContactSyncService.php | 35 +++-- .../cmdb-import-owner-identity/proposal.md | 2 +- .../specs/cmdb-export-import/spec.md | 24 +++- .../cmdb-import-owner-identity/tasks.md | 6 +- .../Service/Cmdb/CmdbOwnerIdentityTest.php | 62 ++++++++- .../Service/CmdbExportImportServiceTest.php | 129 ++++++++++++++++-- .../Service/StackiqContactSyncServiceTest.php | 45 +++++- 10 files changed, 431 insertions(+), 68 deletions(-) diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md index 94f64928e..146842d09 100644 --- a/docs/features/cmdb-import.md +++ b/docs/features/cmdb-import.md @@ -344,12 +344,23 @@ is not linked to the application. Within **Stackiq CMDB owners** the import recognises an owner, in this order: 1. by the **employee number**, when the row has one. Two people with the same - name stay two owners, and an owner whose name changes keeps their contact. - The number is not stored on the contact; the contact's id is derived from - it and the municipality. + name stay two owners, also when they share an e-mail address, and an owner + whose name changes keeps their contact. The number is not stored on the + contact; the contact's id is a keyed hash of it, the municipality and the + server's secret, so the number cannot be read back from it. A value + without a digit other than 0, such as `-`, `0` or `n.v.t.`, is not an + employee number; leading zeros and a decimal `.0` are ignored. 2. by the **e-mail address**, when the row has one and no employee number. 3. by an exact match on the **name** otherwise. +A row without an employee number never takes a contact that was created +for an employee number, even with the same e-mail address or name: that +contact belongs to that employee. When the server has no `secret` in +`config.php`, employee numbers are not used (the log says so once per +import) and owners are matched by e-mail address or name. Changing that +`secret` gives every owner with an employee number a new contact on the +next import; the old contacts stay. + When nothing matches, the contact is created there, with the e-mail address when the row has one. The stackiq contact person object only holds the link to that contact, the role and the municipality. The same owner on several @@ -361,9 +372,12 @@ Those names are placeholders: the export does not have these columns yet. When your export names them differently, change the source column of the business-owner pack in OpenRegister (**Edit in OpenRegister** in the **CMDB import** section); no stackiq update is needed. An export without the -columns imports as before, without a warning. Once you add the employee -number, an owner who was found by name until then gets a new contact; the -old contact stays in **Stackiq CMDB owners**. +columns imports as before, without a warning. That holds for the +placeholder names only: once you rename the columns, an export that has +them on only one of the two CMDB sheets warns that the column is missing +on the other. Once you add the employee number, an owner who was found by +name or e-mail address until then gets a new contact; the old contact +stays in **Stackiq CMDB owners**. When the Contacts app is disabled, applications and usages are still imported; the owners are skipped and each affected row carries a warning. diff --git a/lib/Service/Cmdb/CmdbOwnerIdentity.php b/lib/Service/Cmdb/CmdbOwnerIdentity.php index ec1db29b0..794bb42fe 100644 --- a/lib/Service/Cmdb/CmdbOwnerIdentity.php +++ b/lib/Service/Cmdb/CmdbOwnerIdentity.php @@ -4,8 +4,9 @@ * How the CMDB import identifies an owner across imports. * * An owner with an employee number is the contact whose UID derives from - * that number and the municipality; the number itself is never stored. An - * owner without one is identified by e-mail address, else by display name. + * that number, the municipality and this instance's secret; the number + * itself is never stored. An owner without one is identified by e-mail + * address, else by display name. * * @category Service * @package OCA\Stackiq\Service\Cmdb @@ -24,6 +25,9 @@ namespace OCA\Stackiq\Service\Cmdb; +use OCP\IConfig; +use Psr\Log\LoggerInterface; + /** * Derives the contact UID and the per-run cache key of an owner. * @@ -36,12 +40,43 @@ class CmdbOwnerIdentity { public const UID_PREFIX = 'stackiq-cmdb-owner-'; /** - * The contact UID of an owner with this employee number, or null without one. + * Whether the missing instance secret was logged already. + * + * @var boolean + */ + private bool $missingSecretLogged = false; + + /** + * Constructor. + * + * @param IConfig $config Reads this instance's `secret`, from which the UID's HMAC key derives. + * @param LoggerInterface $logger Says, once, that employee numbers are not used without a secret; never handed a number. + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + public function __construct( + private readonly IConfig $config, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * The contact UID of an owner with this employee number, or null without a usable one. + * + * An HMAC-SHA256 with a key derived from this instance's `secret` + * (config.php), so the employee number cannot be recovered from the UID + * by hashing every possible number, and scoped to the municipality, so + * the same number at two municipalities is two owners. A number Excel + * stored as a decimal ("1234.0"), with leading zeros ("01234") or with + * surrounding spaces, non-breaking ones included, is the same number as + * "1234". A value without a digit other than 0 ("-", "n.v.t.", "0") is + * no employee number: it gives null, so the owner is matched on the + * e-mail address or the name instead of sharing one UID with every + * other owner whose cell holds the same placeholder. * - * A hash, so the employee number is not readable from the contact, and - * scoped to the municipality, so the same number at two municipalities - * is two owners. A number Excel stored as a decimal ("1234.0") is the - * same number as "1234". + * Fail-mode (ADR-102): closed. Without a `secret` the number is not used + * (null, logged once), never hashed with an empty key. A changed secret + * gives every owner a new UID, so a new contact on the next import. * * @param string $employeeNumber The employee number from the owner pack. * @param string $municipalityUuid The municipality. @@ -51,15 +86,67 @@ class CmdbOwnerIdentity { * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 */ public function contactUid(string $employeeNumber, string $municipalityUuid): ?string { - $number = mb_strtolower(trim($employeeNumber)); - $number = (string)preg_replace('/^(\d+)\.0+$/', '$1', $number); - if ($number === '') { + $number = self::normaliseNumber(employeeNumber: $employeeNumber); + if ($number === null) { + return null; + } + + $secret = $this->config->getSystemValueString('secret', ''); + if ($secret === '') { + if ($this->missingSecretLogged === false) { + $this->missingSecretLogged = true; + $this->logger->warning('CmdbOwnerIdentity: this instance has no secret in config.php; owners are matched without their employee number'); + } + return null; } - return self::UID_PREFIX . hash('sha256', $municipalityUuid . '|' . $number); + $key = hash_hmac('sha256', self::UID_PREFIX, $secret); + return self::UID_PREFIX . hash_hmac('sha256', $municipalityUuid . '|' . $number, $key); }//end contactUid() + /** + * A random UID (UUID v4) for the contact of an owner without an employee number. + * + * The import gives every new owner contact its UID, so the contacts + * bridge never matches one on its own (on an e-mail address it would + * also take a contact identified by an employee number). + * + * @return string + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + public function newContactUid(): string { + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0F) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3F) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); + }//end newContactUid() + + /** + * The employee number in its compared form, or null when the value is no employee number. + * + * @param string $employeeNumber The value from the owner pack. + * + * @return string|null + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + private static function normaliseNumber(string $employeeNumber): ?string { + $number = mb_strtolower((string)preg_replace('/^[\s\x{00A0}]+|[\s\x{00A0}]+$/u', '', $employeeNumber)); + $number = (string)preg_replace('/^(\d+)\.0+$/', '$1', $number); + if (preg_match('/^\d+$/', $number) === 1) { + $number = ltrim($number, '0'); + } + + if (preg_match('/[1-9]/', $number) !== 1) { + return null; + } + + return $number; + }//end normaliseNumber() + /** * The key under which one import run remembers the owner's contact. * diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php index 5d6ca6386..9562a3682 100644 --- a/lib/Service/CmdbExportImportService.php +++ b/lib/Service/CmdbExportImportService.php @@ -342,6 +342,7 @@ class CmdbExportImportService { * @param LoggerInterface $logger Logger; never handed person data. * @param ILockingProvider $lockingProvider Serialises imports per register. * @param IUserSession $userSession Names the admin who ran an import in its audit log lines. + * @param CmdbOwnerIdentity $ownerIdentity Derives an owner's contact UID and cache key. * * @SuppressWarnings(PHPMD.ExcessiveParameterList) Each collaborator is one concern of the * import (the file, the packs, OpenRegister, Contacts, progress, the lock); grouping them @@ -361,6 +362,7 @@ public function __construct( private readonly LoggerInterface $logger, private readonly ILockingProvider $lockingProvider, private readonly IUserSession $userSession, + private readonly CmdbOwnerIdentity $ownerIdentity, ) { }//end __construct() @@ -1547,11 +1549,16 @@ private function resolveOwners(array $values, int $rowNumber, string $municipali * other address books. With an employee number, the contact is the one * whose UID derives from that number and the municipality, so two * namesakes stay two contacts and a renamed owner keeps the same one; - * the number itself is never stored. Without a number but with an - * e-mail address, StackiqContactSyncService matches on it there or - * creates the contact. Without either, only a contact there whose - * display name is exactly the owner's name (case-insensitive) is reused, - * so an owner known by name alone is not created again on every import. + * the number itself is never stored, and an e-mail address on the row + * never matches another contact (a shared mailbox would merge two + * owners again): the contact is created with that UID. Without a + * number, a contact there with the row's e-mail address is reused, and + * without either, a contact whose display name is exactly the owner's + * name (case-insensitive), so an owner known by name alone is not + * created again on every import; neither ever reuses a contact + * identified by an employee number, which belongs to that employee. + * A new contact gets its UID from here, so StackiqContactSyncService + * does no matching of its own. * * @param array $identity name, email, employeeNumber and role from the owner pack. * @param string $municipalityUuid The municipality whose employee numbers these are. @@ -1565,13 +1572,12 @@ private function resolveContactUid(array $identity, string $municipalityUuid): ? $parts = self::splitPersonName(name: (string)$identity['name']); $displayName = trim($parts['voornaam'] . ' ' . $parts['achternaam']); $email = trim((string)($identity['email'] ?? '')); - $ownerIdentity = new CmdbOwnerIdentity(); - $numberUid = $ownerIdentity->contactUid( + $numberUid = $this->ownerIdentity->contactUid( employeeNumber: (string)($identity['employeeNumber'] ?? ''), municipalityUuid: $municipalityUuid ); - $cacheKey = $ownerIdentity->cacheKey(contactUid: $numberUid, email: $email, displayName: $displayName); + $cacheKey = $this->ownerIdentity->cacheKey(contactUid: $numberUid, email: $email, displayName: $displayName); if (array_key_exists($cacheKey, $this->contactUids) === true) { return $this->contactUids[$cacheKey]; } @@ -1594,7 +1600,7 @@ private function resolveContactUid(array $identity, string $municipalityUuid): ? record: $record, addressBookUri: self::OWNER_ADDRESS_BOOK_URI, displayName: $this->l10n->t('Stackiq CMDB owners'), - fixedUid: $numberUid + fixedUid: ($numberUid ?? $this->ownerIdentity->newContactUid()) ); if ($uid === '') { $uid = null; @@ -1606,10 +1612,7 @@ private function resolveContactUid(array $identity, string $municipalityUuid): ? }//end resolveContactUid() /** - * The contact an owner already has: by the UID from the employee number, else by exact name when there is no e-mail address. - * - * With an e-mail address and no employee number, StackiqContactSyncService - * matches on the address itself when it resolves the contact. + * The contact an owner already has: by the UID from the employee number, else by e-mail address, else by exact name. * * @param string|null $numberUid The UID from the employee number, or null. * @param string $email The e-mail address, or ''. @@ -1624,11 +1627,11 @@ private function existingOwnerContact(?string $numberUid, string $email, string return $this->contactByUid(uid: $numberUid); } - if ($email === '') { - return $this->contactByDisplayName(displayName: $displayName); + if ($email !== '') { + return $this->contactByProperty(property: 'EMAIL', key: 'emails', value: $email); } - return null; + return $this->contactByProperty(property: 'FN', key: 'name', value: $displayName); }//end existingOwnerContact() /** @@ -1652,29 +1655,37 @@ private function contactByUid(string $uid): ?string { }//end contactByUid() /** - * The contact in the owners' address book whose display name is exactly this one, case-insensitive. + * The contact in the owners' address book whose e-mail address or display name is exactly this one, case-insensitive. * * Only the dedicated "Stackiq CMDB owners" address book is searched: a * namesake in another address book of the admin, such as a personal - * contact, is never linked to an imported owner. + * contact, is never linked to an imported owner. A contact identified by + * an employee number is never returned: it is that employee's, and an + * owner without a number who shares a name or mailbox is someone else + * as far as the import can tell. * - * @param string $displayName The display name. + * @param string $property The vCard property to search: EMAIL or FN. + * @param string $key The key of that value in a search result: emails (every address) or name. + * @param string $value The e-mail address or display name. * * @return string|null The contact UID, or null. * * @spec openspec/changes/cmdb-export-import/tasks.md#task-6 + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 */ - private function contactByDisplayName(string $displayName): ?string { - $needle = mb_strtolower($displayName); - $contacts = $this->contactSync->searchNamedAddressBook(query: $displayName, addressBookUri: self::OWNER_ADDRESS_BOOK_URI, properties: ['FN']); + private function contactByProperty(string $property, string $key, string $value): ?string { + $needle = mb_strtolower(trim($value)); + $contacts = $this->contactSync->searchNamedAddressBook(query: $value, addressBookUri: self::OWNER_ADDRESS_BOOK_URI, properties: [$property]); foreach ($contacts as $contact) { - if (mb_strtolower(trim((string)($contact['name'] ?? ''))) === $needle) { - return (string)$contact['uid']; + $uid = (string)($contact['uid'] ?? ''); + $values = array_map(fn (mixed $candidate): string => mb_strtolower(trim((string)$candidate)), (array)($contact[$key] ?? [])); + if (str_starts_with($uid, CmdbOwnerIdentity::UID_PREFIX) === false && in_array($needle, $values, true) === true) { + return $uid; } } return null; - }//end contactByDisplayName() + }//end contactByProperty() /** * Find or create the contact person of a contact for the municipality. diff --git a/lib/Service/StackiqContactSyncService.php b/lib/Service/StackiqContactSyncService.php index 256e9866e..b5fc363bc 100644 --- a/lib/Service/StackiqContactSyncService.php +++ b/lib/Service/StackiqContactSyncService.php @@ -179,11 +179,11 @@ public function syncToContacts(string $objectType, array $record): ?string { /** * Resolve the contact of a record, creating it in a dedicated address book of the signed-in user. * - * A contact the record already links (`contactsUid`) is kept. Otherwise - * a contact with the record's e-mail address is reused only when it is - * in the address book with this URI: a contact in another address book - * of the user is never matched, so the user's own contacts are never - * linked to imported records. A new contact goes into that address book, + * A contact the record already links (`contactsUid`) is kept. Otherwise, + * without a fixed UID, a contact with the record's e-mail address is + * reused only when it is in the address book with this URI: a contact + * in another address book of the user is never matched, so the user's + * own contacts are never linked to imported records. A new contact goes into that address book, * which is created with the display name when the user has none, and * never into the user's own first writable address book. * @@ -192,6 +192,9 @@ public function syncToContacts(string $objectType, array $record): ?string { * @param string $addressBookUri The address book's URI, stable across languages. * @param string $displayName The display name for a new address book. * @param string|null $fixedUid The UID for a new contact, so the caller can find it again by UID; a random one when null. + * With a fixed UID the record's e-mail address matches no existing contact: the + * caller identifies the contact by that UID, and a shared e-mail address must not + * merge two of them. * * @return ?string The contacts UID, or null when it could not be resolved or created. * @@ -214,7 +217,7 @@ public function syncToNamedAddressBook( return $existingUid; } - $matched = $this->namedAddressBookContactByEmail(record: $record, addressBookUri: $addressBookUri); + $matched = $this->namedAddressBookContactByEmail(record: $record, addressBookUri: $addressBookUri, fixedUid: $fixedUid); if ($matched !== null) { return $matched; } @@ -259,10 +262,12 @@ public function syncToNamedAddressBook( * @param string $addressBookUri The address book's URI. * @param array $properties The vCard properties to search, such as FN or EMAIL. * - * @return array> The matching contacts, as searchContacts() returns them; + * @return array> The matching contacts, as searchContacts() returns them, + * plus `emails`: every e-mail address of the contact; * none when the user has no such address book. * * @spec openspec/specs/softwarecatalog-contacts-to-nc/spec.md + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 */ public function searchNamedAddressBook(string $query, string $addressBookUri, array $properties): array { $contacts = []; @@ -271,6 +276,10 @@ public function searchNamedAddressBook(string $query, string $addressBookUri, ar 'uid' => (string)$result['UID'], 'name' => $this->firstValue(value: ($result['FN'] ?? '')), 'email' => $this->firstValue(value: ($result['EMAIL'] ?? '')), + 'emails' => array_map( + fn (mixed $email): string => $this->firstValue(value: [$email]), + array_values((array)($result['EMAIL'] ?? [])) + ), 'addressBookKey' => (string)($result['addressbook-key'] ?? ''), ]; } @@ -281,12 +290,22 @@ public function searchNamedAddressBook(string $query, string $addressBookUri, ar /** * The contact in the named address book with the record's e-mail address, or null. * + * Always null with a fixed UID: the caller identifies the contact by + * that UID, and a shared e-mail address must not merge two of them. + * * @param array $record The relationship record. * @param string $addressBookUri The address book's URI. + * @param string|null $fixedUid The UID the caller gives a new contact, or null. * * @return string|null The contact UID. + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 */ - private function namedAddressBookContactByEmail(array $record, string $addressBookUri): ?string { + private function namedAddressBookContactByEmail(array $record, string $addressBookUri, ?string $fixedUid): ?string { + if ($fixedUid !== null) { + return null; + } + $email = trim((string)($record['e-mailadres'] ?? $record['email'] ?? '')); foreach ($this->namedAddressBookResults(query: $email, addressBookUri: $addressBookUri, properties: ['EMAIL']) as $result) { if ($this->valueMatches(value: ($result['EMAIL'] ?? ''), needle: $email) === true) { diff --git a/openspec/changes/cmdb-import-owner-identity/proposal.md b/openspec/changes/cmdb-import-owner-identity/proposal.md index e3c511e71..1cf1c96e3 100644 --- a/openspec/changes/cmdb-import-owner-identity/proposal.md +++ b/openspec/changes/cmdb-import-owner-identity/proposal.md @@ -43,5 +43,5 @@ None. ## Risks -- An owner first imported by name gets a second contact once the employee number arrives; the old contact stays in the address book. Documented. +- An owner first imported by name or e-mail address gets a second contact once the employee number arrives; the old contact stays in the address book. Documented. - An unedited stored business-owner pack is replaced by 1.1.0 on update (REQ-CMDB-022); an edited one is kept and the new columns are then not added, with the existing "newer shipped version not applied" warning. diff --git a/openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec.md index b2f4c6ae7..82909bf0d 100644 --- a/openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec.md +++ b/openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec.md @@ -16,7 +16,7 @@ Nextcloud OCP interfaces used: `OCP\Contacts\IManager` (through `StackiqContactS ### Requirement: The owner SHALL become a contact person of the municipality through Nextcloud Contacts, never a user account, and SHALL never be publicly readable (REQ-CMDB-010) -The business owner pack SHALL map "Applicatie Eigenaar (Persoon)" (the display name, which may be a function instead of a person's name) to `name` and "Applicatie Eigenaar (Functie)" (the role) to `role`, and SHALL map the optional columns "Applicatie Eigenaar (E-mail)" to `email` and "Applicatie Eigenaar (Personeelsnummer)" to `employeeNumber`. Those two column names are placeholders until the municipality names the columns of its export; an administrator renames their `source` in OpenRegister's pack editor (REQ-CMDB-021), and the import then reads the renamed columns without a stackiq release. The profile SHALL list the two placeholder columns as absent on both CMDB sheets, so an export without them raises no "optional column not found" warning. The business-owner pack SHALL map, and set as a default, only `name`, `role`, `email` and `employeeNumber` (extending the list in REQ-CMDB-021). No technical owner SHALL be imported; the functional administrator columns SHALL NOT be read. For the owner the service SHALL resolve a Nextcloud contact through `StackiqContactSyncService`, all in a dedicated address book "Stackiq CMDB owners" of the admin who runs the import (`StackiqContactSyncService::syncToNamedAddressBook()` and `searchNamedAddressBook()`), which is created on first use: when the row has an employee number, by the contact whose UID is `stackiq-cmdb-owner-` followed by the SHA-256 of the municipality's uuid, `|` and the lower-cased, trimmed number (a number Excel stored as `1234.0` counts as `1234`), and otherwise by creating the contact with that UID, so two namesakes with their own number stay two owners and a renamed owner keeps the contact; the number itself SHALL NOT be stored on the contact. Without an employee number it SHALL match on the e-mail address when the row has one, and otherwise on an exact match on the display name, and otherwise create one. A new contact SHALL carry the e-mail address when the row has one. It SHALL NOT match a contact in any other address book of the admin, and SHALL NOT add an owner to them. It SHALL then reuse or create one `contactPerson` with that `contactsUid`, `organization` = the municipality and `role` = "Applicatie Eigenaar (Functie)" when given, and SHALL set `usage.businessOwner` to it. An owner first imported by name or e-mail address and later with an employee number gets the employee-number contact and its own `contactPerson`; the usage follows the export (REQ-CMDB-009) and the earlier contact stays in the address book. The import SHALL NOT create Nextcloud user accounts. When Nextcloud Contacts is unavailable, the row SHALL be imported without an owner and SHALL carry a warning. `contactPerson` and `usage` SHALL have no public read rule, so the owner is never readable by an anonymous visitor; a published module SHALL refer to them by relation only. +The business owner pack SHALL map "Applicatie Eigenaar (Persoon)" (the display name, which may be a function instead of a person's name) to `name` and "Applicatie Eigenaar (Functie)" (the role) to `role`, and SHALL map the optional columns "Applicatie Eigenaar (E-mail)" to `email` and "Applicatie Eigenaar (Personeelsnummer)" to `employeeNumber`. Those two column names are placeholders until the municipality names the columns of its export; an administrator renames their `source` in OpenRegister's pack editor (REQ-CMDB-021), and the import then reads the renamed columns without a stackiq release. The profile SHALL list the two placeholder columns as absent on both CMDB sheets, so an export without them raises no "optional column not found" warning; after a rename the absent list no longer names them, so an export with the renamed columns on only one CMDB sheet warns for the other. The business-owner pack SHALL map, and set as a default, only `name`, `role`, `email` and `employeeNumber` (extending the list in REQ-CMDB-021). No technical owner SHALL be imported; the functional administrator columns SHALL NOT be read. For the owner the service SHALL resolve a Nextcloud contact through `StackiqContactSyncService`, all in a dedicated address book "Stackiq CMDB owners" of the admin who runs the import (`StackiqContactSyncService::syncToNamedAddressBook()` and `searchNamedAddressBook()`), which is created on first use: when the row has an employee number, by the contact whose UID is `stackiq-cmdb-owner-` followed by the HMAC-SHA256, keyed with a key derived from the instance's `secret`, of the municipality's uuid, `|` and the lower-cased, trimmed number (a number Excel stored as `1234.0`, or with leading zeros as `01234`, counts as `1234`), and otherwise by creating the contact with that UID, without matching another contact on the row's e-mail address, so two namesakes with their own number stay two owners, also with a shared e-mail address, and a renamed owner keeps the contact; the number itself SHALL NOT be stored on the contact, and SHALL NOT be recoverable from the UID without the instance's secret. A value without a digit other than 0 (such as `-`, `0` or `n.v.t.`) SHALL count as no employee number; without an instance secret the number SHALL NOT be used, and a warning SHALL be logged once per import. Without an employee number it SHALL match on the e-mail address when the row has one (any address of the contact), and otherwise on an exact match on the display name, and otherwise create one; neither match SHALL return a contact whose UID carries the `stackiq-cmdb-owner-` prefix, which belongs to an employee number. A new contact SHALL carry the e-mail address when the row has one. It SHALL NOT match a contact in any other address book of the admin, and SHALL NOT add an owner to them. It SHALL then reuse or create one `contactPerson` with that `contactsUid`, `organization` = the municipality and `role` = "Applicatie Eigenaar (Functie)" when given, and SHALL set `usage.businessOwner` to it. An owner first imported by name or e-mail address and later with an employee number gets the employee-number contact and its own `contactPerson`; the usage follows the export (REQ-CMDB-009) and the earlier contact stays in the address book. The import SHALL NOT create Nextcloud user accounts. When Nextcloud Contacts is unavailable, the row SHALL be imported without an owner and SHALL carry a warning. `contactPerson` and `usage` SHALL have no public read rule, so the owner is never readable by an anonymous visitor; a published module SHALL refer to them by relation only. #### Scenario: The owner becomes the business owner @e2e exclude Needs a Contacts address book; tests/Unit/Service/CmdbExportImportServiceTest.php asserts the calls to a StackiqContactSyncService test double and the saved contactPerson. @@ -75,6 +75,28 @@ The business owner pack SHALL map "Applicatie Eigenaar (Persoon)" (the display n - **THEN** two contacts and two `contactPerson` objects SHALL exist, and the UID of the first SHALL be the one derived from `1001` and the municipality, without the number in it - **AND** when a later export names the owner of `1001` `Nieuwenaam, Voornaam` with the number stored as `1001.0`, the usage SHALL keep that same contact person +#### Scenario: A shared e-mail address does not merge owners with their own employee number +@e2e exclude Needs a CardDAV backend; tests/Unit/Service/CmdbExportImportServiceTest.php testASharedEmailAddressDoesNotMergeOwnersWithTheirOwnNumber asserts two contacts, and tests/Unit/Service/StackiqContactSyncServiceTest.php testAGivenUidIsNotOverriddenByAnEmailMatch asserts the given UID wins over an e-mail match. + +- **GIVEN** two rows whose owner is `Achternaam, Voornaam`, both with e-mail address `team@example.org`, with employee numbers `1001` and `2002` +- **WHEN** the export is imported +- **THEN** two contacts SHALL exist, with the UIDs derived from `1001` and `2002`, and two `contactPerson` objects + +#### Scenario: An owner without an employee number never takes an employee-number contact +@e2e exclude Needs a CardDAV backend; tests/Unit/Service/CmdbExportImportServiceTest.php testAnOwnerWithoutANumberNeverTakesANumberContact covers it. + +- **GIVEN** a row with owner `Achternaam, Voornaam`, e-mail address `team@example.org` and employee number `1001` +- **AND** a row with another owner and the same e-mail address, and a row with owner `Achternaam, Voornaam`, both without an employee number +- **WHEN** the export is imported +- **THEN** three contacts and three `contactPerson` objects SHALL exist + +#### Scenario: A placeholder in the employee number column is no employee number +@e2e exclude Needs a CardDAV backend; tests/Unit/Service/CmdbExportImportServiceTest.php testAPlaceholderInTheNumberColumnIsNoEmployeeNumber and tests/Unit/Service/Cmdb/CmdbOwnerIdentityTest.php testAValueWithoutANumberGivesNoUid cover it. + +- **GIVEN** two rows with different owners whose employee number cell holds `-` +- **WHEN** the export is imported +- **THEN** the owners SHALL be matched on their names and two contacts SHALL exist, neither with a UID derived from an employee number + #### Scenario: Without an employee number the e-mail address matches the owner @e2e exclude Needs a CardDAV backend; tests/Unit/Service/CmdbExportImportServiceTest.php testAnOwnerWithOnlyAnEmailAddressIsMatchedOnIt. diff --git a/openspec/changes/cmdb-import-owner-identity/tasks.md b/openspec/changes/cmdb-import-owner-identity/tasks.md index 339b1d45f..99508b7e4 100644 --- a/openspec/changes/cmdb-import-owner-identity/tasks.md +++ b/openspec/changes/cmdb-import-owner-identity/tasks.md @@ -21,7 +21,11 @@ Spec: `openspec/changes/cmdb-import-owner-identity/specs/cmdb-export-import/spec - **acceptance_criteria**: - GIVEN two namesakes with their own employee number THEN two contacts and two contact persons - GIVEN a renamed owner with the same number (also as `1001.0`) THEN the same contact person - - GIVEN the same number at another municipality THEN another contact UID; the number is not readable from the UID + - GIVEN the same number at another municipality THEN another contact UID; the UID is keyed with the instance secret, so the number cannot be recovered from it by hashing candidate numbers + - GIVEN two namesakes with their own number and the same e-mail address THEN two contacts + - GIVEN a number cell holding `-`, `0` or `n.v.t.` THEN no employee number; GIVEN `01001` THEN the same UID as `1001` + - GIVEN an owner without a number who shares an e-mail address or name with an employee-number contact THEN a contact of their own + - GIVEN no instance secret THEN the number is not used and one warning is logged - GIVEN only an e-mail address THEN the owner is matched on it, not on a namesake without one - GIVEN a fixed UID WHEN a new contact is created THEN the card has that UID - [x] Implement diff --git a/tests/Unit/Service/Cmdb/CmdbOwnerIdentityTest.php b/tests/Unit/Service/Cmdb/CmdbOwnerIdentityTest.php index 4a165d482..ca0f3e68b 100644 --- a/tests/Unit/Service/Cmdb/CmdbOwnerIdentityTest.php +++ b/tests/Unit/Service/Cmdb/CmdbOwnerIdentityTest.php @@ -19,7 +19,9 @@ namespace OCA\Stackiq\Tests\Unit\Service\Cmdb; use OCA\Stackiq\Service\Cmdb\CmdbOwnerIdentity; +use OCP\IConfig; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; /** * How an owner is identified across imports. @@ -28,29 +30,77 @@ */ class CmdbOwnerIdentityTest extends TestCase { /** - * The UID hides the number, is scoped to the municipality and ignores Excel's decimal form. + * An identity whose instance has this secret. + * + * @param string $secret The `secret` from config.php. + * + * @return CmdbOwnerIdentity + */ + private function identity(string $secret = 'instance-secret'): CmdbOwnerIdentity { + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueString')->willReturnCallback(fn (string $key, string $default = ''): string => $key === 'secret' ? $secret : $default); + return new CmdbOwnerIdentity(config: $config, logger: $this->createMock(LoggerInterface::class)); + }//end identity() + + /** + * The UID is keyed with the instance secret, scoped to the municipality and ignores Excel's decimal form and leading zeros. * * @return void */ public function testTheContactUidDerivesFromTheNumberAndTheMunicipality(): void { - $identity = new CmdbOwnerIdentity(); + $identity = $this->identity(); $uid = $identity->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-1'); - $this->assertStringStartsWith(CmdbOwnerIdentity::UID_PREFIX, (string)$uid); - $this->assertStringNotContainsString('1001', (string)$uid); + $this->assertSame(CmdbOwnerIdentity::UID_PREFIX . hash_hmac('sha256', 'muni-1|1001', hash_hmac('sha256', CmdbOwnerIdentity::UID_PREFIX, 'instance-secret')), $uid); + $this->assertNotSame(CmdbOwnerIdentity::UID_PREFIX . hash_hmac('sha256', 'muni-1|1001', 'instance-secret'), $uid, 'the raw instance secret is not the key'); + $this->assertNotSame(CmdbOwnerIdentity::UID_PREFIX . hash('sha256', 'muni-1|1001'), $uid, 'not an unkeyed hash anyone can recompute'); + $this->assertNotSame($uid, $this->identity(secret: 'other-secret')->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-1')); $this->assertSame($uid, $identity->contactUid(employeeNumber: ' 1001.0 ', municipalityUuid: 'muni-1')); + $this->assertSame($uid, $identity->contactUid(employeeNumber: '01001', municipalityUuid: 'muni-1')); + $this->assertSame($uid, $identity->contactUid(employeeNumber: "\u{00A0}1001\u{00A0}", municipalityUuid: 'muni-1'), 'non-breaking spaces'); $this->assertNotSame($uid, $identity->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-2')); $this->assertNotSame($uid, $identity->contactUid(employeeNumber: '10010', municipalityUuid: 'muni-1')); - $this->assertNull($identity->contactUid(employeeNumber: ' ', municipalityUuid: 'muni-1')); + $this->assertNotNull($identity->contactUid(employeeNumber: 'P1001', municipalityUuid: 'muni-1'), 'a number with letters is a number'); }//end testTheContactUidDerivesFromTheNumberAndTheMunicipality() + /** + * An empty cell or a placeholder is no employee number, and without a secret no number is used. + * + * @return void + */ + public function testAValueWithoutANumberGivesNoUid(): void { + $identity = $this->identity(); + foreach ([' ', '-', '0', '000', '0.0', 'n.v.t.', 'onbekend', '?'] as $placeholder) { + $this->assertNull($identity->contactUid(employeeNumber: $placeholder, municipalityUuid: 'muni-1'), $placeholder); + } + + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueString')->willReturn(''); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('warning'); + $withoutSecret = new CmdbOwnerIdentity(config: $config, logger: $logger); + $this->assertNull($withoutSecret->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-1'), 'fail-closed without a secret'); + $this->assertNull($withoutSecret->contactUid(employeeNumber: '2002', municipalityUuid: 'muni-1'), 'logged once per run'); + }//end testAValueWithoutANumberGivesNoUid() + + /** + * A contact without an employee number gets a random UUID, never one with the number prefix. + * + * @return void + */ + public function testANewContactUidIsARandomUuid(): void { + $uid = $this->identity()->newContactUid(); + $this->assertMatchesRegularExpression('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $uid); + $this->assertNotSame($uid, $this->identity()->newContactUid()); + }//end testANewContactUidIsARandomUuid() + /** * The employee number wins over the e-mail address, which wins over the name. * * @return void */ public function testTheCacheKeyFollowsTheMatchOrder(): void { - $identity = new CmdbOwnerIdentity(); + $identity = $this->identity(); $this->assertSame('uid:x', $identity->cacheKey(contactUid: 'x', email: 'A@example.org', displayName: 'Naam')); $this->assertSame('email:a@example.org', $identity->cacheKey(contactUid: null, email: 'A@example.org', displayName: 'Naam')); $this->assertSame('name:naam', $identity->cacheKey(contactUid: null, email: '', displayName: 'Naam')); diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php index f8480f8c6..73d6034ff 100644 --- a/tests/Unit/Service/CmdbExportImportServiceTest.php +++ b/tests/Unit/Service/CmdbExportImportServiceTest.php @@ -60,6 +60,7 @@ use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use Psr\Log\AbstractLogger; +use Psr\Log\LoggerInterface; use RuntimeException; /** @@ -867,10 +868,14 @@ function (string $query, string $addressBookUri, array $properties): array { $haystack = (string)$uid; } + if ($properties === ['EMAIL']) { + $haystack = implode(' ', array_merge([$contact['email']], $contact['emails'] ?? [])); + } + if (($contact['book'] ?? 'personal') === $addressBookUri && str_contains(mb_strtolower($haystack), mb_strtolower($query)) === true ) { - $found[] = ['uid' => $uid, 'name' => $contact['name'], 'email' => $contact['email']]; + $found[] = ['uid' => $uid, 'name' => $contact['name'], 'email' => $contact['email'], 'emails' => array_merge([$contact['email']], $contact['emails'] ?? [])]; } } @@ -883,7 +888,7 @@ function (string $objectType, array $record, string $addressBookUri, string $dis $this->addressBooks[$addressBookUri] = $displayName; $email = (string)($record['email'] ?? ''); foreach ($this->contacts as $uid => $contact) { - if ($email !== '' && ($contact['book'] ?? 'personal') === $addressBookUri && strcasecmp($contact['email'], $email) === 0) { + if ($fixedUid === null && $email !== '' && ($contact['book'] ?? 'personal') === $addressBookUri && strcasecmp($contact['email'], $email) === 0) { return $uid; } } @@ -1107,10 +1112,22 @@ function (string $id) use ($objectService, $schemaMapper, $packs, $archiveHandle l10n: $this->l10n(), logger: $this->logger(), lockingProvider: $this->locks, - userSession: $this->adminSession() + userSession: $this->adminSession(), + ownerIdentity: $this->ownerIdentity() ); }//end service() + /** + * The owner identity with a fixed instance secret. + * + * @return CmdbOwnerIdentity + */ + private function ownerIdentity(): CmdbOwnerIdentity { + $config = $this->createMock(IConfig::class); + $config->method('getSystemValueString')->willReturnCallback(fn (string $key, string $default = ''): string => $key === 'secret' ? 'instance-secret' : $default); + return new CmdbOwnerIdentity(config: $config, logger: $this->createMock(LoggerInterface::class)); + }//end ownerIdentity() + /** * A session signed in as "admin". * @@ -3219,9 +3236,9 @@ public function testAnOwnerWithAnEmployeeNumberIsMatchedOnItNotOnTheName(): void $this->assertCount(2, $this->contacts, 'two namesakes with their own number are two contacts'); $this->assertCount(2, $this->objects(self::CONTACT_PERSON)); - $uid = (new CmdbOwnerIdentity())->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-1'); + $uid = $this->ownerIdentity()->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-1'); $this->assertArrayHasKey($uid, $this->contacts); - $this->assertStringNotContainsString('1001', (string)$uid, 'the number is not readable from the contact'); + $this->assertStringNotContainsString('1001', (string)$uid, 'the number is not in the contact UID'); // The owner changed their name; Excel stored the number as a decimal. $renamed = ['Applicatie Eigenaar (Persoon)' => 'Nieuwenaam, Voornaam', 'Applicatie Eigenaar (Personeelsnummer)' => '1001.0']; @@ -3231,10 +3248,106 @@ public function testAnOwnerWithAnEmployeeNumberIsMatchedOnItNotOnTheName(): void $this->assertCount(2, $this->contacts, 'the renamed owner is the same contact'); $people = array_column($this->objects(self::CONTACT_PERSON), null, 'contactsUid'); $this->assertSame($people[$uid]['id'], $this->objects(self::USAGE)[0]['businessOwner']); - $this->assertNull((new CmdbOwnerIdentity())->contactUid(employeeNumber: ' ', municipalityUuid: 'muni-1')); - $this->assertNotSame($uid, (new CmdbOwnerIdentity())->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-2'), 'scoped to the municipality'); + $this->assertNull($this->ownerIdentity()->contactUid(employeeNumber: ' ', municipalityUuid: 'muni-1')); + $this->assertNotSame($uid, $this->ownerIdentity()->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-2'), 'scoped to the municipality'); }//end testAnOwnerWithAnEmployeeNumberIsMatchedOnItNotOnTheName() + /** + * Two namesakes with their own number and one shared mailbox stay two owners: the e-mail address never overrides the number. + * + * @return void + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + public function testASharedEmailAddressDoesNotMergeOwnersWithTheirOwnNumber(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $first = ['Applicatie Eigenaar (Persoon)' => 'Achternaam, Voornaam', 'Applicatie Eigenaar (E-mail)' => 'team@example.org', 'Applicatie Eigenaar (Personeelsnummer)' => '1001']; + $namesake = ['Applicatie Eigenaar (Persoon)' => 'Achternaam, Voornaam', 'Applicatie Eigenaar (E-mail)' => 'team@example.org', 'Applicatie Eigenaar (Personeelsnummer)' => '2002']; + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: $first, row: 2), $this->row(appId: '2', cells: $namesake, row: 3)])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $identity = $this->ownerIdentity(); + $this->assertEqualsCanonicalizing( + [$identity->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-1'), $identity->contactUid(employeeNumber: '2002', municipalityUuid: 'muni-1')], + array_keys($this->contacts) + ); + $this->assertCount(2, $this->objects(self::CONTACT_PERSON)); + }//end testASharedEmailAddressDoesNotMergeOwnersWithTheirOwnNumber() + + /** + * An owner known by e-mail address gets the employee-number contact once the number arrives; the old contact stays. + * + * @return void + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + public function testAnOwnerFirstKnownByEmailGetsTheNumberContact(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $this->contacts['contact-email'] = ['name' => 'Voornaam Achternaam', 'email' => 'v.achternaam@example.org', 'book' => 'stackiq-cmdb-owners']; + $owner = ['Applicatie Eigenaar (Persoon)' => 'Achternaam, Voornaam', 'Applicatie Eigenaar (E-mail)' => 'v.achternaam@example.org', 'Applicatie Eigenaar (Personeelsnummer)' => '1001']; + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: $owner)])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $uid = $this->ownerIdentity()->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-1'); + $this->assertArrayHasKey('contact-email', $this->contacts, 'the earlier contact stays'); + $this->assertArrayHasKey($uid, $this->contacts); + $people = array_column($this->objects(self::CONTACT_PERSON), null, 'contactsUid'); + $this->assertSame($people[$uid]['id'], $this->objects(self::USAGE)[0]['businessOwner']); + }//end testAnOwnerFirstKnownByEmailGetsTheNumberContact() + + /** + * A placeholder in the number column is no employee number: owners with "-" stay apart by name. + * + * @return void + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + public function testAPlaceholderInTheNumberColumnIsNoEmployeeNumber(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $first = ['Applicatie Eigenaar (Persoon)' => 'Eerste, Voornaam', 'Applicatie Eigenaar (Personeelsnummer)' => '-']; + $second = ['Applicatie Eigenaar (Persoon)' => 'Tweede, Voornaam', 'Applicatie Eigenaar (Personeelsnummer)' => '-']; + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: $first, row: 2), $this->row(appId: '2', cells: $second, row: 3)])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertCount(2, $this->contacts, 'two owners, not one contact for "-"'); + $this->assertSame([], array_filter(array_keys($this->contacts), fn (string $uid): bool => str_starts_with($uid, CmdbOwnerIdentity::UID_PREFIX))); + }//end testAPlaceholderInTheNumberColumnIsNoEmployeeNumber() + + /** + * An owner without a number never takes a contact identified by an employee number, by e-mail address or by name. + * + * @return void + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + public function testAnOwnerWithoutANumberNeverTakesANumberContact(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $numbered = ['Applicatie Eigenaar (Persoon)' => 'Achternaam, Voornaam', 'Applicatie Eigenaar (E-mail)' => 'team@example.org', 'Applicatie Eigenaar (Personeelsnummer)' => '1001']; + $byEmail = ['Applicatie Eigenaar (Persoon)' => 'Anders, Iemand', 'Applicatie Eigenaar (E-mail)' => 'team@example.org']; + $byName = ['Applicatie Eigenaar (Persoon)' => 'Achternaam, Voornaam']; + $rows = [$this->row(appId: '1', cells: $numbered, row: 2), $this->row(appId: '2', cells: $byEmail, row: 3), $this->row(appId: '3', cells: $byName, row: 4)]; + $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertCount(3, $this->contacts, 'the number contact, one for the shared mailbox, one for the namesake'); + $this->assertCount(3, $this->objects(self::CONTACT_PERSON)); + }//end testAnOwnerWithoutANumberNeverTakesANumberContact() + + /** + * Any e-mail address of an owner contact matches, not only its first one. + * + * @return void + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + public function testASecondEmailAddressOfAContactMatches(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $this->contacts['contact-two'] = ['name' => 'Voornaam Achternaam', 'email' => 'eerste@example.org', 'emails' => ['tweede@example.org'], 'book' => 'stackiq-cmdb-owners']; + $owner = ['Applicatie Eigenaar (Persoon)' => 'Achternaam, Voornaam', 'Applicatie Eigenaar (E-mail)' => 'Tweede@example.org']; + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: $owner)]))->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertSame(['contact-two'], array_keys($this->contacts)); + }//end testASecondEmailAddressOfAContactMatches() + /** * Without an employee number an e-mail address matches the owner, and is stored on the new contact. * @@ -3279,7 +3392,7 @@ public function testRenamedOwnerColumnsInOpenRegisterAreRead(): void { ->import(path: '', options: ['municipalityUuid' => 'muni-1']); $this->assertSame([], $report['rows'][0]['warnings']); - $uid = (new CmdbOwnerIdentity())->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-1'); + $uid = $this->ownerIdentity()->contactUid(employeeNumber: '1001', municipalityUuid: 'muni-1'); $this->assertSame([$uid], array_keys($this->contacts), 'matched on the renamed employee number column'); $this->assertSame('v.achternaam@example.org', $this->contacts[$uid]['email'], 'the renamed e-mail column'); }//end testRenamedOwnerColumnsInOpenRegisterAreRead() diff --git a/tests/Unit/Service/StackiqContactSyncServiceTest.php b/tests/Unit/Service/StackiqContactSyncServiceTest.php index 96c2731dd..368fba5c3 100644 --- a/tests/Unit/Service/StackiqContactSyncServiceTest.php +++ b/tests/Unit/Service/StackiqContactSyncServiceTest.php @@ -89,7 +89,7 @@ function (string $pattern, array $properties) use ($contacts): array { $contacts, static function (array $contact) use ($pattern, $properties): bool { foreach ($properties as $property) { - if (str_contains(mb_strtolower((string)($contact[$property] ?? '')), mb_strtolower($pattern)) === true) { + if (str_contains(mb_strtolower(json_encode($contact[$property] ?? '', JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)), mb_strtolower($pattern)) === true) { return true; } } @@ -221,6 +221,31 @@ public function testANewContactTakesTheGivenUid(): void { $this->assertStringContainsString("\r\nEMAIL:owner@example.org\r\n", $backend->cards[0][2]); }//end testANewContactTakesTheGivenUid() + /** + * With a given UID the e-mail address matches no existing contact, so a shared mailbox does not merge two owners. + * + * @return void + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + public function testAGivenUidIsNotOverriddenByAnEmailMatch(): void { + $backend = $this->backend(); + $backend->books['principals/users/admin|stackiq-cmdb-owners'] = ['id' => 7, 'displayname' => 'Stackiq CMDB owners']; + $contacts = [['UID' => 'owners-1', 'FN' => 'Voornaam Achternaam', 'EMAIL' => 'team@example.org', 'addressbook-key' => '7']]; + $service = $this->service(backend: $backend, contacts: $contacts); + + $uid = $service->syncToNamedAddressBook( + objectType: 'contactPerson', + record: ['voornaam' => 'Voornaam', 'achternaam' => 'Achternaam', 'email' => 'team@example.org'], + addressBookUri: 'stackiq-cmdb-owners', + displayName: 'Stackiq CMDB owners', + fixedUid: 'stackiq-cmdb-owner-def' + ); + + $this->assertSame('stackiq-cmdb-owner-def', $uid); + $this->assertCount(1, $backend->cards, 'a new contact, not the one with the same e-mail address'); + }//end testAGivenUidIsNotOverriddenByAnEmailMatch() + /** * An e-mail address matches only a contact in the named address book; a personal contact with it is not linked. * @@ -247,6 +272,24 @@ public function testAnExistingContactIsMatchedOnlyInTheNamedAddressBook(): void $this->assertSame(['owners-1'], array_column($service->searchNamedAddressBook(query: 'e', addressBookUri: 'stackiq-cmdb-owners', properties: ['FN']), 'uid')); }//end testAnExistingContactIsMatchedOnlyInTheNamedAddressBook() + /** + * A search result lists every e-mail address of the contact, so a match on the second one is possible. + * + * @return void + * + * @spec openspec/changes/cmdb-import-owner-identity/tasks.md#task-2 + */ + public function testASearchResultListsEveryEmailAddress(): void { + $backend = $this->backend(); + $backend->books['principals/users/admin|stackiq-cmdb-owners'] = ['id' => 7, 'displayname' => 'Stackiq CMDB owners']; + $contacts = [['UID' => 'owners-1', 'FN' => 'Voornaam Achternaam', 'EMAIL' => ['eerste@example.org', ['value' => 'tweede@example.org']], 'addressbook-key' => '7']]; + + $found = $this->service(backend: $backend, contacts: $contacts)->searchNamedAddressBook(query: 'tweede', addressBookUri: 'stackiq-cmdb-owners', properties: ['EMAIL']); + + $this->assertSame('eerste@example.org', $found[0]['email']); + $this->assertSame(['eerste@example.org', 'tweede@example.org'], $found[0]['emails']); + }//end testASearchResultListsEveryEmailAddress() + /** * Without the named address book a search finds nothing and creates no address book. *