diff --git a/lib/Service/ConnectionReportService.php b/lib/Service/ConnectionReportService.php index 646c48e8e..dc598f4ed 100644 --- a/lib/Service/ConnectionReportService.php +++ b/lib/Service/ConnectionReportService.php @@ -83,6 +83,26 @@ class ConnectionReportService { */ public const KEY_EOL = 'eol-feed'; + /** + * The pull reason FederationService records for switched-off federation, which is never reported. + * + * The `federation` switch in lib/Settings/connections.json reads + * `federation_enabled`, so integriq shows `disabled` without a report + * (hydra connection-registry D4 rule 2b). + * + * @var string + */ + public const PULL_REASON_SWITCHED_OFF = 'federation disabled'; + + /** + * The EOL sync degrade reason for a switched-off sync, which is never reported. + * + * The `eol-feed` switch reads `enabled` inside `eol_sync_config` itself. + * + * @var string + */ + public const EOL_REASON_SWITCHED_OFF = 'disabled'; + /** * The longest failure reason a message carries. * @@ -93,15 +113,13 @@ class ConnectionReportService { /** * What each EOL sync degrade reason means for the row, as status and message. * - * The reasons are the ones EolSyncService::degrade() records. + * The reasons are the ones EolSyncService::degrade() records, apart from + * `disabled`: the `eol-feed` switch in lib/Settings/connections.json reads + * a switched-off sync itself, so that reason reports nothing. * * @var array */ public const EOL_REASONS = [ - 'disabled' => [ - 'unconfigured', - 'End-of-life sync is switched off. Switch it on in the End-of-life feed sync section.', - ], 'openregister-not-installed' => [ 'unavailable', 'The end-of-life sync needs OpenRegister, and it is not installed.', @@ -203,6 +221,9 @@ public function describeEmail(array $configStatus, array $transportLabels): arra * * A ready federation gets no report: only a pull can tell whether the peers * answer, so the row reads the declared "Not checked yet" until then. + * Switched-off federation gets none either: the `federation` switch in + * lib/Settings/connections.json reads `federation_enabled` itself, and + * integriq shows `disabled`. * * @param array $status The result of FederationService::getStatus(). * @@ -215,9 +236,13 @@ public function federationPeersChanged(array $status): bool { return false; } + $available = ($status['available'] ?? false) === true; + if ($available === true && ($status['enabled'] ?? false) !== true) { + return false; + } + $blocked = $this->federationBlocker( - available: ($status['available'] ?? false) === true, - enabled: ($status['enabled'] ?? false) === true, + available: $available, peerCount: count((array) ($status['peers'] ?? [])) ); if ($blocked === null) { @@ -237,7 +262,12 @@ public function federationPeersChanged(array $status): bool { * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function federationPulled(array $pull): bool { - [$status, $message] = $this->describePull(pull: $pull); + $described = $this->describePull(pull: $pull); + if ($described === null) { + return false; + } + + [$status, $message] = $described; return $this->report(key: self::KEY_FEDERATION, status: $status, message: $message); }//end federationPulled() @@ -247,16 +277,19 @@ public function federationPulled(array $pull): bool { * * @param array $pull The result of FederationService::pullAllPeers(). * - * @return array{0: string, 1: string} The status and the message. + * @return array{0: string, 1: string}|null The status and the message, or null when federation is switched off. * * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ - public function describePull(array $pull): array { + public function describePull(array $pull): ?array { $reason = (string) ($pull['reason'] ?? ''); if (($pull['ok'] ?? false) !== true) { + if ($reason === self::PULL_REASON_SWITCHED_OFF) { + return null; + } + $blocked = $this->federationBlocker( available: $reason !== 'OpenCatalogi unavailable', - enabled: $reason !== 'federation disabled', peerCount: 1 ); @@ -291,26 +324,18 @@ public function describePull(array $pull): array { }//end describePull() /** - * After an EOL sync settings save: refresh, then report a switched-off sync. + * After an EOL sync settings save: refresh, and send no report. * - * @param array $config The configuration as saved. + * The save may have switched the sync on or off, and the `eol-feed` switch + * in lib/Settings/connections.json reads `enabled` inside + * `eol_sync_config` itself. A run reports what the sync met. * - * @return bool True when a report was sent. + * @return bool True when the refresh was sent. * * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ - public function eolSyncConfigSaved(array $config): bool { - if ($this->refresh(key: self::KEY_EOL) === false) { - return false; - } - - if (($config['enabled'] ?? false) === true) { - return false; - } - - [$status, $message] = self::EOL_REASONS['disabled']; - - return $this->report(key: self::KEY_EOL, status: $status, message: $message); + public function eolSyncConfigSaved(): bool { + return $this->refresh(key: self::KEY_EOL); }//end eolSyncConfigSaved() /** @@ -323,7 +348,12 @@ public function eolSyncConfigSaved(array $config): bool { * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ public function eolSyncRan(array $runStatus): bool { - [$status, $message] = $this->describeEolRun(runStatus: $runStatus); + $described = $this->describeEolRun(runStatus: $runStatus); + if ($described === null) { + return false; + } + + [$status, $message] = $described; return $this->report(key: self::KEY_EOL, status: $status, message: $message); }//end eolSyncRan() @@ -333,11 +363,11 @@ public function eolSyncRan(array $runStatus): bool { * * @param array $runStatus The status EolSyncService::run() recorded. * - * @return array{0: string, 1: string} The status and the message. + * @return array{0: string, 1: string}|null The status and the message, or null when the sync is switched off. * * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met */ - public function describeEolRun(array $runStatus): array { + public function describeEolRun(array $runStatus): ?array { if (($runStatus['available'] ?? false) === true) { return [ 'configured', @@ -347,6 +377,9 @@ public function describeEolRun(array $runStatus): array { } $reason = (string) ($runStatus['reason'] ?? ''); + if ($reason === self::EOL_REASON_SWITCHED_OFF) { + return null; + } return (self::EOL_REASONS[$reason] ?? ['error', 'The last end-of-life sync stopped: ' . $this->shorten(text: $reason)]); }//end describeEolRun() @@ -416,24 +449,19 @@ protected function resolveEventClass(string $eventClass): ?string { /** * The state that keeps federation from pulling at all, or null when none does. * + * Switched-off federation is not a blocker here: the callers leave it to the + * row's switch, which integriq reads itself. + * * @param bool $available Whether OpenCatalogi is installed. - * @param bool $enabled Whether federation_enabled is on. * @param int $peerCount How many peers are configured. * * @return array{0: string, 1: string}|null The status and the message, or null. */ - private function federationBlocker(bool $available, bool $enabled, int $peerCount): ?array { + private function federationBlocker(bool $available, int $peerCount): ?array { if ($available === false) { return ['unavailable', 'Federation needs the OpenCatalogi app, and it is not installed.']; } - if ($enabled === false) { - return [ - 'unconfigured', - 'Federation is switched off. Run occ config:app:set stackiq federation_enabled --value=true --type=boolean.', - ]; - } - if ($peerCount === 0) { return ['unconfigured', 'Federation is on, and no peer catalog is added yet.']; } diff --git a/lib/Service/EolSyncService.php b/lib/Service/EolSyncService.php index 8864d4fb2..86be572b3 100644 --- a/lib/Service/EolSyncService.php +++ b/lib/Service/EolSyncService.php @@ -96,7 +96,7 @@ public function getConfig(): array { */ public function updateConfig(array $data): array { $result = $this->settingsService->updateEolSyncConfig($data); - $this->connectionReports?->eolSyncConfigSaved(config: (array) ($result['config'] ?? [])); + $this->connectionReports?->eolSyncConfigSaved(); return $result; }//end updateConfig() diff --git a/lib/Settings/connections.json b/lib/Settings/connections.json index e2fcb8591..2e8c10bd2 100644 --- a/lib/Settings/connections.json +++ b/lib/Settings/connections.json @@ -21,6 +21,10 @@ "order": 20, "settingsUrl": "/settings/admin/stackiq#section-federation", "reportedOnly": true, + "switch": { + "configKey": "federation_enabled" + }, + "disabledMessage": "Federation is switched off. Set the federation_enabled app setting to true with occ to switch it on.", "unconfiguredMessage": "Not checked yet. Choose Pull now in the Catalog federation section to check the peers." }, { @@ -30,6 +34,11 @@ "order": 30, "settingsUrl": "/settings/admin/stackiq#section-eol-sync", "reportedOnly": true, + "switch": { + "configKey": "eol_sync_config", + "jsonPath": "enabled" + }, + "disabledMessage": "End-of-life sync is switched off. Switch it on in the End-of-life feed sync section.", "sourceTemplate": "endoflife-date", "unconfiguredMessage": "Not checked yet. Choose Sync now in the End-of-life feed sync section." } diff --git a/openspec/changes/adopt-connection-registry/design.md b/openspec/changes/adopt-connection-registry/design.md index 24231e76d..be0bc86f9 100644 --- a/openspec/changes/adopt-connection-registry/design.md +++ b/openspec/changes/adopt-connection-registry/design.md @@ -42,7 +42,7 @@ The `null` transport still reads Simulated: rule 3 sits above every report. | Stackiq sees | Status | Message | |---|---|---| | OpenCatalogi not installed | `unavailable` | "Federation needs the OpenCatalogi app, and it is not installed." | -| `federation_enabled` off | `unconfigured` | names the `occ` command | +| `federation_enabled` off | nothing: the refresh alone, and the switch makes integriq read `disabled` with the declared message | | | No peers | `unconfigured` | "Federation is on, and no peer catalog is added yet." | | Ready | nothing | the refresh alone, so the row reads the declared "Not checked yet" | @@ -56,14 +56,14 @@ The `null` transport still reads Simulated: rule 3 sits above every report. A message names a peer by host only, never by its full URL, and cuts a failure reason at 160 characters. -**End-of-life feed, on an EOL sync settings save.** A refresh, and `unconfigured` when `enabled` is off. Otherwise the row reads "Not checked yet" until the next run. +**End-of-life feed, on an EOL sync settings save.** A refresh and no report. When `enabled` is off the switch makes integriq read `disabled`. Otherwise the row reads "Not checked yet" until the next run. **End-of-life feed, after a run** (Sync now, or `EolSyncJob`). `EolSyncService::run()` already records a status. The report maps its `reason`: | Reason | Status | |---|---| | none, the run completed | `configured`, with the matched and skipped counts | -| `disabled` | `unconfigured` | +| `disabled` | nothing, the switch says it | | `openregister-not-installed` | `unavailable` | | `object-service-unavailable` | `error` | | `module-schema-not-configured` | `unconfigured` | @@ -78,15 +78,15 @@ A message names a peer by host only, never by its full URL, and cuts a failure r - `src/manifest.d/connection-registry.json`: an `index` page `Integrations` at `/settings/integrations`, `requiresApp` integriq, `permission: admin`, `showAdd: false`, and the columns connection, status, status message, last checked and settings. - Its menu entry `IntegrationsMenu` sits in the settings gear with `query: {app: stackiq}`, `permission: admin` and `visibleIf.appInstalled: integriq`. -- `src/services/connectionRegistry.js` holds the two formatters and `openIntegriqConnections`. -- `App.vue` passes the formatters through CnAppRoot's `formatters` prop. It passed none before this change. `src/customComponents.js` carries the handler, because CnIndexPage resolves a header action's handler against `customComponents`. +- `src/services/connectionRegistry.js` holds `openIntegriqConnections`. +- `App.vue` passes no `formatters`: CnAppRoot supplies the two built-ins. `src/customComponents.js` carries the handler, because CnIndexPage resolves a header action's handler against `customComponents`. -**Formatters.** The installed `@conduction/nextcloud-vue` 2.39.0 ships no `connectionStatus` built-in, so stackiq carries a local copy with all six labels, `limited` included. +**Formatters.** `@conduction/nextcloud-vue` 3.2.0 ships `connectionStatus` and `connectionSettingsLabel` as built-ins, `disabled` included (nextcloud-vue#1173). Stackiq carried a local copy while it resolved 2.39.0, and dropped it on moving to 3.2.0. ## D4. Contract misfits -- **A boolean app-config key.** `federation_enabled` is typed boolean. Integriq's reader answers `typed` for a type conflict, which counts as filled, so a `requiredConfig` on it would read Configured while federation is off. The contract has no way to say "filled and true". `reportedOnly` works around it. -- **A flag inside a blob.** `eol_sync_config` holds `{"enabled": false, …}`. `adapter.jsonPath` reads inside a blob, but only rule 3 uses it, and "switched off" is not "simulated". A `requiredConfig` with a JSON path would fit this row. +- **A boolean app-config key.** `federation_enabled` is typed boolean, and a stored `false` counted as filled. Resolved by hydra#676 (`false` reads empty) and hydra#677: the row declares `switch: {"configKey": "federation_enabled"}` and reads `disabled` while it is off. +- **A flag inside a blob.** `eol_sync_config` holds `{"enabled": false, …}`. Resolved by hydra#677: the row declares `switch: {"configKey": "eol_sync_config", "jsonPath": "enabled"}`. An unset blob has no `enabled` and reads off, which matches `SettingsService::getEolSyncConfig()`'s default. - **Completeness that depends on the adapter.** Email needs different keys per transport. `requiredConfig` is one fixed list. - **Gate 116's vendored schema is behind integriq.** `hydra-gates/scripts/schemas/connections.schema.json` on `.github` `main` has no `jsonPath`, `simulatedValues` or `reportedOnly`, so gate 116 warns on every file that uses the hydra#673 fields. The file validates against integriq's own schema on `development`. diff --git a/openspec/changes/adopt-connection-registry/proposal.md b/openspec/changes/adopt-connection-registry/proposal.md index b43e66e1a..d2d3f8e36 100644 --- a/openspec/changes/adopt-connection-registry/proposal.md +++ b/openspec/changes/adopt-connection-registry/proposal.md @@ -18,14 +18,14 @@ Hydra change `connection-registry` (hydra#667, amended in hydra#673 and hydra#67 - New `lib/Settings/connections.json` with three connections: `email`, `federation` and `eol-feed`. - `email` names `email_transport_type` as its adapter key, and only `null` reads Simulated. An empty value is not simulated: stackiq falls back to SMTP. -- `federation` and `eol-feed` are `reportedOnly`. Only stackiq can see OpenCatalogi, `federation_enabled` (a boolean key) and the sync outcome. +- `federation` and `eol-feed` are `reportedOnly`. Only stackiq can see OpenCatalogi, the peers and the sync outcome. Each declares its on/off setting as a `switch` (hydra#677): `federation_enabled`, and `enabled` inside `eol_sync_config`, so a switched-off feature reads Switched off. - `eol-feed` offers integriq's `endoflife-date` source as its template. - The three settings sections get stable ids: `section-email`, `section-federation` and `section-eol-sync`. - An email settings save, a peer add or remove, and an EOL sync settings save send `ConnectionRefreshRequestedEvent` for that connection, then report what stackiq can see. - A federation pull and an EOL sync run report their outcome. Both run on a schedule or on the admin's button, never on a page request. - An Integrations page under the settings gear, over integriq's `app_connection` schema, preset to `app=stackiq`, admin only, and only shown when integriq is installed. - Add integration opens `/apps/integriq/connections?app=stackiq&link=1`. -- Local `connectionStatus` and `connectionSettingsLabel` formatters with all six statuses, and the strings in English and Dutch. +- The `connectionStatus` and `connectionSettingsLabel` formatters come from `@conduction/nextcloud-vue` 3.2.0, which labels all seven statuses. The page strings are in English and Dutch. ## Depends on diff --git a/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md b/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md index 7aae69757..2d3c4ab48 100644 --- a/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md +++ b/openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md @@ -13,7 +13,7 @@ Admins see stackiq's outside connections on one page, with a status stackiq can ### Requirement: REQ-STACKIQ-CONN-001 Stackiq declares its outside connections in one static file -Stackiq SHALL declare `email`, `federation` and `eol-feed` in `lib/Settings/connections.json` in the shape of hydra connection-registry design D2 (hydra REQ-CONN-001). The `email` entry SHALL name `email_transport_type` as its adapter key with `simulatedValues` holding `null` and not the empty string, because an empty transport sends mail through SMTP. The `federation` and `eol-feed` entries SHALL be `reportedOnly`. The `eol-feed` entry SHALL offer integriq's `endoflife-date` source template. Every `settingsUrl` SHALL point at a section id that exists in the admin settings page. +Stackiq SHALL declare `email`, `federation` and `eol-feed` in `lib/Settings/connections.json` in the shape of hydra connection-registry design D2 (hydra REQ-CONN-001). The `email` entry SHALL name `email_transport_type` as its adapter key with `simulatedValues` holding `null` and not the empty string, because an empty transport sends mail through SMTP. The `federation` and `eol-feed` entries SHALL be `reportedOnly`. The `federation` entry SHALL declare `federation_enabled` as its `switch`, and the `eol-feed` entry SHALL declare `enabled` inside `eol_sync_config` as its `switch`, so a switched-off feature reads `disabled` (hydra connection-registry D12 items 6, 7 and 9). The `eol-feed` entry SHALL offer integriq's `endoflife-date` source template. Every `settingsUrl` SHALL point at a section id that exists in the admin settings page. #### Scenario: The declaration names this app and passes integriq's schema @e2e exclude A static file with no browser surface; tests/Unit/Settings/ConnectionsDeclarationTest.php checks the shape, the app id, unique keys and the anchors. @@ -25,6 +25,14 @@ Stackiq SHALL declare `email`, `federation` and `eol-feed` in `lib/Settings/conn - **AND** every key SHALL be unique - **AND** every `#section-…` anchor SHALL be an id in a settings section component +#### Scenario: Switched-off federation and a switched-off sync read disabled +@e2e exclude The rule lives in integriq's resolver; tests/Unit/Settings/ConnectionsDeclarationTest.php asserts both switches and that the code reads the same keys with an off default. + +- **GIVEN** integriq has synced stackiq's declaration +- **WHEN** `federation_enabled` holds `false`, or `eol_sync_config` holds `{"enabled": false}` +- **THEN** integriq's rule 2b SHALL resolve that row as `disabled` +- **AND** stackiq SHALL send no report that says the feature is off + #### Scenario: The null transport reads simulated, and an empty one does not @e2e tests/e2e/workflows/integrations-page.spec.ts @@ -35,7 +43,7 @@ Stackiq SHALL declare `email`, `federation` and `eol-feed` in `lib/Settings/conn ### Requirement: REQ-STACKIQ-CONN-002 A save asks integriq to look again, and a run reports what it met -When a save writes the settings of a declared connection, stackiq SHALL send `ConnectionRefreshRequestedEvent` with app `stackiq` and that key, and SHALL send it before any report for that key (hydra REQ-CONN-004, hydra#674). An email settings save SHALL then report what `SymfonyEmailService::isEmailSystemConfigured()` sees. A peer add or remove SHALL report OpenCatalogi missing as `unavailable`, and federation off or without peers as `unconfigured`. A federation pull SHALL report every peer answering as `configured`, some as `limited` and none as `error`. An EOL sync run SHALL report its recorded outcome. A message SHALL name a peer by host only. Both events SHALL be named by string and sent only when the class exists. Neither SHALL change the response of the request, job or run that sent it. No page request SHALL send an event. +When a save writes the settings of a declared connection, stackiq SHALL send `ConnectionRefreshRequestedEvent` with app `stackiq` and that key, and SHALL send it before any report for that key (hydra REQ-CONN-004, hydra#674). An email settings save SHALL then report what `SymfonyEmailService::isEmailSystemConfigured()` sees. A peer add or remove SHALL report OpenCatalogi missing as `unavailable`, and federation without peers as `unconfigured`. Switched-off federation and a switched-off EOL sync SHALL send the refresh and no report, from a save, a pull or a run, because the row's switch says it. A federation pull SHALL report every peer answering as `configured`, some as `limited` and none as `error`. An EOL sync run SHALL report its recorded outcome. A message SHALL name a peer by host only. Both events SHALL be named by string and sent only when the class exists. Neither SHALL change the response of the request, job or run that sent it. No page request SHALL send an event. #### Scenario: Saving email settings refreshes, then reports @e2e exclude The event is not observable from a browser; tests/Unit/Service/ConnectionReportServiceTest.php and tests/Unit/Controller/SettingsControllerConnectionReportTest.php assert the order and the unchanged response. @@ -70,7 +78,7 @@ When a save writes the settings of a declared connection, stackiq SHALL send `Co ### Requirement: REQ-STACKIQ-CONN-003 An admin reads the connections on an Integrations page -Stackiq SHALL render an `index` page at `/settings/integrations` over `integriq/app_connection`, reached from the settings gear and preset to `app` equal to `stackiq` through its menu entry's `query` (hydra REQ-CONN-006). The page and its menu entry SHALL be admin only. The page SHALL require Integriq, and the menu entry SHALL only render when integriq is installed. The status column SHALL name all six statuses, `limited` included. The page SHALL NOT offer a generic Add button. Its Add integration action SHALL open `/apps/integriq/connections?app=stackiq&link=1`. +Stackiq SHALL render an `index` page at `/settings/integrations` over `integriq/app_connection`, reached from the settings gear and preset to `app` equal to `stackiq` through its menu entry's `query` (hydra REQ-CONN-006). The page and its menu entry SHALL be admin only. The page SHALL require Integriq, and the menu entry SHALL only render when integriq is installed. The status column SHALL name all seven statuses, `limited` and `disabled` included, through the `connectionStatus` formatter `@conduction/nextcloud-vue` ships. The page SHALL NOT offer a generic Add button. Its Add integration action SHALL open `/apps/integriq/connections?app=stackiq&link=1`. #### Scenario: The page lists only the rows of stackiq @e2e tests/e2e/workflows/integrations-page.spec.ts @@ -88,7 +96,7 @@ Stackiq SHALL render an `index` page at `/settings/integrations` over `integriq/ - **THEN** the browser SHALL open integriq's Connections overview with `app=stackiq` and `link=1` #### Scenario: A connection that works in part reads Limited -@e2e exclude Only a federation pull with a failing peer produces limited; tests/vitest/connectionRegistry.spec.js asserts the label in English and Dutch. +@e2e exclude Only a federation pull with a failing peer produces limited; tests/vitest/connectionRegistry.spec.js asserts the status column uses the library's built-in connectionStatus, whose labels nextcloud-vue's tests/utils/builtInFormatters.spec.js (formatConnectionStatus) asserts, with Beperkt in the library's l10n/nl.json. - **GIVEN** a row whose status is `limited` - **WHEN** the page renders it diff --git a/openspec/changes/adopt-connection-registry/tasks.md b/openspec/changes/adopt-connection-registry/tasks.md index c524a3beb..bbac546b4 100644 --- a/openspec/changes/adopt-connection-registry/tasks.md +++ b/openspec/changes/adopt-connection-registry/tasks.md @@ -29,6 +29,11 @@ - [x] 4.1 Write `tests/e2e/workflows/integrations-page.spec.ts`. - [x] 4.2 Install integriq in the CI `additional-apps`. -## 5. After integriq ships +## 5. Switch and built-in formatters (hydra#677) -- [ ] 5.1 Run the e2e spec against an instance with both apps, then archive this change. +- [x] 5.1 Declare `switch` on `federation` and `eol-feed`, and stop reporting `unconfigured` for a switched-off feature. +- [x] 5.2 Move `@conduction/nextcloud-vue` to the release with the built-in connection formatters and delete the local copy. + +## 6. After integriq ships + +- [ ] 6.1 Run the e2e spec against an instance with both apps, then archive this change. diff --git a/package-lock.json b/package-lock.json index 9dce10ec0..f60e53540 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@babel/core": "^7.22.9", "@codemirror/lang-json": "^6.0.0", - "@conduction/nextcloud-vue": "^2.37.0", + "@conduction/nextcloud-vue": "^3.2.0", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.5.0", "@nextcloud/capabilities": "^1.2.1", @@ -2202,9 +2202,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "2.39.0", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.39.0.tgz", - "integrity": "sha512-LmiQwc2VizxNfdzrVXF/A2NwItjIBg5DGi2EbvkMZwA8wXAgSaDWO2uant5TU+AHXotK5Csfqe0OXSe/b5qJqA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-3.2.0.tgz", + "integrity": "sha512-jRKOE/xpLnsk9L8i2G6loifDJpRC+ORCsnfkpySDwAT3MRTriKDRXkc/lxfPHxXzXNeCJfiDPEEYbwFHFOUS9Q==", "license": "EUPL-1.2", "dependencies": { "@ckpack/vue-color": "^1.6.0", @@ -2221,11 +2221,10 @@ "@microsoft/fetch-event-source": "^2.0.1", "@nextcloud/dialogs": "^7.4.1", "@nextcloud/event-bus": "^3.3.3", - "@nextcloud/files": "^3.12.2", + "@nextcloud/files": "^4.0.0", "@nextcloud/notify_push": "^1.4.0", "@nextcloud/password-confirmation": "^6.1.0", "@toast-ui/editor": "^3.2.2", - "@types/react": "^18.0.0", "@uiw/codemirror-theme-github": "^4.25.8", "@vue-flow/background": "^1.3.2", "@vue-flow/core": "^1.48.2", @@ -2235,12 +2234,11 @@ "ajv-formats": "^3.0.1", "apexcharts": "^4.7.0", "codemirror": "^6.0.0", - "dompurify": "^3.0.0", + "commander": "^14.0.3", "leaflet": "^1.9.0", "leaflet.markercluster": "^1.5.3", "linkifyjs": "^4.3.3", "lodash": "^4.17.21", - "marked": "^12.0.0", "style-mod": "^4.0.0", "vue-codemirror6": "^1.4.3", "vue3-apexcharts": "~1.8.0", @@ -2259,6 +2257,7 @@ "@nextcloud/initial-state": "^2.2.0 || ^3.0.0", "@nextcloud/l10n": "^2.0.0 || ^3.0.0", "@nextcloud/router": "^2.0.0 || ^3.0.0", + "@nextcloud/stylelint-config": "^3.2.2", "@nextcloud/vue": "^9.0.0", "@vueuse/core": "^11.0.0 || ^14.0.0", "axe-core": "^4.10.0", @@ -2267,20 +2266,18 @@ "eslint": "^8.56.0 || ^9.0.0 || ^10.0.0", "eslint-plugin-vue": "^9.21.0 || ^10.0.0", "gridstack": "^12.0.0 || ^13.0.0", - "marked": "^12.0.0", + "marked": ">=12 <19", "pinia": "^2.0.0 || ^3.0.0 || ^4.0.0", + "stylelint": "^17.9.1", "vue": "^3.5.0", "vue-eslint-parser": "^9.4.0 || ^10.0.0", "vue-material-design-icons": "^5.0.0" }, "peerDependenciesMeta": { - "axe-core": { + "@nextcloud/stylelint-config": { "optional": true }, - "dexie": { - "optional": true - }, - "dompurify": { + "axe-core": { "optional": true }, "eslint": { @@ -2289,7 +2286,7 @@ "eslint-plugin-vue": { "optional": true }, - "marked": { + "stylelint": { "optional": true }, "vue-eslint-parser": { @@ -2297,18 +2294,6 @@ } } }, - "node_modules/@conduction/nextcloud-vue/node_modules/marked": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", - "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/@csstools/color-helpers": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", @@ -5275,66 +5260,6 @@ "node": "^20 || ^22 || ^24" } }, - "node_modules/@nextcloud/dialogs/node_modules/@nextcloud/files": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-4.0.0.tgz", - "integrity": "sha512-TmecnZIS+PGWGtRh7RpGEboCT4K6iTbHULUcfR6hs3eEzjDVsCc1Ldf8popGY/70lbpdlfYle8xbXnPIo3qaXA==", - "license": "AGPL-3.0-or-later", - "dependencies": { - "@nextcloud/auth": "^2.5.3", - "@nextcloud/capabilities": "^1.2.1", - "@nextcloud/l10n": "^3.4.1", - "@nextcloud/logger": "^3.0.3", - "@nextcloud/paths": "^3.0.0", - "@nextcloud/router": "^3.1.0", - "@nextcloud/sharing": "^0.3.0", - "is-svg": "^6.1.0", - "typescript-event-target": "^1.1.2", - "webdav": "^5.9.0" - }, - "engines": { - "node": "^24.0.0" - } - }, - "node_modules/@nextcloud/dialogs/node_modules/@nextcloud/files/node_modules/@nextcloud/files": { - "version": "3.12.2", - "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.12.2.tgz", - "integrity": "sha512-vBo8tf3Xh6efiF8CrEo3pKj9AtvAF6RdDGO1XKL65IxV8+UUd9Uxl2lUExHlzoDRRczCqfGfaWfRRaFhYqce5Q==", - "license": "AGPL-3.0-or-later", - "optional": true, - "dependencies": { - "@nextcloud/auth": "^2.5.3", - "@nextcloud/capabilities": "^1.2.1", - "@nextcloud/l10n": "^3.4.1", - "@nextcloud/logger": "^3.0.3", - "@nextcloud/paths": "^3.0.0", - "@nextcloud/router": "^3.1.0", - "@nextcloud/sharing": "^0.3.0", - "cancelable-promise": "^4.3.1", - "is-svg": "^6.1.0", - "typescript-event-target": "^1.1.1", - "webdav": "^5.8.0" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || ^24.0.0" - } - }, - "node_modules/@nextcloud/dialogs/node_modules/@nextcloud/files/node_modules/@nextcloud/sharing": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@nextcloud/sharing/-/sharing-0.3.0.tgz", - "integrity": "sha512-kV7qeUZvd1fTKeFyH+W5Qq5rNOqG9rLATZM3U9MBxWXHJs3OxMqYQb8UQ3NYONzsX3zDGJmdQECIGHm1ei2sCA==", - "license": "GPL-3.0-or-later", - "dependencies": { - "@nextcloud/initial-state": "^3.0.0", - "is-svg": "^6.1.0" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || ^24.0.0" - }, - "optionalDependencies": { - "@nextcloud/files": "^3.12.0" - } - }, "node_modules/@nextcloud/eslint-config": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@nextcloud/eslint-config/-/eslint-config-9.0.1.tgz", @@ -5402,10 +5327,32 @@ } }, "node_modules/@nextcloud/files": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-4.0.0.tgz", + "integrity": "sha512-TmecnZIS+PGWGtRh7RpGEboCT4K6iTbHULUcfR6hs3eEzjDVsCc1Ldf8popGY/70lbpdlfYle8xbXnPIo3qaXA==", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@nextcloud/auth": "^2.5.3", + "@nextcloud/capabilities": "^1.2.1", + "@nextcloud/l10n": "^3.4.1", + "@nextcloud/logger": "^3.0.3", + "@nextcloud/paths": "^3.0.0", + "@nextcloud/router": "^3.1.0", + "@nextcloud/sharing": "^0.3.0", + "is-svg": "^6.1.0", + "typescript-event-target": "^1.1.2", + "webdav": "^5.9.0" + }, + "engines": { + "node": "^24.0.0" + } + }, + "node_modules/@nextcloud/files/node_modules/@nextcloud/files": { "version": "3.12.2", "resolved": "https://registry.npmjs.org/@nextcloud/files/-/files-3.12.2.tgz", "integrity": "sha512-vBo8tf3Xh6efiF8CrEo3pKj9AtvAF6RdDGO1XKL65IxV8+UUd9Uxl2lUExHlzoDRRczCqfGfaWfRRaFhYqce5Q==", "license": "AGPL-3.0-or-later", + "optional": true, "dependencies": { "@nextcloud/auth": "^2.5.3", "@nextcloud/capabilities": "^1.2.1", @@ -6927,22 +6874,6 @@ "undici-types": "~8.3.0" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, "node_modules/@types/semver": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", @@ -9536,7 +9467,8 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/cancelable-promise/-/cancelable-promise-4.3.1.tgz", "integrity": "sha512-A/8PwLk/T7IJDfUdQ68NR24QHa8rIlnN/stiJEBo6dmVUkD4K14LswG0w3VwdeK/o7qOwRUR1k2MhK5Rpy2m7A==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/caniuse-lite": { "version": "1.0.30001810", @@ -9878,7 +9810,6 @@ "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index 3f80fbe12..2e4d53577 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "dependencies": { "@babel/core": "^7.22.9", "@codemirror/lang-json": "^6.0.0", - "@conduction/nextcloud-vue": "^2.37.0", + "@conduction/nextcloud-vue": "^3.2.0", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "^2.5.0", "@nextcloud/capabilities": "^1.2.1", diff --git a/src/App.vue b/src/App.vue index 83e9381f7..1987ef141 100644 --- a/src/App.vue +++ b/src/App.vue @@ -22,7 +22,6 @@ :customComponents="customComponents" :registry="registry" :pageTypes="pageTypes" - :formatters="formatters" appId="stackiq" :translate="translateForApp" :permissions="permissions" @@ -76,7 +75,6 @@ import OrganisationSwitcher from './components/organisations/OrganisationSwitche import Dialogs from './dialogs/Dialogs.vue' import Modals from './modals/Modals.vue' import { setActiveOrganisationUuid } from './composables/orClient.js' -import { createConnectionFormatters } from './services/connectionRegistry.js' import { settingsStore } from './store/store.js' export default { @@ -150,17 +148,6 @@ export default { data() { return { - /** - * Named cell formatters merged over CnAppRoot's built-ins. - * `connectionStatus` and `connectionSettingsLabel` render the - * Integrations page (adopt-connection-registry); nextcloud-vue - * 2.39.0 ships neither as a built-in. Before this change the app - * passed no formatters at all. - */ - formatters: createConnectionFormatters((source) => - ncT('stackiq', source), - ), - objectSidebarState: reactive({ active: false, open: true, diff --git a/src/services/connectionRegistry.js b/src/services/connectionRegistry.js index 081d40bf5..af895e94a 100644 --- a/src/services/connectionRegistry.js +++ b/src/services/connectionRegistry.js @@ -2,15 +2,14 @@ // Copyright (C) 2026 Conduction B.V. /** - * The Integrations page's two formatters and its Add integration handler. + * The Integrations page's Add integration handler. * * The rows on that page are integriq's `app_connection` objects (hydra change - * connection-registry, design D8). The installed @conduction/nextcloud-vue - * 2.39.0 ships neither formatter, so stackiq carries this copy until a - * release with the built-ins is pinned. The names are the contract's, so the - * copies across the fleet stay interchangeable. + * connection-registry, design D8). Its two formatters, `connectionStatus` and + * `connectionSettingsLabel`, are built into @conduction/nextcloud-vue from + * 3.2.0, so CnAppRoot supplies them and stackiq no longer carries a copy. * - * Pure: the translator, the URL builder and the navigation are passed in, so + * Pure: the URL builder and the navigation are passed in, so * the module runs under vitest's node environment with nothing mocked. * * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-003-an-admin-reads-the-connections-on-an-integrations-page @@ -23,62 +22,6 @@ export const INTEGRIQ_CONNECTIONS_PATH = '/apps/integriq/connections?app=stackiq&link=1' -/** - * The English label for each of the six registry statuses (design D3). - * - * `limited` came with hydra#673: the connection works in part. - */ -export const CONNECTION_STATUS_LABELS = Object.freeze({ - configured: 'Configured', - limited: 'Limited', - unconfigured: 'Not configured', - simulated: 'Simulated', - unavailable: 'Not available', - error: 'Error', -}) - -/** - * Build the two connection formatters around a translator. - * - * @param {function(string): string} translate Translates an English source string for this app. - * @return {{connectionStatus: function(unknown): string, connectionSettingsLabel: function(unknown): string}} The formatters, keyed by their manifest names. - * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-003-an-admin-reads-the-connections-on-an-integrations-page - */ -export function createConnectionFormatters(translate) { - return { - /** - * The label for a status. An unknown value renders itself, because a - * status the app cannot name is still a status the admin should see. - * - * @param {unknown} value The row's `status`. - * @return {string} The label, the raw value, or '' when missing. - * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-003-an-admin-reads-the-connections-on-an-integrations-page - */ - connectionStatus(value) { - const source = - typeof value === 'string' - && Object.hasOwn(CONNECTION_STATUS_LABELS, value) - ? CONNECTION_STATUS_LABELS[value] - : null - return source ? translate(source) : String(value ?? '') - }, - - /** - * The Open settings link text, or '' when the row has nowhere to send a - * reader. An empty text makes the link cell fall through to plain text. - * - * @param {unknown} value The row's `settingsUrl`. - * @return {string} The link text, or ''. - * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-003-an-admin-reads-the-connections-on-an-integrations-page - */ - connectionSettingsLabel(value) { - return typeof value === 'string' && value.length > 0 - ? translate('Open settings') - : '' - }, - } -} - /** * Build the Add integration header-action handler. * diff --git a/tests/Unit/Service/ConnectionReportCallersTest.php b/tests/Unit/Service/ConnectionReportCallersTest.php index 449b06082..153ac5fa6 100644 --- a/tests/Unit/Service/ConnectionReportCallersTest.php +++ b/tests/Unit/Service/ConnectionReportCallersTest.php @@ -226,15 +226,13 @@ private function eol(?ConnectionReportService $reports, bool $enabled): EolSyncS }//end eol() /** - * An EOL settings save hands the saved config to the reporter. + * An EOL settings save asks the reporter for a refresh. * * @return void */ public function testAnEolSaveAsksForARefresh(): void { $reports = $this->reporter(); - $reports->expects($this->once())->method('eolSyncConfigSaved')->with( - $this->callback(callback: static fn (array $config): bool => $config['enabled'] === false && $config['register'] === 'integriq') - ); + $reports->expects($this->once())->method('eolSyncConfigSaved')->with(); $result = $this->eol(reports: $reports, enabled: false)->updateConfig(['enabled' => false]); diff --git a/tests/Unit/Service/ConnectionReportServiceTest.php b/tests/Unit/Service/ConnectionReportServiceTest.php index 9afe6d45e..391deb02c 100644 --- a/tests/Unit/Service/ConnectionReportServiceTest.php +++ b/tests/Unit/Service/ConnectionReportServiceTest.php @@ -235,7 +235,7 @@ public function testAFailingEmailReadNeverEscapes(): void { }//end testAFailingEmailReadNeverEscapes() /** - * A peer change refreshes, and reports only a state that blocks federation. + * A peer change refreshes, and reports only a state that blocks federation, never the switch. * * @return void */ @@ -243,7 +243,8 @@ public function testAPeerChangeReportsOnlyABlockingState(): void { $service = $this->service(); $service->federationPeersChanged(status: ['available' => false, 'enabled' => true, 'peers' => [['url' => 'https://a.example']]]); - $service->federationPeersChanged(status: ['available' => true, 'enabled' => false, 'peers' => [['url' => 'https://a.example']]]); + // Off with no peers: the switch outranks "no peer catalog", so nothing is reported. + $service->federationPeersChanged(status: ['available' => true, 'enabled' => false, 'peers' => []]); $service->federationPeersChanged(status: ['available' => true, 'enabled' => true, 'peers' => []]); $this->assertFalse( condition: $service->federationPeersChanged(status: ['available' => true, 'enabled' => true, 'peers' => [['url' => 'https://a.example']]]) @@ -254,7 +255,6 @@ public function testAPeerChangeReportsOnlyABlockingState(): void { 'refresh:federation', 'report:federation:unavailable', 'refresh:federation', - 'report:federation:unconfigured', 'refresh:federation', 'report:federation:unconfigured', 'refresh:federation', @@ -262,8 +262,7 @@ public function testAPeerChangeReportsOnlyABlockingState(): void { actual: $this->sentSummary() ); $this->assertStringContainsString(needle: 'OpenCatalogi', haystack: $this->sent[1]->message); - $this->assertStringContainsString(needle: 'federation_enabled', haystack: $this->sent[3]->message); - $this->assertStringContainsString(needle: 'no peer catalog', haystack: $this->sent[5]->message); + $this->assertStringContainsString(needle: 'no peer catalog', haystack: $this->sent[4]->message); }//end testAPeerChangeReportsOnlyABlockingState() /** @@ -284,7 +283,10 @@ public function testAPullMapsItsOutcome(): void { actual: $service->describePull(pull: ['ok' => true, 'peers' => [['peer' => 'https://a.example'] + $ok]]) ); $this->assertSame(expected: 'unconfigured', actual: $service->describePull(pull: ['ok' => true, 'peers' => []])[0]); - $this->assertSame(expected: 'unconfigured', actual: $service->describePull(pull: ['ok' => false, 'reason' => 'federation disabled'])[0]); + $this->assertNull( + actual: $service->describePull(pull: ['ok' => false, 'reason' => 'federation disabled']), + message: 'the federation switch reads a switched-off pull, so nothing is reported' + ); $this->assertSame(expected: 'unavailable', actual: $service->describePull(pull: ['ok' => false, 'reason' => 'OpenCatalogi unavailable'])[0]); $this->assertSame( expected: ['error', 'The last federation pull failed: something else'], @@ -345,33 +347,38 @@ public function testAPullMessageCarriesOnlyTheHostAndAShortReason(): void { * @return void */ public function testAPullReportsWithoutARefresh(): void { - $this->assertTrue(condition: $this->service()->federationPulled(pull: ['ok' => false, 'reason' => 'federation disabled'])); - $this->assertSame(expected: ['report:federation:unconfigured'], actual: $this->sentSummary()); + $service = $this->service(); + + $this->assertFalse(condition: $service->federationPulled(pull: ['ok' => false, 'reason' => 'federation disabled'])); + $this->assertTrue(condition: $service->federationPulled(pull: ['ok' => false, 'reason' => 'OpenCatalogi unavailable'])); + $this->assertSame(expected: ['report:federation:unavailable'], actual: $this->sentSummary()); }//end testAPullReportsWithoutARefresh() /** - * An EOL settings save refreshes, and reports only a switched-off sync. + * An EOL settings save refreshes and reports nothing, and a switched-off run reports nothing. + * + * The `eol-feed` switch reads `enabled` inside `eol_sync_config`, so + * integriq resolves `disabled` itself. * * @return void */ - public function testAnEolSaveReportsOnlyASwitchedOffSync(): void { + public function testAnEolSaveOnlyRefreshes(): void { $service = $this->service(); - $this->assertTrue(condition: $service->eolSyncConfigSaved(config: ['enabled' => false])); - $this->assertFalse(condition: $service->eolSyncConfigSaved(config: ['enabled' => true])); + $this->assertTrue(condition: $service->eolSyncConfigSaved()); + $this->assertFalse(condition: $service->eolSyncRan(runStatus: ['available' => false, 'reason' => 'disabled'])); - $this->assertSame(expected: ['refresh:eol-feed', 'report:eol-feed:unconfigured', 'refresh:eol-feed'], actual: $this->sentSummary()); - }//end testAnEolSaveReportsOnlyASwitchedOffSync() + $this->assertSame(expected: ['refresh:eol-feed'], actual: $this->sentSummary()); + }//end testAnEolSaveOnlyRefreshes() /** - * Every reason EolSyncService records maps to a status, and an unknown one reads error. + * Every reason EolSyncService records maps to a status, a switched-off sync to nothing, and an unknown one reads error. * * @return void */ public function testEachEolRunOutcomeMapsToTheDesignedStatus(): void { $service = $this->service(); $expected = [ - 'disabled' => 'unconfigured', 'openregister-not-installed' => 'unavailable', 'object-service-unavailable' => 'error', 'module-schema-not-configured' => 'unconfigured', @@ -386,6 +393,7 @@ public function testEachEolRunOutcomeMapsToTheDesignedStatus(): void { ); } + $this->assertNull(actual: $service->describeEolRun(runStatus: ['available' => false, 'reason' => 'disabled'])); $this->assertStringContainsString( needle: 'endoflife.date source in integriq', haystack: $service->describeEolRun(runStatus: ['available' => false, 'reason' => 'eol-register-or-schema-not-found'])[1] @@ -413,7 +421,7 @@ public function testTheEolReasonsAreTheOnesTheSyncRecords(): void { $recorded = array_values(array_unique($matches[1])); sort($recorded); - $known = array_keys(ConnectionReportService::EOL_REASONS); + $known = [...array_keys(ConnectionReportService::EOL_REASONS), ConnectionReportService::EOL_REASON_SWITCHED_OFF]; sort($known); $this->assertNotSame(expected: [], actual: $recorded); @@ -435,8 +443,8 @@ public function testWithoutIntegriqNothingIsSentOrLogged(): void { $this->assertFalse(condition: $service->emailSettingsSaved()); $this->assertFalse(condition: $service->federationPeersChanged(status: ['available' => false])); $this->assertFalse(condition: $service->federationPulled(pull: ['ok' => false, 'reason' => 'federation disabled'])); - $this->assertFalse(condition: $service->eolSyncConfigSaved(config: ['enabled' => false])); - $this->assertFalse(condition: $service->eolSyncRan(runStatus: ['available' => false, 'reason' => 'disabled'])); + $this->assertFalse(condition: $service->eolSyncConfigSaved()); + $this->assertFalse(condition: $service->eolSyncRan(runStatus: ['available' => false, 'reason' => 'object-service-unavailable'])); }//end testWithoutIntegriqNothingIsSentOrLogged() /** @@ -483,7 +491,7 @@ public function testAThrowingListenerNeverEscapes(): void { $service = new ConnectionReportService(eventDispatcher: $dispatcher, emailService: $this->emailService, logger: $this->logger); - $this->assertFalse(condition: $service->eolSyncRan(runStatus: ['available' => false, 'reason' => 'disabled'])); - $this->assertFalse(condition: $service->eolSyncConfigSaved(config: ['enabled' => true])); + $this->assertFalse(condition: $service->eolSyncRan(runStatus: ['available' => false, 'reason' => 'object-service-unavailable'])); + $this->assertFalse(condition: $service->eolSyncConfigSaved()); }//end testAThrowingListenerNeverEscapes() }//end class diff --git a/tests/Unit/Settings/ConnectionsDeclarationTest.php b/tests/Unit/Settings/ConnectionsDeclarationTest.php index 9b82677b9..9dad4350f 100644 --- a/tests/Unit/Settings/ConnectionsDeclarationTest.php +++ b/tests/Unit/Settings/ConnectionsDeclarationTest.php @@ -36,10 +36,12 @@ * * The rules mirror integriq's `lib/Settings/connections.schema.json` on * `development` field for field, including the hydra#673 amendments - * (`adapter.jsonPath`, `adapter.simulatedValues`, `reportedOnly`). That schema - * is not a dependency of this repo, so the rules are restated here. The file - * was also validated against the schema itself, fetched from integriq - * `development` with `gh api`, when this test was written. + * (`adapter.jsonPath`, `adapter.simulatedValues`, `reportedOnly`) and the + * hydra#677 ones (`switch`, `disabledMessage`). That schema is not a + * dependency of this repo, so the rules are restated here. The file was also + * validated against the schema itself, fetched from integriq `development` + * with `gh api` (last changed in 64b437fc2df24827985ce6e919fe5e47c5205617, + * integriq#2024), when each amendment was adopted. * * @coversNothing */ @@ -61,6 +63,8 @@ class ConnectionsDeclarationTest extends TestCase { 'reportedOnly' => 'boolean', 'available' => 'boolean', 'unavailableMessage' => 'string', + 'switch' => 'array', + 'disabledMessage' => 'string', 'unconfiguredMessage' => 'string', 'sourceTemplate' => 'string', ]; @@ -77,6 +81,17 @@ class ConnectionsDeclarationTest extends TestCase { 'simulatedMessage' => 'string', ]; + /** + * The fields the schema allows inside `switch`, with their JSON type. + * + * @var array + */ + private const SWITCH_FIELD_TYPES = [ + 'configKey' => 'string', + 'jsonPath' => 'string', + 'offValues' => 'array', + ]; + /** * The three connections, in page order. * @@ -190,6 +205,11 @@ public function testEveryEntryHasTheShapeIntegriqValidates(): void { $this->assertSame(expected: self::FIELD_TYPES[$field], actual: $this->jsonType(value: $value), message: $key . '.' . $field); } + foreach (($connection['switch'] ?? []) as $field => $value) { + $this->assertArrayHasKey(key: $field, array: self::SWITCH_FIELD_TYPES, message: $key . '.switch.' . $field . ' is not a schema field'); + $this->assertSame(expected: self::SWITCH_FIELD_TYPES[$field], actual: $this->jsonType(value: $value), message: $key . '.switch.' . $field); + } + foreach (($connection['adapter'] ?? []) as $field => $value) { $this->assertArrayHasKey(key: $field, array: self::ADAPTER_FIELD_TYPES, message: $key . '.adapter.' . $field . ' is not a schema field'); $this->assertSame(expected: self::ADAPTER_FIELD_TYPES[$field], actual: $this->jsonType(value: $value), message: $key . '.adapter.' . $field); @@ -285,8 +305,9 @@ public function testOnlyTheNullTransportReadsSimulated(): void { /** * Federation and the end-of-life feed are reported only, and neither guesses from settings. * - * `federation_enabled` is a boolean key, which integriq's reader counts as - * filled whatever it holds, and `eol_sync_config` is filled after any save. + * A filled `federation_enabled` or `eol_sync_config` says the feature may + * run, not that a peer or the feed answered, so neither is required config. + * Each carries its on/off setting as a `switch` instead. * * @return void */ @@ -304,6 +325,34 @@ public function testFederationAndTheFeedAreReportedOnly(): void { $this->assertArrayNotHasKey(key: 'requiredConfig', array: $byKey['email']); }//end testFederationAndTheFeedAreReportedOnly() + /** + * Federation and the end-of-life sync are switched off through the settings stackiq reads. + * + * Neither switch lists `offValues`, so integriq reads it as off when the + * value is empty: unset, `false`, `0`, or a missing `enabled` inside the + * blob (hydra connection-registry D2, D4 rule 2b, D12 items 6 and 7). Both + * defaults in the code are off, so an unset key reading off is the truth. + * + * @return void + */ + public function testFederationAndTheSyncDeclareTheirSwitches(): void { + $byKey = $this->connectionsByKey(); + + $this->assertSame(expected: ['configKey' => 'federation_enabled'], actual: $byKey['federation']['switch']); + $this->assertSame(expected: ['configKey' => 'eol_sync_config', 'jsonPath' => 'enabled'], actual: $byKey['eol-feed']['switch']); + $this->assertArrayNotHasKey(key: 'switch', array: $byKey['email']); + foreach (['federation', 'eol-feed'] as $key) { + $this->assertStringContainsString(needle: 'switched off', haystack: $byKey[$key]['disabledMessage'], message: $key); + } + + $federation = (string) file_get_contents($this->root() . '/lib/Service/Federation/FederationConfig.php'); + $this->assertStringContainsString(needle: "getValueBool(Application::APP_ID, 'federation_enabled', false)", haystack: $federation); + + $settings = (string) file_get_contents($this->root() . '/lib/Service/SettingsService.php'); + $this->assertStringContainsString(needle: "EOL_SYNC_CONFIG_KEY = 'eol_sync_config'", haystack: $settings); + $this->assertStringContainsString(needle: "'enabled' => (\$decoded['enabled'] ?? false) === true", haystack: $settings); + }//end testFederationAndTheSyncDeclareTheirSwitches() + /** * The end-of-life feed offers integriq's endoflife.date source. * diff --git a/tests/vitest/connectionRegistry.spec.js b/tests/vitest/connectionRegistry.spec.js index 8853490a3..3e25f0e34 100644 --- a/tests/vitest/connectionRegistry.spec.js +++ b/tests/vitest/connectionRegistry.spec.js @@ -1,3 +1,4 @@ +// @vitest-environment jsdom /** * SPDX-License-Identifier: EUPL-1.2 * SPDX-FileCopyrightText: 2026 Conduction B.V. @@ -9,16 +10,23 @@ * one icon by NAME. A misspelled name renders a raw enum, no glyph, or an Add * integration that does nothing, and none of them logs a thing. So this spec * reads the real fragment and checks every name against what has to answer it. + * The two formatters are @conduction/nextcloud-vue built-ins since 3.2.0, so + * their names are checked against the installed library, not a local copy. + * + * The library's map is IMPORTED and called, not read as text. A regex over the + * module source answers about the file on disk, which is one step beside the + * question: whether the formatter the page resolves actually returns the label. + * The import reaches @nextcloud/auth through formatMetric, which wants a + * `window`, so this file runs on jsdom rather than the suite's default node. * * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-003-an-admin-reads-the-connections-on-an-integrations-page */ +import { BUILT_IN_FORMATTERS } from '@conduction/nextcloud-vue/src/utils/builtInFormatters.js' import * as fs from 'fs' import * as path from 'path' import { describe, expect, it } from 'vitest' import { - CONNECTION_STATUS_LABELS, - createConnectionFormatters, createConnectionHandlers, INTEGRIQ_CONNECTIONS_PATH, } from '../../src/services/connectionRegistry.js' @@ -29,60 +37,23 @@ const fragment = JSON.parse(read('src', 'manifest.d', 'connection-registry.json' const page = fragment.pages.find((p) => p.id === 'Integrations') const menu = fragment.menu.find((m) => m.id === 'IntegrationsMenu') -/** A translator that marks what it translated, so a missing call shows. */ -const translate = (source) => `t:${source}` - -describe('connection formatters', () => { - const formatters = createConnectionFormatters(translate) - - it('labels all six statuses, limited included', () => { - expect(Object.keys(CONNECTION_STATUS_LABELS).sort()).toEqual([ - 'configured', - 'error', - 'limited', - 'simulated', - 'unavailable', - 'unconfigured', - ]) - expect(formatters.connectionStatus('configured')).toBe('t:Configured') - expect(formatters.connectionStatus('limited')).toBe('t:Limited') - expect(formatters.connectionStatus('unconfigured')).toBe('t:Not configured') - expect(formatters.connectionStatus('simulated')).toBe('t:Simulated') - expect(formatters.connectionStatus('unavailable')).toBe('t:Not available') - expect(formatters.connectionStatus('error')).toBe('t:Error') - }) - - // A connection that works in part is neither working nor broken, so it must - // not borrow either label. - it('keeps limited apart from configured, not available and error', () => { - const limited = formatters.connectionStatus('limited') - expect(limited).not.toBe(formatters.connectionStatus('configured')) - expect(limited).not.toBe(formatters.connectionStatus('unavailable')) - expect(limited).not.toBe(formatters.connectionStatus('error')) - }) - - it('renders an unknown status as itself and a missing one as empty', () => { - expect(formatters.connectionStatus('degraded')).toBe('degraded') - expect(formatters.connectionStatus('toString')).toBe('toString') - expect(formatters.connectionStatus(null)).toBe('') - expect(formatters.connectionStatus(undefined)).toBe('') - }) - - it('offers Open settings only when the row has a settings link', () => { - expect(formatters.connectionSettingsLabel('/settings/admin/stackiq')).toBe( - 't:Open settings', - ) - expect(formatters.connectionSettingsLabel('')).toBe('') - expect(formatters.connectionSettingsLabel(undefined)).toBe('') - expect(formatters.connectionSettingsLabel(null)).toBe('') - }) +/** + * The formatter registry CnAppRoot provides, built the way CnAppRoot builds it: + * the library's built-ins under whatever the app passes in its `formatters` + * prop. A same-named local formatter wins, which is why stackiq passes none. + * + * @param {object} appFormatters What the app hands CnAppRoot. Empty by default. + * @return {object} The merged registry, keyed by formatter name. + */ +function shellFormatterRegistry(appFormatters = {}) { + return { ...BUILT_IN_FORMATTERS, ...appFormatters } +} - it('ships an English and a Dutch catalogue entry for every label the page shows', () => { +describe('connection strings', () => { + it('ships an English and a Dutch catalogue entry for every label the page declares', () => { const en = JSON.parse(read('l10n', 'en.json')).translations const nl = JSON.parse(read('l10n', 'nl.json')).translations const labels = [ - ...Object.values(CONNECTION_STATUS_LABELS), - 'Open settings', page.title, menu.label, page.config.folderSidebar.allLabel, @@ -93,9 +64,6 @@ describe('connection formatters', () => { expect(en[label], `en: ${label}`).toBe(label) expect(nl[label], `nl: ${label}`).toBeTruthy() } - expect(nl.Limited).toBe('Beperkt') - // The browser reads the .js catalogue, never the .json one. - expect(read('l10n', 'nl.js')).toContain('"Limited": "Beperkt"') }) }) @@ -144,31 +112,59 @@ describe('the Integrations page declaration', () => { expect(menu.visibleIf).toEqual({ appInstalled: 'integriq' }) }) - it('names only formatters and handlers that exist, and wires both into the app', () => { - const formatters = createConnectionFormatters(translate) + it('names only formatters the library ships and handlers that exist, and wires the handler into the app', () => { + const registry = shellFormatterRegistry() const handlers = createConnectionHandlers({ generateUrl: (p) => p, assign: () => {}, }) - for (const column of page.config.columns.filter((c) => c.formatter)) { - expect(typeof formatters[column.formatter], column.formatter).toBe( - 'function', - ) + expect(typeof registry.date, 'the built-in formatter map was read').toBe( + 'function', + ) + const named = page.config.columns + .filter((c) => c.formatter) + .map((c) => c.formatter) + expect(named.sort()).toEqual(['connectionSettingsLabel', 'connectionStatus']) + for (const formatter of named) { + expect( + typeof registry[formatter], + `@conduction/nextcloud-vue ships ${formatter}`, + ).toBe('function') } for (const action of page.config.headerActions) { expect(typeof handlers[action.handler], action.handler).toBe('function') } - expect(read('src', 'App.vue')).toContain(':formatters="formatters"') - expect(read('src', 'App.vue')).toContain( - 'formatters: createConnectionFormatters(', - ) expect(read('src', 'customComponents.js')).toMatch( /^\t\.\.\.createConnectionHandlers\(\{$/m, ) }) + // The library labels all seven statuses. A copy of the formatter passed to + // CnAppRoot would win over the built-in and could predate `disabled`, which + // is the status the federation and eol-feed switches introduce. + it('lets the library label the statuses, disabled included', () => { + const registry = shellFormatterRegistry() + + expect(registry.connectionStatus('disabled')).toBe('Switched off') + expect(registry.connectionStatus('unconfigured')).toBe('Not configured') + expect(registry.connectionStatus('configured')).toBe('Configured') + }) + + // THE SHADOW. CnAppRoot merges `{ ...BUILT_IN_FORMATTERS, ...formatters }`, + // so a local formatter under either name silently replaces the built-in and + // nothing logs. stackiq passes no formatters at all, and this states what + // that buys: the built-in is what the Status column resolves. + it('passes CnAppRoot no formatters, so nothing shadows the built-ins', () => { + const shadow = shellFormatterRegistry({ + connectionStatus: () => 'a local copy answered', + }) + + expect(shadow.connectionStatus('disabled')).toBe('a local copy answered') + expect(read('src', 'App.vue')).not.toContain(':formatters=') + }) + it('names an icon src/icons.js registers', () => { const icons = read('src', 'icons.js') for (const icon of [