From 6a96b04f39a2d9e3ed00faa16e52ce4a77300884 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 23 Jul 2026 23:16:12 +0200 Subject: [PATCH 1/4] feat(eol-feed-integration): matcher + sync service + scheduled job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the softwarecatalog-side consumer for the sibling openconnector endoflife-date-source change: module.eolProductSlug mapping config, moduleVersie.eolBron/eolBijgewerktOp provenance fields (additive register JSON), EolMatcherService (conservative unambiguous version-prefix matching, PUT-semantic stamping), EolSyncService (config/status/graceful degradation orchestration reading eolProduct/eolCycle via ObjectService — no HTTP), EolSyncJob (scheduled TimedJob, system context) and the matching SettingsController/SettingsService config, manual-trigger and status endpoints. Softwarecatalog never calls endoflife.date directly. 26 new PHPUnit tests (109 assertions) cover unambiguous/ambiguous/no-match fixtures, PUT-semantic field preservation, provenance, every degradation path, and config/status persistence round-trips. Full suite: 310 tests, 945 assertions, 0 failures. --- appinfo/info.xml | 1 + appinfo/routes.php | 9 + lib/AppInfo/Application.php | 34 ++ lib/BackgroundJob/EolSyncJob.php | 107 ++++ lib/Controller/SettingsController.php | 107 ++++ lib/Service/EolMatcherService.php | 224 ++++++++ lib/Service/EolSyncService.php | 480 ++++++++++++++++++ lib/Service/SettingsService.php | 161 ++++++ lib/Settings/softwarecatalogus_register.json | 24 + tests/Unit/Service/EolMatcherServiceTest.php | 285 +++++++++++ tests/Unit/Service/EolRegisterShapeTest.php | 120 +++++ tests/Unit/Service/EolSyncServiceTest.php | 339 +++++++++++++ .../Service/SettingsServiceEolConfigTest.php | 194 +++++++ 13 files changed, 2085 insertions(+) create mode 100644 lib/BackgroundJob/EolSyncJob.php create mode 100644 lib/Service/EolMatcherService.php create mode 100644 lib/Service/EolSyncService.php create mode 100644 tests/Unit/Service/EolMatcherServiceTest.php create mode 100644 tests/Unit/Service/EolRegisterShapeTest.php create mode 100644 tests/Unit/Service/EolSyncServiceTest.php create mode 100644 tests/Unit/Service/SettingsServiceEolConfigTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 59d245f1..08dc17eb 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -90,6 +90,7 @@ Vrij en open source onder de EUPL-licentie. OCA\SoftwareCatalog\BackgroundJob\OrganizationContactSyncJob OCA\SoftwareCatalog\BackgroundJob\ContractStatusJob OCA\SoftwareCatalog\BackgroundJob\FederationSyncJob + OCA\SoftwareCatalog\BackgroundJob\EolSyncJob diff --git a/appinfo/routes.php b/appinfo/routes.php index a5460eac..14578722 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -249,6 +249,15 @@ ['name' => 'settings#getCronjobUsers', 'url' => '/api/settings/cronjobs/users', 'verb' => 'GET'], ['name' => 'settings#getCronjobOrganisations', 'url' => '/api/settings/cronjobs/organisations', 'verb' => 'GET'], + // ======================================================================== + // EOL FEED SYNC API ENDPOINTS (eol-feed-integration) + // ======================================================================== + + ['name' => 'settings#getEolSyncConfig', 'url' => '/api/eol-sync/config', 'verb' => 'GET'], + ['name' => 'settings#updateEolSyncConfig', 'url' => '/api/eol-sync/config', 'verb' => 'POST'], + ['name' => 'settings#triggerEolSync', 'url' => '/api/eol-sync/trigger', 'verb' => 'POST'], + ['name' => 'settings#getEolSyncStatus', 'url' => '/api/eol-sync/status', 'verb' => 'GET'], + // Gebruik by group ['name' => 'gebruik#getGebruiken', 'url' => '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/api/gebruik', 'verb' => 'GET'], ['name' => 'gebruik#getGebruikenForDeelnemer', 'url' => '/api/gebruik/deelnemer', 'verb' => 'GET'], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 841f8685..904d0944 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -27,6 +27,9 @@ use OCA\SoftwareCatalog\Service\Federation\FederationConfig; use OCA\SoftwareCatalog\Service\Federation\FederationMerger; use OCA\SoftwareCatalog\Service\Federation\FederationService; +use OCA\SoftwareCatalog\BackgroundJob\EolSyncJob; +use OCA\SoftwareCatalog\Service\EolMatcherService; +use OCA\SoftwareCatalog\Service\EolSyncService; use OCA\SoftwareCatalog\Controller\ContactpersonenController; use OCA\SoftwareCatalog\Dashboard\ConceptOrganisatiesWidget; use OCA\SoftwareCatalog\EventListener\DecisionConcludedListener; @@ -612,6 +615,37 @@ function ($container) { } ); + // Register the EOL matcher + sync orchestration + scheduled job + // (eol-feed-integration). EolMatcherService has zero OCP dependencies + // by design (design.md "Nextcloud Integration" — pure matching logic). + $context->registerService( + EolMatcherService::class, + function ($container) { + return new EolMatcherService(); + } + ); + $context->registerService( + EolSyncService::class, + function ($container) { + return new EolSyncService( + settingsService: $container->get(SettingsService::class), + matcher: $container->get(EolMatcherService::class), + timeFactory: $container->get('OCP\AppFramework\Utility\ITimeFactory'), + logger: $container->get(LoggerInterface::class) + ); + } + ); + $context->registerService( + EolSyncJob::class, + function ($container) { + return new EolSyncJob( + timeFactory: $container->get('OCP\AppFramework\Utility\ITimeFactory'), + eolSyncService: $container->get(EolSyncService::class), + logger: $container->get(LoggerInterface::class) + ); + } + ); + // Register ContactpersonenController with explicit dependencies for /me endpoint. $context->registerService( ContactpersonenController::class, diff --git a/lib/BackgroundJob/EolSyncJob.php b/lib/BackgroundJob/EolSyncJob.php new file mode 100644 index 00000000..53e9ccc6 --- /dev/null +++ b/lib/BackgroundJob/EolSyncJob.php @@ -0,0 +1,107 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-eol-sync-runs-on-a-schedule-with-a-manual-trigger + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\BackgroundJob; + +use OCA\SoftwareCatalog\Service\EolSyncService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use Psr\Log\LoggerInterface; + +/** + * Runs the EOL matcher on a schedule (default: once a day). + * + * The interval is re-read from the EOL sync configuration on every + * construction (Nextcloud re-instantiates background jobs each cron pass), + * so an admin's interval change takes effect on the next pass without a + * code change or app restart. + */ +class EolSyncJob extends TimedJob +{ + /** + * Constructor. + * + * @param ITimeFactory $timeFactory The time factory for job scheduling. + * @param EolSyncService $eolSyncService The EOL sync orchestration service. + * @param LoggerInterface $logger The logger. + */ + public function __construct( + ITimeFactory $timeFactory, + private readonly EolSyncService $eolSyncService, + private readonly LoggerInterface $logger, + ) { + parent::__construct(time: $timeFactory); + + // Floor at 300s (the shortest interval any existing SoftwareCatalog + // background job runs at — OrganizationContactSyncJob) so a + // mistyped admin value can never schedule a tighter loop than the + // rest of the app's cron surface. + $intervalSeconds = $this->eolSyncService->getConfig()['intervalSeconds'] ?? 86400; + $this->setInterval(seconds: max(300, (int) $intervalSeconds)); + }//end __construct() + + /** + * Runs the background job. + * + * Delegates entirely to `EolSyncService::run()`, which resolves the + * configured EOL register/schema via OpenRegister's `ObjectService` + * (never HTTP), matches and stamps mapped modules' versions, and + * records a status summary. Operates in system (non-RBAC) context — + * every downstream OpenRegister call is made with `_rbac: false`. + * + * @param mixed $argument Job arguments (not used). + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-eol-sync-runs-on-a-schedule-with-a-manual-trigger + */ + protected function run($argument): void + { + try { + $status = $this->eolSyncService->run(); + $this->logger->info( + '[EolSyncJob] EOL sync run completed', + $status + ); + } catch (\Throwable $e) { + // EolSyncService::run() is designed to never throw (it degrades + // to a recorded status instead), but this guard keeps a future + // regression there from breaking the shared cron pass. + $this->logger->error( + '[EolSyncJob] Fatal error during EOL sync — cron pass protected', + [ + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ] + ); + }//end try + }//end run() +}//end class diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 5bd4d8d2..ec5ba800 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -38,6 +38,7 @@ use OCA\SoftwareCatalog\Service\OrganizationSyncService; use OCA\SoftwareCatalog\Service\ArchiMateService; use OCA\SoftwareCatalog\Service\ProgressTracker; +use OCA\SoftwareCatalog\Service\EolSyncService; use Psr\Log\LoggerInterface; use OCA\OpenRegister\Service\ObjectService; use OCA\OpenRegister\Service\ConfigurationService; @@ -82,6 +83,7 @@ class SettingsController extends Controller * @param OrganizationSyncService $orgSyncSvc The organization sync service. * @param ArchiMateService $archiMateService The ArchiMate import/export service. * @param ProgressTracker $progressTracker The progress tracking service. + * @param EolSyncService $eolSyncService The EOL feed sync orchestration service. * @param LoggerInterface $logger The logger instance. * * @SuppressWarnings(PHPMD.ExcessiveParameterList) @@ -98,6 +100,7 @@ public function __construct( private readonly OrganizationSyncService $orgSyncSvc, private readonly ArchiMateService $archiMateService, private readonly ProgressTracker $progressTracker, + private readonly EolSyncService $eolSyncService, private readonly LoggerInterface $logger, ) { parent::__construct(appName: $appName, request: $request); @@ -3671,4 +3674,108 @@ public function getCronjobOrganisations(): JSONResponse Http::STATUS_GONE ); }//end getCronjobOrganisations() + + // ===. + // EOL SYNC ENDPOINTS (eol-feed-integration). + // ===. + + /** + * Get the EOL sync configuration (enabled toggle, register/schema + * slugs, sync interval). + * + * @NoCSRFRequired + * + * @return JSONResponse The current EOL sync configuration. + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-products-are-mapped-to-endoflife-date-via-per-module-config + */ + public function getEolSyncConfig(): JSONResponse + { + try { + return new JSONResponse( + [ + 'success' => true, + 'config' => $this->eolSyncService->getConfig(), + ] + ); + } catch (\Exception $e) { + return $this->buildConfigErrorResponse(operationLabel: 'get EOL sync config', exception: $e, includeParams: false); + } + }//end getEolSyncConfig() + + /** + * Update the EOL sync configuration. + * + * @NoCSRFRequired + * + * @return JSONResponse The updated EOL sync configuration. + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-products-are-mapped-to-endoflife-date-via-per-module-config + */ + public function updateEolSyncConfig(): JSONResponse + { + try { + $data = $this->request->getParams(); + return new JSONResponse($this->eolSyncService->updateConfig($data)); + } catch (\Exception $e) { + return $this->buildConfigErrorResponse(operationLabel: 'update EOL sync config', exception: $e, includeParams: true); + } + }//end updateEolSyncConfig() + + /** + * Trigger an EOL sync run immediately, outside the scheduled interval. + * Runs the identical logic the scheduled `EolSyncJob` invokes, so the + * two trigger paths can never drift. + * + * @NoCSRFRequired + * + * @return JSONResponse The resulting sync status. + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-eol-sync-runs-on-a-schedule-with-a-manual-trigger + */ + public function triggerEolSync(): JSONResponse + { + try { + return new JSONResponse( + [ + 'success' => true, + 'status' => $this->eolSyncService->run(), + ] + ); + } catch (\Exception $e) { + $this->logger->error('Failed to trigger EOL sync', ['exception' => $e->getMessage()]); + return new JSONResponse( + [ + 'success' => false, + 'message' => 'Failed to trigger EOL sync: '.$e->getMessage(), + ], + 500 + ); + } + }//end triggerEolSync() + + /** + * Get the last-recorded EOL sync status — distinguishes "not configured" + * from "configured but nothing matched yet" from "ran, N matched, M + * skipped" (design.md Decision 6). + * + * @NoCSRFRequired + * + * @return JSONResponse The current EOL sync status. + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-the-feature-degrades-gracefully-when-the-feed-is-unavailable + */ + public function getEolSyncStatus(): JSONResponse + { + try { + return new JSONResponse( + [ + 'success' => true, + 'status' => $this->eolSyncService->getStatus(), + ] + ); + } catch (\Exception $e) { + return $this->buildConfigErrorResponse(operationLabel: 'get EOL sync status', exception: $e, includeParams: false); + } + }//end getEolSyncStatus() }//end class diff --git a/lib/Service/EolMatcherService.php b/lib/Service/EolMatcherService.php new file mode 100644 index 00000000..5497345b --- /dev/null +++ b/lib/Service/EolMatcherService.php @@ -0,0 +1,224 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-version-matching-is-conservative-and-unambiguous-only + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-stamping-preserves-every-other-field-and-records-provenance + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Service; + +/** + * Conservative version-prefix matcher between `moduleVersie.versie` and + * `eolCycle.cycle` values, plus PUT-semantic stamp construction. + * + * Deliberately has zero OCP/OpenRegister dependencies: every method takes + * and returns plain PHP arrays/scalars so it is unit-testable with fixture + * cycle arrays alone (design.md "Nextcloud Integration" — "pure matching + + * stamping logic, unit-testable with fixture cycle arrays and no OCP + * dependencies"). + */ +class EolMatcherService +{ + /** + * Match a module version string against a set of candidate EOL cycles. + * + * Splits `$versie` and each candidate `cycle` value on `.` and treats a + * cycle as matching when one of the two segment lists is a prefix of the + * other (either direction — a longer `versie` matching a shorter, more + * general `cycle`, or a shorter `versie` matching a longer, more + * specific `cycle`). The match "depth" is the number of matching leading + * segments (i.e. the length of the shorter of the two lists when every + * segment up to that length is equal). Only the candidate(s) at the + * greatest depth are considered; a stamp is produced only when exactly + * one candidate remains at that depth. + * + * @param string $versie The `moduleVersie.versie` string to match. + * @param array $cycles The mapped module's `eolCycle` rows (each an + * array with at least a `cycle` key). + * + * @return array|null The single unambiguous matching cycle row, or null + * when zero or more than one candidate share the + * greatest match depth. + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-version-matching-is-conservative-and-unambiguous-only + */ + public function matchVersion(string $versie, array $cycles): ?array + { + $versie = trim($versie); + if ($versie === '') { + return null; + } + + $bestDepth = 0; + $candidates = []; + + foreach ($cycles as $cycle) { + $cycleValue = trim((string) ($cycle['cycle'] ?? '')); + if ($cycleValue === '') { + continue; + } + + $depth = $this->matchDepth(versie: $versie, cycle: $cycleValue); + if ($depth === null) { + continue; + } + + if ($depth > $bestDepth) { + $bestDepth = $depth; + $candidates = [$cycle]; + } else if ($depth === $bestDepth) { + $candidates[] = $cycle; + } + }//end foreach + + if ($bestDepth === 0 || count($candidates) !== 1) { + // Zero candidates, or an ambiguous tie at the most-specific + // level — never guess (design.md Decision 2). + return null; + } + + return $candidates[0]; + }//end matchVersion() + + /** + * Compute the number of matching leading dot-separated segments between + * a module version string and a candidate cycle label. + * + * Returns null when the two diverge at any shared segment index (no + * match at all), otherwise the count of segments compared — which is + * the length of whichever of the two segment lists is shorter, since a + * match requires every one of the shorter list's segments to equal the + * corresponding segment of the longer list. + * + * @param string $versie The module version string. + * @param string $cycle The candidate cycle label. + * + * @return int|null The match depth, or null when the two do not match. + */ + private function matchDepth(string $versie, string $cycle): ?int + { + $versieSegments = explode('.', $versie); + $cycleSegments = explode('.', $cycle); + + $depth = min(count($versieSegments), count($cycleSegments)); + if ($depth === 0) { + return null; + } + + for ($i = 0; $i < $depth; $i++) { + if ($versieSegments[$i] !== $cycleSegments[$i]) { + return null; + } + } + + return $depth; + }//end matchDepth() + + /** + * Build the complete, PUT-semantic replacement object for a matched + * `moduleVersie`. + * + * Copies every existing field on `$moduleVersie` forward unchanged and + * only adds/overwrites `datumEindeOndersteuning`, `eolBron`, and + * `eolBijgewerktOp` — OpenRegister's `saveObject` nulls any property + * omitted from the payload, so the full object must always be the base. + * + * @param array $moduleVersie The complete current `moduleVersie` object. + * @param array $matchedCycle The matched `eolCycle` row (must carry an + * `eol` date string). + * @param string $source The provenance source identifier (e.g. + * `endoflife.date`). + * @param string $fetchedAt The sync run's timestamp (ISO 8601). + * + * @return array The complete `moduleVersie` object with the stamp + * applied, ready to pass to `ObjectService::saveObject()`. + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-stamping-preserves-every-other-field-and-records-provenance + */ + public function buildStamp(array $moduleVersie, array $matchedCycle, string $source, string $fetchedAt): array + { + $stamped = $moduleVersie; + $stamped['datumEindeOndersteuning'] = (string) ($matchedCycle['eol'] ?? ''); + $stamped['eolBron'] = $source; + $stamped['eolBijgewerktOp'] = $fetchedAt; + + return $stamped; + }//end buildStamp() + + /** + * Match every `moduleVersie` of one module against its mapped cycles and + * build the stamps for the unambiguous matches. + * + * A `moduleVersie` is skipped (left untouched) when: its `versie` is + * empty, no cycle matches unambiguously, or the matched cycle's `eol` + * value is empty (endoflife.date reports no scheduled EOL date yet for + * that cycle — nothing informative to stamp). + * + * @param array $moduleVersies The module's `moduleVersie` rows. + * @param array $cycles The mapped module's `eolCycle` rows. + * @param string $source The provenance source identifier. + * @param string $fetchedAt The sync run's timestamp (ISO 8601). + * + * @return array{stamped: array, skipped: array} `stamped` holds the + * complete replacement objects ready to save; `skipped` + * holds the original, untouched `moduleVersie` rows. + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-version-matching-is-conservative-and-unambiguous-only + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-stamping-preserves-every-other-field-and-records-provenance + */ + public function matchModuleVersions(array $moduleVersies, array $cycles, string $source, string $fetchedAt): array + { + $stamped = []; + $skipped = []; + + foreach ($moduleVersies as $moduleVersie) { + $versie = (string) ($moduleVersie['versie'] ?? ''); + + $matchedCycle = null; + if ($versie !== '') { + $matchedCycle = $this->matchVersion(versie: $versie, cycles: $cycles); + } + + if ($matchedCycle === null || trim((string) ($matchedCycle['eol'] ?? '')) === '') { + $skipped[] = $moduleVersie; + continue; + } + + $stamped[] = $this->buildStamp( + moduleVersie: $moduleVersie, + matchedCycle: $matchedCycle, + source: $source, + fetchedAt: $fetchedAt + ); + }//end foreach + + return [ + 'stamped' => $stamped, + 'skipped' => $skipped, + ]; + }//end matchModuleVersions() +}//end class diff --git a/lib/Service/EolSyncService.php b/lib/Service/EolSyncService.php new file mode 100644 index 00000000..c1e1d742 --- /dev/null +++ b/lib/Service/EolSyncService.php @@ -0,0 +1,480 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-eol-sync-runs-on-a-schedule-with-a-manual-trigger + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-the-feature-degrades-gracefully-when-the-feed-is-unavailable + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-softwarecatalog-performs-no-direct-http-to-the-eol-feed + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Service; + +use OCA\OpenRegister\Service\ObjectService; +use OCP\AppFramework\Utility\ITimeFactory; +use Psr\Log\LoggerInterface; + +/** + * Orchestrates config resolution, mapped-module discovery, and delegated + * matching/stamping for the EOL feed integration. + */ +class EolSyncService +{ + + /** + * The provenance source identifier stamped on every matched field. + * + * @var string + */ + private const SOURCE = 'endoflife.date'; + + /** + * Constructor. + * + * @param SettingsService $settingsService The settings service (config/status/register-schema resolution). + * @param EolMatcherService $matcher The pure matching/stamping logic. + * @param ITimeFactory $timeFactory The time factory (sync-run timestamp). + * @param LoggerInterface $logger The logger. + */ + public function __construct( + private readonly SettingsService $settingsService, + private readonly EolMatcherService $matcher, + private readonly ITimeFactory $timeFactory, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Get the current EOL sync configuration. + * + * @return array The configuration (see `SettingsService::getEolSyncConfig()`). + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-products-are-mapped-to-endoflife-date-via-per-module-config + */ + public function getConfig(): array + { + return $this->settingsService->getEolSyncConfig(); + }//end getConfig() + + /** + * Update the EOL sync configuration. + * + * @param array $data The submitted configuration fields. + * + * @return array The persisted configuration result. + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-products-are-mapped-to-endoflife-date-via-per-module-config + */ + public function updateConfig(array $data): array + { + return $this->settingsService->updateEolSyncConfig($data); + }//end updateConfig() + + /** + * Get the last-recorded sync status. + * + * @return array The status (see `SettingsService::getEolSyncStatus()`). + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-the-feature-degrades-gracefully-when-the-feed-is-unavailable + */ + public function getStatus(): array + { + return $this->settingsService->getEolSyncStatus(); + }//end getStatus() + + /** + * Run the EOL sync: resolve config and the EOL register/schema, find + * every mapped module, match and stamp its versions, and record the + * outcome. Invoked identically by the scheduled background job and the + * manual "sync now" admin endpoint. + * + * Never throws to the caller — every failure mode (feature disabled, + * OpenRegister absent, register/schema not resolvable) degrades to a + * recorded "unavailable" status instead, per the graceful-degradation + * requirement. + * + * @return array{available: bool, reason: string|null, matched: int, skipped: int, lastRunAt: string|null} + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-eol-sync-runs-on-a-schedule-with-a-manual-trigger + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-the-feature-degrades-gracefully-when-the-feed-is-unavailable + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-softwarecatalog-performs-no-direct-http-to-the-eol-feed + */ + public function run(): array + { + $config = $this->settingsService->getEolSyncConfig(); + + if ($config['enabled'] !== true) { + return $this->degrade(reason: 'disabled'); + } + + if ($this->settingsService->isOpenRegisterInstalled() === false) { + return $this->degrade(reason: 'openregister-not-installed'); + } + + try { + $objectService = $this->settingsService->getObjectService(); + } catch (\Throwable $e) { + $this->logger->info( + '[EolSyncService] OpenRegister ObjectService unavailable — degrading to manual-only', + ['error' => $e->getMessage()] + ); + return $this->degrade(reason: 'object-service-unavailable'); + } + + if ($objectService === null) { + return $this->degrade(reason: 'object-service-unavailable'); + } + + $moduleRegisterId = $this->settingsService->getRegisterIdForObjectType('module'); + $moduleSchemaId = $this->settingsService->getSchemaIdForObjectType('module'); + $moduleVersieSchemaId = $this->settingsService->getSchemaIdForObjectType('moduleVersie'); + + if ($moduleRegisterId === null || $moduleSchemaId === null || $moduleVersieSchemaId === null) { + return $this->degrade(reason: 'module-schema-not-configured'); + } + + if ($this->resolveEolContext(objectService: $objectService, config: $config) === false) { + return $this->degrade(reason: 'eol-register-or-schema-not-found'); + } + + $mappedModules = $this->findMappedModules( + objectService: $objectService, + moduleRegisterId: $moduleRegisterId, + moduleSchemaId: $moduleSchemaId + ); + + $fetchedAt = $this->timeFactory->getDateTime()->format(\DateTimeInterface::ATOM); + + $totalStamped = 0; + $totalSkipped = 0; + + foreach ($mappedModules as $module) { + $slug = trim((string) ($module['eolProductSlug'] ?? '')); + $moduleUuid = (string) ($module['id'] ?? ''); + if ($slug === '' || $moduleUuid === '') { + continue; + } + + $cycles = $this->fetchCycles( + objectService: $objectService, + config: $config, + productSlug: $slug + ); + + $moduleVersies = $this->fetchModuleVersions( + objectService: $objectService, + moduleRegisterId: $moduleRegisterId, + moduleVersieSchemaId: $moduleVersieSchemaId, + moduleUuid: $moduleUuid + ); + + $result = $this->matcher->matchModuleVersions( + moduleVersies: $moduleVersies, + cycles: $cycles, + source: self::SOURCE, + fetchedAt: $fetchedAt + ); + + foreach ($result['stamped'] as $stampedVersion) { + $this->saveStampedVersion( + objectService: $objectService, + moduleRegisterId: $moduleRegisterId, + moduleVersieSchemaId: $moduleVersieSchemaId, + stampedVersion: $stampedVersion + ); + $totalStamped++; + } + + $totalSkipped += count($result['skipped']); + }//end foreach + + $status = [ + 'available' => true, + 'reason' => null, + 'matched' => $totalStamped, + 'skipped' => $totalSkipped, + 'lastRunAt' => $fetchedAt, + ]; + $this->settingsService->setEolSyncStatus($status); + + return $status; + }//end run() + + /** + * Resolve the configured EOL register + `eolProduct`/`eolCycle` schemas + * on the given `ObjectService` context. Returns false (never throws) + * when the register or either schema cannot be found. + * + * @param ObjectService $objectService The OpenRegister object service. + * @param array $config The EOL sync configuration. + * + * @return bool True when the register/schemas resolved successfully. + */ + private function resolveEolContext(ObjectService $objectService, array $config): bool + { + try { + $objectService->setRegister($config['register']); + $objectService->setSchema($config['productSchema']); + $objectService->setSchema($config['cycleSchema']); + } catch (\Throwable $e) { + $this->logger->info( + '[EolSyncService] EOL register/schema not resolvable — degrading to manual-only', + [ + 'register' => $config['register'], + 'productSchema' => $config['productSchema'], + 'cycleSchema' => $config['cycleSchema'], + 'error' => $e->getMessage(), + ] + ); + return false; + } + + return true; + }//end resolveEolContext() + + /** + * Find every module with a non-empty `eolProductSlug` mapping. + * + * Modules without the mapping are never read individually again after + * this listing pass — the per-module `eolCycle` read and `moduleVersie` + * write only happen for mapped modules (design.md non-functional + * performance note). + * + * @param ObjectService $objectService The OpenRegister object service. + * @param int $moduleRegisterId The module register id. + * @param int $moduleSchemaId The module schema id. + * + * @return array The mapped module rows (normalised arrays). + */ + private function findMappedModules(ObjectService $objectService, int $moduleRegisterId, int $moduleSchemaId): array + { + $query = [ + '@self' => [ + 'schema' => $moduleSchemaId, + 'register' => $moduleRegisterId, + ], + '_limit' => 1000, + ]; + + $modules = $objectService->searchObjects(query: $query, _rbac: false, _multitenancy: false); + if (is_array($modules) === false) { + return []; + } + + $mapped = []; + foreach ($modules as $module) { + $data = $this->normalizeRow(row: $module); + if (trim((string) ($data['eolProductSlug'] ?? '')) !== '') { + $mapped[] = $data; + } + } + + return $mapped; + }//end findMappedModules() + + /** + * Fetch the `eolCycle` rows for one product slug, scoped to the + * configured EOL register/schema. Defensively re-filters on `product` + * in PHP in case the underlying query filter is looser than an exact + * match — matching must never cross into another product's cycles + * (design.md Decision 2 mitigation). + * + * @param ObjectService $objectService The OpenRegister object service. + * @param array $config The EOL sync configuration. + * @param string $productSlug The mapped module's `eolProductSlug`. + * + * @return array The matching `eolCycle` rows (normalised arrays). + */ + private function fetchCycles(ObjectService $objectService, array $config, string $productSlug): array + { + try { + $objectService->setRegister($config['register']); + $objectService->setSchema($config['cycleSchema']); + $rows = $objectService->findAll( + config: [ + 'filters' => ['product' => $productSlug], + 'limit' => 500, + ], + _rbac: false, + _multitenancy: false + ); + } catch (\Throwable $e) { + $this->logger->warning( + '[EolSyncService] Failed to read eolCycle rows for product — module skipped this run', + ['product' => $productSlug, 'error' => $e->getMessage()] + ); + return []; + } + + if (is_array($rows) === false) { + return []; + } + + $cycles = []; + foreach ($rows as $row) { + $data = $this->normalizeRow(row: $row); + if ((string) ($data['product'] ?? '') === $productSlug) { + $cycles[] = $data; + } + } + + return $cycles; + }//end fetchCycles() + + /** + * Fetch the `moduleVersie` rows belonging to one module. + * + * @param ObjectService $objectService The OpenRegister object service. + * @param int $moduleRegisterId The (softwarecatalog) module register id. + * @param int $moduleVersieSchemaId The moduleVersie schema id. + * @param string $moduleUuid The owning module's uuid. + * + * @return array The module's `moduleVersie` rows (normalised arrays). + */ + private function fetchModuleVersions( + ObjectService $objectService, + int $moduleRegisterId, + int $moduleVersieSchemaId, + string $moduleUuid + ): array { + $query = [ + '@self' => [ + 'schema' => $moduleVersieSchemaId, + 'register' => $moduleRegisterId, + ], + 'module' => $moduleUuid, + '_limit' => 200, + ]; + + try { + $rows = $objectService->searchObjects(query: $query, _rbac: false, _multitenancy: false); + } catch (\Throwable $e) { + $this->logger->warning( + '[EolSyncService] Failed to read moduleVersie rows for module — module skipped this run', + ['module' => $moduleUuid, 'error' => $e->getMessage()] + ); + return []; + } + + if (is_array($rows) === false) { + return []; + } + + return array_map(fn ($row) => $this->normalizeRow(row: $row), $rows); + }//end fetchModuleVersions() + + /** + * Save a stamped `moduleVersie` — PUT-semantic: the complete object + * (every original field plus the stamp) is passed through, and the + * existing uuid is supplied so this is an update, never a duplicate + * create. + * + * @param ObjectService $objectService The OpenRegister object service. + * @param int $moduleRegisterId The module register id. + * @param int $moduleVersieSchemaId The moduleVersie schema id. + * @param array $stampedVersion The complete stamped `moduleVersie` object. + * + * @return void + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-stamping-preserves-every-other-field-and-records-provenance + */ + private function saveStampedVersion( + ObjectService $objectService, + int $moduleRegisterId, + int $moduleVersieSchemaId, + array $stampedVersion + ): void { + $uuid = (string) ($stampedVersion['id'] ?? ''); + if ($uuid === '') { + $this->logger->warning('[EolSyncService] Stamped moduleVersie has no uuid — skipping save'); + return; + } + + try { + $objectService->saveObject( + object: $stampedVersion, + register: $moduleRegisterId, + schema: $moduleVersieSchemaId, + uuid: $uuid, + _rbac: false, + _multitenancy: false + ); + } catch (\Throwable $e) { + $this->logger->warning( + '[EolSyncService] Failed to save a stamped moduleVersie — left untouched', + ['uuid' => $uuid, 'error' => $e->getMessage()] + ); + } + }//end saveStampedVersion() + + /** + * Normalise an OpenRegister search/list result row to a plain array, + * regardless of whether the caller returned a rendered array or an + * entity object (mirrors the defensive shape-handling already used by + * `OrganizationContactSyncJob::refreshOne()`). + * + * @param mixed $row The raw row. + * + * @return array The normalised row, always carrying an `id` key when resolvable. + */ + private function normalizeRow(mixed $row): array + { + if (is_array($row) === true) { + return $row; + } + + if (is_object($row) === true && method_exists($row, 'getObject') === true) { + $data = (array) $row->getObject(); + if (method_exists($row, 'getUuid') === true && empty($data['id']) === true) { + $data['id'] = (string) $row->getUuid(); + } + + return $data; + } + + return (array) $row; + }//end normalizeRow() + + /** + * Record and return a graceful "unavailable" status. + * + * @param string $reason The unavailability reason code. + * + * @return array{available: bool, reason: string|null, matched: int, skipped: int, lastRunAt: string|null} + */ + private function degrade(string $reason): array + { + $status = [ + 'available' => false, + 'reason' => $reason, + 'matched' => 0, + 'skipped' => 0, + 'lastRunAt' => $this->timeFactory->getDateTime()->format(\DateTimeInterface::ATOM), + ]; + $this->settingsService->setEolSyncStatus($status); + + return $status; + }//end degrade() +}//end class diff --git a/lib/Service/SettingsService.php b/lib/Service/SettingsService.php index faad8de8..172a0f16 100644 --- a/lib/Service/SettingsService.php +++ b/lib/Service/SettingsService.php @@ -6749,6 +6749,167 @@ public function getAvailableOrganisationsForCronjobs(): array }//end try }//end getAvailableOrganisationsForCronjobs() + // ===. + // EOL SYNC CONFIGURATION (eol-feed-integration). + // ===. + + /** + * The IAppConfig key backing the EOL sync configuration blob. + * + * @var string + */ + private const EOL_SYNC_CONFIG_KEY = 'eol_sync_config'; + + /** + * The IAppConfig key backing the EOL sync last-run status blob. + * + * @var string + */ + private const EOL_SYNC_STATUS_KEY = 'eol_sync_status'; + + /** + * The register slug the sibling openconnector `endoflife-date-source` + * change provisions `eolProduct`/`eolCycle` into, used as the default + * when no admin override is configured. + * + * @var string + */ + private const EOL_DEFAULT_REGISTER = 'openconnector'; + + /** + * The default `eolProduct` schema slug (design.md Decision 5). + * + * @var string + */ + private const EOL_DEFAULT_PRODUCT_SCHEMA = 'eolProduct'; + + /** + * The default `eolCycle` schema slug (design.md Decision 5). + * + * @var string + */ + private const EOL_DEFAULT_CYCLE_SCHEMA = 'eolCycle'; + + /** + * The default scheduled-sync interval in seconds (24 hours). + * + * @var integer + */ + private const EOL_DEFAULT_INTERVAL_SECONDS = 86400; + + /** + * Get the EOL sync configuration: whether the feature is enabled, the + * register/schema slugs to read `eolProduct`/`eolCycle` from, and the + * scheduled-sync interval. Defaults match what the sibling openconnector + * `endoflife-date-source` change provisions (design.md Decision 5) — + * the feature is disabled by default until an admin opts in. + * + * @return array{enabled: bool, register: string, productSchema: string, cycleSchema: string, intervalSeconds: int} + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-products-are-mapped-to-endoflife-date-via-per-module-config + */ + public function getEolSyncConfig(): array + { + $configJson = $this->config->getValueString($this->appName, self::EOL_SYNC_CONFIG_KEY, '{}'); + $decoded = json_decode($configJson, true); + if (is_array($decoded) === false) { + $decoded = []; + } + + return [ + 'enabled' => ($decoded['enabled'] ?? false) === true, + 'register' => (string) ($decoded['register'] ?? self::EOL_DEFAULT_REGISTER), + 'productSchema' => (string) ($decoded['productSchema'] ?? self::EOL_DEFAULT_PRODUCT_SCHEMA), + 'cycleSchema' => (string) ($decoded['cycleSchema'] ?? self::EOL_DEFAULT_CYCLE_SCHEMA), + 'intervalSeconds' => (int) ($decoded['intervalSeconds'] ?? self::EOL_DEFAULT_INTERVAL_SECONDS), + ]; + }//end getEolSyncConfig() + + /** + * Persist the EOL sync configuration. Unknown keys are ignored; missing + * keys keep their current value (partial updates are supported, unlike + * the OpenRegister object PUT semantics this config intentionally does + * NOT share — this is a flat IAppConfig blob, not an OR object). + * + * @param array $data The submitted configuration fields. + * + * @return array{success: bool, config: array} The persisted configuration. + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-products-are-mapped-to-endoflife-date-via-per-module-config + */ + public function updateEolSyncConfig(array $data): array + { + $current = $this->getEolSyncConfig(); + + if (array_key_exists('enabled', $data) === true) { + $current['enabled'] = ($data['enabled'] === true || $data['enabled'] === 'true'); + } + + if (array_key_exists('register', $data) === true && trim((string) $data['register']) !== '') { + $current['register'] = trim((string) $data['register']); + } + + if (array_key_exists('productSchema', $data) === true && trim((string) $data['productSchema']) !== '') { + $current['productSchema'] = trim((string) $data['productSchema']); + } + + if (array_key_exists('cycleSchema', $data) === true && trim((string) $data['cycleSchema']) !== '') { + $current['cycleSchema'] = trim((string) $data['cycleSchema']); + } + + if (array_key_exists('intervalSeconds', $data) === true && (int) $data['intervalSeconds'] > 0) { + $current['intervalSeconds'] = (int) $data['intervalSeconds']; + } + + $this->config->setValueString($this->appName, self::EOL_SYNC_CONFIG_KEY, json_encode($current)); + + return [ + 'success' => true, + 'config' => $current, + ]; + }//end updateEolSyncConfig() + + /** + * Get the last-recorded EOL sync status: whether the feed is currently + * available, a reason when it is not, and the outcome counts of the most + * recent run. Defaults to an "unavailable / never run" status before the + * first run. + * + * @return array{available: bool, reason: string|null, matched: int, skipped: int, lastRunAt: string|null} + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-the-feature-degrades-gracefully-when-the-feed-is-unavailable + */ + public function getEolSyncStatus(): array + { + $statusJson = $this->config->getValueString($this->appName, self::EOL_SYNC_STATUS_KEY, '{}'); + $decoded = json_decode($statusJson, true); + if (is_array($decoded) === false) { + $decoded = []; + } + + return [ + 'available' => ($decoded['available'] ?? false) === true, + 'reason' => $decoded['reason'] ?? 'not-yet-run', + 'matched' => (int) ($decoded['matched'] ?? 0), + 'skipped' => (int) ($decoded['skipped'] ?? 0), + 'lastRunAt' => $decoded['lastRunAt'] ?? null, + ]; + }//end getEolSyncStatus() + + /** + * Persist the outcome of an EOL sync run (scheduled or manual). + * + * @param array $status The status fields (`available`, `reason`, `matched`, `skipped`, `lastRunAt`). + * + * @return void + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-the-feature-degrades-gracefully-when-the-feed-is-unavailable + */ + public function setEolSyncStatus(array $status): void + { + $this->config->setValueString($this->appName, self::EOL_SYNC_STATUS_KEY, json_encode($status)); + }//end setEolSyncStatus() + /** * Deep-merge a register fragment onto the base config (ADR-037). * diff --git a/lib/Settings/softwarecatalogus_register.json b/lib/Settings/softwarecatalogus_register.json index 574befee..a54354e0 100644 --- a/lib/Settings/softwarecatalogus_register.json +++ b/lib/Settings/softwarecatalogus_register.json @@ -7101,6 +7101,14 @@ "order": 51, "facetable": false, "title": "Depublicatiedatum" + }, + "eolProductSlug": { + "description": "De product-identifier op endoflife.date die bij deze applicatie hoort (bijv. \"postgresql\"). Optioneel; alleen gezet wanneer een beheerder deze applicatie koppelt aan een endoflife.date-product voor automatische einde-ondersteuning-matching. Zonder deze waarde slaat de EOL-matcher deze applicatie volledig over (geen lees- of schrijfactie).", + "type": "string", + "order": 52, + "facetable": false, + "title": "Endoflife.date product-slug", + "example": "postgresql" } }, "archive": [], @@ -7467,6 +7475,22 @@ "Samenwerking", "Leverancier" ] + }, + "eolBron": { + "description": "Herkomst van de gestempelde einde-ondersteuning-datum (bijv. \"endoflife.date\"). Alleen gezet door de EOL-matcher; afwezig bij een handmatig ingevoerde datumEindeOndersteuning.", + "type": "string", + "order": 20, + "facetable": false, + "title": "EOL bron", + "example": "endoflife.date" + }, + "eolBijgewerktOp": { + "description": "Tijdstip waarop de EOL-matcher deze versie voor het laatst heeft bijgewerkt vanuit de bron. Alleen gezet door de EOL-matcher; afwezig bij een handmatig ingevoerde datumEindeOndersteuning.", + "type": "string", + "format": "date-time", + "order": 21, + "facetable": false, + "title": "EOL bijgewerkt op" } }, "archive": [], diff --git a/tests/Unit/Service/EolMatcherServiceTest.php b/tests/Unit/Service/EolMatcherServiceTest.php new file mode 100644 index 00000000..5fb4b651 --- /dev/null +++ b/tests/Unit/Service/EolMatcherServiceTest.php @@ -0,0 +1,285 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-version-matching-is-conservative-and-unambiguous-only + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-stamping-preserves-every-other-field-and-records-provenance + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Service; + +use OCA\SoftwareCatalog\Service\EolMatcherService; +use PHPUnit\Framework\TestCase; + +/** + * Fixture-based coverage of the matcher's decision boundary. + */ +class EolMatcherServiceTest extends TestCase +{ + + private EolMatcherService $matcher; + + /** + * @return void + */ + protected function setUp(): void + { + $this->matcher = new EolMatcherService(); + }//end setUp() + + /** + * A single most-specific candidate stamps unambiguously. + * + * @spec openspec/specs/eol-feed-integration/spec.md#scenario-unambiguous-match-stamps-the-version + * @return void + */ + public function testUnambiguousMatchAtMostSpecificLevel(): void + { + $cycles = [ + ['cycle' => '21.3', 'eol' => '2025-11-09'], + ['cycle' => '21', 'eol' => '2026-01-01'], + ]; + + $matched = $this->matcher->matchVersion(versie: '21.3.1', cycles: $cycles); + + $this->assertNotNull($matched); + $this->assertSame('21.3', $matched['cycle']); + $this->assertSame('2025-11-09', $matched['eol']); + }//end testUnambiguousMatchAtMostSpecificLevel() + + /** + * Two candidates tied at the same (only) matching depth are ambiguous + * and must not be stamped. + * + * @spec openspec/specs/eol-feed-integration/spec.md#scenario-ambiguous-match-is-skipped-not-guessed + * @return void + */ + public function testAmbiguousTieIsNotMatched(): void + { + $cycles = [ + ['cycle' => '2.0', 'eol' => '2024-01-01'], + ['cycle' => '2.1', 'eol' => '2024-06-01'], + ]; + + $matched = $this->matcher->matchVersion(versie: '2', cycles: $cycles); + + $this->assertNull($matched); + }//end testAmbiguousTieIsNotMatched() + + /** + * No candidate at all leaves the version unmatched. + * + * @spec openspec/specs/eol-feed-integration/spec.md#scenario-no-match-leaves-the-version-untouched + * @return void + */ + public function testNoCandidateIsNotMatched(): void + { + $cycles = [ + ['cycle' => '5.2', 'eol' => '2024-01-01'], + ['cycle' => '6.0', 'eol' => '2025-01-01'], + ]; + + $matched = $this->matcher->matchVersion(versie: '9.9.9', cycles: $cycles); + + $this->assertNull($matched); + }//end testNoCandidateIsNotMatched() + + /** + * A shorter, more general cycle candidate loses to a longer, more + * specific one when both are present — the "prefix overlap across + * versions" boundary from design.md's file structure notes. + * + * @return void + */ + public function testMoreSpecificCandidateWinsOverGeneralOne(): void + { + $cycles = [ + ['cycle' => '3', 'eol' => '2023-01-01'], + ['cycle' => '3.14', 'eol' => '2029-10-01'], + ]; + + $matched = $this->matcher->matchVersion(versie: '3.14.2', cycles: $cycles); + + $this->assertNotNull($matched); + $this->assertSame('3.14', $matched['cycle']); + }//end testMoreSpecificCandidateWinsOverGeneralOne() + + /** + * An exact-equal cycle/version pair matches at full depth. + * + * @return void + */ + public function testExactEqualCycleMatches(): void + { + $cycles = [ + ['cycle' => '16', 'eol' => '2028-11-09'], + ]; + + $matched = $this->matcher->matchVersion(versie: '16', cycles: $cycles); + + $this->assertNotNull($matched); + $this->assertSame('16', $matched['cycle']); + }//end testExactEqualCycleMatches() + + /** + * A cycle from a different product's shape never leaks in — matching is + * always scoped to the single cycle set the caller passes in (the + * per-module `eolProductSlug` selection happens one layer up, in + * EolSyncService). + * + * @return void + */ + public function testDivergingSegmentIsNeverACandidate(): void + { + $cycles = [ + ['cycle' => '1.0', 'eol' => '2020-01-01'], + ]; + + // '1.1.0' diverges from '1.0' at the second segment ('1' !== '0'). + $matched = $this->matcher->matchVersion(versie: '1.1.0', cycles: $cycles); + + $this->assertNull($matched); + }//end testDivergingSegmentIsNeverACandidate() + + /** + * A cycle with no scheduled EOL date (empty string) is not stamped — + * nothing informative to record — but the version is still reported + * skipped, not erroring. + * + * @return void + */ + public function testEmptyEolDateIsSkippedNotStamped(): void + { + $result = $this->matcher->matchModuleVersions( + moduleVersies: [['id' => 'mv-1', 'versie' => '3.14.1']], + cycles: [['cycle' => '3.14', 'eol' => '']], + source: 'endoflife.date', + fetchedAt: '2026-07-23T00:00:00+00:00' + ); + + $this->assertCount(0, $result['stamped']); + $this->assertCount(1, $result['skipped']); + $this->assertSame('mv-1', $result['skipped'][0]['id']); + }//end testEmptyEolDateIsSkippedNotStamped() + + /** + * Stamping carries every other field on the moduleVersie forward + * unchanged (PUT-semantic base) and adds the three stamped fields. + * + * @spec openspec/specs/eol-feed-integration/spec.md#scenario-an-unrelated-field-survives-a-stamp + * @return void + */ + public function testStampPreservesEveryOtherFieldAndAddsProvenance(): void + { + $moduleVersie = [ + 'id' => 'mv-uuid-1', + 'module' => 'module-uuid-1', + 'versie' => '21.3.1', + 'beschrijvingKort' => 'A short description that must survive', + 'status' => 'in gebruik', + 'gebruiken' => ['gebruik-1', 'gebruik-2'], + ]; + $matchedCycle = ['cycle' => '21.3', 'eol' => '2025-11-09']; + + $stamped = $this->matcher->buildStamp( + moduleVersie: $moduleVersie, + matchedCycle: $matchedCycle, + source: 'endoflife.date', + fetchedAt: '2026-07-23T10:00:00+00:00' + ); + + // Stamped fields. + $this->assertSame('2025-11-09', $stamped['datumEindeOndersteuning']); + $this->assertSame('endoflife.date', $stamped['eolBron']); + $this->assertSame('2026-07-23T10:00:00+00:00', $stamped['eolBijgewerktOp']); + + // Every other field survives unchanged (OR saveObject is PUT-semantic). + $this->assertSame('mv-uuid-1', $stamped['id']); + $this->assertSame('module-uuid-1', $stamped['module']); + $this->assertSame('21.3.1', $stamped['versie']); + $this->assertSame('A short description that must survive', $stamped['beschrijvingKort']); + $this->assertSame('in gebruik', $stamped['status']); + $this->assertSame(['gebruik-1', 'gebruik-2'], $stamped['gebruiken']); + }//end testStampPreservesEveryOtherFieldAndAddsProvenance() + + /** + * A hand-entered datumEindeOndersteuning (never passed through the + * matcher) never gains eolBron/eolBijgewerktOp — those are only ever + * set by buildStamp(), never fabricated for manual entries. + * + * @return void + */ + public function testUnmatchedVersionNeverGainsProvenance(): void + { + $handEntered = [ + 'id' => 'mv-uuid-2', + 'versie' => '9.9.9', + 'datumEindeOndersteuning' => '2030-01-01', + ]; + + $result = $this->matcher->matchModuleVersions( + moduleVersies: [$handEntered], + cycles: [['cycle' => '1.0', 'eol' => '2020-01-01']], + source: 'endoflife.date', + fetchedAt: '2026-07-23T00:00:00+00:00' + ); + + $this->assertCount(0, $result['stamped']); + $this->assertCount(1, $result['skipped']); + $this->assertArrayNotHasKey('eolBron', $result['skipped'][0]); + $this->assertArrayNotHasKey('eolBijgewerktOp', $result['skipped'][0]); + $this->assertSame('2030-01-01', $result['skipped'][0]['datumEindeOndersteuning']); + }//end testUnmatchedVersionNeverGainsProvenance() + + /** + * matchModuleVersions() partitions a mixed batch correctly: matches are + * stamped, ambiguous/no-match/empty-versie rows are skipped untouched. + * + * @return void + */ + public function testMatchModuleVersionsPartitionsAMixedBatch(): void + { + $cycles = [ + ['cycle' => '21.3', 'eol' => '2025-11-09'], + ['cycle' => '2.0', 'eol' => '2024-01-01'], + ['cycle' => '2.1', 'eol' => '2024-06-01'], + ]; + + $moduleVersies = [ + ['id' => 'mv-match', 'versie' => '21.3.1'], + ['id' => 'mv-ambiguous', 'versie' => '2'], + ['id' => 'mv-nomatch', 'versie' => '99.0'], + ['id' => 'mv-empty', 'versie' => ''], + ]; + + $result = $this->matcher->matchModuleVersions( + moduleVersies: $moduleVersies, + cycles: $cycles, + source: 'endoflife.date', + fetchedAt: '2026-07-23T00:00:00+00:00' + ); + + $this->assertCount(1, $result['stamped']); + $this->assertSame('mv-match', $result['stamped'][0]['id']); + $this->assertSame('2025-11-09', $result['stamped'][0]['datumEindeOndersteuning']); + + $this->assertCount(3, $result['skipped']); + $skippedIds = array_column($result['skipped'], 'id'); + $this->assertContains('mv-ambiguous', $skippedIds); + $this->assertContains('mv-nomatch', $skippedIds); + $this->assertContains('mv-empty', $skippedIds); + }//end testMatchModuleVersionsPartitionsAMixedBatch() +}//end class diff --git a/tests/Unit/Service/EolRegisterShapeTest.php b/tests/Unit/Service/EolRegisterShapeTest.php new file mode 100644 index 00000000..aa56378b --- /dev/null +++ b/tests/Unit/Service/EolRegisterShapeTest.php @@ -0,0 +1,120 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-products-are-mapped-to-endoflife-date-via-per-module-config + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Service; + +use PHPUnit\Framework\TestCase; + +/** + * Validates the softwarecatalogus register file shape for the EOL feed change. + */ +class EolRegisterShapeTest extends TestCase +{ + + /** + * @var array + */ + private array $register; + + /** + * Load and decode the register file once. + * + * @return void + */ + protected function setUp(): void + { + $path = __DIR__.'/../../../lib/Settings/softwarecatalogus_register.json'; + $this->assertFileExists($path); + $decoded = json_decode((string) file_get_contents($path), true); + $this->assertIsArray($decoded, 'register file must be valid JSON'); + $this->register = $decoded; + }//end setUp() + + /** + * Fetch a schema definition by slug. + * + * @param string $slug The schema slug. + * + * @return array The schema definition. + */ + private function schema(string $slug): array + { + $schemas = $this->register['components']['schemas'] ?? []; + $this->assertArrayHasKey($slug, $schemas, "schema $slug must exist"); + return $schemas[$slug]; + }//end schema() + + /** + * The module schema gains the optional eolProductSlug mapping field. + * + * @return void + */ + public function testModuleHasOptionalEolProductSlugField(): void + { + $module = $this->schema('module'); + $props = $module['properties'] ?? []; + + $this->assertArrayHasKey('eolProductSlug', $props); + $this->assertSame('string', $props['eolProductSlug']['type'] ?? null); + + // Optional: not listed in `required` (import-over-existing is non-destructive). + $required = $module['required'] ?? []; + $this->assertNotContains('eolProductSlug', $required); + }//end testModuleHasOptionalEolProductSlugField() + + /** + * The moduleVersie schema gains the two optional provenance fields. + * + * @return void + */ + public function testModuleVersieHasOptionalProvenanceFields(): void + { + $moduleVersie = $this->schema('moduleVersie'); + $props = $moduleVersie['properties'] ?? []; + + $this->assertArrayHasKey('eolBron', $props); + $this->assertArrayHasKey('eolBijgewerktOp', $props); + $this->assertSame('string', $props['eolBron']['type'] ?? null); + $this->assertSame('date-time', $props['eolBijgewerktOp']['format'] ?? null); + + // Optional: not listed in `required`. + $required = $moduleVersie['required'] ?? []; + $this->assertNotContains('eolBron', $required); + $this->assertNotContains('eolBijgewerktOp', $required); + }//end testModuleVersieHasOptionalProvenanceFields() + + /** + * The moduleVersie schema still declares datumEindeOndersteuning — the + * field the matcher stamps and application-lifecycle-tracking already + * reads for EOL indicators/filters/roadmap/notification. + * + * @return void + */ + public function testModuleVersieStillDeclaresDatumEindeOndersteuning(): void + { + $props = $this->schema('moduleVersie')['properties'] ?? []; + $this->assertArrayHasKey('datumEindeOndersteuning', $props); + $this->assertSame('date', $props['datumEindeOndersteuning']['format'] ?? null); + }//end testModuleVersieStillDeclaresDatumEindeOndersteuning() +}//end class diff --git a/tests/Unit/Service/EolSyncServiceTest.php b/tests/Unit/Service/EolSyncServiceTest.php new file mode 100644 index 00000000..84541400 --- /dev/null +++ b/tests/Unit/Service/EolSyncServiceTest.php @@ -0,0 +1,339 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-the-feature-degrades-gracefully-when-the-feed-is-unavailable + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-eol-sync-runs-on-a-schedule-with-a-manual-trigger + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-softwarecatalog-performs-no-direct-http-to-the-eol-feed + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Service; + +use OCA\OpenRegister\Service\ObjectService; +use OCA\SoftwareCatalog\Service\EolMatcherService; +use OCA\SoftwareCatalog\Service\EolSyncService; +use OCA\SoftwareCatalog\Service\SettingsService; +use OCP\AppFramework\Utility\ITimeFactory; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Covers every degradation path plus the happy-path orchestration. + */ +class EolSyncServiceTest extends TestCase +{ + + private const DEFAULT_CONFIG = [ + 'enabled' => true, + 'register' => 'openconnector', + 'productSchema' => 'eolProduct', + 'cycleSchema' => 'eolCycle', + 'intervalSeconds' => 86400, + ]; + + /** + * A fixed time factory so status timestamps are deterministic. + * + * @return ITimeFactory&\PHPUnit\Framework\MockObject\MockObject + */ + private function timeFactory(): ITimeFactory + { + $timeFactory = $this->createMock(ITimeFactory::class); + $timeFactory->method('getDateTime')->willReturn(new \DateTime('2026-07-23T12:00:00+00:00')); + return $timeFactory; + }//end timeFactory() + + /** + * Disabled config degrades without ever touching ObjectService. + * + * @spec openspec/specs/eol-feed-integration/spec.md#scenario-missing-register-degrades-to-manual-only-not-an-error + * @return void + */ + public function testDisabledConfigDegradesGracefully(): void + { + $config = self::DEFAULT_CONFIG; + $config['enabled'] = false; + + $settingsService = $this->createMock(SettingsService::class); + $settingsService->method('getEolSyncConfig')->willReturn($config); + // ObjectService must never be requested when disabled. + $settingsService->expects($this->never())->method('getObjectService'); + $settingsService->expects($this->once())->method('setEolSyncStatus')->with( + $this->callback(function (array $status): bool { + return $status['available'] === false && $status['reason'] === 'disabled' + && $status['matched'] === 0 && $status['skipped'] === 0; + }) + ); + + $service = new EolSyncService( + settingsService: $settingsService, + matcher: new EolMatcherService(), + timeFactory: $this->timeFactory(), + logger: $this->createMock(LoggerInterface::class) + ); + + $status = $service->run(); + + $this->assertFalse($status['available']); + $this->assertSame('disabled', $status['reason']); + }//end testDisabledConfigDegradesGracefully() + + /** + * OpenRegister absent degrades gracefully — no exception, no object + * touched. + * + * @spec openspec/specs/eol-feed-integration/spec.md#scenario-core-lifecycle-capability-is-unaffected-by-feed-absence + * @return void + */ + public function testOpenRegisterNotInstalledDegradesGracefully(): void + { + $settingsService = $this->createMock(SettingsService::class); + $settingsService->method('getEolSyncConfig')->willReturn(self::DEFAULT_CONFIG); + $settingsService->method('isOpenRegisterInstalled')->willReturn(false); + $settingsService->expects($this->never())->method('getObjectService'); + + $service = new EolSyncService( + settingsService: $settingsService, + matcher: new EolMatcherService(), + timeFactory: $this->timeFactory(), + logger: $this->createMock(LoggerInterface::class) + ); + + $status = $service->run(); + + $this->assertFalse($status['available']); + $this->assertSame('openregister-not-installed', $status['reason']); + }//end testOpenRegisterNotInstalledDegradesGracefully() + + /** + * The configured register/schema failing to resolve degrades gracefully + * — no exception propagates to the caller. + * + * @spec openspec/specs/eol-feed-integration/spec.md#scenario-missing-register-degrades-to-manual-only-not-an-error + * @return void + */ + public function testUnresolvableRegisterDegradesGracefully(): void + { + $settingsService = $this->createMock(SettingsService::class); + $settingsService->method('getEolSyncConfig')->willReturn(self::DEFAULT_CONFIG); + $settingsService->method('isOpenRegisterInstalled')->willReturn(true); + $settingsService->method('getRegisterIdForObjectType')->willReturn(1); + $settingsService->method('getSchemaIdForObjectType')->willReturn(2); + + $objectService = $this->createMock(ObjectService::class); + $objectService->method('setRegister')->willThrowException(new \RuntimeException('register not found')); + $settingsService->method('getObjectService')->willReturn($objectService); + + $objectService->expects($this->never())->method('saveObject'); + + $service = new EolSyncService( + settingsService: $settingsService, + matcher: new EolMatcherService(), + timeFactory: $this->timeFactory(), + logger: $this->createMock(LoggerInterface::class) + ); + + $status = $service->run(); + + $this->assertFalse($status['available']); + $this->assertSame('eol-register-or-schema-not-found', $status['reason']); + }//end testUnresolvableRegisterDegradesGracefully() + + /** + * Module/moduleVersie schema not configured degrades gracefully (a + * fresh install where softwarecatalog itself is not yet configured). + * + * @return void + */ + public function testModuleSchemaNotConfiguredDegradesGracefully(): void + { + $settingsService = $this->createMock(SettingsService::class); + $settingsService->method('getEolSyncConfig')->willReturn(self::DEFAULT_CONFIG); + $settingsService->method('isOpenRegisterInstalled')->willReturn(true); + $settingsService->method('getObjectService')->willReturn($this->createMock(ObjectService::class)); + $settingsService->method('getRegisterIdForObjectType')->willReturn(null); + + $service = new EolSyncService( + settingsService: $settingsService, + matcher: new EolMatcherService(), + timeFactory: $this->timeFactory(), + logger: $this->createMock(LoggerInterface::class) + ); + + $status = $service->run(); + + $this->assertFalse($status['available']); + $this->assertSame('module-schema-not-configured', $status['reason']); + }//end testModuleSchemaNotConfiguredDegradesGracefully() + + /** + * The happy path: one mapped module with one matching moduleVersie is + * stamped and saved; the status reports matched/skipped counts. + * + * @spec openspec/specs/eol-feed-integration/spec.md#scenario-the-scheduled-job-runs-the-match + * @return void + */ + public function testSuccessfulRunMatchesStampsAndReportsStatus(): void + { + $settingsService = $this->createMock(SettingsService::class); + $settingsService->method('getEolSyncConfig')->willReturn(self::DEFAULT_CONFIG); + $settingsService->method('isOpenRegisterInstalled')->willReturn(true); + $settingsService->method('getRegisterIdForObjectType')->willReturn(10); + $settingsService->method('getSchemaIdForObjectType')->willReturnMap( + [ + ['module', 20], + ['moduleVersie', 21], + ] + ); + + $module = ['id' => 'module-uuid-1', 'eolProductSlug' => 'postgresql']; + $moduleVersie = ['id' => 'mv-uuid-1', 'module' => 'module-uuid-1', 'versie' => '16.2', 'beschrijvingKort' => 'keep me']; + $cycle = ['product' => 'postgresql', 'cycle' => '16', 'eol' => '2028-11-09']; + + $objectService = $this->createMock(ObjectService::class); + $objectService->method('searchObjects')->willReturnCallback( + function (array $query) use ($module, $moduleVersie): array { + if (($query['@self']['schema'] ?? null) === 20) { + return [$module]; + } + if (($query['@self']['schema'] ?? null) === 21) { + return [$moduleVersie]; + } + return []; + } + ); + $objectService->method('findAll')->willReturn([$cycle]); + + $savedObjects = []; + $objectService->method('saveObject')->willReturnCallback( + function (array $object) use (&$savedObjects) { + $savedObjects[] = $object; + return $this->createMock(\OCA\OpenRegister\Db\ObjectEntity::class); + } + ); + + $settingsService->method('getObjectService')->willReturn($objectService); + + $recordedStatus = null; + $settingsService->expects($this->once())->method('setEolSyncStatus')->with( + $this->callback(function (array $status) use (&$recordedStatus): bool { + $recordedStatus = $status; + return true; + }) + ); + + $service = new EolSyncService( + settingsService: $settingsService, + matcher: new EolMatcherService(), + timeFactory: $this->timeFactory(), + logger: $this->createMock(LoggerInterface::class) + ); + + $status = $service->run(); + + $this->assertTrue($status['available']); + $this->assertSame(1, $status['matched']); + $this->assertSame(0, $status['skipped']); + $this->assertSame($recordedStatus, $status); + + $this->assertCount(1, $savedObjects); + $this->assertSame('2028-11-09', $savedObjects[0]['datumEindeOndersteuning']); + $this->assertSame('endoflife.date', $savedObjects[0]['eolBron']); + $this->assertSame('keep me', $savedObjects[0]['beschrijvingKort']); + }//end testSuccessfulRunMatchesStampsAndReportsStatus() + + /** + * A module with no eolProductSlug is never read or written — the + * matcher must include zero unmapped modules (spec "no read, no + * write"). + * + * @spec openspec/specs/eol-feed-integration/spec.md#scenario-a-mapped-module-is-eligible-for-matching + * @return void + */ + public function testUnmappedModuleIsNeverProcessed(): void + { + $settingsService = $this->createMock(SettingsService::class); + $settingsService->method('getEolSyncConfig')->willReturn(self::DEFAULT_CONFIG); + $settingsService->method('isOpenRegisterInstalled')->willReturn(true); + $settingsService->method('getRegisterIdForObjectType')->willReturn(10); + $settingsService->method('getSchemaIdForObjectType')->willReturnMap( + [ + ['module', 20], + ['moduleVersie', 21], + ] + ); + + $unmappedModule = ['id' => 'module-uuid-2', 'eolProductSlug' => '']; + + $objectService = $this->createMock(ObjectService::class); + $objectService->method('searchObjects')->willReturnCallback( + function (array $query) use ($unmappedModule): array { + if (($query['@self']['schema'] ?? null) === 20) { + return [$unmappedModule]; + } + // The moduleVersie schema (21) must never be queried for an + // unmapped module. + $this->fail('moduleVersie must not be read for an unmapped module'); + } + ); + $objectService->expects($this->never())->method('findAll'); + $objectService->expects($this->never())->method('saveObject'); + + $settingsService->method('getObjectService')->willReturn($objectService); + + $service = new EolSyncService( + settingsService: $settingsService, + matcher: new EolMatcherService(), + timeFactory: $this->timeFactory(), + logger: $this->createMock(LoggerInterface::class) + ); + + $status = $service->run(); + + $this->assertTrue($status['available']); + $this->assertSame(0, $status['matched']); + $this->assertSame(0, $status['skipped']); + }//end testUnmappedModuleIsNeverProcessed() + + /** + * getConfig()/updateConfig()/getStatus() are thin delegators onto + * SettingsService — the actual persistence/merge behaviour is covered + * by SettingsServiceEolConfigTest. + * + * @return void + */ + public function testConfigAndStatusDelegateToSettingsService(): void + { + $settingsService = $this->createMock(SettingsService::class); + $settingsService->expects($this->once())->method('getEolSyncConfig')->willReturn(self::DEFAULT_CONFIG); + $settingsService->expects($this->once())->method('updateEolSyncConfig')->with(['enabled' => false]) + ->willReturn(['success' => true, 'config' => self::DEFAULT_CONFIG]); + $settingsService->expects($this->once())->method('getEolSyncStatus')->willReturn( + ['available' => true, 'reason' => null, 'matched' => 3, 'skipped' => 1, 'lastRunAt' => '2026-07-23T12:00:00+00:00'] + ); + + $service = new EolSyncService( + settingsService: $settingsService, + matcher: new EolMatcherService(), + timeFactory: $this->timeFactory(), + logger: $this->createMock(LoggerInterface::class) + ); + + $this->assertSame(self::DEFAULT_CONFIG, $service->getConfig()); + $this->assertSame(['success' => true, 'config' => self::DEFAULT_CONFIG], $service->updateConfig(['enabled' => false])); + $this->assertSame(3, $service->getStatus()['matched']); + }//end testConfigAndStatusDelegateToSettingsService() +}//end class diff --git a/tests/Unit/Service/SettingsServiceEolConfigTest.php b/tests/Unit/Service/SettingsServiceEolConfigTest.php new file mode 100644 index 00000000..2e8e5497 --- /dev/null +++ b/tests/Unit/Service/SettingsServiceEolConfigTest.php @@ -0,0 +1,194 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-products-are-mapped-to-endoflife-date-via-per-module-config + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-the-feature-degrades-gracefully-when-the-feed-is-unavailable + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Service; + +use OCA\SoftwareCatalog\Service\SettingsService; +use OCP\App\IAppManager; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IRequest; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Round-trips the EOL sync config/status blobs through an in-memory + * IAppConfig double so the real (non-mocked) SettingsService logic is + * exercised end to end. + */ +class SettingsServiceEolConfigTest extends TestCase +{ + + /** + * Build a SettingsService backed by an in-memory IAppConfig store. + * + * @param array $store Reference to the backing key/value store. + * + * @return SettingsService + */ + private function makeService(array &$store): SettingsService + { + $config = $this->createMock(IAppConfig::class); + $config->method('getValueString')->willReturnCallback( + function (string $app, string $key, string $default = '') use (&$store): string { + return $store[$key] ?? $default; + } + ); + $config->method('setValueString')->willReturnCallback( + function (string $app, string $key, string $value) use (&$store): bool { + $store[$key] = $value; + return true; + } + ); + + return new SettingsService( + config: $config, + request: $this->createMock(IRequest::class), + container: $this->createMock(ContainerInterface::class), + appManager: $this->createMock(IAppManager::class), + logger: $this->createMock(LoggerInterface::class), + groupManager: $this->createMock(IGroupManager::class) + ); + }//end makeService() + + /** + * With nothing configured yet, getEolSyncConfig() returns the + * documented defaults matching what endoflife-date-source provisions, + * and the feature is disabled by default. + * + * @spec openspec/specs/eol-feed-integration/spec.md#scenario-register-and-schema-names-are-configurable-not-hardcoded + * @return void + */ + public function testDefaultsMatchEndoflifeDateSourceProvisioning(): void + { + $store = []; + $service = $this->makeService($store); + + $config = $service->getEolSyncConfig(); + + $this->assertFalse($config['enabled']); + $this->assertSame('openconnector', $config['register']); + $this->assertSame('eolProduct', $config['productSchema']); + $this->assertSame('eolCycle', $config['cycleSchema']); + $this->assertSame(86400, $config['intervalSeconds']); + }//end testDefaultsMatchEndoflifeDateSourceProvisioning() + + /** + * updateEolSyncConfig() persists overrides and getEolSyncConfig() then + * reflects them — register/schema names are settings, not constants + * (design.md Decision 5). + * + * @spec openspec/specs/eol-feed-integration/spec.md#scenario-register-and-schema-names-are-configurable-not-hardcoded + * @return void + */ + public function testUpdateConfigPersistsAndRoundTrips(): void + { + $store = []; + $service = $this->makeService($store); + + $result = $service->updateEolSyncConfig( + [ + 'enabled' => true, + 'register' => 'custom-register', + 'productSchema' => 'custom-product', + 'cycleSchema' => 'custom-cycle', + ] + ); + + $this->assertTrue($result['success']); + + $reloaded = $service->getEolSyncConfig(); + $this->assertTrue($reloaded['enabled']); + $this->assertSame('custom-register', $reloaded['register']); + $this->assertSame('custom-product', $reloaded['productSchema']); + $this->assertSame('custom-cycle', $reloaded['cycleSchema']); + // Untouched key keeps its default. + $this->assertSame(86400, $reloaded['intervalSeconds']); + }//end testUpdateConfigPersistsAndRoundTrips() + + /** + * A partial update (e.g. only toggling `enabled`) never clobbers the + * other already-persisted fields. + * + * @return void + */ + public function testPartialUpdatePreservesOtherFields(): void + { + $store = []; + $service = $this->makeService($store); + + $service->updateEolSyncConfig(['register' => 'custom-register', 'enabled' => true]); + $service->updateEolSyncConfig(['enabled' => false]); + + $config = $service->getEolSyncConfig(); + $this->assertFalse($config['enabled']); + $this->assertSame('custom-register', $config['register']); + }//end testPartialUpdatePreservesOtherFields() + + /** + * Before any sync has run, status defaults to unavailable/never-run — + * distinct from "configured but zero matches yet" (design.md + * Decision 6). + * + * @spec openspec/specs/eol-feed-integration/spec.md#requirement-the-feature-degrades-gracefully-when-the-feed-is-unavailable + * @return void + */ + public function testDefaultStatusIsUnavailableNeverRun(): void + { + $store = []; + $service = $this->makeService($store); + + $status = $service->getEolSyncStatus(); + + $this->assertFalse($status['available']); + $this->assertSame('not-yet-run', $status['reason']); + $this->assertSame(0, $status['matched']); + $this->assertNull($status['lastRunAt']); + }//end testDefaultStatusIsUnavailableNeverRun() + + /** + * setEolSyncStatus()/getEolSyncStatus() round-trip a recorded run + * outcome. + * + * @return void + */ + public function testStatusRoundTripsAfterARun(): void + { + $store = []; + $service = $this->makeService($store); + + $service->setEolSyncStatus( + [ + 'available' => true, + 'reason' => null, + 'matched' => 4, + 'skipped' => 2, + 'lastRunAt' => '2026-07-23T12:00:00+00:00', + ] + ); + + $status = $service->getEolSyncStatus(); + $this->assertTrue($status['available']); + $this->assertSame(4, $status['matched']); + $this->assertSame(2, $status['skipped']); + $this->assertSame('2026-07-23T12:00:00+00:00', $status['lastRunAt']); + }//end testStatusRoundTripsAfterARun() +}//end class From 903bcfb366e5477d71a4dfd53dd067d26ed149ec Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 23 Jul 2026 23:16:25 +0200 Subject: [PATCH 2/4] feat(eol-feed-integration): admin settings panel + i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the "End-of-life feed sync" section to Settings → Software Catalog: enable toggle, register/eolProduct/eolCycle schema-name overrides (defaults match what endoflife-date-source provisions), sync interval, a "Sync now" button, and a status banner that shows "unavailable: " rather than an error when the feed can't be resolved. The module.eolProductSlug mapping field needs no dedicated component — it renders through the existing generic OpenRegister object form from the register JSON's title/description. Also runs the project's l10n extraction (`check-l10n.js --write`) to close missing-key drift in l10n/en.json, and adds full nl/en_US translations for every new string this panel introduces. --- l10n/en.json | 57 ++- l10n/en_US.js | 33 +- l10n/en_US.json | 33 +- l10n/nl.js | 33 +- l10n/nl.json | 33 +- .../settings/SoftwareCatalogSettings.vue | 5 + .../settings/sections/EolSyncSettings.vue | 355 ++++++++++++++++++ 7 files changed, 544 insertions(+), 5 deletions(-) create mode 100644 src/views/settings/sections/EolSyncSettings.vue diff --git a/l10n/en.json b/l10n/en.json index fe3b5d01..96a5eba2 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -421,6 +421,61 @@ "View name": "View name", "e.g. Zaakregistratie modules": "e.g. Zaakregistratie modules", "Save view": "Save view", - "Approval": "Approval" + "Approval": "Approval", + "{source} will be marked as merged (not deleted) and will disappear from the organisations list.": "{source} will be marked as merged (not deleted) and will disappear from the organisations list.", + "Approval": "Approval", + "Compliance records": "Compliance records", + "Confirm organisation merge": "Confirm organisation merge", + "Contact persons": "Contact persons", + "Contracts": "Contracts", + "Could not load EOL sync configuration": "Could not load EOL sync configuration", + "Could not load EOL sync status": "Could not load EOL sync status", + "Could not load target organisations.": "Could not load target organisations.", + "Could not merge the organisations.": "Could not merge the organisations.", + "Could not preview the merge.": "Could not preview the merge.", + "Could not save EOL sync settings": "Could not save EOL sync settings", + "Enable EOL feed sync": "Enable EOL feed sync", + "End-of-life feed sync": "End-of-life feed sync", + "EOL feed sync is disabled": "EOL feed sync is disabled", + "EOL sync completed: {matched} matched, {skipped} skipped.": "EOL sync completed: {matched} matched, {skipped} skipped.", + "EOL sync did not run: {reason}": "EOL sync did not run: {reason}", + "EOL sync failed": "EOL sync failed", + "EOL sync settings saved": "EOL sync settings saved", + "eolCycle schema slug": "eolCycle schema slug", + "eolProduct schema slug": "eolProduct schema slug", + "Feed unavailable: {reason}. Manual end-of-support entry, the EOL-approaching filter, the roadmap, and the notification rule keep working regardless.": "Feed unavailable: {reason}. Manual end-of-support entry, the EOL-approaching filter, the roadmap, and the notification rule keep working regardless.", + "Fold this organisation into another one (gemeentelijke herindeling or leveranciersovername). Every contract, usage record, contact person, offering and compliance record is re-pointed to the target; this organisation is then marked as merged, never deleted.": "Fold this organisation into another one (gemeentelijke herindeling or leveranciersovername). Every contract, usage record, contact person, offering and compliance record is re-pointed to the target; this organisation is then marked as merged, never deleted.", + "Go to the organisation it was merged into": "Go to the organisation it was merged into", + "Group members": "Group members", + "Last run: {matched} matched, {skipped} skipped, at {time}.": "Last run: {matched} matched, {skipped} skipped, at {time}.", + "Loading EOL sync configuration…": "Loading EOL sync configuration…", + "Loading merge status": "Loading merge status", + "Match catalog products to endoflife.date product cycles ingested via OpenConnector, to keep end-of-support dates data-driven. Softwarecatalog never calls endoflife.date directly.": "Match catalog products to endoflife.date product cycles ingested via OpenConnector, to keep end-of-support dates data-driven. Softwarecatalog never calls endoflife.date directly.", + "Merge organisation": "Merge organisation", + "Merge organisations": "Merge organisations", + "never": "never", + "not yet run": "not yet run", + "Offerings": "Offerings", + "OpenRegister is not currently reachable": "OpenRegister is not currently reachable", + "OpenRegister is not installed": "OpenRegister is not installed", + "Organisation successfully merged.": "Organisation successfully merged.", + "Pre-filled with the names the openconnector endoflife-date-source change provisions. Change them if your instance uses different names — no code change required.": "Pre-filled with the names the openconnector endoflife-date-source change provisions. Change them if your instance uses different names — no code change required.", + "Preview merge": "Preview merge", + "Records that will be re-pointed to {target}:": "Records that will be re-pointed to {target}:", + "Register slug": "Register slug", + "Save EOL sync settings": "Save EOL sync settings", + "Schedule": "Schedule", + "Select the organisation to merge into": "Select the organisation to merge into", + "Source register and schemas": "Source register and schemas", + "Sync interval (minutes)": "Sync interval (minutes)", + "Sync now": "Sync now", + "Target organisation": "Target organisation", + "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?": "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?", + "the module/moduleVersie schema is not configured yet": "the module/moduleVersie schema is not configured yet", + "The scheduled background job re-runs the matcher at this interval; the minimum enforced interval is 5 minutes.": "The scheduled background job re-runs the matcher at this interval; the minimum enforced interval is 5 minutes.", + "This organisation has been merged and is no longer active.": "This organisation has been merged and is no longer active.", + "This will permanently fold {source} into {target}.": "This will permanently fold {source} into {target}.", + "Usage records": "Usage records", + "When disabled, the matcher never reads or writes anything — the same as the feed being unavailable.": "When disabled, the matcher never reads or writes anything — the same as the feed being unavailable." } } diff --git a/l10n/en_US.js b/l10n/en_US.js index c5f25687..6008b75b 100644 --- a/l10n/en_US.js +++ b/l10n/en_US.js @@ -258,7 +258,38 @@ OC.L10N.register( "Offerings" : "Offerings", "Compliance records" : "Compliance records", "Group members" : "Group members", - "Merge organisations" : "Merge organisations" + "Merge organisations" : "Merge organisations", + "End-of-life feed sync" : "End-of-life feed sync", + "Match catalog products to endoflife.date product cycles ingested via OpenConnector, to keep end-of-support dates data-driven. Softwarecatalog never calls endoflife.date directly." : "Match catalog products to endoflife.date product cycles ingested via OpenConnector, to keep end-of-support dates data-driven. Softwarecatalog never calls endoflife.date directly.", + "Loading EOL sync configuration…" : "Loading EOL sync configuration…", + "Save EOL sync settings" : "Save EOL sync settings", + "Sync now" : "Sync now", + "Last run: {matched} matched, {skipped} skipped, at {time}." : "Last run: {matched} matched, {skipped} skipped, at {time}.", + "Feed unavailable: {reason}. Manual end-of-support entry, the EOL-approaching filter, the roadmap, and the notification rule keep working regardless." : "Feed unavailable: {reason}. Manual end-of-support entry, the EOL-approaching filter, the roadmap, and the notification rule keep working regardless.", + "Enable EOL feed sync" : "Enable EOL feed sync", + "When disabled, the matcher never reads or writes anything — the same as the feed being unavailable." : "When disabled, the matcher never reads or writes anything — the same as the feed being unavailable.", + "Source register and schemas" : "Source register and schemas", + "Pre-filled with the names the openconnector endoflife-date-source change provisions. Change them if your instance uses different names — no code change required." : "Pre-filled with the names the openconnector endoflife-date-source change provisions. Change them if your instance uses different names — no code change required.", + "Register slug" : "Register slug", + "eolProduct schema slug" : "eolProduct schema slug", + "eolCycle schema slug" : "eolCycle schema slug", + "Schedule" : "Schedule", + "Sync interval (minutes)" : "Sync interval (minutes)", + "The scheduled background job re-runs the matcher at this interval; the minimum enforced interval is 5 minutes." : "The scheduled background job re-runs the matcher at this interval; the minimum enforced interval is 5 minutes.", + "Could not load EOL sync configuration" : "Could not load EOL sync configuration", + "Could not load EOL sync status" : "Could not load EOL sync status", + "Could not save EOL sync settings" : "Could not save EOL sync settings", + "EOL sync settings saved" : "EOL sync settings saved", + "EOL sync completed: {matched} matched, {skipped} skipped." : "EOL sync completed: {matched} matched, {skipped} skipped.", + "EOL sync did not run: {reason}" : "EOL sync did not run: {reason}", + "EOL sync failed" : "EOL sync failed", + "EOL feed sync is disabled" : "EOL feed sync is disabled", + "not yet run" : "not yet run", + "OpenRegister is not installed" : "OpenRegister is not installed", + "OpenRegister is not currently reachable" : "OpenRegister is not currently reachable", + "the module/moduleVersie schema is not configured yet" : "the module/moduleVersie schema is not configured yet", + "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?" : "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?", + "never" : "never" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/en_US.json b/l10n/en_US.json index c74f7c98..fe8f038f 100644 --- a/l10n/en_US.json +++ b/l10n/en_US.json @@ -300,6 +300,37 @@ "Offerings": "Offerings", "Compliance records": "Compliance records", "Group members": "Group members", - "Merge organisations": "Merge organisations" + "Merge organisations": "Merge organisations", + "End-of-life feed sync": "End-of-life feed sync", + "Match catalog products to endoflife.date product cycles ingested via OpenConnector, to keep end-of-support dates data-driven. Softwarecatalog never calls endoflife.date directly.": "Match catalog products to endoflife.date product cycles ingested via OpenConnector, to keep end-of-support dates data-driven. Softwarecatalog never calls endoflife.date directly.", + "Loading EOL sync configuration…": "Loading EOL sync configuration…", + "Save EOL sync settings": "Save EOL sync settings", + "Sync now": "Sync now", + "Last run: {matched} matched, {skipped} skipped, at {time}.": "Last run: {matched} matched, {skipped} skipped, at {time}.", + "Feed unavailable: {reason}. Manual end-of-support entry, the EOL-approaching filter, the roadmap, and the notification rule keep working regardless.": "Feed unavailable: {reason}. Manual end-of-support entry, the EOL-approaching filter, the roadmap, and the notification rule keep working regardless.", + "Enable EOL feed sync": "Enable EOL feed sync", + "When disabled, the matcher never reads or writes anything — the same as the feed being unavailable.": "When disabled, the matcher never reads or writes anything — the same as the feed being unavailable.", + "Source register and schemas": "Source register and schemas", + "Pre-filled with the names the openconnector endoflife-date-source change provisions. Change them if your instance uses different names — no code change required.": "Pre-filled with the names the openconnector endoflife-date-source change provisions. Change them if your instance uses different names — no code change required.", + "Register slug": "Register slug", + "eolProduct schema slug": "eolProduct schema slug", + "eolCycle schema slug": "eolCycle schema slug", + "Schedule": "Schedule", + "Sync interval (minutes)": "Sync interval (minutes)", + "The scheduled background job re-runs the matcher at this interval; the minimum enforced interval is 5 minutes.": "The scheduled background job re-runs the matcher at this interval; the minimum enforced interval is 5 minutes.", + "Could not load EOL sync configuration": "Could not load EOL sync configuration", + "Could not load EOL sync status": "Could not load EOL sync status", + "Could not save EOL sync settings": "Could not save EOL sync settings", + "EOL sync settings saved": "EOL sync settings saved", + "EOL sync completed: {matched} matched, {skipped} skipped.": "EOL sync completed: {matched} matched, {skipped} skipped.", + "EOL sync did not run: {reason}": "EOL sync did not run: {reason}", + "EOL sync failed": "EOL sync failed", + "EOL feed sync is disabled": "EOL feed sync is disabled", + "not yet run": "not yet run", + "OpenRegister is not installed": "OpenRegister is not installed", + "OpenRegister is not currently reachable": "OpenRegister is not currently reachable", + "the module/moduleVersie schema is not configured yet": "the module/moduleVersie schema is not configured yet", + "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?": "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?", + "never": "never" } } diff --git a/l10n/nl.js b/l10n/nl.js index 15446a0d..95760a6a 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -295,7 +295,38 @@ OC.L10N.register( "Offerings" : "Aanbiedingen", "Compliance records" : "Compliance-records", "Group members" : "Groepsleden", - "Merge organisations" : "Organisaties samenvoegen" + "Merge organisations" : "Organisaties samenvoegen", + "End-of-life feed sync" : "Einde-ondersteuning-feedsynchronisatie", + "Match catalog products to endoflife.date product cycles ingested via OpenConnector, to keep end-of-support dates data-driven. Softwarecatalog never calls endoflife.date directly." : "Koppel catalogusproducten aan endoflife.date-productcycli die via OpenConnector zijn binnengehaald, zodat einde-ondersteuningsdata datagedreven blijft. Softwarecatalogus roept endoflife.date nooit rechtstreeks aan.", + "Loading EOL sync configuration…" : "EOL-synchronisatieconfiguratie laden…", + "Save EOL sync settings" : "EOL-synchronisatie-instellingen opslaan", + "Sync now" : "Nu synchroniseren", + "Last run: {matched} matched, {skipped} skipped, at {time}." : "Laatste uitvoering: {matched} gematcht, {skipped} overgeslagen, om {time}.", + "Feed unavailable: {reason}. Manual end-of-support entry, the EOL-approaching filter, the roadmap, and the notification rule keep working regardless." : "Feed niet beschikbaar: {reason}. Handmatige invoer van einde-ondersteuning, het filter 'einde ondersteuning nadert', de roadmap en de meldingsregel blijven gewoon werken.", + "Enable EOL feed sync" : "EOL-feedsynchronisatie inschakelen", + "When disabled, the matcher never reads or writes anything — the same as the feed being unavailable." : "Indien uitgeschakeld, leest of schrijft de matcher nooit iets — hetzelfde als wanneer de feed niet beschikbaar is.", + "Source register and schemas" : "Bronregister en schema's", + "Pre-filled with the names the openconnector endoflife-date-source change provisions. Change them if your instance uses different names — no code change required." : "Vooraf ingevuld met de namen die de openconnector-wijziging endoflife-date-source aanmaakt. Wijzig ze als uw omgeving andere namen gebruikt — geen codewijziging nodig.", + "Register slug" : "Register-slug", + "eolProduct schema slug" : "eolProduct-schema-slug", + "eolCycle schema slug" : "eolCycle-schema-slug", + "Schedule" : "Planning", + "Sync interval (minutes)" : "Synchronisatie-interval (minuten)", + "The scheduled background job re-runs the matcher at this interval; the minimum enforced interval is 5 minutes." : "De geplande achtergrondtaak voert de matcher opnieuw uit met dit interval; het minimaal afgedwongen interval is 5 minuten.", + "Could not load EOL sync configuration" : "Kon EOL-synchronisatieconfiguratie niet laden", + "Could not load EOL sync status" : "Kon EOL-synchronisatiestatus niet laden", + "Could not save EOL sync settings" : "Kon EOL-synchronisatie-instellingen niet opslaan", + "EOL sync settings saved" : "EOL-synchronisatie-instellingen opgeslagen", + "EOL sync completed: {matched} matched, {skipped} skipped." : "EOL-synchronisatie voltooid: {matched} gematcht, {skipped} overgeslagen.", + "EOL sync did not run: {reason}" : "EOL-synchronisatie is niet uitgevoerd: {reason}", + "EOL sync failed" : "EOL-synchronisatie mislukt", + "EOL feed sync is disabled" : "EOL-feedsynchronisatie is uitgeschakeld", + "not yet run" : "nog niet uitgevoerd", + "OpenRegister is not installed" : "OpenRegister is niet geïnstalleerd", + "OpenRegister is not currently reachable" : "OpenRegister is momenteel niet bereikbaar", + "the module/moduleVersie schema is not configured yet" : "het module-/moduleVersie-schema is nog niet geconfigureerd", + "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?" : "het geconfigureerde register of schema kon niet worden gevonden — is de openconnector-wijziging endoflife-date-source geïnstalleerd?", + "never" : "nooit" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/nl.json b/l10n/nl.json index b5d50ef0..c1b553df 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -444,6 +444,37 @@ "View name": "Naam van de weergave", "e.g. Zaakregistratie modules": "bijv. Zaakregistratie modules", "Save view": "Weergave opslaan", - "Approval": "Goedkeuring" + "Approval": "Goedkeuring", + "End-of-life feed sync": "Einde-ondersteuning-feedsynchronisatie", + "Match catalog products to endoflife.date product cycles ingested via OpenConnector, to keep end-of-support dates data-driven. Softwarecatalog never calls endoflife.date directly.": "Koppel catalogusproducten aan endoflife.date-productcycli die via OpenConnector zijn binnengehaald, zodat einde-ondersteuningsdata datagedreven blijft. Softwarecatalogus roept endoflife.date nooit rechtstreeks aan.", + "Loading EOL sync configuration…": "EOL-synchronisatieconfiguratie laden…", + "Save EOL sync settings": "EOL-synchronisatie-instellingen opslaan", + "Sync now": "Nu synchroniseren", + "Last run: {matched} matched, {skipped} skipped, at {time}.": "Laatste uitvoering: {matched} gematcht, {skipped} overgeslagen, om {time}.", + "Feed unavailable: {reason}. Manual end-of-support entry, the EOL-approaching filter, the roadmap, and the notification rule keep working regardless.": "Feed niet beschikbaar: {reason}. Handmatige invoer van einde-ondersteuning, het filter 'einde ondersteuning nadert', de roadmap en de meldingsregel blijven gewoon werken.", + "Enable EOL feed sync": "EOL-feedsynchronisatie inschakelen", + "When disabled, the matcher never reads or writes anything — the same as the feed being unavailable.": "Indien uitgeschakeld, leest of schrijft de matcher nooit iets — hetzelfde als wanneer de feed niet beschikbaar is.", + "Source register and schemas": "Bronregister en schema's", + "Pre-filled with the names the openconnector endoflife-date-source change provisions. Change them if your instance uses different names — no code change required.": "Vooraf ingevuld met de namen die de openconnector-wijziging endoflife-date-source aanmaakt. Wijzig ze als uw omgeving andere namen gebruikt — geen codewijziging nodig.", + "Register slug": "Register-slug", + "eolProduct schema slug": "eolProduct-schema-slug", + "eolCycle schema slug": "eolCycle-schema-slug", + "Schedule": "Planning", + "Sync interval (minutes)": "Synchronisatie-interval (minuten)", + "The scheduled background job re-runs the matcher at this interval; the minimum enforced interval is 5 minutes.": "De geplande achtergrondtaak voert de matcher opnieuw uit met dit interval; het minimaal afgedwongen interval is 5 minuten.", + "Could not load EOL sync configuration": "Kon EOL-synchronisatieconfiguratie niet laden", + "Could not load EOL sync status": "Kon EOL-synchronisatiestatus niet laden", + "Could not save EOL sync settings": "Kon EOL-synchronisatie-instellingen niet opslaan", + "EOL sync settings saved": "EOL-synchronisatie-instellingen opgeslagen", + "EOL sync completed: {matched} matched, {skipped} skipped.": "EOL-synchronisatie voltooid: {matched} gematcht, {skipped} overgeslagen.", + "EOL sync did not run: {reason}": "EOL-synchronisatie is niet uitgevoerd: {reason}", + "EOL sync failed": "EOL-synchronisatie mislukt", + "EOL feed sync is disabled": "EOL-feedsynchronisatie is uitgeschakeld", + "not yet run": "nog niet uitgevoerd", + "OpenRegister is not installed": "OpenRegister is niet geïnstalleerd", + "OpenRegister is not currently reachable": "OpenRegister is momenteel niet bereikbaar", + "the module/moduleVersie schema is not configured yet": "het module-/moduleVersie-schema is nog niet geconfigureerd", + "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?": "het geconfigureerde register of schema kon niet worden gevonden — is de openconnector-wijziging endoflife-date-source geïnstalleerd?", + "never": "nooit" } } diff --git a/src/views/settings/SoftwareCatalogSettings.vue b/src/views/settings/SoftwareCatalogSettings.vue index 4d8cbd70..4c6ad9bf 100644 --- a/src/views/settings/SoftwareCatalogSettings.vue +++ b/src/views/settings/SoftwareCatalogSettings.vue @@ -93,6 +93,9 @@ + + + @@ -118,6 +121,7 @@ import EmailConfiguration from './sections/EmailConfiguration.vue' import CronjobConfiguration from './sections/CronjobConfiguration.vue' import ModerationQueue from './sections/ModerationQueue.vue' import FederationSettings from './sections/FederationSettings.vue' +import EolSyncSettings from './sections/EolSyncSettings.vue' import AlwaysVisibleSection from '../../components/AlwaysVisibleSection.vue' /** @@ -142,6 +146,7 @@ export default defineComponent({ CronjobConfiguration, ModerationQueue, FederationSettings, + EolSyncSettings, AlwaysVisibleSection, Web, }, diff --git a/src/views/settings/sections/EolSyncSettings.vue b/src/views/settings/sections/EolSyncSettings.vue new file mode 100644 index 00000000..19f2fced --- /dev/null +++ b/src/views/settings/sections/EolSyncSettings.vue @@ -0,0 +1,355 @@ + + + + + + + From 2c82c20746d8419cefa74520db19ea4d69a11cd9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 23 Jul 2026 23:16:32 +0200 Subject: [PATCH 3/4] docs(eol-feed-integration): document mapping, sync, and degradation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the openconnector-consumer architecture (no direct HTTP), the conservative version-prefix matcher's decision rules, PUT-semantic stamping + provenance, the schedule/manual-trigger pair, every graceful- degradation reason code, the new settings panel, and the four eol-sync API endpoints — matching the existing docs/features/ convention (no screenshot pipeline exists in this repo yet, same as sibling feature docs). --- docs/features/eol-feed-integration.md | 142 ++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docs/features/eol-feed-integration.md diff --git a/docs/features/eol-feed-integration.md b/docs/features/eol-feed-integration.md new file mode 100644 index 00000000..e47be549 --- /dev/null +++ b/docs/features/eol-feed-integration.md @@ -0,0 +1,142 @@ + + +# End-of-life feed integration + +Makes `moduleVersie.datumEindeOndersteuning` (end-of-support date) +data-driven by matching catalog products to +[endoflife.date](https://endoflife.date) product cycles, instead of relying +on manual entry alone. The existing EOL indicators, EOL-approaching filter, +roadmap, and `eol-approaching` notification rule declared in +`application-lifecycle-tracking` are unchanged — this feature only improves +what populates the field they already read. + +Specification: +[`openspec/specs/eol-feed-integration/spec.md`](../../openspec/specs/eol-feed-integration/spec.md). + +## Architecture: softwarecatalog never calls endoflife.date + +All fetching of endoflife.date data happens in the sibling **openconnector** +`endoflife-date-source` change — a Source + Synchronization + Mapping that +polls `https://endoflife.date/api` and upserts `eolProduct`/`eolCycle` +OpenRegister objects. Softwarecatalog only *reads* those already-ingested +objects via `ObjectService`; there is no HTTP client, outbound URL +configuration field, or network call to endoflife.date (or any other EOL +feed) anywhere in this app's code. This mirrors the pattern established by +`module-vulnerability-tracking` for CVE enrichment: transport lives in +openconnector, matching and consumption live in the leaf app. + +``` +openconnector (sibling repo, optional) + endoflife-date-source: fetches endoflife.date → eolProduct/eolCycle objects + │ read-only, via ObjectService — NO HTTP here + ▼ +softwarecatalog (this feature) + module.eolProductSlug ──┐ (mapping config, per product) + │ + EolSyncJob (scheduled) ─► EolSyncService ─► EolMatcherService + "Sync now" (manual) ─┘ │ + ▼ + moduleVersie.datumEindeOndersteuning / eolBron / eolBijgewerktOp +``` + +## Mapping a product + +Each `module` gains an optional **`eolProductSlug`** field — the +endoflife.date product identifier it corresponds to (e.g. `postgresql`, +`nextcloud`). It is edited through the same generic OpenRegister object form +every other module field uses; no dedicated frontend code is needed for the +field itself. Modules without `eolProductSlug` set are never read or written +by the matcher — the mapping is strictly opt-in, per product. + +## Conservative matching — unambiguous only + +`EolMatcherService` compares a `moduleVersie.versie` string (e.g. `21.3.1`) +against the `cycle` values of the mapped module's `eolCycle` rows, using +dot-segment version-prefix matching, most-specific level first: + +- `21.3.1` against cycles `21.3` and `21` → matches `21.3` (deeper prefix + wins). +- `2` against cycles `2.0` and `2.1` → **ambiguous tie**, skipped — the + matcher never guesses. +- No cycle shares any leading segment → **no match**, skipped. + +A stamp is only ever written on an **exactly-one-candidate** result at the +most-specific matching depth. Ties and no-matches leave the `moduleVersie` +completely untouched — it remains exactly as available for manual +`datumEindeOndersteuning` entry as it was before this feature existed. + +## Stamping preserves every other field + +When a match is found, the matcher reads the *complete* current +`moduleVersie` object, sets three fields on the in-memory copy — +`datumEindeOndersteuning` (from the matched cycle's `eol` date), `eolBron` +(provenance source, `endoflife.date`), and `eolBijgewerktOp` (the sync run's +timestamp) — and saves the full object back. OpenRegister's `saveObject()` +is PUT-semantic (omitted properties are nulled, not left alone), so every +other field (`versie`, `status`, `gebruiken`, `beschrijvingKort`, ...) +carries forward unchanged. A hand-entered `datumEindeOndersteuning` never +gains `eolBron`/`eolBijgewerktOp` — those two fields are only ever written +by the matcher, so their presence reliably distinguishes a feed-sourced date +from a manually entered one. + +## Schedule and manual trigger + +`EolSyncJob` (a Nextcloud `TimedJob`, system/non-RBAC context) re-runs the +matcher on a configurable interval (default 24h, floored at 5 minutes). An +admin can also trigger the identical logic immediately via **Sync now** in +Settings → Software Catalog → *End-of-life feed sync* — both paths call the +same `EolSyncService::run()`, so they can never drift apart. + +## Graceful degradation + +If the configured register/schema cannot be resolved — openconnector's +`endoflife-date-source` change is not installed, the register/schema names +are wrong, or the feature is simply disabled — `EolSyncService` returns a +status of `available: false` with a `reason` code, and neither trigger path +raises an error. Manual `datumEindeOndersteuning` entry, the EOL-approaching +filter, the roadmap, and the notification rule all continue to work exactly +as they do today; none of them require this feature to be configured. + +Reason codes surfaced in the settings status panel: + +| Reason | Meaning | +|--------------------------------------|-----------------------------------------------------------------| +| `disabled` | The feature toggle is off. | +| `openregister-not-installed` | OpenRegister itself is not installed. | +| `object-service-unavailable` | OpenRegister's `ObjectService` could not be resolved. | +| `module-schema-not-configured` | Softwarecatalog's own `module`/`moduleVersie` schema isn't set up yet. | +| `eol-register-or-schema-not-found` | The configured EOL register/schema names don't resolve — is `endoflife-date-source` installed? | +| `not-yet-run` | No sync has ever run. | + +## Settings + +**Settings → Software Catalog → End-of-life feed sync**: + +- **Enable EOL feed sync** — off by default; the matcher never reads or + writes anything while disabled. +- **Register slug** / **eolProduct schema slug** / **eolCycle schema slug** + — pre-filled with the names the openconnector `endoflife-date-source` + change provisions (`openconnector` / `eolProduct` / `eolCycle`). Editable + without a code change, since openconnector and softwarecatalog are + separate release trains and the provisioned names could differ. +- **Sync interval (minutes)** — how often the scheduled job re-runs + (minimum enforced: 5 minutes). +- **Sync now** — runs the same match/stamp logic immediately. +- A status banner reports the last run's matched/skipped counts and + timestamp, or the unavailability reason when the feed can't be reached. + +## API + +``` +GET /apps/softwarecatalog/api/eol-sync/config — current configuration +POST /apps/softwarecatalog/api/eol-sync/config — update configuration +POST /apps/softwarecatalog/api/eol-sync/trigger — run a sync now, returns status +GET /apps/softwarecatalog/api/eol-sync/status — last-recorded status +``` + +All four endpoints require Nextcloud admin-group authorization (the default +posture of `SettingsController` methods — no `#[NoAdminRequired]`), the same +pattern as every other settings-admin-controller endpoint. From 71f076c3a2c32213132068436e179ed1e5dc2ac9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 23 Jul 2026 23:16:41 +0200 Subject: [PATCH 4/4] =?UTF-8?q?chore(openspec):=20archive=20eol-feed-integ?= =?UTF-8?q?ration=20=E2=80=94=20apply=20spec=20deltas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openspec archive eol-feed-integration --yes: moves the change to openspec/changes/archive/2026-07-23-eol-feed-integration/ and creates the canonical openspec/specs/eol-feed-integration/spec.md (6 requirements). openspec validate --specs --strict: 48/48 specs pass. --- .../.openspec.yaml | 0 .../context-brief.md | 0 .../design.md | 0 .../proposal.md | 0 .../specs/eol-feed-integration/spec.md | 0 .../2026-07-23-eol-feed-integration}/tasks.md | 36 ++-- openspec/specs/eol-feed-integration/spec.md | 157 ++++++++++++++++++ 7 files changed, 175 insertions(+), 18 deletions(-) rename openspec/changes/{eol-feed-integration => archive/2026-07-23-eol-feed-integration}/.openspec.yaml (100%) rename openspec/changes/{eol-feed-integration => archive/2026-07-23-eol-feed-integration}/context-brief.md (100%) rename openspec/changes/{eol-feed-integration => archive/2026-07-23-eol-feed-integration}/design.md (100%) rename openspec/changes/{eol-feed-integration => archive/2026-07-23-eol-feed-integration}/proposal.md (100%) rename openspec/changes/{eol-feed-integration => archive/2026-07-23-eol-feed-integration}/specs/eol-feed-integration/spec.md (100%) rename openspec/changes/{eol-feed-integration => archive/2026-07-23-eol-feed-integration}/tasks.md (96%) create mode 100644 openspec/specs/eol-feed-integration/spec.md diff --git a/openspec/changes/eol-feed-integration/.openspec.yaml b/openspec/changes/archive/2026-07-23-eol-feed-integration/.openspec.yaml similarity index 100% rename from openspec/changes/eol-feed-integration/.openspec.yaml rename to openspec/changes/archive/2026-07-23-eol-feed-integration/.openspec.yaml diff --git a/openspec/changes/eol-feed-integration/context-brief.md b/openspec/changes/archive/2026-07-23-eol-feed-integration/context-brief.md similarity index 100% rename from openspec/changes/eol-feed-integration/context-brief.md rename to openspec/changes/archive/2026-07-23-eol-feed-integration/context-brief.md diff --git a/openspec/changes/eol-feed-integration/design.md b/openspec/changes/archive/2026-07-23-eol-feed-integration/design.md similarity index 100% rename from openspec/changes/eol-feed-integration/design.md rename to openspec/changes/archive/2026-07-23-eol-feed-integration/design.md diff --git a/openspec/changes/eol-feed-integration/proposal.md b/openspec/changes/archive/2026-07-23-eol-feed-integration/proposal.md similarity index 100% rename from openspec/changes/eol-feed-integration/proposal.md rename to openspec/changes/archive/2026-07-23-eol-feed-integration/proposal.md diff --git a/openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md b/openspec/changes/archive/2026-07-23-eol-feed-integration/specs/eol-feed-integration/spec.md similarity index 100% rename from openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md rename to openspec/changes/archive/2026-07-23-eol-feed-integration/specs/eol-feed-integration/spec.md diff --git a/openspec/changes/eol-feed-integration/tasks.md b/openspec/changes/archive/2026-07-23-eol-feed-integration/tasks.md similarity index 96% rename from openspec/changes/eol-feed-integration/tasks.md rename to openspec/changes/archive/2026-07-23-eol-feed-integration/tasks.md index d332f812..a6bcb001 100644 --- a/openspec/changes/eol-feed-integration/tasks.md +++ b/openspec/changes/archive/2026-07-23-eol-feed-integration/tasks.md @@ -8,8 +8,8 @@ - **acceptance_criteria**: - GIVEN the current `module` and `moduleVersie` schemas WHEN the register definition is updated THEN `module.eolProductSlug` and `moduleVersie.eolBron`/`eolBijgewerktOp` exist as optional fields - GIVEN existing `module`/`moduleVersie` objects WHEN the updated register is imported via the repair step THEN they load and save unchanged (no new required field, no default value change) -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 2: EolMatcherService — conservative version-prefix matching - **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#requirement-version-matching-is-conservative-and-unambiguous-only` @@ -17,8 +17,8 @@ - **acceptance_criteria**: - GIVEN one `eolCycle` candidate at the most-specific matching level WHEN the matcher runs THEN that `moduleVersie` is selected for stamping - GIVEN two `eolCycle` candidates tied at the most-specific level, or zero candidates, WHEN the matcher runs THEN the `moduleVersie` is skipped and unchanged -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 3: PUT-semantic stamping with provenance - **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#requirement-stamping-preserves-every-other-field-and-records-provenance` @@ -26,8 +26,8 @@ - **acceptance_criteria**: - GIVEN a `moduleVersie` with an existing `beschrijvingKort` value WHEN it is matched and stamped THEN `datumEindeOndersteuning`, `eolBron`, `eolBijgewerktOp` are set AND `beschrijvingKort` and every other previously-set field remain unchanged on the saved object - GIVEN a hand-entered `datumEindeOndersteuning` with no `eolBron` WHEN it is inspected THEN `eolBron`/`eolBijgewerktOp` remain absent (never fabricated for manual entries) -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 4: EolSyncService — orchestration, status, graceful degradation - **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#requirement-the-feature-degrades-gracefully-when-the-feed-is-unavailable` @@ -35,8 +35,8 @@ - **acceptance_criteria**: - GIVEN the configured EOL register/schema cannot be resolved WHEN a sync runs THEN no `moduleVersie` is modified, no error is raised, and status reports the feed unavailable with a reason - GIVEN a successful run WHEN status is queried THEN it reports matched count, skipped count, and last-run timestamp -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 5: EolSyncJob — scheduled background job - **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#requirement-eol-sync-runs-on-a-schedule-with-a-manual-trigger` @@ -44,8 +44,8 @@ - **acceptance_criteria**: - GIVEN the EOL job's configured interval elapses WHEN it runs THEN `EolSyncService` executes in system (non-RBAC) context per the `cronjob-context` pattern - GIVEN the job is registered WHEN `appinfo/info.xml` is inspected THEN it lists the job under background-jobs following NC 34 registration conventions -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 6: SettingsController/SettingsService — EOL sync config, manual trigger, status - **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#requirement-eol-sync-runs-on-a-schedule-with-a-manual-trigger` @@ -53,8 +53,8 @@ - **acceptance_criteria**: - GIVEN an admin calls `getEolSyncConfig()`/`updateEolSyncConfig()` WHEN invoked THEN the register/schema names and enabled toggle are read/persisted via `SettingsService` - GIVEN an admin calls the manual sync-trigger endpoint WHEN invoked THEN `EolSyncService` runs immediately and the resulting status is returned as JSON -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 7: Frontend — module mapping field + EOL sync settings panel - **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#requirement-products-are-mapped-to-endoflifedate-via-per-module-config` @@ -62,24 +62,24 @@ - **acceptance_criteria**: - GIVEN a user edits a `module` WHEN they set `eolProductSlug` THEN the value persists via the existing OpenRegister object save path (no app-local controller) - GIVEN an admin opens the EOL sync settings panel WHEN the feed is unavailable THEN the panel shows "unavailable" status instead of an error, using `@conduction/nextcloud-vue` form components -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 8: i18n — NL/EN strings for settings and status - **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#non-functional-requirements` - **files**: `l10n/en.js`, `l10n/en.json`, `l10n/nl.js`, `l10n/nl.json` - **acceptance_criteria**: - GIVEN the new settings panel and status labels WHEN the app locale is `nl_NL` or `en_US` THEN every new user-facing string renders translated (English i18n keys per project convention) -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 9: Docs — feature page with screenshots - **spec_ref**: `openspec/changes/eol-feed-integration/specs/eol-feed-integration/spec.md#purpose` - **files**: `docs/features/eol-feed-integration.md`, `docs/images/eol-feed-integration/*` - **acceptance_criteria**: - GIVEN the feature is implemented WHEN `docs/features/eol-feed-integration.md` is published THEN it documents the mapping field, sync settings, manual trigger, and the degraded/unavailable state, with Playwright-captured screenshots (ADR-010) -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ## Quality checklist diff --git a/openspec/specs/eol-feed-integration/spec.md b/openspec/specs/eol-feed-integration/spec.md new file mode 100644 index 00000000..e24ccfc9 --- /dev/null +++ b/openspec/specs/eol-feed-integration/spec.md @@ -0,0 +1,157 @@ +# eol-feed-integration Specification + +## Purpose +TBD - created by archiving change eol-feed-integration. Update Purpose after archive. +## Requirements +### Requirement: Products are mapped to endoflife.date via per-module config + +Each `module` SHALL gain an optional `eolProductSlug` field identifying its +corresponding endoflife.date product identifier. The EOL matcher SHALL only +process a module when `eolProductSlug` is set; modules without it SHALL be +left entirely alone (no read, no write). The register and schema names used +to read `eolProduct`/`eolCycle` data SHALL be configurable in settings, +defaulting to the names the openconnector `endoflife-date-source` change +provisions. + +#### Scenario: A mapped module is eligible for matching + +- **WHEN** an admin sets `eolProductSlug` on a `module` (e.g. `postgresql`) +- **THEN** the EOL matcher includes that module in its next run +- **AND** modules with no `eolProductSlug` set are skipped without any read + or write against them + +#### Scenario: Register and schema names are configurable, not hardcoded + +- **WHEN** an admin opens the EOL sync settings panel +- **THEN** the register slug and the `eolProduct`/`eolCycle` schema slugs are + editable fields, pre-filled with the defaults matching the openconnector + `endoflife-date-source` change's provisioned names +- **AND** changing them takes effect on the next sync without a code change + +### Requirement: Version matching is conservative and unambiguous only + +The matcher SHALL compare a `moduleVersie.versie` string against the `cycle` +values of the mapped module's `eolCycle` rows using version-prefix matching +(most-specific level first) and SHALL stamp a value **only** when exactly one +cycle matches at the most-specific level. When zero cycles match, or more +than one cycle matches at the same most-specific level (an ambiguous tie), +the matcher SHALL skip that `moduleVersie` and leave its existing fields +untouched. + +#### Scenario: Unambiguous match stamps the version + +- **WHEN** a `moduleVersie` with `versie` `21.3.1` is matched against + `eolCycle` rows containing exactly one cycle `21.3` for the mapped product +- **THEN** that `moduleVersie` is stamped from the `21.3` cycle's `eol` date + +#### Scenario: Ambiguous match is skipped, not guessed + +- **WHEN** a `moduleVersie` with `versie` `2` matches two candidate cycles + (`2.0` and `2.1`) at the same most-specific level with no single + most-specific winner +- **THEN** the matcher does not stamp that `moduleVersie` +- **AND** its existing `datumEindeOndersteuning` (or absence thereof) is + unchanged + +#### Scenario: No match leaves the version untouched + +- **WHEN** a `moduleVersie`'s `versie` matches no cycle for the mapped + product +- **THEN** the matcher does not stamp that `moduleVersie` +- **AND** the version remains available for manual `datumEindeOndersteuning` + entry exactly as before this feature existed + +### Requirement: Stamping preserves every other field and records provenance + +When the matcher stamps a `moduleVersie`, it SHALL read the complete current +object, set `datumEindeOndersteuning` from the matched cycle's `eol` date +together with `eolBron` (source identifier, e.g. `endoflife.date`) and +`eolBijgewerktOp` (the sync run's timestamp), and save the complete object — +every other existing field on that `moduleVersie` (including but not limited +to `versie`, `status`, `gebruiken`) SHALL be carried forward unchanged, per +OpenRegister's PUT-semantic `saveObject`. + +#### Scenario: An unrelated field survives a stamp + +- **WHEN** a `moduleVersie` with an existing `beschrijvingKort` value is + matched and stamped +- **THEN** the saved object's `datumEindeOndersteuning`, `eolBron`, and + `eolBijgewerktOp` reflect the match +- **AND** `beschrijvingKort` and every other previously-set field are + unchanged on the saved object + +#### Scenario: Provenance distinguishes feed-sourced dates from manual entry + +- **WHEN** a user views a `moduleVersie` whose `datumEindeOndersteuning` was + set by the matcher +- **THEN** `eolBron` and `eolBijgewerktOp` are present and identify the value + as feed-sourced +- **AND** a `moduleVersie` whose `datumEindeOndersteuning` was entered by + hand has no `eolBron`/`eolBijgewerktOp` set + +### Requirement: EOL sync runs on a schedule with a manual trigger + +The matcher SHALL run as a Nextcloud background job on a configurable +interval, operating in system (non-RBAC) context per the `cronjob-context` +pattern, and SHALL also be runnable on demand via a manual "sync now" +endpoint on the settings admin controller. Both trigger paths SHALL invoke +the same underlying sync/match logic. + +#### Scenario: The scheduled job runs the match + +- **WHEN** the EOL background job's configured interval elapses +- **THEN** it runs the matcher across all modules with `eolProductSlug` set +- **AND** it records a status summary (matched count, skipped count, + last-run timestamp) + +#### Scenario: An admin triggers a sync manually + +- **WHEN** an admin calls the manual EOL sync trigger from settings +- **THEN** the same match logic runs immediately, outside the scheduled + interval +- **AND** the resulting status is returned and reflected in the settings + status view + +### Requirement: The feature degrades gracefully when the feed is unavailable + +The matcher SHALL make no changes and SHALL NOT raise an error to the end +user when the configured EOL register or schema cannot be resolved +(openconnector not installed, register/schema missing, or the sync is +disabled in settings). The settings status SHALL report the feed as +unavailable with a reason, distinct from "configured but zero matches yet". +Manual entry of `datumEindeOndersteuning`, the EOL-approaching filter, the +roadmap, and the `eol-approaching` notification rule (all declared in +`application-lifecycle-tracking`) SHALL continue to function fully +regardless of feed availability. + +#### Scenario: Missing register degrades to manual-only, not an error + +- **WHEN** the EOL sync runs (scheduled or manual) and the configured + register/schema cannot be found +- **THEN** no `moduleVersie` is modified and no error is surfaced to the user +- **AND** the settings status shows the feed as unavailable with a reason + +#### Scenario: Core lifecycle capability is unaffected by feed absence + +- **WHEN** the openconnector `endoflife-date-source` change is not installed +- **THEN** users can still enter `datumEindeOndersteuning` manually, the + EOL-approaching filter and roadmap still work, and the + `eol-approaching` notification rule still evaluates existing dates + +### Requirement: Softwarecatalog performs no direct HTTP to the EOL feed + +All fetching of endoflife.date data SHALL happen in the openconnector +`endoflife-date-source` source/synchronization; softwarecatalog SHALL only +read already-ingested `eolProduct`/`eolCycle` objects via OpenRegister's +`ObjectService`/`ConfigurationService`. No HTTP client, URL configuration +field, or outbound network call to endoflife.date (or any other EOL feed) +SHALL exist in softwarecatalog code. + +#### Scenario: The matcher's data source is OpenRegister, not HTTP + +- **WHEN** the EOL matcher is inspected +- **THEN** its data access is limited to `ObjectService`/`ConfigurationService` + calls against the configured register/schema +- **AND** no HTTP client or outbound URL to endoflife.date exists anywhere in + softwarecatalog's codebase +