From cf584e0a416be903c423061ed42b29a4276488c0 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 23 Jul 2026 23:40:37 +0200 Subject: [PATCH 1/7] feat(portfolio-rationalization-time): add TIME classification fields to gebruik schema Adds timeClassification (enum Tolerate/Invest/Migrate/Eliminate), timeRationale, and timeReviewDate as additive, optional properties on the gebruik schema (version 1.3.0 -> 1.4.0), matching the status field's enum-on-string convention. Reuses the existing cloudDienstverleningsmodel field for the cloud-transition metric rather than adding a competing deployment-model field (design.md Decision 1). Also registers the portfolio-report route and seeds the portfolio_report_page_size_ceiling app-config default (500) in the InitializeSettings repair step, only when unset. --- appinfo/routes.php | 4 +++ lib/Repair/InitializeSettings.php | 11 ++++++ lib/Settings/softwarecatalogus_register.json | 36 +++++++++++++++++++- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index 14578722..ab7ee994 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -262,6 +262,10 @@ ['name' => 'gebruik#getGebruiken', 'url' => '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/api/gebruik', 'verb' => 'GET'], ['name' => 'gebruik#getGebruikenForDeelnemer', 'url' => '/api/gebruik/deelnemer', 'verb' => 'GET'], + // Portfolio rationalization report (TIME quadrants + EOL + cloud + cost), JSON or CSV (?format=csv). + // @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + ['name' => 'portfolioReport#index', 'url' => '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/api/portfolio-report', 'verb' => 'GET'], + // SPA catch-all — serves the Vue app for any frontend route (history mode routing) ['name' => 'dashboard#page', 'url' => '/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], 'defaults' => ['path' => '']], ], diff --git a/lib/Repair/InitializeSettings.php b/lib/Repair/InitializeSettings.php index 8e780325..6d96bb6a 100644 --- a/lib/Repair/InitializeSettings.php +++ b/lib/Repair/InitializeSettings.php @@ -31,6 +31,8 @@ * * @category Repair * @package OCA\SoftwareCatalog\Repair + * + * @spec openspec/specs/repair-init/spec.md */ class InitializeSettings implements IRepairStep { @@ -54,6 +56,8 @@ public function __construct( * Returns the name of this repair step. * * @return string The repair step name + * + * @spec openspec/specs/repair-init/spec.md */ public function getName(): string { @@ -101,6 +105,13 @@ public function run(IOutput $output): void $this->config->setValueInt(Application::APP_ID, 'eol_warning_window_days', 180); } + // @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-aggregation-queries-are-bounded + // Seed the portfolio-report page-size ceiling default only when + // unset, so an operator's chosen bound survives upgrades. + if ($this->config->hasKey(Application::APP_ID, 'portfolio_report_page_size_ceiling') === false) { + $this->config->setValueInt(Application::APP_ID, 'portfolio_report_page_size_ceiling', 500); + } + // @spec openspec/specs/federated-catalog-sync/spec.md // Seed federation defaults only when unset (admin overrides survive). if ($this->config->hasKey(Application::APP_ID, 'federation_enabled') === false) { diff --git a/lib/Settings/softwarecatalogus_register.json b/lib/Settings/softwarecatalogus_register.json index a54354e0..1cd36129 100644 --- a/lib/Settings/softwarecatalogus_register.json +++ b/lib/Settings/softwarecatalogus_register.json @@ -2485,7 +2485,7 @@ "slug": "gebruik", "title": "Gebruik", "description": "Het gebruik van applicaties, diensten en koppelingen door afnemers", - "version": "1.3.0", + "version": "1.4.0", "summary": "", "icon": "Usage", "x-openregister-notifications": { @@ -2909,6 +2909,40 @@ "facetable": false, "title": "Geplande vervangingsdatum", "example": "Bijvoorbeeld: 2027-01-01" + }, + "timeClassification": { + "description": "Gartner TIME-classificatie van dit gebruik: Tolerate (gedogen), Invest (investeren), Migrate (migreren) of Eliminate (uitfaseren). Wordt per gebruik vastgelegd, niet op de module zelf.", + "type": "string", + "visible": true, + "order": 32, + "facetable": true, + "title": "TIME-classificatie", + "enum": [ + "Tolerate", + "Invest", + "Migrate", + "Eliminate" + ], + "example": "Bijvoorbeeld: Migrate" + }, + "timeRationale": { + "description": "Onderbouwing van de TIME-classificatie voor dit gebruik.", + "type": "string", + "visible": true, + "order": 33, + "facetable": false, + "title": "TIME-onderbouwing", + "example": "Bijvoorbeeld: Verouderd platform, opvolger reeds gepland" + }, + "timeReviewDate": { + "description": "Datum waarop de TIME-classificatie van dit gebruik opnieuw beoordeeld moet worden.", + "type": "string", + "format": "date", + "visible": true, + "order": 34, + "facetable": false, + "title": "TIME-herbeoordelingsdatum", + "example": "Bijvoorbeeld: 2027-01-01" } }, "archive": [], From 9b366928f529f19afd0218bb75aa9f9b7273ae0e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 23 Jul 2026 23:41:04 +0200 Subject: [PATCH 2/7] feat(portfolio-rationalization-time): add PortfolioReportController/Service Adds a bounded, RBAC-scoped read endpoint (GET /api/portfolio-report) that composes three existing derivations into one aggregate: TIME quadrant counts from gebruik.timeClassification, end-of-support exposure (reusing the application-lifecycle-tracking rule), cloud-transition share (from cloudDienstverleningsmodel), and annualised cost overlay (reusing the contract-administration cost derivation). A CSV format=csv variant reuses the same bounded/scoped row set. - PortfolioReportDerivation: pure phase/EOL/cost/relation-id rules, no I/O. - PortfolioReportService: OpenRegister query orchestration; every searchObjectsPaginated() call carries an explicit _limit; discloses truncation ("first N of M") when an organisation's gebruik count exceeds the configured page-size ceiling. - PortfolioReportController: deny-before-query organisation-access gate (isAuthorisedForOrganisation) evaluated before any report/CSV query is built. admin/ambtenaar bypass; every other caller is scoped to their own active organisation only - a strict subset of the landed vendor-visibility-rbac REQ-002/REQ-003 grants, never broader. 30 PHPUnit tests (controller + service + derivation), all passing. --- lib/Controller/PortfolioReportController.php | 169 +++++ lib/Service/PortfolioReportDerivation.php | 264 ++++++++ lib/Service/PortfolioReportService.php | 590 ++++++++++++++++++ .../PortfolioReportControllerTest.php | 301 +++++++++ .../Service/PortfolioReportServiceTest.php | 499 +++++++++++++++ .../PortfolioTimeRegisterShapeTest.php | 149 +++++ 6 files changed, 1972 insertions(+) create mode 100644 lib/Controller/PortfolioReportController.php create mode 100644 lib/Service/PortfolioReportDerivation.php create mode 100644 lib/Service/PortfolioReportService.php create mode 100644 tests/Unit/Controller/PortfolioReportControllerTest.php create mode 100644 tests/Unit/Service/PortfolioReportServiceTest.php create mode 100644 tests/Unit/Service/PortfolioTimeRegisterShapeTest.php diff --git a/lib/Controller/PortfolioReportController.php b/lib/Controller/PortfolioReportController.php new file mode 100644 index 00000000..ffcf5d28 --- /dev/null +++ b/lib/Controller/PortfolioReportController.php @@ -0,0 +1,169 @@ + + * @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/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-and-csv-export-are-scoped-to-the-requesters-authorised-organisations + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Controller; + +use Exception; +use OCA\SoftwareCatalog\Service\PortfolioReportService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataDownloadResponse; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IConfig; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Controller for the portfolio rationalization report endpoint. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md + */ +class PortfolioReportController extends Controller +{ + /** + * Constructor for PortfolioReportController. + * + * @param string $appName The app name. + * @param IRequest $request The request object. + * @param IUserSession $userSession The user session service. + * @param IGroupManager $groupManager The group manager service. + * @param IConfig $config The configuration service. + * @param PortfolioReportService $reportService The report aggregation service. + */ + public function __construct( + string $appName, + IRequest $request, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + private readonly IConfig $config, + private readonly PortfolioReportService $reportService, + ) { + parent::__construct(appName: $appName, request: $request); + }//end __construct() + + /** + * Serve the portfolio rationalization report for an organisation, as + * JSON (default) or CSV (`?format=csv`). + * + * Deny-before-query (REQ-001/REQ-005 of `vendor-visibility-rbac`, + * applied to this endpoint per `portfolio-rationalization-time` + * REQ "Report and CSV export are scoped..."): the caller's + * organisation-access is resolved and checked BEFORE + * `PortfolioReportService::buildReport()`/`buildCsv()` ever issues an + * OpenRegister query for the requested organisation. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return JSONResponse|DataDownloadResponse + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-and-csv-export-are-scoped-to-the-requesters-authorised-organisations + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-csv-export-of-the-portfolio-report + */ + public function index() + { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['message' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $organisation = (string) $this->request->getParam('organisation', ''); + if ($organisation === '') { + return new JSONResponse(['message' => 'organisation is required'], Http::STATUS_BAD_REQUEST); + } + + if ($this->isAuthorisedForOrganisation(user: $user, organisationUuid: $organisation) === false) { + // Fail closed: denied BEFORE any report query is built. + return new JSONResponse(['message' => 'Not authorised for this organisation'], Http::STATUS_FORBIDDEN); + } + + $format = (string) $this->request->getParam('format', 'json'); + + try { + if ($format === 'csv') { + $csv = $this->reportService->buildCsv(organisationUuid: $organisation); + return new DataDownloadResponse($csv, 'portfolio-report-'.$organisation.'.csv', 'text/csv'); + } + + return new JSONResponse($this->reportService->buildReport(organisationUuid: $organisation)); + } catch (Exception $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + }//end index() + + /** + * Whether the caller is authorised to see `$organisationUuid`'s + * portfolio report. + * + * Reuses the same role/organisation resolution mechanism as + * `GebruikController::resolveUserRoles()` / `applyAanbodScopeToOptions()` + * (per design.md: this change plugs into the current + * tenant/organisation-scoping mechanism rather than inventing a new + * matrix). `admin`/`ambtenaar` may request any organisation's report + * (existing unrestricted-read bypass); every other authenticated user + * may request only their own active organisation's report — a report + * is a synthesis of another organisation's gebruik/contract data, which + * `vendor-visibility-rbac` REQ-002/REQ-003 do not grant beyond the + * caller's own organisation or offered-products relationship. + * + * @param \OCP\IUser $user The authenticated caller. + * @param string $organisationUuid The requested organisation uuid. + * + * @return bool True when the caller may see this organisation's report. + * + * @spec openspec/specs/vendor-visibility-rbac/spec.md#requirement-gebruik-beheerder-reads-of-gebruik-objects-must-be-scoped-to-the-caller-s-own-organisation-req-003 + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-and-csv-export-are-scoped-to-the-requesters-authorised-organisations + */ + private function isAuthorisedForOrganisation(\OCP\IUser $user, string $organisationUuid): bool + { + $groups = $this->groupManager->getUserGroups(user: $user); + $groupNames = array_map( + static function (IGroup $group) { + return $group->getGID(); + }, + $groups + ); + + $isAdmin = in_array('admin', $groupNames, true); + $isAmbtenaar = in_array('ambtenaar', $groupNames, true); + if ($isAdmin === true || $isAmbtenaar === true) { + return true; + } + + $orgUuid = (string) $this->config->getUserValue( + userId: $user->getUID(), + appName: 'core', + key: 'organisation' + ); + + if ($orgUuid === '') { + return false; + } + + return $orgUuid === $organisationUuid; + }//end isAuthorisedForOrganisation() +}//end class diff --git a/lib/Service/PortfolioReportDerivation.php b/lib/Service/PortfolioReportDerivation.php new file mode 100644 index 00000000..0feeba47 --- /dev/null +++ b/lib/Service/PortfolioReportDerivation.php @@ -0,0 +1,264 @@ + + * @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/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Service; + +use DateTimeImmutable; + +/** + * Pure derivation helpers for the portfolio rationalization report. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md + */ +class PortfolioReportDerivation +{ + /** + * End-of-support "approaching" look-ahead window in days, matching + * `application-lifecycle-tracking`'s default `eol_warning_window_days`. + */ + public const EOL_WINDOW_DAYS = 180; + + /** + * Derive the lifecycle phase of a gebruik — the most advanced phase + * whose start date is in the past. Mirrors `src/utils/lifecyclePhase.js` + * `derivePhase()`. + * + * @param array $gebruik The gebruik data bag. + * @param DateTimeImmutable $now Reference moment. + * + * @return string The derived phase. + * + * @spec openspec/specs/application-lifecycle-tracking/spec.md + */ + public function deriveLifecyclePhase(array $gebruik, DateTimeImmutable $now): string + { + $steps = [ + 'Uitgefaseerd' => 'startDatumUitGefaseerd', + 'Uit te faseren' => 'startDatumUitTeFaseren', + 'In productie' => 'startDatumInProductie', + 'Gepland' => 'startDatumGepland', + 'Verwerving' => 'startDatumVerwerving', + ]; + + foreach ($steps as $phase => $field) { + $date = $this->parseDate(value: $gebruik[$field] ?? null); + if ($date !== null && $date <= $now) { + return $phase; + } + } + + return 'Onbekend'; + }//end deriveLifecyclePhase() + + /** + * Derive end-of-support state from a moduleVersie. Mirrors + * `src/utils/lifecyclePhase.js` `endOfSupportState()`. + * + * @param array|null $moduleVersie The linked moduleVersie data bag. + * @param DateTimeImmutable $now Reference moment. + * + * @return array{passed: bool, withdrawn: bool, endDate: string|null, withdrawnDate: string|null} + * + * @spec openspec/specs/application-lifecycle-tracking/spec.md + */ + public function deriveEolState(?array $moduleVersie, DateTimeImmutable $now): array + { + $endRaw = $moduleVersie['datumEindeOndersteuning'] ?? null; + $withdrawnRaw = $moduleVersie['datumTeruggetrokken'] ?? null; + if (is_string($withdrawnRaw) === false || trim($withdrawnRaw) === '') { + $withdrawnRaw = null; + } + + $endDate = null; + if (is_string($endRaw) === true) { + $endDate = $endRaw; + } + + $end = $this->parseDate(value: $endRaw); + + return [ + 'passed' => $end !== null && $end <= $now, + 'withdrawn' => $withdrawnRaw !== null, + 'endDate' => $endDate, + 'withdrawnDate' => $withdrawnRaw, + ]; + }//end deriveEolState() + + /** + * Whether a moduleVersie's end-of-support falls within the approaching + * window. Mirrors `src/utils/lifecyclePhase.js` `isEolApproaching()`. + * + * @param array|null $moduleVersie The linked moduleVersie data bag. + * @param DateTimeImmutable $now Reference moment. + * + * @return bool True when end-of-support is within `self::EOL_WINDOW_DAYS`. + * + * @spec openspec/specs/application-lifecycle-tracking/spec.md + */ + public function isEolApproaching(?array $moduleVersie, DateTimeImmutable $now): bool + { + $end = $this->parseDate(value: $moduleVersie['datumEindeOndersteuning'] ?? null); + if ($end === null) { + return false; + } + + $horizon = $now->modify('+'.self::EOL_WINDOW_DAYS.' days'); + + return $end > $now && $end <= $horizon; + }//end isEolApproaching() + + /** + * Render a report row's EOL status as a short CSV label. + * + * @param array $row A report row (carries `eol.passed` and `eolApproaching`). + * + * @return string One of `passed`, `approaching`, `ok`. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-csv-export-of-the-portfolio-report + */ + public function eolStatusLabel(array $row): string + { + if ($row['eol']['passed'] === true) { + return 'passed'; + } + + if ($row['eolApproaching'] === true) { + return 'approaching'; + } + + return 'ok'; + }//end eolStatusLabel() + + /** + * Annualised cost of a single contract. Mirrors + * `src/utils/contractCost.js` `annualisedCost()`. + * + * @param array $contract The contract data bag. + * + * @return array{annual: float, oneOff: float} + * + * @spec openspec/specs/contract-administration/spec.md + */ + public function annualisedCost(array $contract): array + { + $amount = $contract['kosten'] ?? null; + if (is_numeric($amount) === false) { + return ['annual' => 0.0, 'oneOff' => 0.0]; + } + + $amount = (float) $amount; + + return match ($contract['kostenPeriode'] ?? null) { + 'Maandelijks' => ['annual' => $amount * 12, 'oneOff' => 0.0], + 'Jaarlijks' => ['annual' => $amount, 'oneOff' => 0.0], + 'Eenmalig' => ['annual' => 0.0, 'oneOff' => $amount], + default => ['annual' => 0.0, 'oneOff' => 0.0], + }; + }//end annualisedCost() + + /** + * Resolve the uuid of a relation value that may be a plain string, a + * nested object (`{id: ...}` / `{uuid: ...}`), or null. Mirrors + * `src/utils/lifecyclePhase.js` `resolveUuid()`. + * + * @param mixed $value A relation value. + * + * @return string The resolved uuid, or '' when unresolved. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + */ + public function resolveRelationId(mixed $value): string + { + if (is_string($value) === true) { + return trim($value); + } + + if (is_array($value) === true) { + $id = $value['uuid'] ?? $value['id'] ?? ($value['@self']['id'] ?? null); + if (is_string($id) === true) { + return trim($id); + } + + return (string) ($id ?? ''); + } + + return ''; + }//end resolveRelationId() + + /** + * Parse a date value, or null when blank/unparseable. Fails closed — + * never throws. + * + * @param mixed $value A raw date string. + * + * @return DateTimeImmutable|null The parsed date, or null. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + */ + public function parseDate(mixed $value): ?DateTimeImmutable + { + if (is_string($value) === false || trim($value) === '') { + return null; + } + + try { + return new DateTimeImmutable($value); + } catch (\Exception $e) { + return null; + } + }//end parseDate() + + /** + * Normalize OpenRegister search results (ObjectEntity or plain array) + * into plain data-bag arrays. + * + * @param array $results Raw search results. + * + * @return array> Normalized data bags. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-aggregation-queries-are-bounded + */ + public function normalizeResults(array $results): array + { + return array_map( + static function ($object) { + if (is_array($object) === true) { + return $object; + } + + if (is_object($object) === true && method_exists($object, 'getObject') === true) { + return $object->getObject(); + } + + return []; + }, + $results + ); + }//end normalizeResults() +}//end class diff --git a/lib/Service/PortfolioReportService.php b/lib/Service/PortfolioReportService.php new file mode 100644 index 00000000..83ff0e75 --- /dev/null +++ b/lib/Service/PortfolioReportService.php @@ -0,0 +1,590 @@ + + * @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/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Service; + +use DateTimeImmutable; +use Exception; +use OCA\OpenRegister\Service\ObjectService; +use OCA\SoftwareCatalog\AppInfo\Application; +use OCP\App\IAppManager; +use OCP\IAppConfig; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Server-side aggregation for the portfolio rationalization report. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md + */ +class PortfolioReportService +{ + /** + * The four Gartner TIME quadrant values, matching the gebruik schema's + * `timeClassification` enum. + */ + public const QUADRANT_TOLERATE = 'Tolerate'; + public const QUADRANT_INVEST = 'Invest'; + public const QUADRANT_MIGRATE = 'Migrate'; + public const QUADRANT_ELIMINATE = 'Eliminate'; + + /** + * The bucket for gebruiken with no `timeClassification` value set. + */ + public const QUADRANT_UNCLASSIFIED = 'Unclassified'; + + /** + * Rendered quadrant order — Unclassified last, so the four TIME + * quadrants read in the canonical Tolerate/Invest/Migrate/Eliminate + * order and unclassified entries stay visible rather than omitted. + */ + public const QUADRANTS = [ + self::QUADRANT_TOLERATE, + self::QUADRANT_INVEST, + self::QUADRANT_MIGRATE, + self::QUADRANT_ELIMINATE, + self::QUADRANT_UNCLASSIFIED, + ]; + + /** + * Default report page-size ceiling, mirrored from + * `InitializeSettings::run()`'s seeded `portfolio_report_page_size_ceiling`. + */ + public const DEFAULT_PAGE_SIZE_CEILING = 500; + + /** + * Contract query limit as a multiple of the gebruik page-size ceiling — + * an organisation's gebruiken may each carry more than one linked + * contract (renewals, multiple services), so the contract bound is + * generous relative to the gebruik bound while staying explicit. + */ + private const CONTRACT_LIMIT_MULTIPLIER = 5; + + /** + * Per-request cache of resolved moduleVersie/module objects, keyed + * `"{schemaId}:{uuid}"`, so the same relation is never fetched twice + * while building one report. + * + * @var array|null> + */ + private array $relationCache = []; + + /** + * Constructor. + * + * @param SettingsService $settingsService Resolves register/schema ids. + * @param IAppManager $appManager The application manager. + * @param ContainerInterface $container The DI container (lazy OR lookup). + * @param LoggerInterface $logger Logger. + * @param IAppConfig $config App configuration (page-size ceiling). + * @param PortfolioReportDerivation $derivation Pure phase/EOL/cost/relation-id derivation helpers. + */ + public function __construct( + private readonly SettingsService $settingsService, + private readonly IAppManager $appManager, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + private readonly IAppConfig $config, + private readonly PortfolioReportDerivation $derivation, + ) { + }//end __construct() + + /** + * Build the portfolio rationalization report for one organisation. + * + * Every OpenRegister query issued here carries an explicit `_limit` + * (`bound-unbounded-searchobjects-scans`). The caller (controller) MUST + * have already authorised the requesting user for `$organisationUuid` + * before invoking this method — this service does not itself gate + * access. + * + * @param string $organisationUuid The `afnemer` organisation UUID to report on. + * + * @return array The report payload. + * + * @throws Exception When OpenRegister or the voorzieningen configuration is unavailable. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-aggregation-queries-are-bounded + */ + public function buildReport(string $organisationUuid): array + { + $this->relationCache = []; + $rows = $this->buildRows(organisationUuid: $organisationUuid); + + return [ + 'organisation' => $organisationUuid, + 'generatedAt' => (new DateTimeImmutable())->format(DATE_ATOM), + 'pageSizeCeiling' => $rows['ceiling'], + 'totalGebruiken' => $rows['total'], + 'includedGebruiken' => count($rows['rows']), + 'truncated' => $rows['truncated'], + 'quadrants' => $this->aggregateQuadrants(rows: $rows['rows']), + 'rows' => $rows['rows'], + ]; + }//end buildReport() + + /** + * Build the CSV export of the same bounded, organisation-scoped row set + * the JSON report uses — never a separate unbounded/unscoped data path. + * + * @param string $organisationUuid The `afnemer` organisation UUID to export. + * + * @return string The CSV document (header + one data row per gebruik). + * + * @throws Exception When OpenRegister or the voorzieningen configuration is unavailable. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-csv-export-of-the-portfolio-report + */ + public function buildCsv(string $organisationUuid): string + { + $this->relationCache = []; + $built = $this->buildRows(organisationUuid: $organisationUuid); + + $handle = fopen('php://temp', 'r+'); + fputcsv( + $handle, + [ + 'organisation', + 'module', + 'timeClassification', + 'timeRationale', + 'timeReviewDate', + 'lifecyclePhase', + 'eolStatus', + 'hostingModel', + 'annualisedCost', + 'oneOffCost', + ] + ); + + foreach ($built['rows'] as $row) { + fputcsv( + $handle, + [ + $organisationUuid, + $row['moduleName'], + $row['timeClassification'] ?? '', + $row['timeRationale'] ?? '', + $row['timeReviewDate'] ?? '', + $row['lifecyclePhase'], + $this->derivation->eolStatusLabel(row: $row), + implode('|', $row['hostingModel']), + (string) $row['annualisedCost'], + (string) $row['oneOffCost'], + ] + ); + } + + rewind($handle); + $csv = stream_get_contents($handle); + fclose($handle); + + if ($csv === false) { + return ''; + } + + return $csv; + }//end buildCsv() + + /** + * Fetch the organisation's gebruiken (bounded), resolve their + * moduleVersie/module/contract context, and build one report row per + * gebruik. + * + * @param string $organisationUuid The `afnemer` organisation UUID. + * + * @return array{rows: array>, total: int, ceiling: int, truncated: bool} + * + * @throws Exception When OpenRegister or configuration resolution fails. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-aggregation-queries-are-bounded + */ + private function buildRows(string $organisationUuid): array + { + $objectService = $this->getObjectService(); + $cfg = $this->getRegisterConfig(); + $ceiling = $this->getPageSizeCeiling(); + $now = new DateTimeImmutable(); + + $gebruikQuery = [ + '@self' => [ + 'register' => $cfg['registerId'], + 'schema' => $cfg['gebruikSchema'], + ], + 'afnemer' => $organisationUuid, + '_limit' => $ceiling, + ]; + + $gebruikResult = $objectService->searchObjectsPaginated(query: $gebruikQuery, _rbac: false, _multitenancy: false); + $gebruiken = $this->derivation->normalizeResults(results: $gebruikResult['results'] ?? []); + $total = (int) ($gebruikResult['total'] ?? count($gebruiken)); + $truncated = $total > count($gebruiken); + + $gebruikIds = []; + foreach ($gebruiken as $gebruik) { + $id = $this->derivation->resolveRelationId(value: $gebruik['id'] ?? ($gebruik['@self']['id'] ?? null)); + if ($id !== '') { + $gebruikIds[] = $id; + } + } + + $contractsByGebruik = $this->fetchContractsForGebruiken(gebruikIds: $gebruikIds, cfg: $cfg, ceiling: $ceiling); + + $rows = []; + foreach ($gebruiken as $gebruik) { + $rows[] = $this->buildRow(gebruik: $gebruik, cfg: $cfg, contractsByGebruik: $contractsByGebruik, now: $now); + } + + return [ + 'rows' => $rows, + 'total' => $total, + 'ceiling' => $ceiling, + 'truncated' => $truncated, + ]; + }//end buildRows() + + /** + * Build one report row for a single gebruik. + * + * @param array $gebruik The gebruik data bag. + * @param array $cfg Resolved register/schema ids. + * @param array> $contractsByGebruik Contract rows indexed by gebruik uuid. + * @param DateTimeImmutable $now Reference moment. + * + * @return array The row. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + */ + private function buildRow(array $gebruik, array $cfg, array $contractsByGebruik, DateTimeImmutable $now): array + { + $gebruikId = $this->derivation->resolveRelationId(value: $gebruik['id'] ?? ($gebruik['@self']['id'] ?? null)); + $moduleId = $this->derivation->resolveRelationId(value: $gebruik['module'] ?? null); + $versieId = $this->derivation->resolveRelationId(value: $gebruik['moduleVersie'] ?? null); + + $module = null; + if ($moduleId !== '') { + $module = $this->fetchRelation(id: $moduleId, schemaId: $cfg['moduleSchema']); + } + + $moduleVersie = null; + if ($versieId !== '') { + $moduleVersie = $this->fetchRelation(id: $versieId, schemaId: $cfg['moduleVersieSchema']); + } + + $eol = $this->derivation->deriveEolState(moduleVersie: $moduleVersie, now: $now); + $eolApproaching = $this->derivation->isEolApproaching(moduleVersie: $moduleVersie, now: $now); + + $cost = $this->sumContractCost(contracts: $contractsByGebruik[$gebruikId] ?? []); + + $hostingModel = $gebruik['cloudDienstverleningsmodel'] ?? []; + if (is_array($hostingModel) === false) { + // A scalar (non-array) stored value — normalise to a one-element + // list so the row/aggregate code always iterates an array. + $hostingModel = [$hostingModel]; + } + + $classification = $this->normalizeClassification(value: $gebruik['timeClassification'] ?? null); + + return [ + 'uuid' => $gebruikId, + 'moduleId' => $moduleId, + 'moduleName' => $module['naam'] ?? $module['title'] ?? $moduleId, + 'timeClassification' => $classification, + 'quadrant' => $classification ?? self::QUADRANT_UNCLASSIFIED, + 'timeRationale' => $gebruik['timeRationale'] ?? null, + 'timeReviewDate' => $gebruik['timeReviewDate'] ?? null, + 'lifecyclePhase' => $this->derivation->deriveLifecyclePhase(gebruik: $gebruik, now: $now), + 'eol' => $eol, + 'eolApproaching' => $eolApproaching, + 'hostingModel' => array_values(array_filter($hostingModel, static fn ($v) => is_string($v) === true && $v !== '')), + 'annualisedCost' => $cost['annual'], + 'oneOffCost' => $cost['oneOff'], + ]; + }//end buildRow() + + /** + * Sum annualised + one-off cost across a set of contracts. + * + * @param array> $contracts Contract data bags. + * + * @return array{annual: float, oneOff: float} + */ + private function sumContractCost(array $contracts): array + { + $cost = ['annual' => 0.0, 'oneOff' => 0.0]; + foreach ($contracts as $contract) { + $c = $this->derivation->annualisedCost(contract: $contract); + $cost['annual'] = $cost['annual'] + $c['annual']; + $cost['oneOff'] = $cost['oneOff'] + $c['oneOff']; + } + + return $cost; + }//end sumContractCost() + + /** + * Normalize a raw `timeClassification` value to one of the four + * canonical quadrant values, or null when absent/invalid. + * + * @param mixed $value The raw stored value. + * + * @return string|null The normalized classification, or null. + */ + private function normalizeClassification(mixed $value): ?string + { + $valid = [ + self::QUADRANT_TOLERATE, + self::QUADRANT_INVEST, + self::QUADRANT_MIGRATE, + self::QUADRANT_ELIMINATE, + ]; + + if (is_string($value) === true && in_array($value, $valid, true) === true) { + return $value; + } + + return null; + }//end normalizeClassification() + + /** + * Aggregate report rows into per-quadrant figures (count, EOL exposure, + * cloud-transition share, summed annualised/one-off cost). + * + * @param array> $rows Report rows from {@see buildRows()}. + * + * @return array> Quadrant key → aggregate figures. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + */ + private function aggregateQuadrants(array $rows): array + { + $quadrants = []; + foreach (self::QUADRANTS as $quadrant) { + $quadrants[$quadrant] = [ + 'count' => 0, + 'eolExposedCount' => 0, + 'cloudTransition' => [], + 'annualisedCost' => 0.0, + 'oneOffCost' => 0.0, + ]; + } + + foreach ($rows as $row) { + $quadrants[$row['quadrant']] = $this->mergeRowIntoQuadrant( + quadrant: $quadrants[$row['quadrant']] ?? $quadrants[self::QUADRANT_UNCLASSIFIED], + row: $row + ); + } + + return $quadrants; + }//end aggregateQuadrants() + + /** + * Fold one report row's figures into a quadrant's running aggregate. + * + * @param array $quadrant The quadrant's current aggregate. + * @param array $row The row to fold in. + * + * @return array The updated aggregate. + */ + private function mergeRowIntoQuadrant(array $quadrant, array $row): array + { + $quadrant['count']++; + if ($row['eol']['passed'] === true || $row['eolApproaching'] === true) { + $quadrant['eolExposedCount']++; + } + + foreach ($row['hostingModel'] as $model) { + $quadrant['cloudTransition'][$model] = ($quadrant['cloudTransition'][$model] ?? 0) + 1; + } + + $quadrant['annualisedCost'] += $row['annualisedCost']; + $quadrant['oneOffCost'] += $row['oneOffCost']; + + return $quadrant; + }//end mergeRowIntoQuadrant() + + /** + * Fetch contracts linked to a bounded set of gebruik ids, indexed by + * gebruik uuid. + * + * @param array $gebruikIds The gebruik uuids to fetch contracts for. + * @param array $cfg Resolved register/schema ids. + * @param int $ceiling The gebruik page-size ceiling (basis for the contract bound). + * + * @return array>> Gebruik uuid → contract rows. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-aggregation-queries-are-bounded + */ + private function fetchContractsForGebruiken(array $gebruikIds, array $cfg, int $ceiling): array + { + if ($gebruikIds === [] || $cfg['contractSchema'] === null) { + return []; + } + + $objectService = $this->getObjectService(); + $query = [ + '@self' => [ + 'register' => $cfg['registerId'], + 'schema' => $cfg['contractSchema'], + ], + 'gebruik' => $gebruikIds, + '_limit' => min($ceiling * self::CONTRACT_LIMIT_MULTIPLIER, 5000), + ]; + + try { + $result = $objectService->searchObjectsPaginated(query: $query, _rbac: false, _multitenancy: false); + } catch (\Throwable $e) { + $this->logger->warning('PortfolioReportService: contract fetch failed', ['error' => $e->getMessage()]); + return []; + } + + $indexed = []; + foreach ($this->derivation->normalizeResults(results: $result['results'] ?? []) as $contract) { + $gebruikId = $this->derivation->resolveRelationId(value: $contract['gebruik'] ?? null); + if ($gebruikId === '') { + continue; + } + + $indexed[$gebruikId][] = $contract; + } + + return $indexed; + }//end fetchContractsForGebruiken() + + /** + * Fetch and cache a single related object (moduleVersie or module) by id. + * + * @param string $id The related object's uuid. + * @param int|null $schemaId The related object's schema id (for a scoped lookup). + * + * @return array|null The related object's data bag, or null when unresolved. + */ + private function fetchRelation(string $id, ?int $schemaId): ?array + { + $cacheKey = $schemaId.':'.$id; + if (array_key_exists($cacheKey, $this->relationCache) === true) { + return $this->relationCache[$cacheKey]; + } + + $result = null; + try { + $objectService = $this->getObjectService(); + $entity = $objectService->find(id: $id, _rbac: false, _multitenancy: false); + if ($entity !== null) { + $result = $entity->getObject(); + } + } catch (\Throwable $e) { + $this->logger->debug('PortfolioReportService: relation fetch failed', ['id' => $id, 'error' => $e->getMessage()]); + } + + $this->relationCache[$cacheKey] = $result; + + return $result; + }//end fetchRelation() + + /** + * Resolve the voorzieningen register + schema ids this service needs. + * + * @return array{registerId: int|null, gebruikSchema: int|null, contractSchema: int|null, moduleSchema: int|null, moduleVersieSchema: int|null} + * + * @throws Exception When the voorzieningen configuration is unavailable. + */ + private function getRegisterConfig(): array + { + $config = $this->settingsService->getVoorzieningenConfig(); + + $registerId = $config['register'] ?? null; + if (empty($registerId) === true) { + throw new Exception('Voorzieningen configuration not found. Please configure the schemas in the admin panel.'); + } + + $toInt = static function ($value): ?int { + if (empty($value) === true) { + return null; + } + + return (int) $value; + }; + + return [ + 'registerId' => (int) $registerId, + 'gebruikSchema' => $toInt($config['gebruik_schema'] ?? null), + 'contractSchema' => $toInt($config['contract_schema'] ?? null), + 'moduleSchema' => $toInt($config['module_schema'] ?? null), + 'moduleVersieSchema' => $toInt($config['moduleVersie_schema'] ?? null), + ]; + }//end getRegisterConfig() + + /** + * Resolve the configured report page-size ceiling, falling back to + * {@see self::DEFAULT_PAGE_SIZE_CEILING} when unset or invalid. + * + * @return int The page-size ceiling (always >= 1). + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-aggregation-queries-are-bounded + */ + private function getPageSizeCeiling(): int + { + try { + $value = $this->config->getValueInt(Application::APP_ID, 'portfolio_report_page_size_ceiling', self::DEFAULT_PAGE_SIZE_CEILING); + } catch (\Throwable $e) { + $value = self::DEFAULT_PAGE_SIZE_CEILING; + } + + if ($value > 0) { + return $value; + } + + return self::DEFAULT_PAGE_SIZE_CEILING; + }//end getPageSizeCeiling() + + /** + * Lazily resolve the OpenRegister ObjectService. + * + * @return ObjectService The service. + * + * @throws Exception When OpenRegister is not installed or unresolvable. + */ + private function getObjectService(): ObjectService + { + if (in_array('openregister', $this->appManager->getInstalledApps(), true) === false) { + throw new Exception('OpenRegister app is not installed'); + } + + try { + return $this->container->get('OCA\OpenRegister\Service\ObjectService'); + } catch (\Throwable $e) { + throw new Exception('Failed to get OpenRegister service: '.$e->getMessage()); + } + }//end getObjectService() +}//end class diff --git a/tests/Unit/Controller/PortfolioReportControllerTest.php b/tests/Unit/Controller/PortfolioReportControllerTest.php new file mode 100644 index 00000000..eb670df9 --- /dev/null +++ b/tests/Unit/Controller/PortfolioReportControllerTest.php @@ -0,0 +1,301 @@ + + * @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/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-and-csv-export-are-scoped-to-the-requesters-authorised-organisations + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Tests\Unit\Controller; + +use OCA\SoftwareCatalog\Controller\PortfolioReportController; +use OCA\SoftwareCatalog\Service\PortfolioReportService; +use OCP\AppFramework\Http\DataDownloadResponse; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IConfig; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; + +/** + * Tests for PortfolioReportController. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md + */ +class PortfolioReportControllerTest extends TestCase +{ + /** + * Build a controller with mocked collaborators. + * + * @param array $groupNames The caller's NC group ids. + * @param string $orgUuid The caller's active organisation. + * @param IUser|null $user The authenticated user (null → unauthenticated). + * @param array $params Request params (`organisation`, `format`). + * @param PortfolioReportService|null $service Optional service stub. + * + * @return PortfolioReportController + */ + private function makeController( + array $groupNames, + string $orgUuid, + ?IUser $user, + array $params, + ?PortfolioReportService $service=null + ): PortfolioReportController { + $groups = array_map( + function (string $name) { + $group = $this->createMock(IGroup::class); + $group->method('getGID')->willReturn($name); + return $group; + }, + $groupNames + ); + + $groupManager = $this->createMock(IGroupManager::class); + $groupManager->method('getUserGroups')->willReturn($groups); + + $config = $this->createMock(IConfig::class); + $config->method('getUserValue')->willReturn($orgUuid); + + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser')->willReturn($user); + + $request = $this->createMock(IRequest::class); + $request->method('getParam')->willReturnCallback( + static function (string $key, $default=null) use ($params) { + return $params[$key] ?? $default; + } + ); + + return new PortfolioReportController( + appName: 'softwarecatalog', + request: $request, + userSession: $userSession, + groupManager: $groupManager, + config: $config, + reportService: $service ?? $this->createMock(PortfolioReportService::class) + ); + }//end makeController() + + /** + * Unauthenticated caller gets 401, never reaches the service. + * + * @return void + */ + public function testUnauthenticatedIsRejected(): void + { + $service = $this->createMock(PortfolioReportService::class); + $service->expects($this->never())->method('buildReport'); + + $controller = $this->makeController([], '', null, ['organisation' => 'org-a'], $service); + $response = $controller->index(); + + $this->assertInstanceOf(JSONResponse::class, $response); + $this->assertSame(401, $response->getStatus()); + }//end testUnauthenticatedIsRejected() + + /** + * Missing `organisation` param is a 400, never reaches the service. + * + * @return void + */ + public function testMissingOrganisationIsBadRequest(): void + { + $service = $this->createMock(PortfolioReportService::class); + $service->expects($this->never())->method('buildReport'); + + $user = $this->createMock(IUser::class); + $controller = $this->makeController(['admin'], 'org-a', $user, [], $service); + $response = $controller->index(); + + $this->assertInstanceOf(JSONResponse::class, $response); + $this->assertSame(400, $response->getStatus()); + }//end testMissingOrganisationIsBadRequest() + + /** + * A non-admin/ambtenaar user requesting ANOTHER organisation's report is + * denied (403) BEFORE the report service is ever invoked — fail closed. + * + * @return void + */ + public function testCrossOrganisationRequestIsDeniedBeforeQuery(): void + { + $service = $this->createMock(PortfolioReportService::class); + $service->expects($this->never())->method('buildReport'); + $service->expects($this->never())->method('buildCsv'); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user-1'); + $controller = $this->makeController( + ['gebruik-beheerder'], + 'municipality-a', + $user, + ['organisation' => 'municipality-b'], + $service + ); + $response = $controller->index(); + + $this->assertInstanceOf(JSONResponse::class, $response); + $this->assertSame(403, $response->getStatus()); + }//end testCrossOrganisationRequestIsDeniedBeforeQuery() + + /** + * A user requesting their OWN organisation's report gets a 200 JSON + * report. + * + * @return void + */ + public function testOwnOrganisationRequestReturnsReport(): void + { + $service = $this->createMock(PortfolioReportService::class); + $service->expects($this->once()) + ->method('buildReport') + ->with('municipality-a') + ->willReturn(['organisation' => 'municipality-a']); + $service->expects($this->never())->method('buildCsv'); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user-1'); + $controller = $this->makeController( + ['gebruik-beheerder'], + 'municipality-a', + $user, + ['organisation' => 'municipality-a'], + $service + ); + $response = $controller->index(); + + $this->assertInstanceOf(JSONResponse::class, $response); + $this->assertSame(200, $response->getStatus()); + }//end testOwnOrganisationRequestReturnsReport() + + /** + * `admin` may request any organisation's report — the existing + * unrestricted-read bypass. + * + * @return void + */ + public function testAdminMayRequestAnyOrganisation(): void + { + $service = $this->createMock(PortfolioReportService::class); + $service->expects($this->once())->method('buildReport')->willReturn([]); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('admin-1'); + $controller = $this->makeController(['admin'], 'org-x', $user, ['organisation' => 'org-y'], $service); + $response = $controller->index(); + + $this->assertSame(200, $response->getStatus()); + }//end testAdminMayRequestAnyOrganisation() + + /** + * `?format=csv` on an authorised request returns a CSV download + * response, not JSON. + * + * @return void + */ + public function testCsvFormatReturnsDownloadResponse(): void + { + $service = $this->createMock(PortfolioReportService::class); + $service->expects($this->once()) + ->method('buildCsv') + ->with('municipality-a') + ->willReturn("organisation,module\nmunicipality-a,Example\n"); + $service->expects($this->never())->method('buildReport'); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user-1'); + $controller = $this->makeController( + ['gebruik-beheerder'], + 'municipality-a', + $user, + ['organisation' => 'municipality-a', 'format' => 'csv'], + $service + ); + $response = $controller->index(); + + $this->assertInstanceOf(DataDownloadResponse::class, $response); + }//end testCsvFormatReturnsDownloadResponse() + + /** + * CSV export for an unauthorised organisation is denied and the CSV + * builder is never invoked. + * + * @return void + */ + public function testCsvFormatDeniedForUnauthorisedOrganisation(): void + { + $service = $this->createMock(PortfolioReportService::class); + $service->expects($this->never())->method('buildCsv'); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user-1'); + $controller = $this->makeController( + ['gebruik-beheerder'], + 'municipality-a', + $user, + ['organisation' => 'municipality-b', 'format' => 'csv'], + $service + ); + $response = $controller->index(); + + $this->assertInstanceOf(JSONResponse::class, $response); + $this->assertSame(403, $response->getStatus()); + }//end testCsvFormatDeniedForUnauthorisedOrganisation() + + /** + * `ambtenaar` bypasses organisation scoping exactly like admin. + * + * @return void + */ + public function testAmbtenaarMayRequestAnyOrganisation(): void + { + $service = $this->createMock(PortfolioReportService::class); + $service->expects($this->once())->method('buildReport')->willReturn([]); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user-1'); + $controller = $this->makeController(['ambtenaar'], 'org-x', $user, ['organisation' => 'org-y'], $service); + $response = $controller->index(); + + $this->assertSame(200, $response->getStatus()); + }//end testAmbtenaarMayRequestAnyOrganisation() + + /** + * A caller with no active organisation configured is denied, never + * silently matched. + * + * @return void + */ + public function testNoActiveOrganisationIsDenied(): void + { + $service = $this->createMock(PortfolioReportService::class); + $service->expects($this->never())->method('buildReport'); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user-1'); + $controller = $this->makeController(['gebruik-beheerder'], '', $user, ['organisation' => 'org-y'], $service); + $response = $controller->index(); + + $this->assertSame(403, $response->getStatus()); + }//end testNoActiveOrganisationIsDenied() +}//end class diff --git a/tests/Unit/Service/PortfolioReportServiceTest.php b/tests/Unit/Service/PortfolioReportServiceTest.php new file mode 100644 index 00000000..9965e204 --- /dev/null +++ b/tests/Unit/Service/PortfolioReportServiceTest.php @@ -0,0 +1,499 @@ + + * @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/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md + * + * 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\PortfolioReportDerivation; +use OCA\SoftwareCatalog\Service\PortfolioReportService; +use OCA\SoftwareCatalog\Service\SettingsService; +use OCP\App\IAppManager; +use OCP\IAppConfig; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use ReflectionClass; +use ReflectionMethod; + +/** + * Tests for PortfolioReportService. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md + */ +class PortfolioReportServiceTest extends TestCase +{ + /** + * Build a service instance without invoking the constructor — sufficient + * for the pure private derivation methods under test. + * + * @return PortfolioReportService + */ + private function makeService(): PortfolioReportService + { + $reflection = new ReflectionClass(PortfolioReportService::class); + return $reflection->newInstanceWithoutConstructor(); + }//end makeService() + + /** + * Invoke a private method via reflection. + * + * @param object $object The instance. + * @param string $method The method name. + * @param array $args Positional arguments. + * + * @return mixed + */ + private function invokePrivate(object $object, string $method, array $args) + { + $reflection = new ReflectionMethod($object, $method); + $reflection->setAccessible(true); + return $reflection->invokeArgs($object, $args); + }//end invokePrivate() + + /** + * Build a fresh derivation helper — every method under it is pure + * (no I/O), so a plain `new` is sufficient (no reflection needed). + * + * @return PortfolioReportDerivation + */ + private function makeDerivation(): PortfolioReportDerivation + { + return new PortfolioReportDerivation(); + }//end makeDerivation() + + // -- deriveLifecyclePhase() ------------------------------------------- + + /** + * The most-advanced past phase date wins. + * + * @return void + */ + public function testDeriveLifecyclePhaseUsesMostAdvancedPastDate(): void + { + $derivation = $this->makeDerivation(); + $now = new \DateTimeImmutable('2026-06-01'); + + $result = $derivation->deriveLifecyclePhase( + [ + 'startDatumInProductie' => '2025-01-01', + 'startDatumUitTeFaseren' => '2027-01-01', + ], + $now + ); + + $this->assertSame('In productie', $result); + }//end testDeriveLifecyclePhaseUsesMostAdvancedPastDate() + + /** + * No phase dates at all → Onbekend, never hidden. + * + * @return void + */ + public function testDeriveLifecyclePhaseReturnsOnbekendWhenNoDates(): void + { + $derivation = $this->makeDerivation(); + $result = $derivation->deriveLifecyclePhase([], new \DateTimeImmutable()); + + $this->assertSame('Onbekend', $result); + }//end testDeriveLifecyclePhaseReturnsOnbekendWhenNoDates() + + // -- deriveEolState() --------------------------------------------------- + + /** + * A past `datumEindeOndersteuning` flags `passed`. + * + * @return void + */ + public function testDeriveEolStateFlagsPassedDate(): void + { + $derivation = $this->makeDerivation(); + $now = new \DateTimeImmutable('2026-06-01'); + + $result = $derivation->deriveEolState(['datumEindeOndersteuning' => '2025-01-01'], $now); + + $this->assertTrue($result['passed']); + $this->assertFalse($result['withdrawn']); + $this->assertSame('2025-01-01', $result['endDate']); + }//end testDeriveEolStateFlagsPassedDate() + + /** + * A future end-of-support date does not flag `passed`. + * + * @return void + */ + public function testDeriveEolStateDoesNotFlagFutureDate(): void + { + $derivation = $this->makeDerivation(); + $now = new \DateTimeImmutable('2026-06-01'); + + $result = $derivation->deriveEolState(['datumEindeOndersteuning' => '2030-01-01'], $now); + + $this->assertFalse($result['passed']); + }//end testDeriveEolStateDoesNotFlagFutureDate() + + /** + * A null moduleVersie (unresolved relation) fails soft — no exception, + * flags stay false. + * + * @return void + */ + public function testDeriveEolStateHandlesNullModuleVersie(): void + { + $derivation = $this->makeDerivation(); + $result = $derivation->deriveEolState(null, new \DateTimeImmutable()); + + $this->assertFalse($result['passed']); + $this->assertFalse($result['withdrawn']); + $this->assertNull($result['endDate']); + }//end testDeriveEolStateHandlesNullModuleVersie() + + // -- isEolApproaching() -------------------------------------------------- + + /** + * A date within the 180-day window is "approaching". + * + * @return void + */ + public function testIsEolApproachingWithinWindow(): void + { + $derivation = $this->makeDerivation(); + $now = new \DateTimeImmutable('2026-06-01'); + + $result = $derivation->isEolApproaching(['datumEindeOndersteuning' => '2026-09-01'], $now); + + $this->assertTrue($result); + }//end testIsEolApproachingWithinWindow() + + /** + * An already-passed date is NOT "approaching" (it is already past). + * + * @return void + */ + public function testIsEolApproachingExcludesPassedDate(): void + { + $derivation = $this->makeDerivation(); + $now = new \DateTimeImmutable('2026-06-01'); + + $result = $derivation->isEolApproaching(['datumEindeOndersteuning' => '2025-01-01'], $now); + + $this->assertFalse($result); + }//end testIsEolApproachingExcludesPassedDate() + + // -- annualisedCost() ----------------------------------------------------- + + /** + * Monthly cost annualises ×12. + * + * @return void + */ + public function testAnnualisedCostMonthly(): void + { + $derivation = $this->makeDerivation(); + $result = $derivation->annualisedCost(['kosten' => 1000, 'kostenPeriode' => 'Maandelijks']); + + $this->assertSame(12000.0, $result['annual']); + $this->assertSame(0.0, $result['oneOff']); + }//end testAnnualisedCostMonthly() + + /** + * Yearly cost passes through ×1. + * + * @return void + */ + public function testAnnualisedCostYearly(): void + { + $derivation = $this->makeDerivation(); + $result = $derivation->annualisedCost(['kosten' => 6000, 'kostenPeriode' => 'Jaarlijks']); + + $this->assertSame(6000.0, $result['annual']); + $this->assertSame(0.0, $result['oneOff']); + }//end testAnnualisedCostYearly() + + /** + * A one-off contract is excluded from the annual figure. + * + * @return void + */ + public function testAnnualisedCostOneOff(): void + { + $derivation = $this->makeDerivation(); + $result = $derivation->annualisedCost(['kosten' => 5000, 'kostenPeriode' => 'Eenmalig']); + + $this->assertSame(0.0, $result['annual']); + $this->assertSame(5000.0, $result['oneOff']); + }//end testAnnualisedCostOneOff() + + /** + * An unknown period or unparseable amount yields zeros, never throws. + * + * @return void + */ + public function testAnnualisedCostUnknownYieldsZeros(): void + { + $derivation = $this->makeDerivation(); + + $result = $derivation->annualisedCost(['kosten' => 100, 'kostenPeriode' => 'Wekelijks']); + $this->assertSame(0.0, $result['annual']); + $this->assertSame(0.0, $result['oneOff']); + + $result = $derivation->annualisedCost([]); + $this->assertSame(0.0, $result['annual']); + $this->assertSame(0.0, $result['oneOff']); + }//end testAnnualisedCostUnknownYieldsZeros() + + // -- resolveRelationId() ------------------------------------------------- + + /** + * A plain string relation value resolves to itself (trimmed). + * + * @return void + */ + public function testResolveRelationIdFromString(): void + { + $derivation = $this->makeDerivation(); + $result = $derivation->resolveRelationId(' uuid-1 '); + + $this->assertSame('uuid-1', $result); + }//end testResolveRelationIdFromString() + + /** + * A nested object relation value resolves via its `id`/`uuid` key. + * + * @return void + */ + public function testResolveRelationIdFromNestedObject(): void + { + $derivation = $this->makeDerivation(); + + $this->assertSame('uuid-2', $derivation->resolveRelationId(['uuid' => 'uuid-2'])); + $this->assertSame('uuid-3', $derivation->resolveRelationId(['id' => 'uuid-3'])); + $this->assertSame('', $derivation->resolveRelationId(null)); + }//end testResolveRelationIdFromNestedObject() + + // -- aggregateQuadrants() ------------------------------------------------- + + /** + * Rows aggregate into quadrant counts, EOL exposure, cloud share, and + * summed cost — Unclassified always present, even with zero rows. + * + * @return void + */ + public function testAggregateQuadrantsCountsAndSums(): void + { + $service = $this->makeService(); + $rows = [ + [ + 'quadrant' => 'Migrate', + 'eol' => ['passed' => true], + 'eolApproaching' => false, + 'hostingModel' => ['SaaS'], + 'annualisedCost' => 1000.0, + 'oneOffCost' => 0.0, + ], + [ + 'quadrant' => 'Migrate', + 'eol' => ['passed' => false], + 'eolApproaching' => false, + 'hostingModel' => ['SaaS'], + 'annualisedCost' => 500.0, + 'oneOffCost' => 0.0, + ], + [ + 'quadrant' => 'Unclassified', + 'eol' => ['passed' => false], + 'eolApproaching' => false, + 'hostingModel' => [], + 'annualisedCost' => 0.0, + 'oneOffCost' => 200.0, + ], + ]; + + $result = $this->invokePrivate($service, 'aggregateQuadrants', [$rows]); + + $this->assertSame(2, $result['Migrate']['count']); + $this->assertSame(1, $result['Migrate']['eolExposedCount']); + $this->assertSame(2, $result['Migrate']['cloudTransition']['SaaS']); + $this->assertSame(1500.0, $result['Migrate']['annualisedCost']); + + $this->assertSame(1, $result['Unclassified']['count']); + $this->assertSame(200.0, $result['Unclassified']['oneOffCost']); + + // Quadrants with zero rows still appear (Tolerate/Invest/Eliminate here). + $this->assertSame(0, $result['Tolerate']['count']); + $this->assertSame(0, $result['Invest']['count']); + $this->assertSame(0, $result['Eliminate']['count']); + }//end testAggregateQuadrantsCountsAndSums() + + // -- Bounded-query invariant (bound-unbounded-searchobjects-scans) ------ + + /** + * `buildReport()`'s gebruik query and its contract lookup both carry an + * explicit `_limit` — never an unbounded `searchObjectsPaginated()` call. + * + * @return void + */ + public function testBuildReportGebruikAndContractQueriesCarryLimit(): void + { + $capturedQueries = []; + + $objectService = $this->createMock(ObjectService::class); + $objectService->method('searchObjectsPaginated')->willReturnCallback( + function (array $query) use (&$capturedQueries): array { + $capturedQueries[] = $query; + $schema = $query['@self']['schema'] ?? null; + if ($schema === 20) { + // gebruik schema — one row, no relations set (keeps the + // rest of buildRow()'s relation-resolution a no-op). + return [ + 'results' => [ + [ + 'id' => 'gebruik-1', + 'afnemer' => 'org-a', + ], + ], + 'total' => 1, + ]; + } + return ['results' => [], 'total' => 0]; + } + ); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn($objectService); + + $appManager = $this->createMock(IAppManager::class); + $appManager->method('getInstalledApps')->willReturn(['openregister']); + + $settingsService = $this->createMock(SettingsService::class); + $settingsService->method('getVoorzieningenConfig')->willReturn( + [ + 'register' => '1', + 'gebruik_schema' => '20', + 'contract_schema' => '21', + 'module_schema' => '22', + 'moduleVersie_schema' => '23', + ] + ); + + $config = $this->createMock(IAppConfig::class); + $config->method('getValueInt')->willReturn(500); + + $reflection = new ReflectionClass(PortfolioReportService::class); + $service = $reflection->newInstanceWithoutConstructor(); + + foreach ( + [ + 'settingsService' => $settingsService, + 'appManager' => $appManager, + 'container' => $container, + 'logger' => $this->createMock(LoggerInterface::class), + 'config' => $config, + 'derivation' => new PortfolioReportDerivation(), + ] as $propertyName => $value + ) { + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + $property->setValue($service, $value); + } + + $service->buildReport('org-a'); + + $this->assertNotEmpty($capturedQueries); + foreach ($capturedQueries as $query) { + $this->assertArrayHasKey('_limit', $query, 'every searchObjectsPaginated() call must carry an explicit _limit'); + $this->assertGreaterThan(0, $query['_limit']); + } + }//end testBuildReportGebruikAndContractQueriesCarryLimit() + + /** + * The report discloses truncation when the organisation's gebruik total + * exceeds the page-size ceiling, rather than presenting a silently + * incomplete total. + * + * @return void + */ + public function testBuildReportDisclosesTruncation(): void + { + $objectService = $this->createMock(ObjectService::class); + $objectService->method('searchObjectsPaginated')->willReturnCallback( + function (array $query): array { + $schema = $query['@self']['schema'] ?? null; + if ($schema === 20) { + return [ + 'results' => [['id' => 'gebruik-1', 'afnemer' => 'org-a']], + // Total exceeds the returned page — the ceiling truncated it. + 'total' => 5, + ]; + } + return ['results' => [], 'total' => 0]; + } + ); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn($objectService); + + $appManager = $this->createMock(IAppManager::class); + $appManager->method('getInstalledApps')->willReturn(['openregister']); + + $settingsService = $this->createMock(SettingsService::class); + $settingsService->method('getVoorzieningenConfig')->willReturn( + [ + 'register' => '1', + 'gebruik_schema' => '20', + 'contract_schema' => '21', + 'module_schema' => '22', + 'moduleVersie_schema' => '23', + ] + ); + + $config = $this->createMock(IAppConfig::class); + $config->method('getValueInt')->willReturn(1); + + $reflection = new ReflectionClass(PortfolioReportService::class); + $service = $reflection->newInstanceWithoutConstructor(); + + foreach ( + [ + 'settingsService' => $settingsService, + 'appManager' => $appManager, + 'container' => $container, + 'logger' => $this->createMock(LoggerInterface::class), + 'config' => $config, + 'derivation' => new PortfolioReportDerivation(), + ] as $propertyName => $value + ) { + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + $property->setValue($service, $value); + } + + $report = $service->buildReport('org-a'); + + $this->assertTrue($report['truncated']); + $this->assertSame(5, $report['totalGebruiken']); + $this->assertSame(1, $report['includedGebruiken']); + }//end testBuildReportDisclosesTruncation() +}//end class diff --git a/tests/Unit/Service/PortfolioTimeRegisterShapeTest.php b/tests/Unit/Service/PortfolioTimeRegisterShapeTest.php new file mode 100644 index 00000000..0ca2b167 --- /dev/null +++ b/tests/Unit/Service/PortfolioTimeRegisterShapeTest.php @@ -0,0 +1,149 @@ + + * @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/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-time-classification-fields-are-recorded-on-the-gebruik-schema + * + * 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 TIME change. + */ +class PortfolioTimeRegisterShapeTest 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 the gebruik schema definition. + * + * @return array + */ + private function gebruikSchema(): array + { + $schemas = $this->register['components']['schemas'] ?? []; + $this->assertArrayHasKey('gebruik', $schemas); + return $schemas['gebruik']; + }//end gebruikSchema() + + /** + * The gebruik schema gains exactly the three TIME properties, all + * optional (not in `required`). + * + * @return void + */ + public function testGebruikHasOptionalTimeFields(): void + { + $gebruik = $this->gebruikSchema(); + $props = $gebruik['properties'] ?? []; + + $this->assertArrayHasKey('timeClassification', $props); + $this->assertArrayHasKey('timeRationale', $props); + $this->assertArrayHasKey('timeReviewDate', $props); + + $required = $gebruik['required'] ?? []; + $this->assertNotContains('timeClassification', $required); + $this->assertNotContains('timeRationale', $required); + $this->assertNotContains('timeReviewDate', $required); + }//end testGebruikHasOptionalTimeFields() + + /** + * `timeClassification` matches the `status` field's enum-on-string + * convention (type: string, enum, title) with the four canonical + * Gartner TIME values. + * + * @return void + */ + public function testTimeClassificationIsEnumOnString(): void + { + $props = $this->gebruikSchema()['properties']; + $field = $props['timeClassification']; + + $this->assertSame('string', $field['type']); + $this->assertSame( + ['Tolerate', 'Invest', 'Migrate', 'Eliminate'], + $field['enum'] + ); + $this->assertArrayHasKey('title', $field); + }//end testTimeClassificationIsEnumOnString() + + /** + * `timeReviewDate` is a `date`-format string field, matching the + * existing phase-date fields' shape. + * + * @return void + */ + public function testTimeReviewDateIsDateField(): void + { + $props = $this->gebruikSchema()['properties']; + + $this->assertSame('string', $props['timeReviewDate']['type']); + $this->assertSame('date', $props['timeReviewDate']['format']); + }//end testTimeReviewDateIsDateField() + + /** + * `timeRationale` is a free-text string field. + * + * @return void + */ + public function testTimeRationaleIsStringField(): void + { + $props = $this->gebruikSchema()['properties']; + + $this->assertSame('string', $props['timeRationale']['type']); + }//end testTimeRationaleIsStringField() + + /** + * design.md Decision 1: no competing `deploymentModel` field was added — + * the existing `cloudDienstverleningsmodel` (Hosting) field remains the + * sole deployment-model source, still facetable. + * + * @return void + */ + public function testNoCompetingDeploymentModelFieldWasAdded(): void + { + $props = $this->gebruikSchema()['properties']; + + $this->assertArrayNotHasKey('deploymentModel', $props); + $this->assertArrayHasKey('cloudDienstverleningsmodel', $props); + $this->assertTrue($props['cloudDienstverleningsmodel']['facetable']); + }//end testNoCompetingDeploymentModelFieldWasAdded() +}//end class From 1622adc54931ad277ded682e2ea0102c319d9901 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 23 Jul 2026 23:41:16 +0200 Subject: [PATCH 3/7] feat(portfolio-rationalization-time): render enum-on-string schema fields as a clearable select ObjectModal.vue (the generic schema-driven object editor gebruik is edited through) rendered every string field as free text, including enum fields like status and the new timeClassification. Adds an enum-aware branch (clearable NcSelect with input-label, ADR-004) ahead of the free-text branch, plus a date-format branch (native date input) - both apply to every enum/date schema field project-wide, not just the three TIME fields. formData already carries the full cloned object forward on open (initializeData(): formData = cloneDeep(activeObject)), so this PUT- semantic form only ever touches the edited key - an unrelated field (status, phase dates, cloudDienstverleningsmodel, ...) is never nulled out by a TIME-only edit. --- src/modals/object/ObjectModal.vue | 37 ++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/modals/object/ObjectModal.vue b/src/modals/object/ObjectModal.vue index c4f610ed..c5a4e28c 100644 --- a/src/modals/object/ObjectModal.vue +++ b/src/modals/object/ObjectModal.vue @@ -108,7 +108,36 @@ import { objectStore, navigationStore, catalogStore } from '../../store/store.js
-