diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..2f8e0750 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,36 @@ +# https://editorconfig.org + +# SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = tab +insert_final_newline = true +trim_trailing_whitespace = true + +[*.yml] +indent_size = 2 +indent_style = space + +[*.md] +trim_trailing_whitespace = false + +[*.svg] +insert_final_newline = false + +[package*.json] +indent_size = 2 +indent_style = space + +[build/psalm-baseline.xml] +indent_size = 2 +indent_style = space + +[config/*config.php] +indent_size = 2 +indent_style = space \ No newline at end of file diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 41ba401f..7cc99a5d 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,2 +1,16 @@ -# Retrofit annotation commit (opsx-annotate, 2026-05-24) -21a8c1f6ecf3a3b2346ed631bb469a8cd3b65e01 +# Revisions to skip in `git blame`. +# +# Enable locally, once: +# git config blame.ignoreRevsFile .git-blame-ignore-revs +# +# GitHub reads this file automatically. Your terminal does not, until you run +# the line above. +# +# Only ever add commits that change formatting and NOTHING else. A commit listed +# here becomes invisible to blame, so a behaviour change hidden inside one would +# be very hard to find later. + +# style: reformat with nextcloud/coding-standard — whitespace only +# The fleet-wide move from a PEAR-derived PHPCS ruleset (4 spaces, next-line +# braces) to Nextcloud's own standard (tabs, same-line braces). +46e028e7046485d8e28cbc3bc786b7d577c83f51 diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index bf5bd156..edc81a64 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -77,11 +77,13 @@ jobs: # their single server, so with the inherited default those three jobs ran # entirely on the version openregister cannot load. # - # launchpad's own appinfo/info.xml floor stays at 29: src/manifest.json - # declares no required app dependency, and launchpad's OpenRegister use is - # via AppHost, probed at runtime. The NC32 constraint here is a property of - # the CI fixture, not of launchpad's code. - nextcloud-test-refs: '["stable32"]' + # THE LIST IS THE WHOLE DECLARED RANGE. This comment previously said + # "launchpad's own appinfo/info.xml floor stays at 29 … the NC32 constraint + # here is a property of the CI fixture, not of launchpad's code" — that is + # no longer true of the file it describes. info.xml on this branch declares + # , so 32 is the app's own + # floor, not a fixture artefact, and 32, 33 and 34 each get a leg. + nextcloud-test-refs: '["stable34", "stable32", "stable33"]' enable-psalm: true enable-phpstan: true enable-phpmetrics: true diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 00000000..db584532 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,20 @@ +getFinder() + ->notPath('vendor') + ->notPath('node_modules') + ->notPath('build') + ->in(__DIR__ . '/lib') + ->in(__DIR__ . '/tests'); + +return $config; diff --git a/appinfo/info.xml b/appinfo/info.xml index fb06fe3f..ccb1e9a5 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -83,10 +83,13 @@ Vrij en open source onder de EUPL-1.2-licentie. - - - - + OCA\LaunchPad\BackgroundJob\OrphanedDataCleanupJob @@ -96,22 +99,6 @@ Vrij en open source onder de EUPL-1.2-licentie. - - OCA\LaunchPad\Repair\InitializeActions - - OCA\LaunchPad\Repair\ApplyActionBaseline - - OCA\LaunchPad\Repair\SeedRolePermissions - - OCA\LaunchPad\Repair\RegisterBackgroundJobs - - OCA\LaunchPad\Repair\ImportLaunchpadRegister - OCA\LaunchPad\Repair\InitializeActions OCA\LaunchPad\Repair\ImportLaunchpadRegister + + OCA\LaunchPad\Repair\InitializeActions + + OCA\LaunchPad\Repair\ApplyActionBaseline + + OCA\LaunchPad\Repair\SeedRolePermissions + + OCA\LaunchPad\Repair\RegisterBackgroundJobs + + OCA\LaunchPad\Repair\ImportLaunchpadRegister + - - OCA\LaunchPad\Settings\LaunchPadAdmin - OCA\LaunchPad\Settings\LaunchPadAdminSection - - OCA\LaunchPad\Command\ExportCommand @@ -159,6 +157,23 @@ Vrij en open source onder de EUPL-1.2-licentie. OCA\LaunchPad\Command\SetupCommand + + OCA\LaunchPad\Settings\LaunchPadAdmin + OCA\LaunchPad\Settings\LaunchPadAdminSection + + + + + + OCA\LaunchPad\Activity\Extension + + + LaunchPad @@ -167,11 +182,4 @@ Vrij en open source onder de EUPL-1.2-licentie. -5 - - - - OCA\LaunchPad\Activity\Extension - diff --git a/composer.json b/composer.json index 04982ff3..e20e84f8 100644 --- a/composer.json +++ b/composer.json @@ -1,103 +1,104 @@ { - "name": "conductionnl/launchpad", - "description": "Enhanced dashboard with grid layout and admin controls for Nextcloud", - "type": "project", - "license": "EUPL-1.2", - "authors": [ - { - "name": "LaunchPad Contributors" - } - ], - "require": { - "php": "^8.3" - }, - "require-dev": { - "cyclonedx/cyclonedx-php-composer": "^6.2", - "edgedesign/phpqa": "^1.27", - "nextcloud/coding-standard": "^1.4", - "nextcloud/ocp": "dev-master", - "phpcsstandards/phpcsextra": "^1.4", - "phpmd/phpmd": "^2.15", - "phpmetrics/phpmetrics": "^2.8", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10", - "roave/security-advisories": "dev-latest", - "squizlabs/php_codesniffer": "^3.9", - "twig/twig": "^3.27.0", - "vimeo/psalm": "^5.26" - }, - "autoload": { - "psr-4": { - "OCA\\LaunchPad\\": "lib/" - } - }, - "autoload-dev": { - "psr-4": { - "Unit\\": "tests/Unit/" - } - }, - "scripts": { - "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", - "lint:initial-state": "php scripts/lint-initial-state.php", - "lint:spec-annotations": "php tools/check-spec-annotations.php", - "lint:licenses": "bash scripts/check-license-headers.sh", - "cs:check": "./vendor/bin/phpcs --standard=phpcs.xml", - "cs:fix": "./vendor/bin/phpcbf --standard=phpcs.xml", - "phpcs": "./vendor/bin/phpcs --standard=phpcs.xml", - "phpcs:fix": "./vendor/bin/phpcbf --standard=phpcs.xml", - "phpcs:output": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ 2>/dev/null | tail -1 > phpcs-output.json", - "phpmd": "E=0; ./vendor/bin/phpmd lib text phpmd.xml || E=$?; ./vendor/bin/phpmd lib text phpmd-unusedparams.xml || E=$?; exit $E", - "phpmetrics": "./vendor/bin/phpmetrics --report-html=phpmetrics lib/", - "phpmetrics:violations": "./vendor/bin/phpmetrics --violations-xml=phpmetrics/violations.xml lib/", - "psalm": "./vendor/bin/psalm --threads=1 --no-cache", - "phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G", - "test": "phpunit --configuration phpunit.xml", - "test:unit": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always", - "test:all": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always", - "test:integration": "if command -v newman >/dev/null 2>&1; then newman run tests/integration/launchpad.postman_collection.json --environment tests/integration/local.env.json; else npx --yes newman run tests/integration/launchpad.postman_collection.json --environment tests/integration/local.env.json; fi", - "newman": "@test:integration", - "newman:coverage": "node tests/integration/.coverage-check.js", - "check": "E=0; for CMD in lint phpcs psalm test:unit; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E", - "check:full": "E=0; for CMD in lint phpcs psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E", - "check:strict": "E=0; for CMD in lint lint:initial-state lint:spec-annotations lint:licenses phpcs phpmd psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E", - "fix": [ - "@cs:fix" - ], - "phpqa": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa", - "phpqa:full": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa --tools phpcs:0,phpmd:0,phploc:0,phpmetrics,phpcpd:0,parallel-lint:0", - "phpqa:ci": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa --tools phpcs,phpmd,phploc,phpmetrics,phpcpd,parallel-lint", - "qa:check": [ - "@phpqa" - ], - "qa:full": [ - "@phpqa:full" - ], - "test:coverage": "./vendor/bin/phpunit --configuration phpunit.xml --coverage-html=coverage/html --coverage-clover=coverage/clover.xml --colors=always", - "coverage:check": "php -r \"\\$xml = simplexml_load_file('coverage/clover.xml'); \\$metrics = \\$xml->project->metrics; \\$statements = (int)\\$metrics['statements']; \\$covered = (int)\\$metrics['coveredstatements']; \\$percentage = \\$statements > 0 ? round((\\$covered / \\$statements) * 100, 2) : 0; echo 'Coverage: ' . \\$percentage . '%' . PHP_EOL; exit(\\$percentage < 75 ? 1 : 0);\"", - "quality:phpcs-score": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ | php -r \"\\$json = json_decode(file_get_contents('php://stdin'), true); \\$errors = \\$json['totals']['errors'] ?? 0; \\$warnings = \\$json['totals']['warnings'] ?? 0; \\$score = 1000 - \\$errors - (\\$warnings / 2); echo 'PHPCS Score: ' . \\$score . ' (Errors: ' . \\$errors . ', Warnings: ' . \\$warnings . ')' . PHP_EOL;\"", - "quality:phpmd-score": "phpmd lib/ json phpmd.xml | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$violations = count(\\$json['files'] ?? []); \\$score = 1000 - (\\$violations * 10); echo 'PHPMD Score: ' . \\$score . ' (Violations: ' . \\$violations . ')' . PHP_EOL;\" || echo 'PHPMD not available'", - "quality:psalm-score": "./vendor/bin/psalm --output-format=json --no-cache | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$errors = count(\\$json ?? []); \\$score = 1000 - (\\$errors * 5); echo 'Psalm Score: ' . \\$score . ' (Errors: ' . \\$errors . ')' . PHP_EOL;\" || echo 'Psalm not available'", - "quality:phpstan-score": "./vendor/bin/phpstan analyse --memory-limit=1G --error-format=json --no-progress | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$errors = \\$json['totals']['file_errors'] ?? 0; \\$score = 1000 - (\\$errors * 5); echo 'PHPStan Score: ' . \\$score . ' (Errors: ' . \\$errors . ')' . PHP_EOL;\" || echo 'PHPStan not available'", - "quality:score": [ - "@quality:phpcs-score", - "@quality:phpmd-score", - "@quality:psalm-score", - "@quality:phpstan-score" - ], - "post-install-cmd": [ - "git config core.hooksPath .githooks || true" - ] - }, - "config": { - "allow-plugins": { - "composer/package-versions-deprecated": true, - "dealerdirect/phpcodesniffer-composer-installer": true, - "cyclonedx/cyclonedx-php-composer": true - }, - "optimize-autoloader": true, - "sort-packages": true, - "platform": { - "php": "8.3" - } - } + "name": "conductionnl/launchpad", + "description": "Enhanced dashboard with grid layout and admin controls for Nextcloud", + "type": "project", + "license": "EUPL-1.2", + "authors": [ + { + "name": "LaunchPad Contributors" + } + ], + "require": { + "php": "^8.3" + }, + "require-dev": { + "conduction/coding-standard": "^1.0", + "conduction/hydra-gates": "^1.0", + "cyclonedx/cyclonedx-php-composer": "^6.2", + "edgedesign/phpqa": "^1.27", + "nextcloud/ocp": "^34.0", + "phpcsstandards/phpcsextra": "^1.4", + "phpmd/phpmd": "^2.15", + "phpmetrics/phpmetrics": "^2.8", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10", + "roave/security-advisories": "dev-latest", + "squizlabs/php_codesniffer": "^3.9", + "twig/twig": "^3.27.0", + "vimeo/psalm": "^5.26" + }, + "autoload": { + "psr-4": { + "OCA\\LaunchPad\\": "lib/" + } + }, + "autoload-dev": { + "psr-4": { + "Unit\\": "tests/Unit/" + } + }, + "scripts": { + "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", + "lint:initial-state": "php scripts/lint-initial-state.php", + "lint:spec-annotations": "php tools/check-spec-annotations.php", + "lint:licenses": "bash scripts/check-license-headers.sh", + "cs:check": "php-cs-fixer fix --dry-run --diff", + "cs:fix": "php-cs-fixer fix", + "phpcs": "./vendor/bin/phpcs --standard=phpcs.xml", + "phpcs:fix": "./vendor/bin/phpcbf --standard=phpcs.xml", + "phpcs:output": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ 2>/dev/null | tail -1 > phpcs-output.json", + "phpmd": "E=0; ./vendor/bin/phpmd lib text phpmd.xml || E=$?; ./vendor/bin/phpmd lib text phpmd-unusedparams.xml || E=$?; exit $E", + "phpmetrics": "./vendor/bin/phpmetrics --report-html=phpmetrics lib/", + "phpmetrics:violations": "./vendor/bin/phpmetrics --violations-xml=phpmetrics/violations.xml lib/", + "psalm": "./vendor/bin/psalm --threads=1 --no-cache", + "phpstan": "./vendor/bin/phpstan analyse --memory-limit=1G", + "test": "phpunit --configuration phpunit.xml", + "test:unit": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always", + "test:all": "./vendor/bin/phpunit --configuration phpunit.xml --colors=always", + "test:integration": "if command -v newman >/dev/null 2>&1; then newman run tests/integration/launchpad.postman_collection.json --environment tests/integration/local.env.json; else npx --yes newman run tests/integration/launchpad.postman_collection.json --environment tests/integration/local.env.json; fi", + "newman": "@test:integration", + "newman:coverage": "node tests/integration/.coverage-check.js", + "check": "E=0; for CMD in lint phpcs psalm test:unit; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E", + "check:full": "E=0; for CMD in lint phpcs psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E", + "check:strict": "E=0; for CMD in lint lint:initial-state lint:spec-annotations lint:licenses phpcs phpmd psalm phpstan test:all; do echo; echo \"=== $CMD ===\"; composer $CMD || E=1; done; echo; if [ $E -eq 0 ]; then echo \"ALL CHECKS PASSED\"; else echo \"SOME CHECKS FAILED (see above)\"; fi; exit $E", + "fix": [ + "@cs:fix" + ], + "phpqa": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa", + "phpqa:full": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa --tools phpcs:0,phpmd:0,phploc:0,phpmetrics,phpcpd:0,parallel-lint:0", + "phpqa:ci": "./vendor/bin/phpqa --report --analyzedDirs lib --buildDir phpqa --tools phpcs,phpmd,phploc,phpmetrics,phpcpd,parallel-lint", + "qa:check": [ + "@phpqa" + ], + "qa:full": [ + "@phpqa:full" + ], + "test:coverage": "./vendor/bin/phpunit --configuration phpunit.xml --coverage-html=coverage/html --coverage-clover=coverage/clover.xml --colors=always", + "coverage:check": "php -r \"\\$xml = simplexml_load_file('coverage/clover.xml'); \\$metrics = \\$xml->project->metrics; \\$statements = (int)\\$metrics['statements']; \\$covered = (int)\\$metrics['coveredstatements']; \\$percentage = \\$statements > 0 ? round((\\$covered / \\$statements) * 100, 2) : 0; echo 'Coverage: ' . \\$percentage . '%' . PHP_EOL; exit(\\$percentage < 75 ? 1 : 0);\"", + "quality:phpcs-score": "./vendor/bin/phpcs --standard=phpcs.xml --report=json lib/ | php -r \"\\$json = json_decode(file_get_contents('php://stdin'), true); \\$errors = \\$json['totals']['errors'] ?? 0; \\$warnings = \\$json['totals']['warnings'] ?? 0; \\$score = 1000 - \\$errors - (\\$warnings / 2); echo 'PHPCS Score: ' . \\$score . ' (Errors: ' . \\$errors . ', Warnings: ' . \\$warnings . ')' . PHP_EOL;\"", + "quality:phpmd-score": "phpmd lib/ json phpmd.xml | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$violations = count(\\$json['files'] ?? []); \\$score = 1000 - (\\$violations * 10); echo 'PHPMD Score: ' . \\$score . ' (Violations: ' . \\$violations . ')' . PHP_EOL;\" || echo 'PHPMD not available'", + "quality:psalm-score": "./vendor/bin/psalm --output-format=json --no-cache | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$errors = count(\\$json ?? []); \\$score = 1000 - (\\$errors * 5); echo 'Psalm Score: ' . \\$score . ' (Errors: ' . \\$errors . ')' . PHP_EOL;\" || echo 'Psalm not available'", + "quality:phpstan-score": "./vendor/bin/phpstan analyse --memory-limit=1G --error-format=json --no-progress | php -r \"\\$input = file_get_contents('php://stdin'); \\$json = json_decode(\\$input, true); \\$errors = \\$json['totals']['file_errors'] ?? 0; \\$score = 1000 - (\\$errors * 5); echo 'PHPStan Score: ' . \\$score . ' (Errors: ' . \\$errors . ')' . PHP_EOL;\" || echo 'PHPStan not available'", + "quality:score": [ + "@quality:phpcs-score", + "@quality:phpmd-score", + "@quality:psalm-score", + "@quality:phpstan-score" + ], + "post-install-cmd": [ + "git config core.hooksPath .githooks || true" + ] + }, + "config": { + "allow-plugins": { + "composer/package-versions-deprecated": true, + "dealerdirect/phpcodesniffer-composer-installer": true, + "cyclonedx/cyclonedx-php-composer": true + }, + "optimize-autoloader": true, + "sort-packages": true, + "platform": { + "php": "8.3" + } + } } diff --git a/composer.lock b/composer.lock index 9fc97b2b..5b534673 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "52bb6fcc1120064de3c3a52f0a39a12e", + "content-hash": "edc4c14f2d591cac0bb3cc2013991e7c", "packages": [], "packages-dev": [ { @@ -460,6 +460,110 @@ ], "time": "2024-05-06T16:37:16+00:00" }, + { + "name": "conduction/coding-standard", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/ConductionNL/coding-standard.git", + "reference": "a1854f13cb735e46ecd010767593b4d7bc90d974" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ConductionNL/coding-standard/zipball/a1854f13cb735e46ecd010767593b4d7bc90d974", + "reference": "a1854f13cb735e46ecd010767593b4d7bc90d974", + "shasum": "" + }, + "require": { + "nextcloud/coding-standard": "^1.4", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Conduction\\CodingStandard\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "EUPL-1.2" + ], + "authors": [ + { + "name": "Conduction", + "homepage": "https://conduction.nl" + } + ], + "description": "Conduction coding standards for the PHP CS Fixer. Extends nextcloud/coding-standard — adds rules, never overrides them.", + "homepage": "https://github.com/ConductionNL/coding-standard", + "keywords": [ + "coding-standard", + "conduction", + "dev", + "nextcloud", + "php-cs-fixer" + ], + "support": { + "docs": "https://docs.conduction.nl/WayOfWork/ci-cd/", + "issues": "https://github.com/ConductionNL/coding-standard/issues", + "source": "https://github.com/ConductionNL/coding-standard/tree/v1.0.0" + }, + "time": "2026-08-12T08:27:21+00:00" + }, + { + "name": "conduction/hydra-gates", + "version": "v1.7.0", + "source": { + "type": "git", + "url": "https://github.com/ConductionNL/.github.git", + "reference": "b9c6520a648468ab2055ba4ce498348b31ab1f57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ConductionNL/.github/zipball/b9c6520a648468ab2055ba4ce498348b31ab1f57", + "reference": "b9c6520a648468ab2055ba4ce498348b31ab1f57", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "bin": [ + "hydra-gates/bin/hydra-gates" + ], + "type": "library", + "extra": { + "hydra-gates": { + "runner": "hydra-gates/scripts/run-hydra-gates.sh", + "helpers": "hydra-gates/scripts/lib", + "schemas": "hydra-gates/scripts/schemas" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "EUPL-1.2" + ], + "authors": [ + { + "name": "Conduction", + "homepage": "https://conduction.nl" + } + ], + "description": "Hydra's mechanical quality gates, packaged so any repo can run them against its own diff. The exit code is the failure COUNT.", + "homepage": "https://github.com/ConductionNL/.github/tree/main/hydra-gates", + "keywords": [ + "conduction", + "gates", + "nextcloud", + "quality", + "static-analysis" + ], + "support": { + "docs": "https://github.com/ConductionNL/.github/blob/main/hydra-gates/README.md", + "issues": "https://github.com/ConductionNL/.github/issues", + "source": "https://github.com/ConductionNL/.github/tree/v1.7.0" + }, + "time": "2026-08-12T09:38:06+00:00" + }, { "name": "consolidation/annotated-command", "version": "4.10.5", @@ -1531,16 +1635,16 @@ }, { "name": "kubawerlos/php-cs-fixer-custom-fixers", - "version": "v3.37.1", + "version": "v3.37.2", "source": { "type": "git", "url": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers.git", - "reference": "e0ec1f602a1d0836909e9079262dbaf58eaf3804" + "reference": "678df979ce743466b42ddb6eea46b3f4c9a7bade" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/kubawerlos/php-cs-fixer-custom-fixers/zipball/e0ec1f602a1d0836909e9079262dbaf58eaf3804", - "reference": "e0ec1f602a1d0836909e9079262dbaf58eaf3804", + "url": "https://api.github.com/repos/kubawerlos/php-cs-fixer-custom-fixers/zipball/678df979ce743466b42ddb6eea46b3f4c9a7bade", + "reference": "678df979ce743466b42ddb6eea46b3f4c9a7bade", "shasum": "" }, "require": { @@ -1571,7 +1675,7 @@ "description": "A set of custom fixers for PHP CS Fixer", "support": { "issues": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/issues", - "source": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/tree/v3.37.1" + "source": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/tree/v3.37.2" }, "funding": [ { @@ -1579,7 +1683,7 @@ "type": "github" } ], - "time": "2026-04-28T16:41:56+00:00" + "time": "2026-05-12T16:22:19+00:00" }, { "name": "league/container", @@ -1776,16 +1880,16 @@ }, { "name": "nextcloud/coding-standard", - "version": "v1.4.0", + "version": "v1.5.0", "source": { "type": "git", "url": "https://github.com/nextcloud/coding-standard.git", - "reference": "8e06808c1423e9208d63d1bd205b9a38bd400011" + "reference": "80547a93236fbb9c783e05f0f0899043851b0dba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud/coding-standard/zipball/8e06808c1423e9208d63d1bd205b9a38bd400011", - "reference": "8e06808c1423e9208d63d1bd205b9a38bd400011", + "url": "https://api.github.com/repos/nextcloud/coding-standard/zipball/80547a93236fbb9c783e05f0f0899043851b0dba", + "reference": "80547a93236fbb9c783e05f0f0899043851b0dba", "shasum": "" }, "require": { @@ -1815,37 +1919,36 @@ ], "support": { "issues": "https://github.com/nextcloud/coding-standard/issues", - "source": "https://github.com/nextcloud/coding-standard/tree/v1.4.0" + "source": "https://github.com/nextcloud/coding-standard/tree/v1.5.0" }, - "time": "2025-06-19T12:27:27+00:00" + "time": "2026-05-19T18:30:09+00:00" }, { "name": "nextcloud/ocp", - "version": "dev-master", + "version": "v34.0.2", "source": { "type": "git", "url": "https://github.com/nextcloud-deps/ocp.git", - "reference": "51b4669e894d49830273d637174a2f1a48118eaa" + "reference": "81cbb2c594afe0fa978885bf7accd0440199520a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/51b4669e894d49830273d637174a2f1a48118eaa", - "reference": "51b4669e894d49830273d637174a2f1a48118eaa", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/81cbb2c594afe0fa978885bf7accd0440199520a", + "reference": "81cbb2c594afe0fa978885bf7accd0440199520a", "shasum": "" }, "require": { - "php": "~8.3 || ~8.4 || ~8.5", + "php": "~8.2 || ~8.3 || ~8.4 || ~8.5", "psr/clock": "^1.0", "psr/container": "^2.0.2", "psr/event-dispatcher": "^1.0", "psr/http-client": "^1.0.3", "psr/log": "^3.0.2" }, - "default-branch": true, "type": "library", "extra": { "branch-alias": { - "dev-master": "35.0.0-dev" + "dev-stable34": "34.0.0-dev" } }, "notification-url": "https://packagist.org/downloads/", @@ -1865,9 +1968,9 @@ "description": "Composer package containing Nextcloud's public OCP API and the unstable NCU API", "support": { "issues": "https://github.com/nextcloud-deps/ocp/issues", - "source": "https://github.com/nextcloud-deps/ocp/tree/master" + "source": "https://github.com/nextcloud-deps/ocp/tree/v34.0.2" }, - "time": "2026-06-26T02:11:08+00:00" + "time": "2026-07-16T01:28:13+00:00" }, { "name": "nikic/php-parser", @@ -2463,16 +2566,16 @@ }, { "name": "php-cs-fixer/shim", - "version": "v3.95.1", + "version": "v3.95.18", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/shim.git", - "reference": "f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a" + "reference": "9b815f2ba5c581faaaec1386dcda4c16d511e6bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a", - "reference": "f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a", + "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/9b815f2ba5c581faaaec1386dcda4c16d511e6bb", + "reference": "9b815f2ba5c581faaaec1386dcda4c16d511e6bb", "shasum": "" }, "require": { @@ -2509,9 +2612,9 @@ "description": "A tool to automatically fix PHP code style", "support": { "issues": "https://github.com/PHP-CS-Fixer/shim/issues", - "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.95.1" + "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.95.18" }, - "time": "2026-04-12T17:00:34+00:00" + "time": "2026-07-30T15:46:28+00:00" }, { "name": "phpcsstandards/phpcsextra", @@ -6211,16 +6314,16 @@ }, { "name": "symfony/console", - "version": "v6.4.36", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9f481cfb580db8bcecc9b2d4c63f3e13df022ad5" + "reference": "3b643aa587acbc42f967a429af088a56ed8f046d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9f481cfb580db8bcecc9b2d4c63f3e13df022ad5", - "reference": "9f481cfb580db8bcecc9b2d4c63f3e13df022ad5", + "url": "https://api.github.com/repos/symfony/console/zipball/3b643aa587acbc42f967a429af088a56ed8f046d", + "reference": "3b643aa587acbc42f967a429af088a56ed8f046d", "shasum": "" }, "require": { @@ -6285,7 +6388,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.36" + "source": "https://github.com/symfony/console/tree/v6.4.43" }, "funding": [ { @@ -6305,7 +6408,7 @@ "type": "tidelift" } ], - "time": "2026-03-27T15:30:51+00:00" + "time": "2026-07-26T14:44:19+00:00" }, { "name": "symfony/dependency-injection", @@ -6394,16 +6497,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -6441,7 +6544,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -6461,20 +6564,20 @@ "type": "tidelift" } ], - "time": "2026-04-13T15:52:40+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v6.4.36", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "fc828863e26ceec86e2513b5e46aa0b149d76b69" + "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/fc828863e26ceec86e2513b5e46aa0b149d76b69", - "reference": "fc828863e26ceec86e2513b5e46aa0b149d76b69", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ac405d324c10ebbbde6a6e58379bf81db10f1dbf", + "reference": "ac405d324c10ebbbde6a6e58379bf81db10f1dbf", "shasum": "" }, "require": { @@ -6525,7 +6628,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.36" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.43" }, "funding": [ { @@ -6545,20 +6648,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T11:18:01+00:00" + "time": "2026-07-21T14:00:19+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -6572,7 +6675,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -6605,7 +6708,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -6616,25 +6719,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", - "version": "v6.4.34", + "version": "v6.4.43", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3" + "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/01ffe0411b842f93c571e5c391f289c3fdd498c3", - "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/9ff03da12d67649fbd1f34ca95951554624d0a16", + "reference": "9ff03da12d67649fbd1f34ca95951554624d0a16", "shasum": "" }, "require": { @@ -6671,7 +6778,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.34" + "source": "https://github.com/symfony/filesystem/tree/v6.4.43" }, "funding": [ { @@ -6691,20 +6798,20 @@ "type": "tidelift" } ], - "time": "2026-02-24T17:51:06+00:00" + "time": "2026-06-27T10:13:35+00:00" }, { "name": "symfony/finder", - "version": "v6.4.34", + "version": "v6.4.42", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "9590e86be1d1c57bfbb16d0dd040345378c20896" + "reference": "0b73dac42493acbadbba644207a715b254e9b029" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/9590e86be1d1c57bfbb16d0dd040345378c20896", - "reference": "9590e86be1d1c57bfbb16d0dd040345378c20896", + "url": "https://api.github.com/repos/symfony/finder/zipball/0b73dac42493acbadbba644207a715b254e9b029", + "reference": "0b73dac42493acbadbba644207a715b254e9b029", "shasum": "" }, "require": { @@ -6739,7 +6846,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v6.4.34" + "source": "https://github.com/symfony/finder/tree/v6.4.42" }, "funding": [ { @@ -6759,7 +6866,7 @@ "type": "tidelift" } ], - "time": "2026-01-28T15:16:37+00:00" + "time": "2026-06-26T15:18:24+00:00" }, { "name": "symfony/polyfill-ctype", @@ -6846,16 +6953,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.37.0", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -6904,7 +7011,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -6924,20 +7031,20 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:13:48+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.37.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -6989,7 +7096,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -7009,20 +7116,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -7074,7 +7181,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -7094,20 +7201,20 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", "shasum": "" }, "require": { @@ -7154,7 +7261,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" }, "funding": [ { @@ -7174,20 +7281,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-26T12:45:58+00:00" }, { "name": "symfony/process", - "version": "v6.4.33", + "version": "v6.4.41", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "c46e854e79b52d07666e43924a20cb6dc546644e" + "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/c46e854e79b52d07666e43924a20cb6dc546644e", - "reference": "c46e854e79b52d07666e43924a20cb6dc546644e", + "url": "https://api.github.com/repos/symfony/process/zipball/c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", + "reference": "c8fc09bdfe9fde9aaa89b415a4477feaccec16a7", "shasum": "" }, "require": { @@ -7219,7 +7326,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v6.4.33" + "source": "https://github.com/symfony/process/tree/v6.4.41" }, "funding": [ { @@ -7239,20 +7346,20 @@ "type": "tidelift" } ], - "time": "2026-01-23T16:02:12+00:00" + "time": "2026-05-23T13:47:21+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -7270,7 +7377,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -7306,7 +7413,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -7326,26 +7433,27 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/string", - "version": "v6.4.34", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "2adaf4106f2ef4c67271971bde6d3fe0a6936432" + "reference": "e394af32256bf9e7bf80849d95e589167c10097b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/2adaf4106f2ef4c67271971bde6d3fe0a6936432", - "reference": "2adaf4106f2ef4c67271971bde6d3fe0a6936432", + "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-grapheme": "~1.33", "symfony/polyfill-intl-normalizer": "~1.0", "symfony/polyfill-mbstring": "~1.0" }, @@ -7353,10 +7461,11 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/http-client": "^5.4|^6.0|^7.0", - "symfony/intl": "^6.2|^7.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^5.4|^6.0|^7.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7395,7 +7504,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.34" + "source": "https://github.com/symfony/string/tree/v7.4.15" }, "funding": [ { @@ -7415,7 +7524,7 @@ "type": "tidelift" } ], - "time": "2026-02-08T20:44:54+00:00" + "time": "2026-07-28T07:33:02+00:00" }, { "name": "symfony/var-exporter", @@ -7876,7 +7985,6 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": { - "nextcloud/ocp": 20, "roave/security-advisories": 20 }, "prefer-stable": false, diff --git a/lib/Activity/ActivityPublisher.php b/lib/Activity/ActivityPublisher.php index 9ce0d4aa..fb39b0ff 100644 --- a/lib/Activity/ActivityPublisher.php +++ b/lib/Activity/ActivityPublisher.php @@ -40,329 +40,328 @@ /** * Thin Activity emission service for LaunchPad. */ -class ActivityPublisher -{ - /** - * Constructor. - * - * @param IManager $manager The NC Activity manager. - * @param IGroupManager $groupManager The NC group manager. - * @param IUserManager $userManager The NC user manager. - * @param DebounceHelper $debounce The debounce guard. - * @param LoggerInterface $logger The logger. - */ - public function __construct( - private readonly IManager $manager, - private readonly IGroupManager $groupManager, - private readonly IUserManager $userManager, - private readonly DebounceHelper $debounce, - private readonly LoggerInterface $logger, - ) { - }//end __construct() +class ActivityPublisher { + /** + * Constructor. + * + * @param IManager $manager The NC Activity manager. + * @param IGroupManager $groupManager The NC group manager. + * @param IUserManager $userManager The NC user manager. + * @param DebounceHelper $debounce The debounce guard. + * @param LoggerInterface $logger The logger. + */ + public function __construct( + private readonly IManager $manager, + private readonly IGroupManager $groupManager, + private readonly IUserManager $userManager, + private readonly DebounceHelper $debounce, + private readonly LoggerInterface $logger, + ) { + }//end __construct() - /** - * Emit a single activity row to `$recipientUserId` for the given - * dashboard. - * - * Unknown event types are silently dropped after a warning log entry - * (REQ-ACT-002 contract). Reaction events go through the per-actor - * debounce (REQ-ACT-007). Every NC `IManager` call is wrapped in a - * try/catch so an Activity failure never propagates back into the - * owning capability's HTTP handler (REQ-ACT-011 scenario). - * - * @param string $type The event-type constant value. - * @param string $actorUserId The acting NC user ID. - * @param string $recipientUserId The recipient NC user ID. - * @param string $dashboardUuid The dashboard UUID. - * @param string $dashboardName The human-readable dashboard name. - * @param string $dashboardLink The absolute deep-link URL. - * @param array $extraParams Optional extra params (e.g. `recipient`, `role`, `target`, `message`). - * - * @return bool True when the event was successfully published; false when suppressed or dropped. - */ - public function publish( - string $type, - string $actorUserId, - string $recipientUserId, - string $dashboardUuid, - string $dashboardName, - string $dashboardLink, - array $extraParams=[] - ): bool { - if (in_array(needle: $type, haystack: Extension::ALL_EVENTS, strict: true) === false) { - $this->logger->warning( - message: 'Unknown LaunchPad activity type rejected', - context: [ - 'type' => $type, - 'dashboard' => $dashboardUuid, - ] - ); - return false; - } + /** + * Emit a single activity row to `$recipientUserId` for the given + * dashboard. + * + * Unknown event types are silently dropped after a warning log entry + * (REQ-ACT-002 contract). Reaction events go through the per-actor + * debounce (REQ-ACT-007). Every NC `IManager` call is wrapped in a + * try/catch so an Activity failure never propagates back into the + * owning capability's HTTP handler (REQ-ACT-011 scenario). + * + * @param string $type The event-type constant value. + * @param string $actorUserId The acting NC user ID. + * @param string $recipientUserId The recipient NC user ID. + * @param string $dashboardUuid The dashboard UUID. + * @param string $dashboardName The human-readable dashboard name. + * @param string $dashboardLink The absolute deep-link URL. + * @param array $extraParams Optional extra params (e.g. `recipient`, `role`, `target`, `message`). + * + * @return bool True when the event was successfully published; false when suppressed or dropped. + */ + public function publish( + string $type, + string $actorUserId, + string $recipientUserId, + string $dashboardUuid, + string $dashboardName, + string $dashboardLink, + array $extraParams = [], + ): bool { + if (in_array(needle: $type, haystack: Extension::ALL_EVENTS, strict: true) === false) { + $this->logger->warning( + message: 'Unknown LaunchPad activity type rejected', + context: [ + 'type' => $type, + 'dashboard' => $dashboardUuid, + ] + ); + return false; + } - if ($type === Extension::EVENT_REACTED - && $this->debounce->allowReaction( - actorUserId: $actorUserId, - dashboardUuid: $dashboardUuid - ) === false - ) { - return false; - } + if ($type === Extension::EVENT_REACTED + && $this->debounce->allowReaction( + actorUserId: $actorUserId, + dashboardUuid: $dashboardUuid + ) === false + ) { + return false; + } - try { - $event = $this->buildEvent( - type: $type, - actorUserId: $actorUserId, - recipientUserId: $recipientUserId, - dashboardUuid: $dashboardUuid, - dashboardName: $dashboardName, - dashboardLink: $dashboardLink, - extraParams: $extraParams - ); - $this->manager->publish(event: $event); - } catch (Throwable $e) { - $this->logger->error( - message: 'LaunchPad Activity publish failed', - context: [ - 'type' => $type, - 'dashboard' => $dashboardUuid, - 'recipient' => $recipientUserId, - 'exception' => $e, - ] - ); - return false; - }//end try + try { + $event = $this->buildEvent( + type: $type, + actorUserId: $actorUserId, + recipientUserId: $recipientUserId, + dashboardUuid: $dashboardUuid, + dashboardName: $dashboardName, + dashboardLink: $dashboardLink, + extraParams: $extraParams + ); + $this->manager->publish(event: $event); + } catch (Throwable $e) { + $this->logger->error( + message: 'LaunchPad Activity publish failed', + context: [ + 'type' => $type, + 'dashboard' => $dashboardUuid, + 'recipient' => $recipientUserId, + 'exception' => $e, + ] + ); + return false; + }//end try - return true; - }//end publish() + return true; + }//end publish() - /** - * Emit one activity row per recipient in `$recipientUserIds`, plus - * one row to the actor (REQ-ACT-005). Recipients are de-duplicated - * to prevent double-emission when the actor is also a recipient. - * - * @param string $type The event-type constant value. - * @param string $actorUserId The acting NC user ID. - * @param string $dashboardUuid The dashboard UUID. - * @param string $dashboardName The dashboard name. - * @param string $dashboardLink The dashboard link. - * @param string[] $recipientUserIds Recipient NC user IDs. - * @param array $extraParams Optional extra params. - * - * @return int The number of rows successfully written. - */ - public function publishToRecipients( - string $type, - string $actorUserId, - string $dashboardUuid, - string $dashboardName, - string $dashboardLink, - array $recipientUserIds, - array $extraParams=[] - ): int { - $unique = array_values( - array: array_unique( - array: array_merge([$actorUserId], $recipientUserIds) - ) - ); + /** + * Emit one activity row per recipient in `$recipientUserIds`, plus + * one row to the actor (REQ-ACT-005). Recipients are de-duplicated + * to prevent double-emission when the actor is also a recipient. + * + * @param string $type The event-type constant value. + * @param string $actorUserId The acting NC user ID. + * @param string $dashboardUuid The dashboard UUID. + * @param string $dashboardName The dashboard name. + * @param string $dashboardLink The dashboard link. + * @param string[] $recipientUserIds Recipient NC user IDs. + * @param array $extraParams Optional extra params. + * + * @return int The number of rows successfully written. + */ + public function publishToRecipients( + string $type, + string $actorUserId, + string $dashboardUuid, + string $dashboardName, + string $dashboardLink, + array $recipientUserIds, + array $extraParams = [], + ): int { + $unique = array_values( + array: array_unique( + array: array_merge([$actorUserId], $recipientUserIds) + ) + ); - $count = 0; - foreach ($unique as $userId) { - $params = $extraParams; - $params['self'] = ($userId === $actorUserId); - $published = $this->publish( - type: $type, - actorUserId: $actorUserId, - recipientUserId: $userId, - dashboardUuid: $dashboardUuid, - dashboardName: $dashboardName, - dashboardLink: $dashboardLink, - extraParams: $params - ); - if ($published === true) { - $count++; - } - } + $count = 0; + foreach ($unique as $userId) { + $params = $extraParams; + $params['self'] = ($userId === $actorUserId); + $published = $this->publish( + type: $type, + actorUserId: $actorUserId, + recipientUserId: $userId, + dashboardUuid: $dashboardUuid, + dashboardName: $dashboardName, + dashboardLink: $dashboardLink, + extraParams: $params + ); + if ($published === true) { + $count++; + } + } - return $count; - }//end publishToRecipients() + return $count; + }//end publishToRecipients() - /** - * Emit activity rows to every member of `$groupId` (REQ-ACT-006). - * - * Returns 0 (without raising) when the group is unknown or empty. - * The actor is included exactly once even when they are also a - * member of the group. - * - * @param string $type The event-type constant value. - * @param string $actorUserId The acting NC user ID. - * @param string $groupId The target group ID. - * @param string $dashboardUuid The dashboard UUID. - * @param string $dashboardName The dashboard name. - * @param string $dashboardLink The dashboard link. - * @param array $extraParams Optional extra params. - * - * @return int The number of rows successfully written. - */ - public function publishToGroup( - string $type, - string $actorUserId, - string $groupId, - string $dashboardUuid, - string $dashboardName, - string $dashboardLink, - array $extraParams=[] - ): int { - $group = $this->groupManager->get(gid: $groupId); - if ($group === null) { - return 0; - } + /** + * Emit activity rows to every member of `$groupId` (REQ-ACT-006). + * + * Returns 0 (without raising) when the group is unknown or empty. + * The actor is included exactly once even when they are also a + * member of the group. + * + * @param string $type The event-type constant value. + * @param string $actorUserId The acting NC user ID. + * @param string $groupId The target group ID. + * @param string $dashboardUuid The dashboard UUID. + * @param string $dashboardName The dashboard name. + * @param string $dashboardLink The dashboard link. + * @param array $extraParams Optional extra params. + * + * @return int The number of rows successfully written. + */ + public function publishToGroup( + string $type, + string $actorUserId, + string $groupId, + string $dashboardUuid, + string $dashboardName, + string $dashboardLink, + array $extraParams = [], + ): int { + $group = $this->groupManager->get(gid: $groupId); + if ($group === null) { + return 0; + } - $userIds = []; - foreach ($group->getUsers() as $user) { - $userIds[] = $user->getUID(); - } + $userIds = []; + foreach ($group->getUsers() as $user) { + $userIds[] = $user->getUID(); + } - return $this->publishToRecipients( - type: $type, - actorUserId: $actorUserId, - dashboardUuid: $dashboardUuid, - dashboardName: $dashboardName, - dashboardLink: $dashboardLink, - recipientUserIds: $userIds, - extraParams: $extraParams - ); - }//end publishToGroup() + return $this->publishToRecipients( + type: $type, + actorUserId: $actorUserId, + dashboardUuid: $dashboardUuid, + dashboardName: $dashboardName, + dashboardLink: $dashboardLink, + recipientUserIds: $userIds, + extraParams: $extraParams + ); + }//end publishToGroup() - /** - * Emit activity rows to every authenticated NC user (REQ-ACT-008). - * - * The full fan-out is gated by - * `DebounceHelper::allowGlobalFanout(dashboardUuid, type)` — - * suppressed events are logged at DEBUG and produce zero rows. - * - * @param string $type The event-type constant value. - * @param string $actorUserId The acting NC user ID. - * @param string $dashboardUuid The dashboard UUID. - * @param string $dashboardName The dashboard name. - * @param string $dashboardLink The dashboard link. - * @param array $extraParams Optional extra params. - * - * @return int The number of rows successfully written (0 when debounced). - */ - public function publishGlobal( - string $type, - string $actorUserId, - string $dashboardUuid, - string $dashboardName, - string $dashboardLink, - array $extraParams=[] - ): int { - if ($this->debounce->allowGlobalFanout( - dashboardUuid: $dashboardUuid, - eventType: $type - ) === false - ) { - $this->logger->debug( - message: 'LaunchPad global activity fan-out debounced', - context: [ - 'type' => $type, - 'dashboard' => $dashboardUuid, - ] - ); - return 0; - } + /** + * Emit activity rows to every authenticated NC user (REQ-ACT-008). + * + * The full fan-out is gated by + * `DebounceHelper::allowGlobalFanout(dashboardUuid, type)` — + * suppressed events are logged at DEBUG and produce zero rows. + * + * @param string $type The event-type constant value. + * @param string $actorUserId The acting NC user ID. + * @param string $dashboardUuid The dashboard UUID. + * @param string $dashboardName The dashboard name. + * @param string $dashboardLink The dashboard link. + * @param array $extraParams Optional extra params. + * + * @return int The number of rows successfully written (0 when debounced). + */ + public function publishGlobal( + string $type, + string $actorUserId, + string $dashboardUuid, + string $dashboardName, + string $dashboardLink, + array $extraParams = [], + ): int { + if ($this->debounce->allowGlobalFanout( + dashboardUuid: $dashboardUuid, + eventType: $type + ) === false + ) { + $this->logger->debug( + message: 'LaunchPad global activity fan-out debounced', + context: [ + 'type' => $type, + 'dashboard' => $dashboardUuid, + ] + ); + return 0; + } - $count = 0; - $this->userManager->callForAllUsers( - callback: function (IUser $user) use ( - $type, - $actorUserId, - $dashboardUuid, - $dashboardName, - $dashboardLink, - $extraParams, - &$count - ): void { - $params = $extraParams; - $params['self'] = ($user->getUID() === $actorUserId); - $ok = $this->publish( - type: $type, - actorUserId: $actorUserId, - recipientUserId: $user->getUID(), - dashboardUuid: $dashboardUuid, - dashboardName: $dashboardName, - dashboardLink: $dashboardLink, - extraParams: $params - ); - if ($ok === true) { - $count++; - } - } - ); + $count = 0; + $this->userManager->callForAllUsers( + callback: function (IUser $user) use ( + $type, + $actorUserId, + $dashboardUuid, + $dashboardName, + $dashboardLink, + $extraParams, + &$count + ): void { + $params = $extraParams; + $params['self'] = ($user->getUID() === $actorUserId); + $ok = $this->publish( + type: $type, + actorUserId: $actorUserId, + recipientUserId: $user->getUID(), + dashboardUuid: $dashboardUuid, + dashboardName: $dashboardName, + dashboardLink: $dashboardLink, + extraParams: $params + ); + if ($ok === true) { + $count++; + } + } + ); - return $count; - }//end publishGlobal() + return $count; + }//end publishGlobal() - /** - * Build the canonical `IEvent` object for a single activity row. - * - * The numeric `objectId` slot in `IEvent::setObject()` requires an - * int per the NC interface; the dashboard UUID is stored in the - * `objectName` slot so the activity row can be deep-linked back to - * the canonical dashboard regardless of database renumbering. The - * subject parameters carry every field rendered by `parse()`. - * - * @param string $type The event type. - * @param string $actorUserId The acting user ID. - * @param string $recipientUserId The recipient user ID. - * @param string $dashboardUuid The dashboard UUID. - * @param string $dashboardName The dashboard name. - * @param string $dashboardLink The dashboard link. - * @param array $extraParams Optional extra params. - * - * @return IEvent The fully populated event. - */ - private function buildEvent( - string $type, - string $actorUserId, - string $recipientUserId, - string $dashboardUuid, - string $dashboardName, - string $dashboardLink, - array $extraParams - ): IEvent { - $event = $this->manager->generateEvent(); - $isSelf = ($actorUserId === $recipientUserId); + /** + * Build the canonical `IEvent` object for a single activity row. + * + * The numeric `objectId` slot in `IEvent::setObject()` requires an + * int per the NC interface; the dashboard UUID is stored in the + * `objectName` slot so the activity row can be deep-linked back to + * the canonical dashboard regardless of database renumbering. The + * subject parameters carry every field rendered by `parse()`. + * + * @param string $type The event type. + * @param string $actorUserId The acting user ID. + * @param string $recipientUserId The recipient user ID. + * @param string $dashboardUuid The dashboard UUID. + * @param string $dashboardName The dashboard name. + * @param string $dashboardLink The dashboard link. + * @param array $extraParams Optional extra params. + * + * @return IEvent The fully populated event. + */ + private function buildEvent( + string $type, + string $actorUserId, + string $recipientUserId, + string $dashboardUuid, + string $dashboardName, + string $dashboardLink, + array $extraParams, + ): IEvent { + $event = $this->manager->generateEvent(); + $isSelf = ($actorUserId === $recipientUserId); - $params = array_merge( - [ - 'self' => $isSelf, - 'actor' => $actorUserId, - 'dashboard' => $dashboardName, - ], - $extraParams - ); + $params = array_merge( + [ + 'self' => $isSelf, + 'actor' => $actorUserId, + 'dashboard' => $dashboardName, + ], + $extraParams + ); - $event - ->setApp(app: Extension::APP_ID) - ->setType(type: $type) - ->setAuthor(author: $actorUserId) - ->setAffectedUser(affectedUser: $recipientUserId) - ->setSubject(subject: $type, parameters: $params) - ->setObject( - objectType: Extension::OBJECT_TYPE, - objectId: 0, - objectName: $dashboardUuid - ) - ->setLink(link: $dashboardLink) - ->setTimestamp(timestamp: time()); + $event + ->setApp(app: Extension::APP_ID) + ->setType(type: $type) + ->setAuthor(author: $actorUserId) + ->setAffectedUser(affectedUser: $recipientUserId) + ->setSubject(subject: $type, parameters: $params) + ->setObject( + objectType: Extension::OBJECT_TYPE, + objectId: 0, + objectName: $dashboardUuid + ) + ->setLink(link: $dashboardLink) + ->setTimestamp(timestamp: time()); - $message = (string) ($extraParams['message'] ?? ''); - if ($message !== '') { - $event->setMessage(message: substr(string: $message, offset: 0, length: 200)); - } + $message = (string)($extraParams['message'] ?? ''); + if ($message !== '') { + $event->setMessage(message: substr(string: $message, offset: 0, length: 200)); + } - return $event; - }//end buildEvent() + return $event; + }//end buildEvent() }//end class diff --git a/lib/Activity/DebounceHelper.php b/lib/Activity/DebounceHelper.php index 72472276..5a7e8d41 100644 --- a/lib/Activity/DebounceHelper.php +++ b/lib/Activity/DebounceHelper.php @@ -51,206 +51,202 @@ /** * Per-window debounce guard for Activity emission. */ -class DebounceHelper -{ - /** - * Debounce TTL in seconds (15 minutes). REQ-ACT-007, REQ-ACT-008. - */ - public const TTL_SECONDS = 900; - - /** - * In-memory fallback store used when APCu is not available. - * - * Keyed by the same APCu key string; the value is the unix - * timestamp at which the entry expires. - * - * @var array - */ - private array $memory = []; - - /** - * Optional clock callable returning the current unix timestamp. - * - * Injected by tests to advance time deterministically without - * sleeping for 15 minutes. - * - * @var callable():int - */ - private $clock; - - /** - * True when the helper is using the real wall-clock and may delegate - * to APCu. False when a test clock is injected — in that case APCu's - * own TTL would not move with the test clock, so the in-memory - * fallback is the only correct backend. - * - * @var boolean - */ - private bool $realClock; - - /** - * Distributed cache used as the cross-request fallback when APCu is - * not usable. Nullable so existing unit-test call sites that build - * `new DebounceHelper($clock)` keep working without a cache backend. - * - * @var ICache|null - */ - private ?ICache $cache; - - /** - * Constructor. - * - * @param (callable():int)|null $clock Optional clock callable; defaults to `time()`. - * @param ICache|null $cache Optional distributed cache used as the - * cross-request fallback when APCu is - * unusable. Null falls back to the - * in-memory array. - */ - public function __construct(?callable $clock=null, ?ICache $cache=null) - { - $this->realClock = ($clock === null); - $this->clock = ($clock ?? static fn(): int => time()); - $this->cache = $cache; - }//end __construct() - - /** - * Check whether a reaction event from `$actorUserId` on - * `$dashboardUuid` may be emitted now. - * - * Returns true on the first call and again after the 900-second - * window has elapsed; returns false for any call inside an active - * window. - * - * @param string $actorUserId The acting user ID. - * @param string $dashboardUuid The dashboard UUID. - * - * @return bool True when emission is allowed. - */ - public function allowReaction( - string $actorUserId, - string $dashboardUuid - ): bool { - $key = sprintf( - 'launchpad_act_react_%s_%s', - $actorUserId, - $dashboardUuid - ); - return $this->claim(key: $key); - }//end allowReaction() - - /** - * Check whether a default-group fan-out for `(dashboardUuid, eventType)` - * may be performed now (REQ-ACT-008). - * - * @param string $dashboardUuid The dashboard UUID. - * @param string $eventType The event type constant value. - * - * @return bool True when fan-out is allowed. - */ - public function allowGlobalFanout( - string $dashboardUuid, - string $eventType - ): bool { - $key = sprintf( - 'launchpad_act_global_%s_%s', - $dashboardUuid, - $eventType - ); - return $this->claim(key: $key); - }//end allowGlobalFanout() - - /** - * Claim the key for the configured TTL. - * - * Three-tier fallback: - * 1. APCu (`apcu_add`) — race-free claim, TTL enforced by APCu. - * 2. `ICache` (distributed) — cross-request fallback when APCu is - * unusable and a cache backend is injected. Implemented as - * `hasKey()`-then-`set()` because `ICache` exposes no atomic - * `add()`; the resulting small race window is acceptable for a - * 15-minute UX debounce guard (it is not a security control). - * 3. In-memory array — only when no cache is injected (defensive) - * or under a test clock (deterministic unit-test path). - * - * @param string $key The full cache key. - * - * @return bool True when the caller successfully claimed the window. - */ - private function claim(string $key): bool - { - $now = ($this->clock)(); - - if ($this->apcuUsable() === true) { - // `apcu_add` returns false when the key already exists, - // which is exactly the semantics we want for a debounce - // claim. The TTL is enforced by APCu itself. - return (bool) apcu_add($key, $now, self::TTL_SECONDS); - } - - // APCu is unusable. When a real clock is in effect and a - // distributed cache is injected, use it so the debounce claim - // survives across requests and PHP-FPM workers regardless of - // APCu availability. The test-clock path deliberately skips the - // cache (its wall-clock TTL cannot follow a fake clock). - if ($this->realClock === true && $this->cache !== null) { - if ($this->cache->hasKey($key) === true) { - return false; - } - - $this->cache->set($key, $now, self::TTL_SECONDS); - return true; - } - - // Purge expired entries opportunistically to keep the in-memory - // store from growing without bound. - foreach ($this->memory as $existingKey => $expiresAt) { - if ($expiresAt <= $now) { - unset($this->memory[$existingKey]); - } - } - - if (array_key_exists(key: $key, array: $this->memory) === true) { - return false; - } - - $this->memory[$key] = ($now + self::TTL_SECONDS); - return true; - }//end claim() - - /** - * True when APCu is actually usable for debounce claims. - * - * `function_exists('apcu_add')` alone is not enough — the function - * is loaded by the extension even when APCu is disabled at runtime - * (most notably under CLI when `apc.enable_cli=0`), in which case - * `apcu_add()` silently returns false on every call. That breaks - * the debounce semantics: the helper would treat every claim as - * "already taken" and reject every emission. - * - * `apcu_enabled()` was introduced in APCu 4.0.5 specifically for - * this gate; when it's available we trust it. When it isn't (very - * old APCu builds), fall back to the existence check + an - * `ini_get('apc.enabled')` probe. - * - * @return bool True when APCu is loaded AND enabled at runtime. - */ - private function apcuUsable(): bool - { - // A test-injected clock cannot move APCu's wall-clock TTL, so - // the in-memory store is the only backend that produces - // deterministic results when the clock is fake. - if ($this->realClock === false) { - return false; - } - - if (function_exists(function: 'apcu_add') === false || function_exists(function: 'apcu_exists') === false) { - return false; - } - - if (function_exists(function: 'apcu_enabled') === true) { - return (bool) apcu_enabled(); - } - - return (bool) ini_get(option: 'apc.enabled'); - }//end apcuUsable() +class DebounceHelper { + /** + * Debounce TTL in seconds (15 minutes). REQ-ACT-007, REQ-ACT-008. + */ + public const TTL_SECONDS = 900; + + /** + * In-memory fallback store used when APCu is not available. + * + * Keyed by the same APCu key string; the value is the unix + * timestamp at which the entry expires. + * + * @var array + */ + private array $memory = []; + + /** + * Optional clock callable returning the current unix timestamp. + * + * Injected by tests to advance time deterministically without + * sleeping for 15 minutes. + * + * @var callable():int + */ + private $clock; + + /** + * True when the helper is using the real wall-clock and may delegate + * to APCu. False when a test clock is injected — in that case APCu's + * own TTL would not move with the test clock, so the in-memory + * fallback is the only correct backend. + * + * @var boolean + */ + private bool $realClock; + + /** + * Distributed cache used as the cross-request fallback when APCu is + * not usable. Nullable so existing unit-test call sites that build + * `new DebounceHelper($clock)` keep working without a cache backend. + * + * @var ICache|null + */ + private ?ICache $cache; + + /** + * Constructor. + * + * @param (callable():int)|null $clock Optional clock callable; defaults to `time()`. + * @param ICache|null $cache Optional distributed cache used as the + * cross-request fallback when APCu is + * unusable. Null falls back to the + * in-memory array. + */ + public function __construct(?callable $clock = null, ?ICache $cache = null) { + $this->realClock = ($clock === null); + $this->clock = ($clock ?? static fn (): int => time()); + $this->cache = $cache; + }//end __construct() + + /** + * Check whether a reaction event from `$actorUserId` on + * `$dashboardUuid` may be emitted now. + * + * Returns true on the first call and again after the 900-second + * window has elapsed; returns false for any call inside an active + * window. + * + * @param string $actorUserId The acting user ID. + * @param string $dashboardUuid The dashboard UUID. + * + * @return bool True when emission is allowed. + */ + public function allowReaction( + string $actorUserId, + string $dashboardUuid, + ): bool { + $key = sprintf( + 'launchpad_act_react_%s_%s', + $actorUserId, + $dashboardUuid + ); + return $this->claim(key: $key); + }//end allowReaction() + + /** + * Check whether a default-group fan-out for `(dashboardUuid, eventType)` + * may be performed now (REQ-ACT-008). + * + * @param string $dashboardUuid The dashboard UUID. + * @param string $eventType The event type constant value. + * + * @return bool True when fan-out is allowed. + */ + public function allowGlobalFanout( + string $dashboardUuid, + string $eventType, + ): bool { + $key = sprintf( + 'launchpad_act_global_%s_%s', + $dashboardUuid, + $eventType + ); + return $this->claim(key: $key); + }//end allowGlobalFanout() + + /** + * Claim the key for the configured TTL. + * + * Three-tier fallback: + * 1. APCu (`apcu_add`) — race-free claim, TTL enforced by APCu. + * 2. `ICache` (distributed) — cross-request fallback when APCu is + * unusable and a cache backend is injected. Implemented as + * `hasKey()`-then-`set()` because `ICache` exposes no atomic + * `add()`; the resulting small race window is acceptable for a + * 15-minute UX debounce guard (it is not a security control). + * 3. In-memory array — only when no cache is injected (defensive) + * or under a test clock (deterministic unit-test path). + * + * @param string $key The full cache key. + * + * @return bool True when the caller successfully claimed the window. + */ + private function claim(string $key): bool { + $now = ($this->clock)(); + + if ($this->apcuUsable() === true) { + // `apcu_add` returns false when the key already exists, + // which is exactly the semantics we want for a debounce + // claim. The TTL is enforced by APCu itself. + return (bool)apcu_add($key, $now, self::TTL_SECONDS); + } + + // APCu is unusable. When a real clock is in effect and a + // distributed cache is injected, use it so the debounce claim + // survives across requests and PHP-FPM workers regardless of + // APCu availability. The test-clock path deliberately skips the + // cache (its wall-clock TTL cannot follow a fake clock). + if ($this->realClock === true && $this->cache !== null) { + if ($this->cache->hasKey($key) === true) { + return false; + } + + $this->cache->set($key, $now, self::TTL_SECONDS); + return true; + } + + // Purge expired entries opportunistically to keep the in-memory + // store from growing without bound. + foreach ($this->memory as $existingKey => $expiresAt) { + if ($expiresAt <= $now) { + unset($this->memory[$existingKey]); + } + } + + if (array_key_exists(key: $key, array: $this->memory) === true) { + return false; + } + + $this->memory[$key] = ($now + self::TTL_SECONDS); + return true; + }//end claim() + + /** + * True when APCu is actually usable for debounce claims. + * + * `function_exists('apcu_add')` alone is not enough — the function + * is loaded by the extension even when APCu is disabled at runtime + * (most notably under CLI when `apc.enable_cli=0`), in which case + * `apcu_add()` silently returns false on every call. That breaks + * the debounce semantics: the helper would treat every claim as + * "already taken" and reject every emission. + * + * `apcu_enabled()` was introduced in APCu 4.0.5 specifically for + * this gate; when it's available we trust it. When it isn't (very + * old APCu builds), fall back to the existence check + an + * `ini_get('apc.enabled')` probe. + * + * @return bool True when APCu is loaded AND enabled at runtime. + */ + private function apcuUsable(): bool { + // A test-injected clock cannot move APCu's wall-clock TTL, so + // the in-memory store is the only backend that produces + // deterministic results when the clock is fake. + if ($this->realClock === false) { + return false; + } + + if (function_exists(function: 'apcu_add') === false || function_exists(function: 'apcu_exists') === false) { + return false; + } + + if (function_exists(function: 'apcu_enabled') === true) { + return (bool)apcu_enabled(); + } + + return (bool)ini_get(option: 'apc.enabled'); + }//end apcuUsable() }//end class diff --git a/lib/Activity/Extension.php b/lib/Activity/Extension.php index ff91ebbe..996b8212 100644 --- a/lib/Activity/Extension.php +++ b/lib/Activity/Extension.php @@ -61,263 +61,259 @@ * @SuppressWarnings(PHPMD.UnusedFormalParameter) — $previousEvent required by IProvider interface. * @spec openspec/specs/activity-feed-integration/spec.md */ -class Extension implements IProvider -{ - /** - * LaunchPad application identifier in the Activity stream. - */ - public const APP_ID = Application::APP_ID; +class Extension implements IProvider { + /** + * LaunchPad application identifier in the Activity stream. + */ + public const APP_ID = Application::APP_ID; - /** - * Object type stored on every emitted IEvent. - * - * The activity object semantics follow NC core: `objectType` is a - * stable string LaunchPad owns; `objectName` carries the dashboard - * UUID (the IEvent::setObject signature requires the numeric - * primary-key int as `objectId`). - */ - public const OBJECT_TYPE = 'launchpad_dashboard'; + /** + * Object type stored on every emitted IEvent. + * + * The activity object semantics follow NC core: `objectType` is a + * stable string LaunchPad owns; `objectName` carries the dashboard + * UUID (the IEvent::setObject signature requires the numeric + * primary-key int as `objectId`). + */ + public const OBJECT_TYPE = 'launchpad_dashboard'; - public const EVENT_CREATED = 'dashboard_created'; - public const EVENT_UPDATED = 'dashboard_updated'; - public const EVENT_DELETED = 'dashboard_deleted'; - public const EVENT_PUBLISHED = 'dashboard_published'; - public const EVENT_UNPUBLISHED = 'dashboard_unpublished'; - public const EVENT_SCHEDULED = 'dashboard_scheduled'; - public const EVENT_SHARED = 'dashboard_shared'; - public const EVENT_PUBLIC_SHARE_CREATED = 'dashboard_public_share_created'; - public const EVENT_COMMENTED = 'dashboard_commented'; - public const EVENT_REACTED = 'dashboard_reacted'; - public const EVENT_RESTORED = 'dashboard_restored'; - public const EVENT_LOCK_OVERRIDDEN = 'dashboard_lock_overridden'; - public const EVENT_ROLE_CHANGED = 'dashboard_role_changed'; - public const EVENT_ACKNOWLEDGED = 'dashboard_acknowledged'; + public const EVENT_CREATED = 'dashboard_created'; + public const EVENT_UPDATED = 'dashboard_updated'; + public const EVENT_DELETED = 'dashboard_deleted'; + public const EVENT_PUBLISHED = 'dashboard_published'; + public const EVENT_UNPUBLISHED = 'dashboard_unpublished'; + public const EVENT_SCHEDULED = 'dashboard_scheduled'; + public const EVENT_SHARED = 'dashboard_shared'; + public const EVENT_PUBLIC_SHARE_CREATED = 'dashboard_public_share_created'; + public const EVENT_COMMENTED = 'dashboard_commented'; + public const EVENT_REACTED = 'dashboard_reacted'; + public const EVENT_RESTORED = 'dashboard_restored'; + public const EVENT_LOCK_OVERRIDDEN = 'dashboard_lock_overridden'; + public const EVENT_ROLE_CHANGED = 'dashboard_role_changed'; + public const EVENT_ACKNOWLEDGED = 'dashboard_acknowledged'; - /** - * Canonical list of every LaunchPad event type registered with NC - * Activity. Used for the per-type opt-out registration loop and - * the unit-test contract. - * - * `dashboard_viewed` is intentionally excluded — view tracking is - * owned by `dashboard-view-analytics` and MUST NOT be published to - * the Activity stream (see REQ-ACT-002 and design D2). - */ - public const ALL_EVENTS = [ - self::EVENT_CREATED, - self::EVENT_UPDATED, - self::EVENT_DELETED, - self::EVENT_PUBLISHED, - self::EVENT_UNPUBLISHED, - self::EVENT_SCHEDULED, - self::EVENT_SHARED, - self::EVENT_PUBLIC_SHARE_CREATED, - self::EVENT_COMMENTED, - self::EVENT_REACTED, - self::EVENT_RESTORED, - self::EVENT_LOCK_OVERRIDDEN, - self::EVENT_ROLE_CHANGED, - self::EVENT_ACKNOWLEDGED, - ]; + /** + * Canonical list of every LaunchPad event type registered with NC + * Activity. Used for the per-type opt-out registration loop and + * the unit-test contract. + * + * `dashboard_viewed` is intentionally excluded — view tracking is + * owned by `dashboard-view-analytics` and MUST NOT be published to + * the Activity stream (see REQ-ACT-002 and design D2). + */ + public const ALL_EVENTS = [ + self::EVENT_CREATED, + self::EVENT_UPDATED, + self::EVENT_DELETED, + self::EVENT_PUBLISHED, + self::EVENT_UNPUBLISHED, + self::EVENT_SCHEDULED, + self::EVENT_SHARED, + self::EVENT_PUBLIC_SHARE_CREATED, + self::EVENT_COMMENTED, + self::EVENT_REACTED, + self::EVENT_RESTORED, + self::EVENT_LOCK_OVERRIDDEN, + self::EVENT_ROLE_CHANGED, + self::EVENT_ACKNOWLEDGED, + ]; - /** - * Constructor. - * - * @param IFactory $l10nFactory The L10N factory. - * @param IURLGenerator $urlGenerator The URL generator. - */ - public function __construct( - private readonly IFactory $l10nFactory, - private readonly IURLGenerator $urlGenerator, - ) { - }//end __construct() + /** + * Constructor. + * + * @param IFactory $l10nFactory The L10N factory. + * @param IURLGenerator $urlGenerator The URL generator. + */ + public function __construct( + private readonly IFactory $l10nFactory, + private readonly IURLGenerator $urlGenerator, + ) { + }//end __construct() - /** - * Parse a raw activity event into a translated, rich-formatted one. - * - * Returns the event with `richSubject`, `parsedSubject`, `icon`, - * and (where applicable) message fields populated. Unknown event - * types throw `UnknownActivityException` so the NC Activity chain - * can pass the event to the next provider (REQ-ACT-001 scenario). - * - * @param string $language The language code. - * @param IEvent $event The raw event. - * @param IEvent|null $previousEvent A previous event for merging (unused). - * - * @return IEvent The parsed event. - * - * @throws UnknownActivityException When the event type is not handled. - * @spec openspec/specs/activity-feed-integration/spec.md - */ - public function parse( - $language, - IEvent $event, - ?IEvent $previousEvent=null - ): IEvent { - if ($event->getApp() !== self::APP_ID) { - throw new UnknownActivityException( - message: 'Unknown app: '.$event->getApp() - ); - } + /** + * Parse a raw activity event into a translated, rich-formatted one. + * + * Returns the event with `richSubject`, `parsedSubject`, `icon`, + * and (where applicable) message fields populated. Unknown event + * types throw `UnknownActivityException` so the NC Activity chain + * can pass the event to the next provider (REQ-ACT-001 scenario). + * + * @param string $language The language code. + * @param IEvent $event The raw event. + * @param IEvent|null $previousEvent A previous event for merging (unused). + * + * @return IEvent The parsed event. + * + * @throws UnknownActivityException When the event type is not handled. + * @spec openspec/specs/activity-feed-integration/spec.md + */ + public function parse( + $language, + IEvent $event, + ?IEvent $previousEvent = null, + ): IEvent { + if ($event->getApp() !== self::APP_ID) { + throw new UnknownActivityException( + message: 'Unknown app: ' . $event->getApp() + ); + } - $type = $event->getType(); - if (in_array(needle: $type, haystack: self::ALL_EVENTS, strict: true) === false) { - throw new UnknownActivityException( - message: 'Unknown subject: '.$type - ); - } + $type = $event->getType(); + if (in_array(needle: $type, haystack: self::ALL_EVENTS, strict: true) === false) { + throw new UnknownActivityException( + message: 'Unknown subject: ' . $type + ); + } - $l = $this->l10nFactory->get(app: self::APP_ID, lang: $language); - $params = $event->getSubjectParameters(); - $isSelf = (bool) ($params['self'] ?? false); - $actor = (string) ($params['actor'] ?? $event->getAuthor()); - $dashboard = (string) ($params['dashboard'] ?? $event->getObjectName()); - $recipient = (string) ($params['recipient'] ?? ''); - $role = (string) ($params['role'] ?? ''); - $target = (string) ($params['target'] ?? ''); + $l = $this->l10nFactory->get(app: self::APP_ID, lang: $language); + $params = $event->getSubjectParameters(); + $isSelf = (bool)($params['self'] ?? false); + $actor = (string)($params['actor'] ?? $event->getAuthor()); + $dashboard = (string)($params['dashboard'] ?? $event->getObjectName()); + $recipient = (string)($params['recipient'] ?? ''); + $role = (string)($params['role'] ?? ''); + $target = (string)($params['target'] ?? ''); - $template = $this->resolveSubjectTemplate( - type: $type, - isSelf: $isSelf - ); - $rendered = strtr( - $l->t($template), - [ - '{actor}' => $actor, - '{dashboard}' => $dashboard, - '{recipient}' => $recipient, - '{role}' => $role, - '{target}' => $target, - ] - ); + $template = $this->resolveSubjectTemplate( + type: $type, + isSelf: $isSelf + ); + $rendered = strtr( + $l->t($template), + [ + '{actor}' => $actor, + '{dashboard}' => $dashboard, + '{recipient}' => $recipient, + '{role}' => $role, + '{target}' => $target, + ] + ); - $event->setRichSubject(subject: $rendered); - $event->setParsedSubject(subject: $rendered); - $event->setIcon(icon: $this->getIcon(eventType: $type)); + $event->setRichSubject(subject: $rendered); + $event->setParsedSubject(subject: $rendered); + $event->setIcon(icon: $this->getIcon(eventType: $type)); - return $event; - }//end parse() + return $event; + }//end parse() - /** - * Return an absolute URL to the per-type Activity icon. - * - * Falls back to `img/activity/launchpad.svg` (the generic LaunchPad icon) - * when `$eventType` is not a known constant. - * - * @param string $eventType The event type string. - * - * @return string The absolute icon URL. - * @spec openspec/specs/activity-feed-integration/spec.md - */ - public function getIcon(string $eventType): string - { - $known = in_array( - needle: $eventType, - haystack: self::ALL_EVENTS, - strict: true - ); - $file = 'activity/launchpad.svg'; - if ($known === true) { - $file = 'activity/'.$eventType.'.svg'; - } + /** + * Return an absolute URL to the per-type Activity icon. + * + * Falls back to `img/activity/launchpad.svg` (the generic LaunchPad icon) + * when `$eventType` is not a known constant. + * + * @param string $eventType The event type string. + * + * @return string The absolute icon URL. + * @spec openspec/specs/activity-feed-integration/spec.md + */ + public function getIcon(string $eventType): string { + $known = in_array( + needle: $eventType, + haystack: self::ALL_EVENTS, + strict: true + ); + $file = 'activity/launchpad.svg'; + if ($known === true) { + $file = 'activity/' . $eventType . '.svg'; + } - return $this->urlGenerator->getAbsoluteURL( - url: $this->urlGenerator->imagePath( - appName: self::APP_ID, - file: $file - ) - ); - }//end getIcon() + return $this->urlGenerator->getAbsoluteURL( + url: $this->urlGenerator->imagePath( + appName: self::APP_ID, + file: $file + ) + ); + }//end getIcon() - /** - * Return the canonical subject-template catalogue keyed by event - * type with `self` (first-person) and `other` (third-person) - * variants (REQ-ACT-010). - * - * Templates use `{placeholder}` substitution that is rendered both - * by `parse()` and by NC Activity's translation layer. - * - * @return array - * @spec openspec/specs/activity-feed-integration/spec.md - */ - public function getSubjectTemplates(): array - { - return [ - self::EVENT_CREATED => [ - 'self' => 'You created dashboard {dashboard}', - 'other' => '{actor} created dashboard {dashboard}', - ], - self::EVENT_UPDATED => [ - 'self' => 'You updated dashboard {dashboard}', - 'other' => '{actor} updated dashboard {dashboard}', - ], - self::EVENT_DELETED => [ - 'self' => 'You deleted dashboard {dashboard}', - 'other' => '{actor} deleted dashboard {dashboard}', - ], - self::EVENT_PUBLISHED => [ - 'self' => 'You published dashboard {dashboard}', - 'other' => '{actor} published dashboard {dashboard}', - ], - self::EVENT_UNPUBLISHED => [ - 'self' => 'You unpublished dashboard {dashboard}', - 'other' => '{actor} unpublished dashboard {dashboard}', - ], - self::EVENT_SCHEDULED => [ - 'self' => 'You scheduled dashboard {dashboard}', - 'other' => '{actor} scheduled dashboard {dashboard}', - ], - self::EVENT_SHARED => [ - 'self' => 'You shared dashboard {dashboard} with {recipient}', - 'other' => '{actor} shared dashboard {dashboard} with {recipient}', - ], - self::EVENT_PUBLIC_SHARE_CREATED => [ - 'self' => 'You created a public link for dashboard {dashboard}', - 'other' => '{actor} created a public link for dashboard {dashboard}', - ], - self::EVENT_COMMENTED => [ - 'self' => 'You commented on dashboard {dashboard}', - 'other' => '{actor} commented on dashboard {dashboard}', - ], - self::EVENT_REACTED => [ - 'self' => 'You reacted to dashboard {dashboard}', - 'other' => '{actor} reacted to dashboard {dashboard}', - ], - self::EVENT_RESTORED => [ - 'self' => 'You restored dashboard {dashboard} to an earlier version', - 'other' => '{actor} restored dashboard {dashboard} to an earlier version', - ], - self::EVENT_LOCK_OVERRIDDEN => [ - 'self' => 'You overrode the lock on dashboard {dashboard}', - 'other' => '{actor} overrode the lock on dashboard {dashboard}', - ], - self::EVENT_ROLE_CHANGED => [ - 'self' => 'Your role in {dashboard} was changed to {role}', - 'other' => "{actor} changed {target}'s role in {dashboard} to {role}", - ], - self::EVENT_ACKNOWLEDGED => [ - 'self' => 'You acknowledged {dashboard}', - 'other' => '{actor} acknowledged {dashboard}', - ], - ]; - }//end getSubjectTemplates() + /** + * Return the canonical subject-template catalogue keyed by event + * type with `self` (first-person) and `other` (third-person) + * variants (REQ-ACT-010). + * + * Templates use `{placeholder}` substitution that is rendered both + * by `parse()` and by NC Activity's translation layer. + * + * @return array + * @spec openspec/specs/activity-feed-integration/spec.md + */ + public function getSubjectTemplates(): array { + return [ + self::EVENT_CREATED => [ + 'self' => 'You created dashboard {dashboard}', + 'other' => '{actor} created dashboard {dashboard}', + ], + self::EVENT_UPDATED => [ + 'self' => 'You updated dashboard {dashboard}', + 'other' => '{actor} updated dashboard {dashboard}', + ], + self::EVENT_DELETED => [ + 'self' => 'You deleted dashboard {dashboard}', + 'other' => '{actor} deleted dashboard {dashboard}', + ], + self::EVENT_PUBLISHED => [ + 'self' => 'You published dashboard {dashboard}', + 'other' => '{actor} published dashboard {dashboard}', + ], + self::EVENT_UNPUBLISHED => [ + 'self' => 'You unpublished dashboard {dashboard}', + 'other' => '{actor} unpublished dashboard {dashboard}', + ], + self::EVENT_SCHEDULED => [ + 'self' => 'You scheduled dashboard {dashboard}', + 'other' => '{actor} scheduled dashboard {dashboard}', + ], + self::EVENT_SHARED => [ + 'self' => 'You shared dashboard {dashboard} with {recipient}', + 'other' => '{actor} shared dashboard {dashboard} with {recipient}', + ], + self::EVENT_PUBLIC_SHARE_CREATED => [ + 'self' => 'You created a public link for dashboard {dashboard}', + 'other' => '{actor} created a public link for dashboard {dashboard}', + ], + self::EVENT_COMMENTED => [ + 'self' => 'You commented on dashboard {dashboard}', + 'other' => '{actor} commented on dashboard {dashboard}', + ], + self::EVENT_REACTED => [ + 'self' => 'You reacted to dashboard {dashboard}', + 'other' => '{actor} reacted to dashboard {dashboard}', + ], + self::EVENT_RESTORED => [ + 'self' => 'You restored dashboard {dashboard} to an earlier version', + 'other' => '{actor} restored dashboard {dashboard} to an earlier version', + ], + self::EVENT_LOCK_OVERRIDDEN => [ + 'self' => 'You overrode the lock on dashboard {dashboard}', + 'other' => '{actor} overrode the lock on dashboard {dashboard}', + ], + self::EVENT_ROLE_CHANGED => [ + 'self' => 'Your role in {dashboard} was changed to {role}', + 'other' => "{actor} changed {target}'s role in {dashboard} to {role}", + ], + self::EVENT_ACKNOWLEDGED => [ + 'self' => 'You acknowledged {dashboard}', + 'other' => '{actor} acknowledged {dashboard}', + ], + ]; + }//end getSubjectTemplates() - /** - * Resolve the subject template string for `$type` honoring the - * self/other variant split. - * - * @param string $type The event-type constant value. - * @param bool $isSelf True when the actor equals the recipient. - * - * @return string The template string with `{placeholder}` tokens. - */ - private function resolveSubjectTemplate(string $type, bool $isSelf): string - { - $templates = $this->getSubjectTemplates(); - $variant = 'other'; - if ($isSelf === true) { - $variant = 'self'; - } + /** + * Resolve the subject template string for `$type` honoring the + * self/other variant split. + * + * @param string $type The event-type constant value. + * @param bool $isSelf True when the actor equals the recipient. + * + * @return string The template string with `{placeholder}` tokens. + */ + private function resolveSubjectTemplate(string $type, bool $isSelf): string { + $templates = $this->getSubjectTemplates(); + $variant = 'other'; + if ($isSelf === true) { + $variant = 'self'; + } - return ($templates[$type][$variant] ?? ''); - }//end resolveSubjectTemplate() + return ($templates[$type][$variant] ?? ''); + }//end resolveSubjectTemplate() }//end class diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 3e117996..758f7886 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -57,318 +57,308 @@ * coupling count beyond * the default threshold. */ -class Application extends App implements IBootstrap -{ - public const APP_ID = 'launchpad'; +class Application extends App implements IBootstrap { + public const APP_ID = 'launchpad'; - /** - * Constructor - * - * @param array $urlParams The URL parameters. - */ - public function __construct(array $urlParams=[]) - { - parent::__construct(appName: self::APP_ID, urlParams: $urlParams); - }//end __construct() + /** + * Constructor + * + * @param array $urlParams The URL parameters. + */ + public function __construct(array $urlParams = []) { + parent::__construct(appName: self::APP_ID, urlParams: $urlParams); + }//end __construct() - /** - * Register services, event listeners, etc. - * - * @param IRegistrationContext $context The registration context. - * - * @return void - */ - public function register(IRegistrationContext $context): void - { - $this->registerUserLifecycle(context: $context); - $this->registerSharedServices(context: $context); - $this->registerCascadeListeners(context: $context); - $this->registerIntegrations(context: $context); + /** + * Register services, event listeners, etc. + * + * @param IRegistrationContext $context The registration context. + * + * @return void + */ + public function register(IRegistrationContext $context): void { + $this->registerUserLifecycle(context: $context); + $this->registerSharedServices(context: $context); + $this->registerCascadeListeners(context: $context); + $this->registerIntegrations(context: $context); - // Observability (ADR-040): re-point the unchanged /api/health and - // /api/metrics routes at thin subclasses of the OpenRegister AppHost - // generic controllers, which render the declarative observability block - // of src/manifest.json. The factories below are lazy — they reference - // no OCA\OpenRegister\… symbol until a request resolves the controller, - // so a disabled/absent OpenRegister never fatals NC bootstrap (the route - // then surfaces the degraded OR-unavailable state instead). $appId is the - // runtime app id `launchpad`; the engine reads the manifest under it and - // emits the launchpad_ Prometheus prefix, preserving the contract. - $this->registerObservability(context: $context); - }//end register() + // Observability (ADR-040): re-point the unchanged /api/health and + // /api/metrics routes at thin subclasses of the OpenRegister AppHost + // generic controllers, which render the declarative observability block + // of src/manifest.json. The factories below are lazy — they reference + // no OCA\OpenRegister\… symbol until a request resolves the controller, + // so a disabled/absent OpenRegister never fatals NC bootstrap (the route + // then surfaces the degraded OR-unavailable state instead). $appId is the + // runtime app id `launchpad`; the engine reads the manifest under it and + // emits the launchpad_ Prometheus prefix, preserving the contract. + $this->registerObservability(context: $context); + }//end register() - /** - * Register the notifier and the user-deletion cascade listener. - * - * @param IRegistrationContext $context The registration context. - * - * @return void - */ - private function registerUserLifecycle(IRegistrationContext $context): void - { - // Render `dashboard_shared` and `dashboard_ownership_transferred` - // notifications via our INotifier. REQ-SHARE-011. - $context->registerNotifierService(notifierClass: Notifier::class); + /** + * Register the notifier and the user-deletion cascade listener. + * + * @param IRegistrationContext $context The registration context. + * + * @return void + */ + private function registerUserLifecycle(IRegistrationContext $context): void { + // Render `dashboard_shared` and `dashboard_ownership_transferred` + // notifications via our INotifier. REQ-SHARE-011. + $context->registerNotifierService(notifierClass: Notifier::class); - // Cascade share cleanup + admin-retention transfer on user deletion. - // Also fires the role-assignment cleanup. REQ-SHARE-012, - // REQ-SHARE-013, REQ-ROLE-010. The same listener also satisfies the - // owned-dashboard enumeration mandated by REQ-CSC-004 — every owned - // dashboard is routed through the deletion path that dispatches - // DashboardDeletedEvent below, triggering the full cascade stack. - $context->registerEventListener( - event: UserDeletedEvent::class, - listener: UserDeletedListener::class - ); - }//end registerUserLifecycle() + // Cascade share cleanup + admin-retention transfer on user deletion. + // Also fires the role-assignment cleanup. REQ-SHARE-012, + // REQ-SHARE-013, REQ-ROLE-010. The same listener also satisfies the + // owned-dashboard enumeration mandated by REQ-CSC-004 — every owned + // dashboard is routed through the deletion path that dispatches + // DashboardDeletedEvent below, triggering the full cascade stack. + $context->registerEventListener( + event: UserDeletedEvent::class, + listener: UserDeletedListener::class + ); + }//end registerUserLifecycle() - /** - * Register the request- and instance-scoped shared services. - * - * @param IRegistrationContext $context The registration context. - * - * @return void - */ - private function registerSharedServices(IRegistrationContext $context): void - { - // REQ-ACT-007/REQ-ACT-008: register DebounceHelper as a shared - // singleton and inject the distributed cache. The PHP singleton - // alone only lives for one request (PHP-FPM rebuilds the DI - // container every request); it is the shared *distributed cache* - // — not the singleton — that makes the 900-second debounce - // guarantee hold across requests and workers when APCu is - // absent. ActivityPublisher autowires from the app namespace — - // no explicit binding needed (referenced here in this docblock - // for the cross-capability discoverability contract: - // {@see ActivityPublisher}). - $context->registerService( - name: DebounceHelper::class, - factory: static fn(\Psr\Container\ContainerInterface $c): DebounceHelper => new DebounceHelper( - cache: $c->get(\OCP\ICacheFactory::class)->createDistributed('launchpad_activity_debounce') - ), - shared: true - ); + /** + * Register the request- and instance-scoped shared services. + * + * @param IRegistrationContext $context The registration context. + * + * @return void + */ + private function registerSharedServices(IRegistrationContext $context): void { + // REQ-ACT-007/REQ-ACT-008: register DebounceHelper as a shared + // singleton and inject the distributed cache. The PHP singleton + // alone only lives for one request (PHP-FPM rebuilds the DI + // container every request); it is the shared *distributed cache* + // — not the singleton — that makes the 900-second debounce + // guarantee hold across requests and workers when APCu is + // absent. ActivityPublisher autowires from the app namespace — + // no explicit binding needed (referenced here in this docblock + // for the cross-capability discoverability contract: + // {@see ActivityPublisher}). + $context->registerService( + name: DebounceHelper::class, + factory: static fn (\Psr\Container\ContainerInterface $c): DebounceHelper => new DebounceHelper( + cache: $c->get(\OCP\ICacheFactory::class)->createDistributed('launchpad_activity_debounce') + ), + shared: true + ); - // Task-7 of dashboard-public-share — request-scoped bearer marker - // shared across the entire request so mutation services can - // assert read-only context without middleware plumbing. - $context->registerService( - name: PublicShareContext::class, - factory: static fn(): PublicShareContext => new PublicShareContext(), - shared: true - ); - }//end registerSharedServices() + // Task-7 of dashboard-public-share — request-scoped bearer marker + // shared across the entire request so mutation services can + // assert read-only context without middleware plumbing. + $context->registerService( + name: PublicShareContext::class, + factory: static fn (): PublicShareContext => new PublicShareContext(), + shared: true + ); + }//end registerSharedServices() - /** - * Register the group- and dashboard-deletion cascade listeners. - * - * @param IRegistrationContext $context The registration context. - * - * @return void - */ - private function registerCascadeListeners(IRegistrationContext $context): void - { - // Role-assignment cascade on group deletion. REQ-ROLE-011. - // Group lifecycle cleanup. REQ-CSC-005. - $context->registerEventListener( - event: GroupDeletedEvent::class, - listener: GroupDeletedListener::class - ); + /** + * Register the group- and dashboard-deletion cascade listeners. + * + * @param IRegistrationContext $context The registration context. + * + * @return void + */ + private function registerCascadeListeners(IRegistrationContext $context): void { + // Role-assignment cascade on group deletion. REQ-ROLE-011. + // Group lifecycle cleanup. REQ-CSC-005. + $context->registerEventListener( + event: GroupDeletedEvent::class, + listener: GroupDeletedListener::class + ); - // DashboardDeletedEvent listener registry. REQ-CSC-002. - // Each listener owns one dependent table (or, for TreeListener, - // recursive child dispatch). Adding a new listener requires only - // appending one registration line below — no edits to existing - // listener classes, the event, or DashboardService. - $context->registerEventListener( - event: DashboardDeletedEvent::class, - listener: WidgetPlacementsListener::class - ); - $context->registerEventListener( - event: DashboardDeletedEvent::class, - listener: ReactionsListener::class - ); - $context->registerEventListener( - event: DashboardDeletedEvent::class, - listener: LocksListener::class - ); - $context->registerEventListener( - event: DashboardDeletedEvent::class, - listener: VersionsListener::class - ); - $context->registerEventListener( - event: DashboardDeletedEvent::class, - listener: PublicSharesListener::class - ); - $context->registerEventListener( - event: DashboardDeletedEvent::class, - listener: MetadataValuesListener::class - ); - $context->registerEventListener( - event: DashboardDeletedEvent::class, - listener: TranslationsListener::class - ); - $context->registerEventListener( - event: DashboardDeletedEvent::class, - listener: ViewAnalyticsListener::class - ); - $context->registerEventListener( - event: DashboardDeletedEvent::class, - listener: TreeListener::class - ); - }//end registerCascadeListeners() + // DashboardDeletedEvent listener registry. REQ-CSC-002. + // Each listener owns one dependent table (or, for TreeListener, + // recursive child dispatch). Adding a new listener requires only + // appending one registration line below — no edits to existing + // listener classes, the event, or DashboardService. + $context->registerEventListener( + event: DashboardDeletedEvent::class, + listener: WidgetPlacementsListener::class + ); + $context->registerEventListener( + event: DashboardDeletedEvent::class, + listener: ReactionsListener::class + ); + $context->registerEventListener( + event: DashboardDeletedEvent::class, + listener: LocksListener::class + ); + $context->registerEventListener( + event: DashboardDeletedEvent::class, + listener: VersionsListener::class + ); + $context->registerEventListener( + event: DashboardDeletedEvent::class, + listener: PublicSharesListener::class + ); + $context->registerEventListener( + event: DashboardDeletedEvent::class, + listener: MetadataValuesListener::class + ); + $context->registerEventListener( + event: DashboardDeletedEvent::class, + listener: TranslationsListener::class + ); + $context->registerEventListener( + event: DashboardDeletedEvent::class, + listener: ViewAnalyticsListener::class + ); + $context->registerEventListener( + event: DashboardDeletedEvent::class, + listener: TreeListener::class + ); + }//end registerCascadeListeners() - /** - * Register the Nextcloud-surface integrations — unified search and the - * content-security-policy contribution. - * - * @param IRegistrationContext $context The registration context. - * - * @return void - */ - private function registerIntegrations(IRegistrationContext $context): void - { - // Surface dashboards, widget content, and metadata values in - // Nextcloud's unified search (Ctrl+K). REQ-SRCH-001. - $context->registerSearchProvider(class: LaunchPadSearchProvider::class); + /** + * Register the Nextcloud-surface integrations — unified search and the + * content-security-policy contribution. + * + * @param IRegistrationContext $context The registration context. + * + * @return void + */ + private function registerIntegrations(IRegistrationContext $context): void { + // Surface dashboards, widget content, and metadata values in + // Nextcloud's unified search (Ctrl+K). REQ-SRCH-001. + $context->registerSearchProvider(class: LaunchPadSearchProvider::class); - // `iframe` widget — contribute the admin allow-listed embed hosts - // to LaunchPad's own `frame-src` CSP directive so the instance CSP - // never blocks an otherwise-permitted embed (REQ-IFRAME-003). - $context->registerEventListener( - event: AddContentSecurityPolicyEvent::class, - listener: CspListener::class - ); - }//end registerIntegrations() + // `iframe` widget — contribute the admin allow-listed embed hosts + // to LaunchPad's own `frame-src` CSP directive so the instance CSP + // never blocks an otherwise-permitted embed (REQ-IFRAME-003). + $context->registerEventListener( + event: AddContentSecurityPolicyEvent::class, + listener: CspListener::class + ); + }//end registerIntegrations() - /** - * Wire the AppHost observability controllers (ADR-040). - * - * Aliases the unchanged `health#index` / `metrics#index` route targets at - * the OpenRegister AppHost generic controllers, per the documented leaf - * adoption pattern (docs/Technical/declarative-observability.md). The - * controller's `$appName` resolves to this leaf's app id, so the engine - * loads `src/manifest.json`'s `observability` block under `launchpad` and - * renders the `launchpad_`-prefixed Prometheus output. The generic - * controllers own the auth posture: health is public (`#[PublicPage]`), - * metrics admin-only. - * - * LaunchPad keeps its own bespoke Dashboard/Preferences/Settings/ - * AdminSettings boilerplate — entangled with the dashboard lifecycle, - * permission matrix and DoS-guarded preferences (see - * openspec/changes/adopt-apphost/design.md). The aliases are class-string - * registrations, so a disabled/absent OpenRegister never fatals NC - * bootstrap; the first request to an aliased route surfaces the degraded - * OR-unavailable state instead. - * - * @param IRegistrationContext $context The registration context. - * - * @return void - */ - private function registerObservability(IRegistrationContext $context): void - { - // Health controller. The generic class is referenced only as a string - // and instantiated inside the closure, so no OCA\OpenRegister symbol is - // touched until a request resolves the controller — keeping NC bootstrap - // fatal-free when OpenRegister is disabled/absent. $appName is pinned to - // this leaf's runtime app id (`launchpad`) so the engine loads the right - // manifest and emits the `launchpad_` prefix, exactly as before adoption. - $context->registerService( - HealthController::class, - // @psalm-suppress UnusedClosureParam,TooManyArguments - static function (\Psr\Container\ContainerInterface $c): HealthController { - return new HealthController( - appName: self::APP_ID, - request: $c->get(\OCP\IRequest::class), - manifestLoader: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\ManifestLoader'), - executor: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\HealthCheckExecutor') - ); - } - ); + /** + * Wire the AppHost observability controllers (ADR-040). + * + * Aliases the unchanged `health#index` / `metrics#index` route targets at + * the OpenRegister AppHost generic controllers, per the documented leaf + * adoption pattern (docs/Technical/declarative-observability.md). The + * controller's `$appName` resolves to this leaf's app id, so the engine + * loads `src/manifest.json`'s `observability` block under `launchpad` and + * renders the `launchpad_`-prefixed Prometheus output. The generic + * controllers own the auth posture: health is public (`#[PublicPage]`), + * metrics admin-only. + * + * LaunchPad keeps its own bespoke Dashboard/Preferences/Settings/ + * AdminSettings boilerplate — entangled with the dashboard lifecycle, + * permission matrix and DoS-guarded preferences (see + * openspec/changes/adopt-apphost/design.md). The aliases are class-string + * registrations, so a disabled/absent OpenRegister never fatals NC + * bootstrap; the first request to an aliased route surfaces the degraded + * OR-unavailable state instead. + * + * @param IRegistrationContext $context The registration context. + * + * @return void + */ + private function registerObservability(IRegistrationContext $context): void { + // Health controller. The generic class is referenced only as a string + // and instantiated inside the closure, so no OCA\OpenRegister symbol is + // touched until a request resolves the controller — keeping NC bootstrap + // fatal-free when OpenRegister is disabled/absent. $appName is pinned to + // this leaf's runtime app id (`launchpad`) so the engine loads the right + // manifest and emits the `launchpad_` prefix, exactly as before adoption. + $context->registerService( + HealthController::class, + // @psalm-suppress UnusedClosureParam,TooManyArguments + static function (\Psr\Container\ContainerInterface $c): HealthController { + return new HealthController( + appName: self::APP_ID, + request: $c->get(\OCP\IRequest::class), + manifestLoader: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\ManifestLoader'), + executor: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\HealthCheckExecutor') + ); + } + ); - // Metrics controller (admin-only — it omits #[NoAdminRequired]). - $context->registerService( - MetricsController::class, - // @psalm-suppress UnusedClosureParam,TooManyArguments - static function (\Psr\Container\ContainerInterface $c): MetricsController { - return new MetricsController( - appName: self::APP_ID, - request: $c->get(\OCP\IRequest::class), - manifestLoader: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\ManifestLoader'), - engine: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\MetricsEngine') - ); - } - ); - }//end registerObservability() + // Metrics controller (admin-only — it omits #[NoAdminRequired]). + $context->registerService( + MetricsController::class, + // @psalm-suppress UnusedClosureParam,TooManyArguments + static function (\Psr\Container\ContainerInterface $c): MetricsController { + return new MetricsController( + appName: self::APP_ID, + request: $c->get(\OCP\IRequest::class), + manifestLoader: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\ManifestLoader'), + engine: self::optional(container: $c, id: 'OCA\\OpenRegister\\AppHost\\Observability\\MetricsEngine') + ); + } + ); + }//end registerObservability() - /** - * Resolve an OpenRegister collaborator, or null when it is unavailable. - * - * The class names are passed as STRINGS and the failure is swallowed, so - * nothing here touches an `OCA\OpenRegister\…` symbol at load time and an - * absent OpenRegister yields a degraded endpoint rather than an exception. - * - * This is the second half of the fix for a real outage mode: the controllers - * used to EXTEND the OpenRegister generic controllers, and Nextcloud's router - * reflects every controller class while scanning attribute routes — so a - * missing parent class was a fatal during route matching that made every - * route in this app return 500. Lazy DI cannot make an `extends` lazy, which - * is why the controllers now inherit from OCP's Controller and take these as - * nullable, untyped collaborators. - * - * @param \Psr\Container\ContainerInterface $container The DI container. - * @param string $id Fully-qualified class name. - * - * @return object|null The service, or null when it cannot be resolved. - */ - private static function optional(\Psr\Container\ContainerInterface $container, string $id): ?object - { - try { - $service = $container->get($id); - if (is_object($service) === true) { - return $service; - } + /** + * Resolve an OpenRegister collaborator, or null when it is unavailable. + * + * The class names are passed as STRINGS and the failure is swallowed, so + * nothing here touches an `OCA\OpenRegister\…` symbol at load time and an + * absent OpenRegister yields a degraded endpoint rather than an exception. + * + * This is the second half of the fix for a real outage mode: the controllers + * used to EXTEND the OpenRegister generic controllers, and Nextcloud's router + * reflects every controller class while scanning attribute routes — so a + * missing parent class was a fatal during route matching that made every + * route in this app return 500. Lazy DI cannot make an `extends` lazy, which + * is why the controllers now inherit from OCP's Controller and take these as + * nullable, untyped collaborators. + * + * @param \Psr\Container\ContainerInterface $container The DI container. + * @param string $id Fully-qualified class name. + * + * @return object|null The service, or null when it cannot be resolved. + */ + private static function optional(\Psr\Container\ContainerInterface $container, string $id): ?object { + try { + $service = $container->get($id); + if (is_object($service) === true) { + return $service; + } - return null; - } catch (\Throwable) { - return null; - } - }//end optional() + return null; + } catch (\Throwable) { + return null; + } + }//end optional() - /** - * App initialization after all apps are registered. - * - * @param IBootContext $context The boot context (unused; required by IBootstrap). - * - * @return void - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) - * `IBootstrap::boot()` mandates the `IBootContext $context` - * parameter. This boot step only installs a process-level libxml - * entity loader, which needs nothing from the context, but the - * parameter cannot be dropped without breaking the interface. - */ - public function boot(IBootContext $context): void - { - // C2: block all external XML entity resolution at the process level. - // LIBXML_NOENT in simplexml_load_string / DOMDocument::loadXML does - // NOT disable entity substitution — it enables it. The only reliable - // defence is to install a null entity loader here at boot time. This - // is safe because Nextcloud itself does not rely on external XML - // entities in its own code. - if (function_exists('libxml_set_external_entity_loader') === true) { - // @psalm-suppress UnusedFunctionCall - libxml_set_external_entity_loader(static fn (): null => null); - } + /** + * App initialization after all apps are registered. + * + * @param IBootContext $context The boot context (unused; required by IBootstrap). + * + * @return void + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + * `IBootstrap::boot()` mandates the `IBootContext $context` + * parameter. This boot step only installs a process-level libxml + * entity loader, which needs nothing from the context, but the + * parameter cannot be dropped without breaking the interface. + */ + public function boot(IBootContext $context): void { + // C2: block all external XML entity resolution at the process level. + // LIBXML_NOENT in simplexml_load_string / DOMDocument::loadXML does + // NOT disable entity substitution — it enables it. The only reliable + // defence is to install a null entity loader here at boot time. This + // is safe because Nextcloud itself does not rely on external XML + // entities in its own code. + if (function_exists('libxml_set_external_entity_loader') === true) { + // @psalm-suppress UnusedFunctionCall + libxml_set_external_entity_loader(static fn (): null => null); + } - // App initialization after all apps are registered. - \OCP\Util::addStyle(application: self::APP_ID, file: 'launchpad'); + // App initialization after all apps are registered. + \OCP\Util::addStyle(application: self::APP_ID, file: 'launchpad'); - // The dashboard view-analytics jobs (REQ-ANLT-003 design D2 + - // REQ-ANLT-009) and the external-feed refresh job (REQ-FRJ-002) are - // registered once via the RegisterBackgroundJobs repair step (install + - // post-migration), NOT on every request. Registering them here issued a - // JobList::has() SELECT against oc_jobs on each web request and tripped - // Nextcloud's "dirty table reads" diagnostic. - }//end boot() + // The dashboard view-analytics jobs (REQ-ANLT-003 design D2 + + // REQ-ANLT-009) and the external-feed refresh job (REQ-FRJ-002) are + // registered once via the RegisterBackgroundJobs repair step (install + + // post-migration), NOT on every request. Registering them here issued a + // JobList::has() SELECT against oc_jobs on each web request and tripped + // Nextcloud's "dirty table reads" diagnostic. + }//end boot() }//end class diff --git a/lib/BackgroundJob/HealthPingRefreshJob.php b/lib/BackgroundJob/HealthPingRefreshJob.php index ec5ecad6..39fe4380 100644 --- a/lib/BackgroundJob/HealthPingRefreshJob.php +++ b/lib/BackgroundJob/HealthPingRefreshJob.php @@ -43,64 +43,62 @@ * @SuppressWarnings(PHPMD.UnusedFormalParameter) — $argument required by TimedJob interface. * @spec openspec/specs/service-health-ping/spec.md */ -class HealthPingRefreshJob extends TimedJob -{ +class HealthPingRefreshJob extends TimedJob { - /** - * Run interval in seconds — matches the minimum permitted per-tile - * interval ({@see HealthPingService::MIN_INTERVAL_SECONDS}) so no - * tile's configured interval is ever starved by the job cadence - * itself; `refreshDuePlacements()` still only touches entries whose - * OWN interval has actually elapsed. - * - * @var integer - */ - public const INTERVAL_SECONDS = 15; + /** + * Run interval in seconds — matches the minimum permitted per-tile + * interval ({@see HealthPingService::MIN_INTERVAL_SECONDS}) so no + * tile's configured interval is ever starved by the job cadence + * itself; `refreshDuePlacements()` still only touches entries whose + * OWN interval has actually elapsed. + * + * @var integer + */ + public const INTERVAL_SECONDS = 15; - /** - * Constructor. - * - * @param ITimeFactory $time Time factory (parent requirement). - * @param HealthPingService $healthPingService The service performing the actual refresh. - * @param LoggerInterface $logger PSR-3 logger. - */ - public function __construct( - ITimeFactory $time, - private readonly HealthPingService $healthPingService, - private readonly LoggerInterface $logger, - ) { - parent::__construct(time: $time); - $this->setInterval(seconds: self::INTERVAL_SECONDS); - $this->setTimeSensitivity(sensitivity: IJob::TIME_INSENSITIVE); - }//end __construct() + /** + * Constructor. + * + * @param ITimeFactory $time Time factory (parent requirement). + * @param HealthPingService $healthPingService The service performing the actual refresh. + * @param LoggerInterface $logger PSR-3 logger. + */ + public function __construct( + ITimeFactory $time, + private readonly HealthPingService $healthPingService, + private readonly LoggerInterface $logger, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: self::INTERVAL_SECONDS); + $this->setTimeSensitivity(sensitivity: IJob::TIME_INSENSITIVE); + }//end __construct() - /** - * Run one refresh tick. Never throws — a single broken placement is - * isolated inside {@see HealthPingService::refreshDuePlacements()}; - * this wrapper additionally guards against any unexpected failure in - * the service call itself so the scheduler's job list is never - * poisoned. - * - * @param mixed $argument Ignored — the job carries no arguments. - * - * @return void - * - * @spec openspec/specs/service-health-ping/spec.md - */ - protected function run($argument): void - { - try { - $refreshed = $this->healthPingService->refreshDuePlacements(); - } catch (Throwable $exception) { - $this->logger->warning( - message: 'launchpad.healthping.job_failed', - context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()] - ); - return; - } + /** + * Run one refresh tick. Never throws — a single broken placement is + * isolated inside {@see HealthPingService::refreshDuePlacements()}; + * this wrapper additionally guards against any unexpected failure in + * the service call itself so the scheduler's job list is never + * poisoned. + * + * @param mixed $argument Ignored — the job carries no arguments. + * + * @return void + * + * @spec openspec/specs/service-health-ping/spec.md + */ + protected function run($argument): void { + try { + $refreshed = $this->healthPingService->refreshDuePlacements(); + } catch (Throwable $exception) { + $this->logger->warning( + message: 'launchpad.healthping.job_failed', + context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()] + ); + return; + } - $this->logger->debug( - message: sprintf('launchpad.healthping.job_run refreshed=%d', $refreshed) - ); - }//end run() + $this->logger->debug( + message: sprintf('launchpad.healthping.job_run refreshed=%d', $refreshed) + ); + }//end run() }//end class diff --git a/lib/BackgroundJob/OrphanedDataCleanupJob.php b/lib/BackgroundJob/OrphanedDataCleanupJob.php index 9fad5c4f..a8815a26 100644 --- a/lib/BackgroundJob/OrphanedDataCleanupJob.php +++ b/lib/BackgroundJob/OrphanedDataCleanupJob.php @@ -44,145 +44,142 @@ * @SuppressWarnings(PHPMD.UnusedFormalParameter) — $argument required by TimedJob interface. * @spec openspec/specs/orphaned-data-cleanup/spec.md */ -class OrphanedDataCleanupJob extends TimedJob -{ - /** - * IAppConfig key holding the JSON-encoded auto-purge category list. - * - * @var string - */ - public const CONFIG_KEY_CATEGORIES = 'cleanup_auto_purge_categories'; - - /** - * Run interval in seconds (24 hours). REQ-CLN-007 "scheduled - * daily". Time-insensitive — the job is allowed to slip into the - * next low-traffic window. - * - * @var int - */ - public const INTERVAL_SECONDS = 86400; - - /** - * Constructor. - * - * @param ITimeFactory $time Time factory - * (parent - * requirement). - * @param OrphanedDataCleanupService $cleanupService The orchestrator. - * @param CategoryRegistryService $registry Category registry - * for the auto-safe - * default list. - * @param IAppConfig $appConfig App config to - * read the - * admin-chosen - * auto-purge - * set. - * @param LoggerInterface $logger PSR-3 logger. - */ - public function __construct( - ITimeFactory $time, - private readonly OrphanedDataCleanupService $cleanupService, - private readonly CategoryRegistryService $registry, - private readonly IAppConfig $appConfig, - private readonly LoggerInterface $logger, - ) { - parent::__construct(time: $time); - $this->setInterval(seconds: self::INTERVAL_SECONDS); - $this->setTimeSensitivity(sensitivity: IJob::TIME_INSENSITIVE); - }//end __construct() - - /** - * Run the auto-purge. - * - * Reads the admin-configured category list, falls back to the - * Tier-A default when none is configured. Skips quietly when the - * list is empty (admin has explicitly disabled auto-purge). - * - * Errors thrown by individual category implementations are - * caught by the parent {@see Job::start()} which logs them; we - * additionally write a structured "skipped" log here when the - * config is empty so cluster operators can grep for the reason. - * - * @param mixed $argument Ignored — the job carries no arguments. - * - * @return void - * - * @spec openspec/specs/orphaned-data-cleanup/spec.md - */ - protected function run($argument): void - { - $categories = $this->resolveCategories(); - - if (count(value: $categories) === 0) { - $this->logger->info( - message: 'launchpad.cleanup.job_skipped reason=no_categories_enabled' - ); - return; - } - - $result = $this->cleanupService->purge( - categoryNames: $categories, - dryRun: false, - userId: null, - source: 'job', - ); - - $this->logger->info( - message: sprintf( - 'launchpad.cleanup.job_run rows=%d duration_ms=%d categories=%s', - $result->getTotalRows(), - $result->getDurationMs(), - implode(separator: ',', array: $categories), - ) - ); - }//end run() - - /** - * Resolve the configured auto-purge categories. - * - * Reads the JSON-encoded list from `IAppConfig` under - * {@see self::CONFIG_KEY_CATEGORIES}. Falls back to the registry's - * Tier-A default list when the config is missing or unparseable. - * An explicit empty array (admin-set) is preserved — that signals - * "auto-purge disabled" and the run() method skips. - * - * @return array The category names. - */ - private function resolveCategories(): array - { - $raw = $this->appConfig->getValueString( - app: Application::APP_ID, - key: self::CONFIG_KEY_CATEGORIES, - default: '' - ); - - if ($raw === '') { - return $this->registry->getAutoSafeCategoryNames(); - } - - $decoded = json_decode(json: $raw, associative: true); - if (is_array(value: $decoded) === false) { - $this->logger->warning( - message: sprintf( - 'launchpad.cleanup.job_config_invalid raw=%s', - $raw - ) - ); - return $this->registry->getAutoSafeCategoryNames(); - } - - $known = $this->registry->getCategoryNames(); - $filtered = []; - foreach ($decoded as $entry) { - if (is_string(value: $entry) === false || $entry === '') { - continue; - } - - if (in_array(needle: $entry, haystack: $known, strict: true) === true) { - $filtered[] = $entry; - } - } - - return $filtered; - }//end resolveCategories() +class OrphanedDataCleanupJob extends TimedJob { + /** + * IAppConfig key holding the JSON-encoded auto-purge category list. + * + * @var string + */ + public const CONFIG_KEY_CATEGORIES = 'cleanup_auto_purge_categories'; + + /** + * Run interval in seconds (24 hours). REQ-CLN-007 "scheduled + * daily". Time-insensitive — the job is allowed to slip into the + * next low-traffic window. + * + * @var int + */ + public const INTERVAL_SECONDS = 86400; + + /** + * Constructor. + * + * @param ITimeFactory $time Time factory + * (parent + * requirement). + * @param OrphanedDataCleanupService $cleanupService The orchestrator. + * @param CategoryRegistryService $registry Category registry + * for the auto-safe + * default list. + * @param IAppConfig $appConfig App config to + * read the + * admin-chosen + * auto-purge + * set. + * @param LoggerInterface $logger PSR-3 logger. + */ + public function __construct( + ITimeFactory $time, + private readonly OrphanedDataCleanupService $cleanupService, + private readonly CategoryRegistryService $registry, + private readonly IAppConfig $appConfig, + private readonly LoggerInterface $logger, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: self::INTERVAL_SECONDS); + $this->setTimeSensitivity(sensitivity: IJob::TIME_INSENSITIVE); + }//end __construct() + + /** + * Run the auto-purge. + * + * Reads the admin-configured category list, falls back to the + * Tier-A default when none is configured. Skips quietly when the + * list is empty (admin has explicitly disabled auto-purge). + * + * Errors thrown by individual category implementations are + * caught by the parent {@see Job::start()} which logs them; we + * additionally write a structured "skipped" log here when the + * config is empty so cluster operators can grep for the reason. + * + * @param mixed $argument Ignored — the job carries no arguments. + * + * @return void + * + * @spec openspec/specs/orphaned-data-cleanup/spec.md + */ + protected function run($argument): void { + $categories = $this->resolveCategories(); + + if (count(value: $categories) === 0) { + $this->logger->info( + message: 'launchpad.cleanup.job_skipped reason=no_categories_enabled' + ); + return; + } + + $result = $this->cleanupService->purge( + categoryNames: $categories, + dryRun: false, + userId: null, + source: 'job', + ); + + $this->logger->info( + message: sprintf( + 'launchpad.cleanup.job_run rows=%d duration_ms=%d categories=%s', + $result->getTotalRows(), + $result->getDurationMs(), + implode(separator: ',', array: $categories), + ) + ); + }//end run() + + /** + * Resolve the configured auto-purge categories. + * + * Reads the JSON-encoded list from `IAppConfig` under + * {@see self::CONFIG_KEY_CATEGORIES}. Falls back to the registry's + * Tier-A default list when the config is missing or unparseable. + * An explicit empty array (admin-set) is preserved — that signals + * "auto-purge disabled" and the run() method skips. + * + * @return array The category names. + */ + private function resolveCategories(): array { + $raw = $this->appConfig->getValueString( + app: Application::APP_ID, + key: self::CONFIG_KEY_CATEGORIES, + default: '' + ); + + if ($raw === '') { + return $this->registry->getAutoSafeCategoryNames(); + } + + $decoded = json_decode(json: $raw, associative: true); + if (is_array(value: $decoded) === false) { + $this->logger->warning( + message: sprintf( + 'launchpad.cleanup.job_config_invalid raw=%s', + $raw + ) + ); + return $this->registry->getAutoSafeCategoryNames(); + } + + $known = $this->registry->getCategoryNames(); + $filtered = []; + foreach ($decoded as $entry) { + if (is_string(value: $entry) === false || $entry === '') { + continue; + } + + if (in_array(needle: $entry, haystack: $known, strict: true) === true) { + $filtered[] = $entry; + } + } + + return $filtered; + }//end resolveCategories() }//end class diff --git a/lib/BackgroundJob/PurgeViewsJob.php b/lib/BackgroundJob/PurgeViewsJob.php index 7b774922..2b95b052 100644 --- a/lib/BackgroundJob/PurgeViewsJob.php +++ b/lib/BackgroundJob/PurgeViewsJob.php @@ -47,67 +47,65 @@ * @SuppressWarnings(PHPMD.UnusedFormalParameter) — $argument required by TimedJob interface. * @spec openspec/specs/dashboard-view-analytics/spec.md */ -class PurgeViewsJob extends TimedJob -{ - /** - * Constructor. - * - * @param ITimeFactory $time Time factory used by - * the parent - * `TimedJob` to gate - * the next-run - * decision. - * @param AnalyticsService $analyticsService Analytics service - * (cutoff date + log - * context). - * @param DashboardViewMapper $viewMapper Aggregate-row - * mapper. - * @param TileClickMapper $tileClickMapper Tile-click - * aggregate-row - * mapper — reuses - * the same cutoff - * date (REQ-TANLT-005). - * @param LoggerInterface $logger PSR logger. - */ - public function __construct( - ITimeFactory $time, - private readonly AnalyticsService $analyticsService, - private readonly DashboardViewMapper $viewMapper, - private readonly TileClickMapper $tileClickMapper, - private readonly LoggerInterface $logger, - ) { - parent::__construct(time: $time); - $this->setInterval(seconds: 86400); - }//end __construct() +class PurgeViewsJob extends TimedJob { + /** + * Constructor. + * + * @param ITimeFactory $time Time factory used by + * the parent + * `TimedJob` to gate + * the next-run + * decision. + * @param AnalyticsService $analyticsService Analytics service + * (cutoff date + log + * context). + * @param DashboardViewMapper $viewMapper Aggregate-row + * mapper. + * @param TileClickMapper $tileClickMapper Tile-click + * aggregate-row + * mapper — reuses + * the same cutoff + * date (REQ-TANLT-005). + * @param LoggerInterface $logger PSR logger. + */ + public function __construct( + ITimeFactory $time, + private readonly AnalyticsService $analyticsService, + private readonly DashboardViewMapper $viewMapper, + private readonly TileClickMapper $tileClickMapper, + private readonly LoggerInterface $logger, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: 86400); + }//end __construct() - /** - * Run the job — delete every aggregate row strictly older than - * the cutoff date, in BOTH the dashboard-views table and the - * tile-clicks table (REQ-TANLT-005 — same cutoff, same run, no - * second job). - * - * @param mixed $argument Required by the base class; unused. - * - * @return void - * - * @spec openspec/specs/dashboard-view-analytics/spec.md - * @spec openspec/specs/dashboard-view-analytics/spec.md - */ - protected function run($argument): void - { - $cutoff = $this->analyticsService->getPurgeCutoffDate(); - $deletedViews = $this->viewMapper->deleteOlderThan(beforeDate: $cutoff); - $deletedClicks = $this->tileClickMapper->deleteOlderThan(beforeDate: $cutoff); + /** + * Run the job — delete every aggregate row strictly older than + * the cutoff date, in BOTH the dashboard-views table and the + * tile-clicks table (REQ-TANLT-005 — same cutoff, same run, no + * second job). + * + * @param mixed $argument Required by the base class; unused. + * + * @return void + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + protected function run($argument): void { + $cutoff = $this->analyticsService->getPurgeCutoffDate(); + $deletedViews = $this->viewMapper->deleteOlderThan(beforeDate: $cutoff); + $deletedClicks = $this->tileClickMapper->deleteOlderThan(beforeDate: $cutoff); - $this->logger->info( - message: 'launchpad analytics purge: deleted '.$deletedViews.' view rows and ' - .$deletedClicks.' tile-click rows older than '.$cutoff, - context: [ - 'viewRows' => $deletedViews, - 'tileRows' => $deletedClicks, - 'cutoff' => $cutoff, - 'retention' => $this->analyticsService->getRetentionDays(), - ] - ); - }//end run() + $this->logger->info( + message: 'launchpad analytics purge: deleted ' . $deletedViews . ' view rows and ' + . $deletedClicks . ' tile-click rows older than ' . $cutoff, + context: [ + 'viewRows' => $deletedViews, + 'tileRows' => $deletedClicks, + 'cutoff' => $cutoff, + 'retention' => $this->analyticsService->getRetentionDays(), + ] + ); + }//end run() }//end class diff --git a/lib/BackgroundJob/SaltRotationJob.php b/lib/BackgroundJob/SaltRotationJob.php index 9850bf67..b12f4dd4 100644 --- a/lib/BackgroundJob/SaltRotationJob.php +++ b/lib/BackgroundJob/SaltRotationJob.php @@ -46,43 +46,41 @@ * @SuppressWarnings(PHPMD.StaticAccess) — UniqueViewerDedup uses a static factory method. * @spec openspec/specs/dashboard-view-analytics/spec.md */ -class SaltRotationJob extends TimedJob -{ - /** - * Constructor. - * - * @param ITimeFactory $time Time factory. - * @param UniqueViewerDedup $dedup Dedup service whose salt is - * rotated. - * @param LoggerInterface $logger PSR logger. - */ - public function __construct( - ITimeFactory $time, - private readonly UniqueViewerDedup $dedup, - private readonly LoggerInterface $logger, - ) { - parent::__construct(time: $time); - $this->setInterval(seconds: 86400); - }//end __construct() +class SaltRotationJob extends TimedJob { + /** + * Constructor. + * + * @param ITimeFactory $time Time factory. + * @param UniqueViewerDedup $dedup Dedup service whose salt is + * rotated. + * @param LoggerInterface $logger PSR logger. + */ + public function __construct( + ITimeFactory $time, + private readonly UniqueViewerDedup $dedup, + private readonly LoggerInterface $logger, + ) { + parent::__construct(time: $time); + $this->setInterval(seconds: 86400); + }//end __construct() - /** - * Run the job — overwrite the persisted daily salt with a fresh - * 32-byte random value (no history kept). - * - * @param mixed $argument Required by the base class; unused. - * - * @return void - * - * @spec openspec/specs/dashboard-view-analytics/spec.md - */ - protected function run($argument): void - { - $today = UniqueViewerDedup::utcDateFor(); - $this->dedup->rotateSalt(viewBucketDate: $today); + /** + * Run the job — overwrite the persisted daily salt with a fresh + * 32-byte random value (no history kept). + * + * @param mixed $argument Required by the base class; unused. + * + * @return void + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + protected function run($argument): void { + $today = UniqueViewerDedup::utcDateFor(); + $this->dedup->rotateSalt(viewBucketDate: $today); - $this->logger->info( - message: 'launchpad analytics salt rotated for '.$today, - context: ['date' => $today] - ); - }//end run() + $this->logger->info( + message: 'launchpad analytics salt rotated for ' . $today, + context: ['date' => $today] + ); + }//end run() }//end class diff --git a/lib/BackgroundJob/TemplateResyncJob.php b/lib/BackgroundJob/TemplateResyncJob.php index f6010c29..e8e13df0 100644 --- a/lib/BackgroundJob/TemplateResyncJob.php +++ b/lib/BackgroundJob/TemplateResyncJob.php @@ -36,88 +36,86 @@ * * @spec openspec/specs/admin-templates/spec.md#requirement-req-resync-005-re-sync-is-idempotent-audited-async-capable-and-notifies-users */ -class TemplateResyncJob extends QueuedJob -{ - /** - * Constructor. - * - * @param ITimeFactory $time Time factory (parent - * requirement). - * @param TemplateResyncService $resyncService The re-sync orchestrator - * — {@see - * TemplateResyncService::applyResync()} - * recomputes the plan fresh - * at run time (rather than - * deserialising a stale - * one), so the apply - * reflects the template's - * state at the moment the - * job actually runs. - * @param LoggerInterface $logger PSR-3 logger. - */ - public function __construct( - ITimeFactory $time, - private readonly TemplateResyncService $resyncService, - private readonly LoggerInterface $logger, - ) { - parent::__construct(time: $time); - }//end __construct() +class TemplateResyncJob extends QueuedJob { + /** + * Constructor. + * + * @param ITimeFactory $time Time factory (parent + * requirement). + * @param TemplateResyncService $resyncService The re-sync orchestrator + * — {@see + * TemplateResyncService::applyResync()} + * recomputes the plan fresh + * at run time (rather than + * deserialising a stale + * one), so the apply + * reflects the template's + * state at the moment the + * job actually runs. + * @param LoggerInterface $logger PSR-3 logger. + */ + public function __construct( + ITimeFactory $time, + private readonly TemplateResyncService $resyncService, + private readonly LoggerInterface $logger, + ) { + parent::__construct(time: $time); + }//end __construct() - /** - * Apply the re-sync plan for `$argument['templateId']` / - * `$argument['strategy']`, writing the audit record and notifying - * every affected user on completion. - * - * Malformed arguments are logged and skipped rather than throwing — - * a throw here would make NC's job runner retry indefinitely with the - * same bad payload. - * - * @param mixed $argument `{templateId: int, strategy: string, - * actingAdminId: string}`. - * - * @return void - * - * @spec openspec/specs/admin-templates/spec.md - */ - protected function run($argument): void - { - $templateId = (int) ($argument['templateId'] ?? 0); - $strategy = (string) ($argument['strategy'] ?? ''); - $actingAdminId = (string) ($argument['actingAdminId'] ?? ''); + /** + * Apply the re-sync plan for `$argument['templateId']` / + * `$argument['strategy']`, writing the audit record and notifying + * every affected user on completion. + * + * Malformed arguments are logged and skipped rather than throwing — + * a throw here would make NC's job runner retry indefinitely with the + * same bad payload. + * + * @param mixed $argument `{templateId: int, strategy: string, + * actingAdminId: string}`. + * + * @return void + * + * @spec openspec/specs/admin-templates/spec.md + */ + protected function run($argument): void { + $templateId = (int)($argument['templateId'] ?? 0); + $strategy = (string)($argument['strategy'] ?? ''); + $actingAdminId = (string)($argument['actingAdminId'] ?? ''); - if ($templateId <= 0 || $strategy === '' || $actingAdminId === '') { - $this->logger->warning( - message: 'launchpad.template_resync.job_skipped reason=invalid_arguments', - context: ['argument' => $argument] - ); - return; - } + if ($templateId <= 0 || $strategy === '' || $actingAdminId === '') { + $this->logger->warning( + message: 'launchpad.template_resync.job_skipped reason=invalid_arguments', + context: ['argument' => $argument] + ); + return; + } - try { - $result = $this->resyncService->applyResync( - templateId: $templateId, - strategy: $strategy, - actingAdminId: $actingAdminId - ); + try { + $result = $this->resyncService->applyResync( + templateId: $templateId, + strategy: $strategy, + actingAdminId: $actingAdminId + ); - $this->logger->info( - message: sprintf( - 'launchpad.template_resync.job_completed template=%d strategy=%s affected=%d total=%d', - $templateId, - $strategy, - $result['affectedCount'], - $result['totalCopies'] - ) - ); - } catch (Throwable $t) { - $this->logger->error( - message: 'launchpad.template_resync.job_failed', - context: [ - 'templateId' => $templateId, - 'strategy' => $strategy, - 'exception' => $t, - ] - ); - }//end try - }//end run() + $this->logger->info( + message: sprintf( + 'launchpad.template_resync.job_completed template=%d strategy=%s affected=%d total=%d', + $templateId, + $strategy, + $result['affectedCount'], + $result['totalCopies'] + ) + ); + } catch (Throwable $t) { + $this->logger->error( + message: 'launchpad.template_resync.job_failed', + context: [ + 'templateId' => $templateId, + 'strategy' => $strategy, + 'exception' => $t, + ] + ); + }//end try + }//end run() }//end class diff --git a/lib/Command/CleanupPurgeCommand.php b/lib/Command/CleanupPurgeCommand.php index 2313f36e..f372b2ef 100644 --- a/lib/Command/CleanupPurgeCommand.php +++ b/lib/Command/CleanupPurgeCommand.php @@ -44,238 +44,235 @@ /** * `launchpad:cleanup:purge` CLI command. */ -class CleanupPurgeCommand extends Command -{ - /** - * Constructor. - * - * @param OrphanedDataCleanupService $cleanupService The orchestrator. - * @param CategoryRegistryService $registry Category registry - * (for the - * unknown-name - * error path). - */ - public function __construct( - private readonly OrphanedDataCleanupService $cleanupService, - private readonly CategoryRegistryService $registry, - ) { - parent::__construct(); - }//end __construct() +class CleanupPurgeCommand extends Command { + /** + * Constructor. + * + * @param OrphanedDataCleanupService $cleanupService The orchestrator. + * @param CategoryRegistryService $registry Category registry + * (for the + * unknown-name + * error path). + */ + public function __construct( + private readonly OrphanedDataCleanupService $cleanupService, + private readonly CategoryRegistryService $registry, + ) { + parent::__construct(); + }//end __construct() - /** - * Configure the command name, description and options. - * - * @return void - * - * @spec openspec/specs/orphaned-data-cleanup/spec.md - */ - protected function configure(): void - { - $this->setName(name: 'launchpad:cleanup:purge') - ->setDescription( - description: 'Delete orphaned LaunchPad data. See options for dry-run and per-category limits.' - ) - ->addOption( - name: 'category', - shortcut: null, - mode: InputOption::VALUE_REQUIRED, - description: 'Limit to one category by name. Default is all.' - ) - ->addOption( - name: 'dry-run', - shortcut: null, - mode: InputOption::VALUE_NONE, - description: 'Wrap deletes in a rolled-back transaction.' - ) - ->addOption( - name: 'yes', - shortcut: 'y', - mode: InputOption::VALUE_NONE, - description: 'Skip the interactive confirmation prompt. Required for cron / CI use.' - ); - }//end configure() + /** + * Configure the command name, description and options. + * + * @return void + * + * @spec openspec/specs/orphaned-data-cleanup/spec.md + */ + protected function configure(): void { + $this->setName(name: 'launchpad:cleanup:purge') + ->setDescription( + description: 'Delete orphaned LaunchPad data. See options for dry-run and per-category limits.' + ) + ->addOption( + name: 'category', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'Limit to one category by name. Default is all.' + ) + ->addOption( + name: 'dry-run', + shortcut: null, + mode: InputOption::VALUE_NONE, + description: 'Wrap deletes in a rolled-back transaction.' + ) + ->addOption( + name: 'yes', + shortcut: 'y', + mode: InputOption::VALUE_NONE, + description: 'Skip the interactive confirmation prompt. Required for cron / CI use.' + ); + }//end configure() - /** - * Execute the purge. - * - * @param InputInterface $input The console input. - * @param OutputInterface $output The console output. - * - * @return int 0 on success, 1 on validation failure. - * - * @spec openspec/specs/orphaned-data-cleanup/spec.md - */ - protected function execute( - InputInterface $input, - OutputInterface $output - ): int { - $categoryOption = $input->getOption(name: 'category'); - $dryRun = (bool) $input->getOption(name: 'dry-run'); - $assumeYes = (bool) $input->getOption(name: 'yes'); + /** + * Execute the purge. + * + * @param InputInterface $input The console input. + * @param OutputInterface $output The console output. + * + * @return int 0 on success, 1 on validation failure. + * + * @spec openspec/specs/orphaned-data-cleanup/spec.md + */ + protected function execute( + InputInterface $input, + OutputInterface $output, + ): int { + $categoryOption = $input->getOption(name: 'category'); + $dryRun = (bool)$input->getOption(name: 'dry-run'); + $assumeYes = (bool)$input->getOption(name: 'yes'); - $categoryNames = []; - if (is_string(value: $categoryOption) === true && $categoryOption !== '') { - if ($this->registry->getCategoryByName(name: $categoryOption) === null) { - $output->writeln( - messages: sprintf( - 'Unknown cleanup category: %s', - $categoryOption - ) - ); - $output->writeln( - messages: sprintf( - 'Valid categories: %s', - implode(separator: ', ', array: $this->registry->getCategoryNames()) - ) - ); + $categoryNames = []; + if (is_string(value: $categoryOption) === true && $categoryOption !== '') { + if ($this->registry->getCategoryByName(name: $categoryOption) === null) { + $output->writeln( + messages: sprintf( + 'Unknown cleanup category: %s', + $categoryOption + ) + ); + $output->writeln( + messages: sprintf( + 'Valid categories: %s', + implode(separator: ', ', array: $this->registry->getCategoryNames()) + ) + ); - return 1; - } + return 1; + } - $categoryNames = [$categoryOption]; - } + $categoryNames = [$categoryOption]; + } - $effectiveCategories = $categoryNames; - if (count(value: $effectiveCategories) === 0) { - $effectiveCategories = $this->registry->getCategoryNames(); - } + $effectiveCategories = $categoryNames; + if (count(value: $effectiveCategories) === 0) { + $effectiveCategories = $this->registry->getCategoryNames(); + } - if ($this->confirmPurge( - input: $input, - output: $output, - assumeYes: $assumeYes, - effectiveCategories: $effectiveCategories - ) === false - ) { - $output->writeln(messages: 'Purge cancelled.'); - return 0; - } + if ($this->confirmPurge( + input: $input, + output: $output, + assumeYes: $assumeYes, + effectiveCategories: $effectiveCategories + ) === false + ) { + $output->writeln(messages: 'Purge cancelled.'); + return 0; + } - $result = $this->cleanupService->purge( - categoryNames: $categoryNames, - dryRun: $dryRun, - userId: null, - source: 'cli', - ); + $result = $this->cleanupService->purge( + categoryNames: $categoryNames, + dryRun: $dryRun, + userId: null, + source: 'cli', + ); - $output->writeln( - messages: $this->formatSummary( - result: $result, - categoryNames: $categoryNames, - dryRun: $dryRun - ) - ); + $output->writeln( + messages: $this->formatSummary( + result: $result, + categoryNames: $categoryNames, + dryRun: $dryRun + ) + ); - $this->writeSkipped(output: $output, skipped: $result->getSkipped()); + $this->writeSkipped(output: $output, skipped: $result->getSkipped()); - return 0; - }//end execute() + return 0; + }//end execute() - /** - * Ask the operator to confirm the purge. - * - * Returns `true` immediately when `--yes` was supplied, or when the - * console has no question helper registered (the pre-existing - * non-interactive fallback). Otherwise the confirmation question is - * asked and its answer returned. - * - * @param InputInterface $input The console input. - * @param OutputInterface $output The console output. - * @param bool $assumeYes Whether `--yes` was - * supplied. - * @param array $effectiveCategories Categories named in - * the prompt. - * - * @return bool True when the purge may proceed. - */ - private function confirmPurge( - InputInterface $input, - OutputInterface $output, - bool $assumeYes, - array $effectiveCategories - ): bool { - if ($assumeYes === true) { - return true; - } + /** + * Ask the operator to confirm the purge. + * + * Returns `true` immediately when `--yes` was supplied, or when the + * console has no question helper registered (the pre-existing + * non-interactive fallback). Otherwise the confirmation question is + * asked and its answer returned. + * + * @param InputInterface $input The console input. + * @param OutputInterface $output The console output. + * @param bool $assumeYes Whether `--yes` was + * supplied. + * @param array $effectiveCategories Categories named in + * the prompt. + * + * @return bool True when the purge may proceed. + */ + private function confirmPurge( + InputInterface $input, + OutputInterface $output, + bool $assumeYes, + array $effectiveCategories, + ): bool { + if ($assumeYes === true) { + return true; + } - $helper = $this->getHelper(name: 'question'); - if (($helper instanceof QuestionHelper) === false) { - return true; - } + $helper = $this->getHelper(name: 'question'); + if (($helper instanceof QuestionHelper) === false) { + return true; + } - $question = new ConfirmationQuestion( - question: sprintf( - 'Delete orphaned data in categories: [%s]? (y/N) ', - implode(separator: ', ', array: $effectiveCategories) - ), - default: false - ); + $question = new ConfirmationQuestion( + question: sprintf( + 'Delete orphaned data in categories: [%s]? (y/N) ', + implode(separator: ', ', array: $effectiveCategories) + ), + default: false + ); - return ($helper->ask(input: $input, output: $output, question: $question) !== false); - }//end confirmPurge() + return ($helper->ask(input: $input, output: $output, question: $question) !== false); + }//end confirmPurge() - /** - * Build the one-line summary written after a purge. - * - * A single explicitly-named category gets the per-category wording; - * every other invocation gets the across-categories wording. Dry runs - * are prefixed so the operator can never mistake a preview for a - * completed purge. - * - * @param CleanupResult $result The purge result. - * @param array $categoryNames The explicitly requested categories. - * @param bool $dryRun Whether this was a dry run. - * - * @return string The summary line. - */ - private function formatSummary( - CleanupResult $result, - array $categoryNames, - bool $dryRun - ): string { - $prefix = 'Purged'; - if ($dryRun === true) { - $prefix = 'DRY-RUN: Would purge'; - } + /** + * Build the one-line summary written after a purge. + * + * A single explicitly-named category gets the per-category wording; + * every other invocation gets the across-categories wording. Dry runs + * are prefixed so the operator can never mistake a preview for a + * completed purge. + * + * @param CleanupResult $result The purge result. + * @param array $categoryNames The explicitly requested categories. + * @param bool $dryRun Whether this was a dry run. + * + * @return string The summary line. + */ + private function formatSummary( + CleanupResult $result, + array $categoryNames, + bool $dryRun, + ): string { + $prefix = 'Purged'; + if ($dryRun === true) { + $prefix = 'DRY-RUN: Would purge'; + } - if (count(value: $categoryNames) === 1) { - return sprintf( - '%s %d items from category \'%s\' in %dms.', - $prefix, - $result->getTotalRows(), - $categoryNames[0], - $result->getDurationMs() - ); - } + if (count(value: $categoryNames) === 1) { + return sprintf( + '%s %d items from category \'%s\' in %dms.', + $prefix, + $result->getTotalRows(), + $categoryNames[0], + $result->getDurationMs() + ); + } - return sprintf( - '%s %d items across %d categories in %dms.', - $prefix, - $result->getTotalRows(), - count(value: $result->getByCategory()), - $result->getDurationMs() - ); - }//end formatSummary() + return sprintf( + '%s %d items across %d categories in %dms.', + $prefix, + $result->getTotalRows(), + count(value: $result->getByCategory()), + $result->getDurationMs() + ); + }//end formatSummary() - /** - * Write the skipped-categories notice when there is one. - * - * @param OutputInterface $output The console output. - * @param array $skipped The skipped category names. - * - * @return void - */ - private function writeSkipped(OutputInterface $output, array $skipped): void - { - if (count(value: $skipped) === 0) { - return; - } + /** + * Write the skipped-categories notice when there is one. + * + * @param OutputInterface $output The console output. + * @param array $skipped The skipped category names. + * + * @return void + */ + private function writeSkipped(OutputInterface $output, array $skipped): void { + if (count(value: $skipped) === 0) { + return; + } - $output->writeln( - messages: sprintf( - 'Skipped categories: %s', - implode(separator: ', ', array: $skipped) - ) - ); - }//end writeSkipped() + $output->writeln( + messages: sprintf( + 'Skipped categories: %s', + implode(separator: ', ', array: $skipped) + ) + ); + }//end writeSkipped() }//end class diff --git a/lib/Command/CleanupScanCommand.php b/lib/Command/CleanupScanCommand.php index b025d3ff..4cc6cc85 100644 --- a/lib/Command/CleanupScanCommand.php +++ b/lib/Command/CleanupScanCommand.php @@ -36,81 +36,79 @@ /** * `launchpad:cleanup:scan` CLI command. */ -class CleanupScanCommand extends Command -{ - /** - * Constructor. - * - * @param OrphanedDataCleanupService $cleanupService The orchestrator. - */ - public function __construct( - private readonly OrphanedDataCleanupService $cleanupService, - ) { - parent::__construct(); - }//end __construct() +class CleanupScanCommand extends Command { + /** + * Constructor. + * + * @param OrphanedDataCleanupService $cleanupService The orchestrator. + */ + public function __construct( + private readonly OrphanedDataCleanupService $cleanupService, + ) { + parent::__construct(); + }//end __construct() - /** - * Configure the command name + description. - * - * @return void - * - * @spec openspec/specs/orphaned-data-cleanup/spec.md - */ - protected function configure(): void - { - $this->setName(name: 'launchpad:cleanup:scan') - ->setDescription( - description: 'Scan LaunchPad storage for orphans by category. Exits non-zero when any are found.' - ); - }//end configure() + /** + * Configure the command name + description. + * + * @return void + * + * @spec openspec/specs/orphaned-data-cleanup/spec.md + */ + protected function configure(): void { + $this->setName(name: 'launchpad:cleanup:scan') + ->setDescription( + description: 'Scan LaunchPad storage for orphans by category. Exits non-zero when any are found.' + ); + }//end configure() - /** - * Execute the scan. - * - * @param InputInterface $input The console input. - * @param OutputInterface $output The console output. - * - * @return int 0 when no orphans, 1 otherwise. - * - * @spec openspec/specs/orphaned-data-cleanup/spec.md - */ - protected function execute( - InputInterface $input, - OutputInterface $output - ): int { - $result = $this->cleanupService->scan(); + /** + * Execute the scan. + * + * @param InputInterface $input The console input. + * @param OutputInterface $output The console output. + * + * @return int 0 when no orphans, 1 otherwise. + * + * @spec openspec/specs/orphaned-data-cleanup/spec.md + */ + protected function execute( + InputInterface $input, + OutputInterface $output, + ): int { + $result = $this->cleanupService->scan(); - $table = new Table(output: $output); - $table->setHeaders(headers: ['Category', 'Count']); + $table = new Table(output: $output); + $table->setHeaders(headers: ['Category', 'Count']); - foreach ($result->getByCategory() as $name => $count) { - $table->addRow(row: [$name, (string) $count]); - } + foreach ($result->getByCategory() as $name => $count) { + $table->addRow(row: [$name, (string)$count]); + } - $table->addRow(row: ['TOTAL', (string) $result->getTotalRows()]); - $table->render(); + $table->addRow(row: ['TOTAL', (string)$result->getTotalRows()]); + $table->render(); - $skipped = $result->getSkipped(); - if (count(value: $skipped) > 0) { - $output->writeln( - messages: sprintf( - 'Skipped categories (feature unavailable): %s', - implode(separator: ', ', array: $skipped) - ) - ); - } + $skipped = $result->getSkipped(); + if (count(value: $skipped) > 0) { + $output->writeln( + messages: sprintf( + 'Skipped categories (feature unavailable): %s', + implode(separator: ', ', array: $skipped) + ) + ); + } - $output->writeln( - messages: sprintf( - 'Scan completed in %dms.', - $result->getDurationMs() - ) - ); + $output->writeln( + messages: sprintf( + 'Scan completed in %dms.', + $result->getDurationMs() + ) + ); - if ($result->getTotalRows() === 0) { - return 0; - } + if ($result->getTotalRows() === 0) { + return 0; + } - return 1; - }//end execute() + return 1; + }//end execute() }//end class diff --git a/lib/Command/CommandBase.php b/lib/Command/CommandBase.php index e732c3a9..bd015c9e 100644 --- a/lib/Command/CommandBase.php +++ b/lib/Command/CommandBase.php @@ -45,338 +45,329 @@ /** * Abstract base for LaunchPad CLI commands (REQ-CLI-002). */ -abstract class CommandBase extends Command -{ - /** - * Constructor. - * - * @param CommandService $commandService Shared exit-code, JSON and - * audit-log helper. - * @param IUserSession $userSession Caller resolution for the - * audit log (REQ-CLI-010). - */ - public function __construct( - protected readonly CommandService $commandService, - private readonly IUserSession $userSession - ) { - parent::__construct(); - }//end __construct() +abstract class CommandBase extends Command { + /** + * Constructor. + * + * @param CommandService $commandService Shared exit-code, JSON and + * audit-log helper. + * @param IUserSession $userSession Caller resolution for the + * audit log (REQ-CLI-010). + */ + public function __construct( + protected readonly CommandService $commandService, + private readonly IUserSession $userSession, + ) { + parent::__construct(); + }//end __construct() - /** - * Wire the three global flags shared by every `launchpad:*` command - * (REQ-CLI-002), then defer to the child for per-command options. - * - * @return void - * - * @spec openspec/specs/cli-commands/spec.md - */ - final protected function configure(): void - { - $this->addOption( - name: 'json', - shortcut: null, - mode: InputOption::VALUE_NONE, - description: 'Emit a single JSON envelope on stdout (REQ-CLI-007).' - ); - $this->addOption( - name: 'quiet', - shortcut: 'q', - mode: InputOption::VALUE_NONE, - description: 'Suppress non-essential output. Errors still go to stderr.' - ); - $this->addOption( - name: 'no-interaction', - shortcut: 'n', - mode: InputOption::VALUE_NONE, - description: 'Skip confirmation prompts (assume yes) — for CI/automation.' - ); + /** + * Wire the three global flags shared by every `launchpad:*` command + * (REQ-CLI-002), then defer to the child for per-command options. + * + * @return void + * + * @spec openspec/specs/cli-commands/spec.md + */ + final protected function configure(): void { + $this->addOption( + name: 'json', + shortcut: null, + mode: InputOption::VALUE_NONE, + description: 'Emit a single JSON envelope on stdout (REQ-CLI-007).' + ); + $this->addOption( + name: 'quiet', + shortcut: 'q', + mode: InputOption::VALUE_NONE, + description: 'Suppress non-essential output. Errors still go to stderr.' + ); + $this->addOption( + name: 'no-interaction', + shortcut: 'n', + mode: InputOption::VALUE_NONE, + description: 'Skip confirmation prompts (assume yes) — for CI/automation.' + ); - $this->configureCommand(); - }//end configure() + $this->configureCommand(); + }//end configure() - /** - * Hook for subclasses to declare name, description, arguments and - * extra options. The three global flags are registered by - * {@see configure()}; subclasses MUST NOT re-declare them. - * - * @return void - * - * @spec openspec/specs/cli-commands/spec.md - */ - abstract protected function configureCommand(): void; + /** + * Hook for subclasses to declare name, description, arguments and + * extra options. The three global flags are registered by + * {@see configure()}; subclasses MUST NOT re-declare them. + * + * @return void + * + * @spec openspec/specs/cli-commands/spec.md + */ + abstract protected function configureCommand(): void; - /** - * Execute the command's business logic. - * - * Returning an exit code MUST use one of the - * {@see CommandService}::EXIT_* constants. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output (use the helpers on - * this class to honour `--quiet` - * and `--json`). - * - * @return int - * - * @spec openspec/specs/cli-commands/spec.md - */ - abstract protected function handle( - InputInterface $input, - OutputInterface $output - ): int; + /** + * Execute the command's business logic. + * + * Returning an exit code MUST use one of the + * {@see CommandService}::EXIT_* constants. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output (use the helpers on + * this class to honour `--quiet` + * and `--json`). + * + * @return int + * + * @spec openspec/specs/cli-commands/spec.md + */ + abstract protected function handle( + InputInterface $input, + OutputInterface $output, + ): int; - /** - * Symfony entry point — wraps {@see handle()} with timing, JSON - * envelope on uncaught exception, and the audit log line - * (REQ-CLI-010). - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * - * @return int - * - * @spec openspec/specs/cli-commands/spec.md - */ - final protected function execute( - InputInterface $input, - OutputInterface $output - ): int { - $started = (int) round(num: (microtime(as_float: true) * 1000)); - $exitCode = CommandService::EXIT_ERROR; - try { - $exitCode = $this->handle(input: $input, output: $output); - } catch (Throwable $e) { - $exitCode = CommandService::EXIT_ERROR; - $envelope = $this->commandService->envelopeError( - exitCode: $exitCode, - code: 'INTERNAL_ERROR', - message: $e->getMessage(), - context: ['exceptionClass' => $e::class] - ); - if ($this->isJson(input: $input) === true) { - $output->writeln(messages: $this->commandService->encodeEnvelope(envelope: $envelope)); - } + /** + * Symfony entry point — wraps {@see handle()} with timing, JSON + * envelope on uncaught exception, and the audit log line + * (REQ-CLI-010). + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * + * @return int + * + * @spec openspec/specs/cli-commands/spec.md + */ + final protected function execute( + InputInterface $input, + OutputInterface $output, + ): int { + $started = (int)round(num: (microtime(as_float: true) * 1000)); + $exitCode = CommandService::EXIT_ERROR; + try { + $exitCode = $this->handle(input: $input, output: $output); + } catch (Throwable $e) { + $exitCode = CommandService::EXIT_ERROR; + $envelope = $this->commandService->envelopeError( + exitCode: $exitCode, + code: 'INTERNAL_ERROR', + message: $e->getMessage(), + context: ['exceptionClass' => $e::class] + ); + if ($this->isJson(input: $input) === true) { + $output->writeln(messages: $this->commandService->encodeEnvelope(envelope: $envelope)); + } - if ($this->isJson(input: $input) === false) { - $this->writeError(output: $output, message: ''.$e->getMessage().''); - } - } finally { - $finished = (int) round(num: (microtime(as_float: true) * 1000)); - $this->commandService->audit( - command: $this->stripPrefix(name: (string) $this->getName()), - args: $this->collectArgsForAudit(input: $input), - exitCode: $exitCode, - durationMs: ($finished - $started), - byUser: $this->resolveByUser() - ); - }//end try + if ($this->isJson(input: $input) === false) { + $this->writeError(output: $output, message: '' . $e->getMessage() . ''); + } + } finally { + $finished = (int)round(num: (microtime(as_float: true) * 1000)); + $this->commandService->audit( + command: $this->stripPrefix(name: (string)$this->getName()), + args: $this->collectArgsForAudit(input: $input), + exitCode: $exitCode, + durationMs: ($finished - $started), + byUser: $this->resolveByUser() + ); + }//end try - return $exitCode; - }//end execute() + return $exitCode; + }//end execute() - /** - * Whether the caller asked for JSON output. - * - * @param InputInterface $input The CLI input. - * - * @return boolean - */ - final protected function isJson(InputInterface $input): bool - { - return (bool) $input->getOption(name: 'json'); - }//end isJson() + /** + * Whether the caller asked for JSON output. + * + * @param InputInterface $input The CLI input. + * + * @return boolean + */ + final protected function isJson(InputInterface $input): bool { + return (bool)$input->getOption(name: 'json'); + }//end isJson() - /** - * Whether the caller asked for quiet output. - * - * @param InputInterface $input The CLI input. - * - * @return boolean - */ - final protected function isQuiet(InputInterface $input): bool - { - return (bool) $input->getOption(name: 'quiet'); - }//end isQuiet() + /** + * Whether the caller asked for quiet output. + * + * @param InputInterface $input The CLI input. + * + * @return boolean + */ + final protected function isQuiet(InputInterface $input): bool { + return (bool)$input->getOption(name: 'quiet'); + }//end isQuiet() - /** - * Whether prompts should be suppressed (CI mode). - * - * @param InputInterface $input The CLI input. - * - * @return boolean - */ - final protected function isNoInteraction(InputInterface $input): bool - { - return (bool) $input->getOption(name: 'no-interaction'); - }//end isNoInteraction() + /** + * Whether prompts should be suppressed (CI mode). + * + * @param InputInterface $input The CLI input. + * + * @return boolean + */ + final protected function isNoInteraction(InputInterface $input): bool { + return (bool)$input->getOption(name: 'no-interaction'); + }//end isNoInteraction() - /** - * Emit a successful payload as either JSON envelope (when `--json`) - * or as the supplied human-readable text (skipped when `--quiet`). - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * @param array|list|null $data Payload. - * @param string $human Optional human-readable line. - * - * @return void - * - * @spec openspec/specs/cli-commands/spec.md - */ - final protected function emitSuccess( - InputInterface $input, - OutputInterface $output, - array|null $data, - string $human='' - ): void { - if ($this->isJson(input: $input) === true) { - $output->writeln( - messages: $this->commandService->encodeEnvelope( - envelope: $this->commandService->envelopeSuccess(data: $data) - ) - ); - return; - } + /** + * Emit a successful payload as either JSON envelope (when `--json`) + * or as the supplied human-readable text (skipped when `--quiet`). + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * @param array|list|null $data Payload. + * @param string $human Optional human-readable line. + * + * @return void + * + * @spec openspec/specs/cli-commands/spec.md + */ + final protected function emitSuccess( + InputInterface $input, + OutputInterface $output, + ?array $data, + string $human = '', + ): void { + if ($this->isJson(input: $input) === true) { + $output->writeln( + messages: $this->commandService->encodeEnvelope( + envelope: $this->commandService->envelopeSuccess(data: $data) + ) + ); + return; + } - if ($human !== '' && $this->isQuiet(input: $input) === false) { - $output->writeln(messages: $human); - } - }//end emitSuccess() + if ($human !== '' && $this->isQuiet(input: $input) === false) { + $output->writeln(messages: $human); + } + }//end emitSuccess() - /** - * Emit an error envelope to stdout (when `--json`) or a `` - * line on stderr (always; `--quiet` does NOT mute errors per - * REQ-CLI-002). - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * @param int $exitCode Exit code constant. - * @param string $code Stable error identifier. - * @param string $message Human-readable text. - * @param array|null $context Optional metadata. - * - * @return int Echoes back the exit code for caller convenience. - * - * @spec openspec/specs/cli-commands/spec.md - */ - final protected function emitError( - InputInterface $input, - OutputInterface $output, - int $exitCode, - string $code, - string $message, - array|null $context=null - ): int { - if ($this->isJson(input: $input) === true) { - $output->writeln( - messages: $this->commandService->encodeEnvelope( - envelope: $this->commandService->envelopeError( - exitCode: $exitCode, - code: $code, - message: $message, - context: $context - ) - ) - ); + /** + * Emit an error envelope to stdout (when `--json`) or a `` + * line on stderr (always; `--quiet` does NOT mute errors per + * REQ-CLI-002). + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * @param int $exitCode Exit code constant. + * @param string $code Stable error identifier. + * @param string $message Human-readable text. + * @param array|null $context Optional metadata. + * + * @return int Echoes back the exit code for caller convenience. + * + * @spec openspec/specs/cli-commands/spec.md + */ + final protected function emitError( + InputInterface $input, + OutputInterface $output, + int $exitCode, + string $code, + string $message, + ?array $context = null, + ): int { + if ($this->isJson(input: $input) === true) { + $output->writeln( + messages: $this->commandService->encodeEnvelope( + envelope: $this->commandService->envelopeError( + exitCode: $exitCode, + code: $code, + message: $message, + context: $context + ) + ) + ); - return $exitCode; - } + return $exitCode; + } - $this->writeError(output: $output, message: ''.$message.''); + $this->writeError(output: $output, message: '' . $message . ''); - return $exitCode; - }//end emitError() + return $exitCode; + }//end emitError() - /** - * Write a line to the dedicated stderr stream when the runtime - * `OutputInterface` actually exposes one (the production - * `ConsoleOutput` does); fall back to the regular stream otherwise - * (in-memory test buffers don't split stderr out). - * - * @param OutputInterface $output Live output handle. - * @param string $message The fully decorated message. - * - * @return void - */ - private function writeError(OutputInterface $output, string $message): void - { - if ($output instanceof ConsoleOutputInterface) { - $output->getErrorOutput()->writeln(messages: $message); - return; - } + /** + * Write a line to the dedicated stderr stream when the runtime + * `OutputInterface` actually exposes one (the production + * `ConsoleOutput` does); fall back to the regular stream otherwise + * (in-memory test buffers don't split stderr out). + * + * @param OutputInterface $output Live output handle. + * @param string $message The fully decorated message. + * + * @return void + */ + private function writeError(OutputInterface $output, string $message): void { + if ($output instanceof ConsoleOutputInterface) { + $output->getErrorOutput()->writeln(messages: $message); + return; + } - $output->writeln(messages: $message); - }//end writeError() + $output->writeln(messages: $message); + }//end writeError() - /** - * Strip the canonical `launchpad:` prefix from the command name for - * audit-log clarity (REQ-CLI-010). - * - * @param string $name The full command name. - * - * @return string - */ - private function stripPrefix(string $name): string - { - if (str_starts_with(haystack: $name, needle: 'launchpad:') === true) { - return substr(string: $name, offset: 7); - } + /** + * Strip the canonical `launchpad:` prefix from the command name for + * audit-log clarity (REQ-CLI-010). + * + * @param string $name The full command name. + * + * @return string + */ + private function stripPrefix(string $name): string { + if (str_starts_with(haystack: $name, needle: 'launchpad:') === true) { + return substr(string: $name, offset: 7); + } - return $name; - }//end stripPrefix() + return $name; + }//end stripPrefix() - /** - * Build a single space-joined argv-tail string for the audit line. - * We use the raw `$argv` so option ordering matches what the - * operator typed (REQ-CLI-010). The Symfony `InputInterface` does - * not expose the original argv slice, so reading `$_SERVER['argv']` - * is intentional here. - * - * @param InputInterface $input CLI input (kept for future API use). - * - * @return string - * - * @SuppressWarnings(PHPMD.Superglobals) - * The audit line must record options in the order the operator - * typed them (REQ-CLI-010). Symfony's `InputInterface` exposes only - * the parsed, normalised token set — it has no accessor for the - * original argv slice — so `$_SERVER['argv']` is the only source. - */ - private function collectArgsForAudit(InputInterface $input): string - { - unset($input); + /** + * Build a single space-joined argv-tail string for the audit line. + * We use the raw `$argv` so option ordering matches what the + * operator typed (REQ-CLI-010). The Symfony `InputInterface` does + * not expose the original argv slice, so reading `$_SERVER['argv']` + * is intentional here. + * + * @param InputInterface $input CLI input (kept for future API use). + * + * @return string + * + * @SuppressWarnings(PHPMD.Superglobals) + * The audit line must record options in the order the operator + * typed them (REQ-CLI-010). Symfony's `InputInterface` exposes only + * the parsed, normalised token set — it has no accessor for the + * original argv slice — so `$_SERVER['argv']` is the only source. + */ + private function collectArgsForAudit(InputInterface $input): string { + unset($input); - $argv = (array) ($_SERVER['argv'] ?? []); - // Drop the binary path and the command name (first two slots - // when invoked via `php occ launchpad:foo ...`). - $tail = array_slice(array: $argv, offset: 2); + $argv = (array)($_SERVER['argv'] ?? []); + // Drop the binary path and the command name (first two slots + // when invoked via `php occ launchpad:foo ...`). + $tail = array_slice(array: $argv, offset: 2); - return implode(separator: ' ', array: array_map(callback: 'strval', array: $tail)); - }//end collectArgsForAudit() + return implode(separator: ' ', array: array_map(callback: 'strval', array: $tail)); + }//end collectArgsForAudit() - /** - * Resolve the caller user id for the audit log. Returns `null` to - * indicate the special `cli` sentinel when no Nextcloud session is - * active (typical for cron / shell invocations) — REQ-CLI-010. - * - * @return string|null - */ - private function resolveByUser(): string|null - { - try { - $user = $this->userSession->getUser(); - if ($user === null) { - return null; - } + /** + * Resolve the caller user id for the audit log. Returns `null` to + * indicate the special `cli` sentinel when no Nextcloud session is + * active (typical for cron / shell invocations) — REQ-CLI-010. + * + * @return string|null + */ + private function resolveByUser(): ?string { + try { + $user = $this->userSession->getUser(); + if ($user === null) { + return null; + } - $uid = $user->getUID(); - if ($uid === '') { - return null; - } + $uid = $user->getUID(); + if ($uid === '') { + return null; + } - return $uid; - } catch (Throwable) { - return null; - } - }//end resolveByUser() + return $uid; + } catch (Throwable) { + return null; + } + }//end resolveByUser() }//end class diff --git a/lib/Command/DashboardDebugShareCommand.php b/lib/Command/DashboardDebugShareCommand.php index 505061b0..e2cf540d 100644 --- a/lib/Command/DashboardDebugShareCommand.php +++ b/lib/Command/DashboardDebugShareCommand.php @@ -36,118 +36,116 @@ /** * `launchpad:dashboard:debug-share` console command. */ -class DashboardDebugShareCommand extends CommandBase -{ - /** - * Constructor. - * - * @param CommandService $commandService Shared CLI helper. - * @param IUserSession $userSession Caller resolution. - * @param DashboardMapper $dashboardMapper Dashboard mapper. - * @param DashboardShareMapper $shareMapper Share mapper. - */ - public function __construct( - CommandService $commandService, - IUserSession $userSession, - private readonly DashboardMapper $dashboardMapper, - private readonly DashboardShareMapper $shareMapper - ) { - parent::__construct(commandService: $commandService, userSession: $userSession); - }//end __construct() +class DashboardDebugShareCommand extends CommandBase { + /** + * Constructor. + * + * @param CommandService $commandService Shared CLI helper. + * @param IUserSession $userSession Caller resolution. + * @param DashboardMapper $dashboardMapper Dashboard mapper. + * @param DashboardShareMapper $shareMapper Share mapper. + */ + public function __construct( + CommandService $commandService, + IUserSession $userSession, + private readonly DashboardMapper $dashboardMapper, + private readonly DashboardShareMapper $shareMapper, + ) { + parent::__construct(commandService: $commandService, userSession: $userSession); + }//end __construct() - /** - * Wire command name, description, and per-command options. - * - * @return void - * - * @spec openspec/specs/cli-commands/spec.md - */ - protected function configureCommand(): void - { - $this->setName(name: 'launchpad:dashboard:debug-share') - ->setDescription(description: 'Dump sharing & lock state for a dashboard.') - ->setHelp( - help: implode( - separator: "\n", - array: [ - 'Print share rows, lock state, version count and view count for support diagnostics.', - '', - 'Examples:', - ' php occ launchpad:dashboard:debug-share a1b2c3d4-... --json', - ' php occ launchpad:dashboard:debug-share a1b2c3d4-... | jq .', - ] - ) - ) - ->addArgument( - name: 'uuid', - mode: InputArgument::REQUIRED, - description: 'Dashboard UUID.' - ); - }//end configureCommand() + /** + * Wire command name, description, and per-command options. + * + * @return void + * + * @spec openspec/specs/cli-commands/spec.md + */ + protected function configureCommand(): void { + $this->setName(name: 'launchpad:dashboard:debug-share') + ->setDescription(description: 'Dump sharing & lock state for a dashboard.') + ->setHelp( + help: implode( + separator: "\n", + array: [ + 'Print share rows, lock state, version count and view count for support diagnostics.', + '', + 'Examples:', + ' php occ launchpad:dashboard:debug-share a1b2c3d4-... --json', + ' php occ launchpad:dashboard:debug-share a1b2c3d4-... | jq .', + ] + ) + ) + ->addArgument( + name: 'uuid', + mode: InputArgument::REQUIRED, + description: 'Dashboard UUID.' + ); + }//end configureCommand() - /** - * Execute the diagnostics dump. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * - * @return int - * - * @spec openspec/specs/cli-commands/spec.md - */ - protected function handle( - InputInterface $input, - OutputInterface $output - ): int { - $uuid = (string) $input->getArgument(name: 'uuid'); + /** + * Execute the diagnostics dump. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * + * @return int + * + * @spec openspec/specs/cli-commands/spec.md + */ + protected function handle( + InputInterface $input, + OutputInterface $output, + ): int { + $uuid = (string)$input->getArgument(name: 'uuid'); - try { - $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); - } catch (DoesNotExistException) { - return $this->emitError( - input: $input, - output: $output, - exitCode: CommandService::EXIT_NOT_FOUND, - code: 'NOT_FOUND', - message: 'Dashboard not found', - context: ['uuid' => $uuid] - ); - } + try { + $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); + } catch (DoesNotExistException) { + return $this->emitError( + input: $input, + output: $output, + exitCode: CommandService::EXIT_NOT_FOUND, + code: 'NOT_FOUND', + message: 'Dashboard not found', + context: ['uuid' => $uuid] + ); + } - $shares = array_map( - callback: static function (DashboardShare $share): array { - return $share->jsonSerialize(); - }, - array: $this->shareMapper->findByDashboardId(dashboardId: (int) $dashboard->getId()) - ); + $shares = array_map( + callback: static function (DashboardShare $share): array { + return $share->jsonSerialize(); + }, + array: $this->shareMapper->findByDashboardId(dashboardId: (int)$dashboard->getId()) + ); - // Lock / version / view capabilities live in sibling specs that - // may or may not have shipped yet; absence is reported as the - // documented sentinel values rather than a hard failure. - $payload = [ - 'uuid' => $uuid, - 'shares' => $shares, - 'locked' => false, - 'lockedBy' => null, - 'lockedAt' => null, - 'versionCount' => 0, - 'viewCount' => 0, - ]; + // Lock / version / view capabilities live in sibling specs that + // may or may not have shipped yet; absence is reported as the + // documented sentinel values rather than a hard failure. + $payload = [ + 'uuid' => $uuid, + 'shares' => $shares, + 'locked' => false, + 'lockedBy' => null, + 'lockedAt' => null, + 'versionCount' => 0, + 'viewCount' => 0, + ]; - if ($this->isJson(input: $input) === true) { - $this->emitSuccess(input: $input, output: $output, data: $payload); - return CommandService::EXIT_SUCCESS; - } + if ($this->isJson(input: $input) === true) { + $this->emitSuccess(input: $input, output: $output, data: $payload); + return CommandService::EXIT_SUCCESS; + } - if ($this->isQuiet(input: $input) === false) { - $output->writeln( - messages: (string) json_encode( - value: $payload, - flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) - ) - ); - } + if ($this->isQuiet(input: $input) === false) { + $output->writeln( + messages: (string)json_encode( + value: $payload, + flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ) + ); + } - return CommandService::EXIT_SUCCESS; - }//end handle() + return CommandService::EXIT_SUCCESS; + }//end handle() }//end class diff --git a/lib/Command/DashboardDeleteCommand.php b/lib/Command/DashboardDeleteCommand.php index eb4f40fb..e930eae2 100644 --- a/lib/Command/DashboardDeleteCommand.php +++ b/lib/Command/DashboardDeleteCommand.php @@ -44,228 +44,226 @@ /** * `launchpad:dashboard:delete` console command. */ -class DashboardDeleteCommand extends CommandBase -{ - /** - * Constructor. - * - * @param CommandService $commandService Shared CLI helper. - * @param IUserSession $userSession Caller resolution. - * @param DashboardMapper $dashboardMapper Dashboard mapper. - * @param WidgetPlacementMapper $placementMapper Widget mapper. - * @param DashboardTreeService $treeService Tree service for - * cascading delete. - * @param IEventDispatcher|null $eventDispatcher Event dispatcher for - * DashboardDeletedEvent - * (SB1 fix, REQ-CSC-001). - */ - public function __construct( - CommandService $commandService, - IUserSession $userSession, - private readonly DashboardMapper $dashboardMapper, - private readonly WidgetPlacementMapper $placementMapper, - private readonly DashboardTreeService $treeService, - private readonly ?IEventDispatcher $eventDispatcher=null, - ) { - parent::__construct(commandService: $commandService, userSession: $userSession); - }//end __construct() +class DashboardDeleteCommand extends CommandBase { + /** + * Constructor. + * + * @param CommandService $commandService Shared CLI helper. + * @param IUserSession $userSession Caller resolution. + * @param DashboardMapper $dashboardMapper Dashboard mapper. + * @param WidgetPlacementMapper $placementMapper Widget mapper. + * @param DashboardTreeService $treeService Tree service for + * cascading delete. + * @param IEventDispatcher|null $eventDispatcher Event dispatcher for + * DashboardDeletedEvent + * (SB1 fix, REQ-CSC-001). + */ + public function __construct( + CommandService $commandService, + IUserSession $userSession, + private readonly DashboardMapper $dashboardMapper, + private readonly WidgetPlacementMapper $placementMapper, + private readonly DashboardTreeService $treeService, + private readonly ?IEventDispatcher $eventDispatcher = null, + ) { + parent::__construct(commandService: $commandService, userSession: $userSession); + }//end __construct() - /** - * Wire command name, description, and per-command options. - * - * @return void - * - * @spec openspec/specs/cli-commands/spec.md - */ - protected function configureCommand(): void - { - $this->setName(name: 'launchpad:dashboard:delete') - ->setDescription(description: 'Delete a dashboard by UUID.') - ->setHelp( - help: implode( - separator: "\n", - array: [ - 'Delete a dashboard. Refuses when children exist unless --cascade is set.', - '', - 'Examples:', - ' php occ launchpad:dashboard:delete a1b2c3d4-... --no-interaction', - ' php occ launchpad:dashboard:delete a1b2c3d4-... --cascade --no-interaction', - ] - ) - ) - ->addArgument( - name: 'uuid', - mode: InputArgument::REQUIRED, - description: 'Dashboard UUID to delete.' - ) - ->addOption( - name: 'cascade', - shortcut: null, - mode: InputOption::VALUE_NONE, - description: 'Recursively delete child dashboards as well.' - ); - }//end configureCommand() + /** + * Wire command name, description, and per-command options. + * + * @return void + * + * @spec openspec/specs/cli-commands/spec.md + */ + protected function configureCommand(): void { + $this->setName(name: 'launchpad:dashboard:delete') + ->setDescription(description: 'Delete a dashboard by UUID.') + ->setHelp( + help: implode( + separator: "\n", + array: [ + 'Delete a dashboard. Refuses when children exist unless --cascade is set.', + '', + 'Examples:', + ' php occ launchpad:dashboard:delete a1b2c3d4-... --no-interaction', + ' php occ launchpad:dashboard:delete a1b2c3d4-... --cascade --no-interaction', + ] + ) + ) + ->addArgument( + name: 'uuid', + mode: InputArgument::REQUIRED, + description: 'Dashboard UUID to delete.' + ) + ->addOption( + name: 'cascade', + shortcut: null, + mode: InputOption::VALUE_NONE, + description: 'Recursively delete child dashboards as well.' + ); + }//end configureCommand() - /** - * Execute the deletion. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * - * @return int - * - * @spec openspec/specs/cli-commands/spec.md - */ - protected function handle( - InputInterface $input, - OutputInterface $output - ): int { - $uuid = (string) $input->getArgument(name: 'uuid'); - $cascade = (bool) $input->getOption(name: 'cascade'); + /** + * Execute the deletion. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * + * @return int + * + * @spec openspec/specs/cli-commands/spec.md + */ + protected function handle( + InputInterface $input, + OutputInterface $output, + ): int { + $uuid = (string)$input->getArgument(name: 'uuid'); + $cascade = (bool)$input->getOption(name: 'cascade'); - try { - $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); - } catch (DoesNotExistException) { - return $this->emitError( - input: $input, - output: $output, - exitCode: CommandService::EXIT_NOT_FOUND, - code: 'NOT_FOUND', - message: 'Dashboard not found', - context: ['uuid' => $uuid] - ); - } + try { + $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); + } catch (DoesNotExistException) { + return $this->emitError( + input: $input, + output: $output, + exitCode: CommandService::EXIT_NOT_FOUND, + code: 'NOT_FOUND', + message: 'Dashboard not found', + context: ['uuid' => $uuid] + ); + } - $childCount = $this->dashboardMapper->countChildrenByParent(parentUuid: $uuid); - if ($childCount > 0 && $cascade === false) { - return $this->emitError( - input: $input, - output: $output, - exitCode: CommandService::EXIT_INVALID_ARGS, - code: 'CHILDREN_EXIST', - message: 'Use --cascade to also delete child dashboards', - context: ['uuid' => $uuid, 'childCount' => $childCount] - ); - } + $childCount = $this->dashboardMapper->countChildrenByParent(parentUuid: $uuid); + if ($childCount > 0 && $cascade === false) { + return $this->emitError( + input: $input, + output: $output, + exitCode: CommandService::EXIT_INVALID_ARGS, + code: 'CHILDREN_EXIST', + message: 'Use --cascade to also delete child dashboards', + context: ['uuid' => $uuid, 'childCount' => $childCount] + ); + } - if ($this->confirmDeletion( - input: $input, - output: $output, - dashboard: $dashboard, - uuid: $uuid - ) === false - ) { - return $this->emitError( - input: $input, - output: $output, - exitCode: CommandService::EXIT_INVALID_ARGS, - code: 'ABORTED', - message: 'Deletion aborted by user.' - ); - } + if ($this->confirmDeletion( + input: $input, + output: $output, + dashboard: $dashboard, + uuid: $uuid + ) === false + ) { + return $this->emitError( + input: $input, + output: $output, + exitCode: CommandService::EXIT_INVALID_ARGS, + code: 'ABORTED', + message: 'Deletion aborted by user.' + ); + } - $this->applyDeletion( - dashboard: $dashboard, - uuid: $uuid, - cascade: $cascade - ); + $this->applyDeletion( + dashboard: $dashboard, + uuid: $uuid, + cascade: $cascade + ); - $cascadeNote = ''; - if ($cascade === true) { - $cascadeNote = ' and '.$childCount.' descendant(s)'; - } + $cascadeNote = ''; + if ($cascade === true) { + $cascadeNote = ' and ' . $childCount . ' descendant(s)'; + } - $this->emitSuccess( - input: $input, - output: $output, - data: ['uuid' => $uuid, 'cascade' => $cascade, 'childCount' => $childCount], - human: 'Deleted dashboard '.$uuid.$cascadeNote - ); + $this->emitSuccess( + input: $input, + output: $output, + data: ['uuid' => $uuid, 'cascade' => $cascade, 'childCount' => $childCount], + human: 'Deleted dashboard ' . $uuid . $cascadeNote + ); - return CommandService::EXIT_SUCCESS; - }//end handle() + return CommandService::EXIT_SUCCESS; + }//end handle() - /** - * Ask the operator to confirm the deletion. - * - * The prompt is skipped entirely — and the deletion allowed — when - * `--no-interaction` was supplied or the caller asked for JSON - * output, because neither mode can service a terminal question. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * @param Dashboard $dashboard The dashboard being deleted (its - * name appears in the prompt). - * @param string $uuid The dashboard UUID. - * - * @return bool True when the deletion may proceed. - */ - private function confirmDeletion( - InputInterface $input, - OutputInterface $output, - Dashboard $dashboard, - string $uuid - ): bool { - if ($this->isNoInteraction(input: $input) === true - || $this->isJson(input: $input) === true - ) { - return true; - } + /** + * Ask the operator to confirm the deletion. + * + * The prompt is skipped entirely — and the deletion allowed — when + * `--no-interaction` was supplied or the caller asked for JSON + * output, because neither mode can service a terminal question. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * @param Dashboard $dashboard The dashboard being deleted (its + * name appears in the prompt). + * @param string $uuid The dashboard UUID. + * + * @return bool True when the deletion may proceed. + */ + private function confirmDeletion( + InputInterface $input, + OutputInterface $output, + Dashboard $dashboard, + string $uuid, + ): bool { + if ($this->isNoInteraction(input: $input) === true + || $this->isJson(input: $input) === true + ) { + return true; + } - $helper = new QuestionHelper(); - $question = new ConfirmationQuestion( - question: sprintf( - 'Delete dashboard "%s" (%s)? [y/N] ', - (string) $dashboard->getName(), - $uuid - ), - default: false - ); + $helper = new QuestionHelper(); + $question = new ConfirmationQuestion( + question: sprintf( + 'Delete dashboard "%s" (%s)? [y/N] ', + (string)$dashboard->getName(), + $uuid + ), + default: false + ); - return (bool) $helper->ask(input: $input, output: $output, question: $question); - }//end confirmDeletion() + return (bool)$helper->ask(input: $input, output: $output, question: $question); + }//end confirmDeletion() - /** - * Delete the dashboard, honouring the `--cascade` flag. - * - * The cascading path delegates to - * {@see DashboardTreeService::deleteSubtree()}, which removes the - * descendants and dispatches its own events. The non-cascading path - * removes this dashboard's widget placements, deletes the row, and - * dispatches {@see DashboardDeletedEvent} itself. - * - * @param Dashboard $dashboard The dashboard to delete. - * @param string $uuid The dashboard UUID. - * @param bool $cascade Whether `--cascade` was supplied. - * - * @return void - */ - private function applyDeletion( - Dashboard $dashboard, - string $uuid, - bool $cascade - ): void { - if ($cascade === true) { - $this->treeService->deleteSubtree(dashboard: $dashboard); - return; - } + /** + * Delete the dashboard, honouring the `--cascade` flag. + * + * The cascading path delegates to + * {@see DashboardTreeService::deleteSubtree()}, which removes the + * descendants and dispatches its own events. The non-cascading path + * removes this dashboard's widget placements, deletes the row, and + * dispatches {@see DashboardDeletedEvent} itself. + * + * @param Dashboard $dashboard The dashboard to delete. + * @param string $uuid The dashboard UUID. + * @param bool $cascade Whether `--cascade` was supplied. + * + * @return void + */ + private function applyDeletion( + Dashboard $dashboard, + string $uuid, + bool $cascade, + ): void { + if ($cascade === true) { + $this->treeService->deleteSubtree(dashboard: $dashboard); + return; + } - $this->placementMapper->deleteByDashboardId(dashboardId: (int) $dashboard->getId()); - $this->dashboardMapper->delete(entity: $dashboard); + $this->placementMapper->deleteByDashboardId(dashboardId: (int)$dashboard->getId()); + $this->dashboardMapper->delete(entity: $dashboard); - // SB1 fix: dispatch DashboardDeletedEvent for cascade cleanup - // (REQ-CSC-001). - if ($this->eventDispatcher === null || $uuid === '') { - return; - } + // SB1 fix: dispatch DashboardDeletedEvent for cascade cleanup + // (REQ-CSC-001). + if ($this->eventDispatcher === null || $uuid === '') { + return; + } - $this->eventDispatcher->dispatchTyped( - new DashboardDeletedEvent( - dashboardUuid: $uuid, - ownerUserId: (string) ($dashboard->getUserId() ?? ''), - type: (string) ($dashboard->getType() ?? Dashboard::TYPE_USER), - deletedAt: new DateTimeImmutable() - ) - ); - }//end applyDeletion() + $this->eventDispatcher->dispatchTyped( + new DashboardDeletedEvent( + dashboardUuid: $uuid, + ownerUserId: (string)($dashboard->getUserId() ?? ''), + type: (string)($dashboard->getType() ?? Dashboard::TYPE_USER), + deletedAt: new DateTimeImmutable() + ) + ); + }//end applyDeletion() }//end class diff --git a/lib/Command/DashboardListCommand.php b/lib/Command/DashboardListCommand.php index cf14a1b9..184cea38 100644 --- a/lib/Command/DashboardListCommand.php +++ b/lib/Command/DashboardListCommand.php @@ -35,352 +35,346 @@ /** * `launchpad:dashboard:list` console command. */ -class DashboardListCommand extends CommandBase -{ - /** - * Allowed values for `--status` (REQ-CLI-003). - * - * @var list - */ - private const ALLOWED_STATUS = ['draft', 'published', 'scheduled']; - - /** - * Constructor. - * - * @param CommandService $commandService Shared CLI helper. - * @param IUserSession $userSession Caller resolution. - * @param DashboardMapper $dashboardMapper Dashboard mapper. - * @param IUserManager $userManager For `--user` validation. - */ - public function __construct( - CommandService $commandService, - IUserSession $userSession, - private readonly DashboardMapper $dashboardMapper, - private readonly IUserManager $userManager - ) { - parent::__construct(commandService: $commandService, userSession: $userSession); - }//end __construct() - - /** - * Wire command name, description, and per-command options. - * - * @return void - * - * @spec openspec/specs/cli-commands/spec.md - */ - protected function configureCommand(): void - { - $this->setName(name: 'launchpad:dashboard:list') - ->setDescription(description: 'List dashboards with optional filters.') - ->setHelp( - help: implode( - separator: "\n", - array: [ - 'List LaunchPad dashboards visible on this instance.', - '', - 'Options:', - ' --user= Restrict to dashboards owned by user.', - ' --group= Restrict to group-shared dashboards for group.', - ' --status= Filter on publication status (draft|published|scheduled).', - '', - 'Examples:', - ' php occ launchpad:dashboard:list', - ' php occ launchpad:dashboard:list --user=alice --status=published --json', - ] - ) - ) - ->addOption( - name: 'user', - shortcut: null, - mode: InputOption::VALUE_REQUIRED, - description: 'Filter by owning user id.' - ) - ->addOption( - name: 'group', - shortcut: null, - mode: InputOption::VALUE_REQUIRED, - description: 'Filter by group id (group-shared dashboards).' - ) - ->addOption( - name: 'status', - shortcut: null, - mode: InputOption::VALUE_REQUIRED, - description: 'Filter by publication status.' - ); - }//end configureCommand() - - /** - * Execute the listing. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * - * @return int - * - * @spec openspec/specs/cli-commands/spec.md - */ - protected function handle( - InputInterface $input, - OutputInterface $output - ): int { - $user = $input->getOption(name: 'user'); - $group = $input->getOption(name: 'group'); - $status = $input->getOption(name: 'status'); - - $rejection = $this->validateFilters( - input: $input, - output: $output, - user: $user, - status: $status - ); - if ($rejection !== null) { - return $rejection; - } - - $dashboards = $this->collect( - user: $this->optionToString(value: $user), - group: $this->optionToString(value: $group), - status: $this->optionToString(value: $status) - ); - - $rows = $this->toRows(dashboards: $dashboards); - - if ($this->isJson(input: $input) === true) { - $this->emitSuccess( - input: $input, - output: $output, - data: ['dashboards' => $rows, 'count' => count(value: $rows)] - ); - return CommandService::EXIT_SUCCESS; - } - - $this->writeTable(input: $input, output: $output, rows: $rows); - - return CommandService::EXIT_SUCCESS; - }//end handle() - - /** - * Validate the `--status` and `--user` filters. - * - * Returns the exit code of the emitted error envelope when a filter - * is rejected, or `null` when both filters are acceptable (including - * when they were not supplied at all). - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * @param mixed $user Raw `--user` option value. - * @param mixed $status Raw `--status` option value. - * - * @return int|null The error exit code, or null when valid. - */ - private function validateFilters( - InputInterface $input, - OutputInterface $output, - mixed $user, - mixed $status - ): ?int { - if ($status !== null - && in_array(needle: (string) $status, haystack: self::ALLOWED_STATUS, strict: true) === false - ) { - return $this->emitError( - input: $input, - output: $output, - exitCode: CommandService::EXIT_INVALID_ARGS, - code: 'INVALID_ARGUMENT', - message: 'Invalid --status value: '.(string) $status, - context: ['allowed' => self::ALLOWED_STATUS] - ); - } - - if ($user !== null && $this->userManager->userExists(uid: (string) $user) === false) { - return $this->emitError( - input: $input, - output: $output, - exitCode: CommandService::EXIT_NOT_FOUND, - code: 'NOT_FOUND', - message: 'User not found: '.(string) $user, - context: ['userId' => (string) $user] - ); - } - - return null; - }//end validateFilters() - - /** - * Normalise a raw console option to a nullable string. - * - * An unset option arrives as `null` and must stay `null` so the - * collector can tell "no filter" from "filter on the empty string". - * - * @param mixed $value The raw option value. - * - * @return string|null The cast value, or null when unset. - */ - private function optionToString(mixed $value): ?string - { - if ($value === null) { - return null; - } - - return (string) $value; - }//end optionToString() - - /** - * Flatten dashboards into the row shape shared by both output modes. - * - * @param array $dashboards The dashboards to flatten. - * - * @return list> The rows. - */ - private function toRows(array $dashboards): array - { - return array_map( - callback: static function (Dashboard $dashboard): array { - return [ - 'uuid' => (string) $dashboard->getUuid(), - 'name' => (string) $dashboard->getName(), - 'type' => (string) $dashboard->getType(), - 'owner' => (string) ($dashboard->getUserId() ?? ''), - 'group' => (string) ($dashboard->getGroupId() ?? ''), - 'publicationStatus' => (string) $dashboard->getPublicationStatus(), - ]; - }, - array: $dashboards - ); - }//end toRows() - - /** - * Render the compact human-readable table. - * - * Writes nothing at all in quiet mode; writes the empty-result notice - * when no dashboard matched; otherwise writes a header plus one line - * per row. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * @param list> $rows The rows to render. - * - * @return void - */ - private function writeTable( - InputInterface $input, - OutputInterface $output, - array $rows - ): void { - if ($this->isQuiet(input: $input) === true) { - return; - } - - if (count(value: $rows) === 0) { - $output->writeln(messages: 'No dashboards match the supplied filters.'); - return; - } - - $output->writeln( - messages: sprintf('%-36s %-20s %-14s %-12s %s', 'UUID', 'NAME', 'TYPE', 'STATUS', 'OWNER') - ); - foreach ($rows as $row) { - $output->writeln( - messages: sprintf( - '%-36s %-20s %-14s %-12s %s', - $row['uuid'], - mb_strimwidth(string: $row['name'], start: 0, width: 20, trim_marker: '..'), - $row['type'], - $row['publicationStatus'], - $row['owner'] - ) - ); - } - }//end writeTable() - - /** - * Collect dashboards across all relevant scopes for the supplied - * filters. The mapper exposes scope-specific finders; we fan out - * and apply post-filters in PHP to keep the command non-invasive - * (no schema or mapper changes). - * - * @param string|null $user Optional user filter. - * @param string|null $group Optional group filter. - * @param string|null $status Optional publication status filter. - * - * @return list - */ - private function collect( - string|null $user, - string|null $group, - string|null $status - ): array { - $dashboards = $this->collectScope(user: $user, group: $group); - - if ($status === null) { - return $dashboards; - } - - return array_values( - array: array_filter( - array: $dashboards, - callback: static function (Dashboard $dashboard) use ($status): bool { - return $dashboard->getPublicationStatus() === $status; - } - ) - ); - }//end collect() - - /** - * Pick the mapper scope that matches the supplied filters. - * - * The three scopes are mutually exclusive and ordered by specificity: - * `--user` wins over `--group`, and with neither filter the whole - * instance-wide scope is walked. - * - * @param string|null $user Optional user filter. - * @param string|null $group Optional group filter. - * - * @return list - */ - private function collectScope(string|null $user, string|null $group): array - { - if ($user !== null) { - return array_values(array: $this->dashboardMapper->findByUserId(userId: $user)); - } - - if ($group !== null) { - return array_values(array: $this->dashboardMapper->findByGroup(groupId: $group)); - } - - return $this->collectInstanceWide(); - }//end collectScope() - - /** - * Walk every dashboard visible instance-wide: the admin templates - * plus every root dashboard and its descendants. - * - * Roots without a UUID cannot be used as a descendant anchor, so - * they are emitted on their own and their (unreachable) subtree is - * skipped. - * - * @return list - */ - private function collectInstanceWide(): array - { - $dashboards = []; - - foreach ($this->dashboardMapper->findAdminTemplates() as $dashboard) { - $dashboards[] = $dashboard; - } - - foreach ($this->dashboardMapper->findByParent(parentUuid: null) as $root) { - $dashboards[] = $root; - $uuid = (string) $root->getUuid(); - if ($uuid === '') { - continue; - } - - foreach ($this->dashboardMapper->findDescendants(ancestorUuid: $uuid) as $child) { - $dashboards[] = $child; - } - } - - return $dashboards; - }//end collectInstanceWide() +class DashboardListCommand extends CommandBase { + /** + * Allowed values for `--status` (REQ-CLI-003). + * + * @var list + */ + private const ALLOWED_STATUS = ['draft', 'published', 'scheduled']; + + /** + * Constructor. + * + * @param CommandService $commandService Shared CLI helper. + * @param IUserSession $userSession Caller resolution. + * @param DashboardMapper $dashboardMapper Dashboard mapper. + * @param IUserManager $userManager For `--user` validation. + */ + public function __construct( + CommandService $commandService, + IUserSession $userSession, + private readonly DashboardMapper $dashboardMapper, + private readonly IUserManager $userManager, + ) { + parent::__construct(commandService: $commandService, userSession: $userSession); + }//end __construct() + + /** + * Wire command name, description, and per-command options. + * + * @return void + * + * @spec openspec/specs/cli-commands/spec.md + */ + protected function configureCommand(): void { + $this->setName(name: 'launchpad:dashboard:list') + ->setDescription(description: 'List dashboards with optional filters.') + ->setHelp( + help: implode( + separator: "\n", + array: [ + 'List LaunchPad dashboards visible on this instance.', + '', + 'Options:', + ' --user= Restrict to dashboards owned by user.', + ' --group= Restrict to group-shared dashboards for group.', + ' --status= Filter on publication status (draft|published|scheduled).', + '', + 'Examples:', + ' php occ launchpad:dashboard:list', + ' php occ launchpad:dashboard:list --user=alice --status=published --json', + ] + ) + ) + ->addOption( + name: 'user', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'Filter by owning user id.' + ) + ->addOption( + name: 'group', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'Filter by group id (group-shared dashboards).' + ) + ->addOption( + name: 'status', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'Filter by publication status.' + ); + }//end configureCommand() + + /** + * Execute the listing. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * + * @return int + * + * @spec openspec/specs/cli-commands/spec.md + */ + protected function handle( + InputInterface $input, + OutputInterface $output, + ): int { + $user = $input->getOption(name: 'user'); + $group = $input->getOption(name: 'group'); + $status = $input->getOption(name: 'status'); + + $rejection = $this->validateFilters( + input: $input, + output: $output, + user: $user, + status: $status + ); + if ($rejection !== null) { + return $rejection; + } + + $dashboards = $this->collect( + user: $this->optionToString(value: $user), + group: $this->optionToString(value: $group), + status: $this->optionToString(value: $status) + ); + + $rows = $this->toRows(dashboards: $dashboards); + + if ($this->isJson(input: $input) === true) { + $this->emitSuccess( + input: $input, + output: $output, + data: ['dashboards' => $rows, 'count' => count(value: $rows)] + ); + return CommandService::EXIT_SUCCESS; + } + + $this->writeTable(input: $input, output: $output, rows: $rows); + + return CommandService::EXIT_SUCCESS; + }//end handle() + + /** + * Validate the `--status` and `--user` filters. + * + * Returns the exit code of the emitted error envelope when a filter + * is rejected, or `null` when both filters are acceptable (including + * when they were not supplied at all). + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * @param mixed $user Raw `--user` option value. + * @param mixed $status Raw `--status` option value. + * + * @return int|null The error exit code, or null when valid. + */ + private function validateFilters( + InputInterface $input, + OutputInterface $output, + mixed $user, + mixed $status, + ): ?int { + if ($status !== null + && in_array(needle: (string)$status, haystack: self::ALLOWED_STATUS, strict: true) === false + ) { + return $this->emitError( + input: $input, + output: $output, + exitCode: CommandService::EXIT_INVALID_ARGS, + code: 'INVALID_ARGUMENT', + message: 'Invalid --status value: ' . (string)$status, + context: ['allowed' => self::ALLOWED_STATUS] + ); + } + + if ($user !== null && $this->userManager->userExists(uid: (string)$user) === false) { + return $this->emitError( + input: $input, + output: $output, + exitCode: CommandService::EXIT_NOT_FOUND, + code: 'NOT_FOUND', + message: 'User not found: ' . (string)$user, + context: ['userId' => (string)$user] + ); + } + + return null; + }//end validateFilters() + + /** + * Normalise a raw console option to a nullable string. + * + * An unset option arrives as `null` and must stay `null` so the + * collector can tell "no filter" from "filter on the empty string". + * + * @param mixed $value The raw option value. + * + * @return string|null The cast value, or null when unset. + */ + private function optionToString(mixed $value): ?string { + if ($value === null) { + return null; + } + + return (string)$value; + }//end optionToString() + + /** + * Flatten dashboards into the row shape shared by both output modes. + * + * @param array $dashboards The dashboards to flatten. + * + * @return list> The rows. + */ + private function toRows(array $dashboards): array { + return array_map( + callback: static function (Dashboard $dashboard): array { + return [ + 'uuid' => (string)$dashboard->getUuid(), + 'name' => (string)$dashboard->getName(), + 'type' => (string)$dashboard->getType(), + 'owner' => (string)($dashboard->getUserId() ?? ''), + 'group' => (string)($dashboard->getGroupId() ?? ''), + 'publicationStatus' => (string)$dashboard->getPublicationStatus(), + ]; + }, + array: $dashboards + ); + }//end toRows() + + /** + * Render the compact human-readable table. + * + * Writes nothing at all in quiet mode; writes the empty-result notice + * when no dashboard matched; otherwise writes a header plus one line + * per row. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * @param list> $rows The rows to render. + * + * @return void + */ + private function writeTable( + InputInterface $input, + OutputInterface $output, + array $rows, + ): void { + if ($this->isQuiet(input: $input) === true) { + return; + } + + if (count(value: $rows) === 0) { + $output->writeln(messages: 'No dashboards match the supplied filters.'); + return; + } + + $output->writeln( + messages: sprintf('%-36s %-20s %-14s %-12s %s', 'UUID', 'NAME', 'TYPE', 'STATUS', 'OWNER') + ); + foreach ($rows as $row) { + $output->writeln( + messages: sprintf( + '%-36s %-20s %-14s %-12s %s', + $row['uuid'], + mb_strimwidth(string: $row['name'], start: 0, width: 20, trim_marker: '..'), + $row['type'], + $row['publicationStatus'], + $row['owner'] + ) + ); + } + }//end writeTable() + + /** + * Collect dashboards across all relevant scopes for the supplied + * filters. The mapper exposes scope-specific finders; we fan out + * and apply post-filters in PHP to keep the command non-invasive + * (no schema or mapper changes). + * + * @param string|null $user Optional user filter. + * @param string|null $group Optional group filter. + * @param string|null $status Optional publication status filter. + * + * @return list + */ + private function collect( + ?string $user, + ?string $group, + ?string $status, + ): array { + $dashboards = $this->collectScope(user: $user, group: $group); + + if ($status === null) { + return $dashboards; + } + + return array_values( + array: array_filter( + array: $dashboards, + callback: static function (Dashboard $dashboard) use ($status): bool { + return $dashboard->getPublicationStatus() === $status; + } + ) + ); + }//end collect() + + /** + * Pick the mapper scope that matches the supplied filters. + * + * The three scopes are mutually exclusive and ordered by specificity: + * `--user` wins over `--group`, and with neither filter the whole + * instance-wide scope is walked. + * + * @param string|null $user Optional user filter. + * @param string|null $group Optional group filter. + * + * @return list + */ + private function collectScope(?string $user, ?string $group): array { + if ($user !== null) { + return array_values(array: $this->dashboardMapper->findByUserId(userId: $user)); + } + + if ($group !== null) { + return array_values(array: $this->dashboardMapper->findByGroup(groupId: $group)); + } + + return $this->collectInstanceWide(); + }//end collectScope() + + /** + * Walk every dashboard visible instance-wide: the admin templates + * plus every root dashboard and its descendants. + * + * Roots without a UUID cannot be used as a descendant anchor, so + * they are emitted on their own and their (unreachable) subtree is + * skipped. + * + * @return list + */ + private function collectInstanceWide(): array { + $dashboards = []; + + foreach ($this->dashboardMapper->findAdminTemplates() as $dashboard) { + $dashboards[] = $dashboard; + } + + foreach ($this->dashboardMapper->findByParent(parentUuid: null) as $root) { + $dashboards[] = $root; + $uuid = (string)$root->getUuid(); + if ($uuid === '') { + continue; + } + + foreach ($this->dashboardMapper->findDescendants(ancestorUuid: $uuid) as $child) { + $dashboards[] = $child; + } + } + + return $dashboards; + }//end collectInstanceWide() }//end class diff --git a/lib/Command/DashboardShowCommand.php b/lib/Command/DashboardShowCommand.php index dc807f4a..dd20205d 100644 --- a/lib/Command/DashboardShowCommand.php +++ b/lib/Command/DashboardShowCommand.php @@ -35,129 +35,127 @@ /** * `launchpad:dashboard:show` console command. */ -class DashboardShowCommand extends CommandBase -{ - /** - * Pattern matching a UUID v4 (the format LaunchPad mints) — accepts - * the relaxed v* variant so older fixtures still validate. - * - * @var string - */ - private const UUID_REGEX = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'; +class DashboardShowCommand extends CommandBase { + /** + * Pattern matching a UUID v4 (the format LaunchPad mints) — accepts + * the relaxed v* variant so older fixtures still validate. + * + * @var string + */ + private const UUID_REGEX = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'; - /** - * Constructor. - * - * @param CommandService $commandService Shared CLI helper. - * @param IUserSession $userSession Caller resolution. - * @param DashboardMapper $dashboardMapper Dashboard mapper. - * @param WidgetPlacementMapper $placementMapper Widget mapper. - */ - public function __construct( - CommandService $commandService, - IUserSession $userSession, - private readonly DashboardMapper $dashboardMapper, - private readonly WidgetPlacementMapper $placementMapper - ) { - parent::__construct(commandService: $commandService, userSession: $userSession); - }//end __construct() + /** + * Constructor. + * + * @param CommandService $commandService Shared CLI helper. + * @param IUserSession $userSession Caller resolution. + * @param DashboardMapper $dashboardMapper Dashboard mapper. + * @param WidgetPlacementMapper $placementMapper Widget mapper. + */ + public function __construct( + CommandService $commandService, + IUserSession $userSession, + private readonly DashboardMapper $dashboardMapper, + private readonly WidgetPlacementMapper $placementMapper, + ) { + parent::__construct(commandService: $commandService, userSession: $userSession); + }//end __construct() - /** - * Wire command name, description, and per-command options. - * - * @return void - * - * @spec openspec/specs/cli-commands/spec.md - */ - protected function configureCommand(): void - { - $this->setName(name: 'launchpad:dashboard:show') - ->setDescription(description: 'Display full dashboard configuration.') - ->setHelp( - help: implode( - separator: "\n", - array: [ - 'Display the full configuration of a single dashboard, including the widget tree.', - '', - 'Examples:', - ' php occ launchpad:dashboard:show a1b2c3d4-e5f6-4789-abcd-ef1234567890', - ' php occ launchpad:dashboard:show a1b2c3d4-e5f6-4789-abcd-ef1234567890 --json', - ] - ) - ) - ->addArgument( - name: 'uuid', - mode: InputArgument::REQUIRED, - description: 'The dashboard UUID.' - ); - }//end configureCommand() + /** + * Wire command name, description, and per-command options. + * + * @return void + * + * @spec openspec/specs/cli-commands/spec.md + */ + protected function configureCommand(): void { + $this->setName(name: 'launchpad:dashboard:show') + ->setDescription(description: 'Display full dashboard configuration.') + ->setHelp( + help: implode( + separator: "\n", + array: [ + 'Display the full configuration of a single dashboard, including the widget tree.', + '', + 'Examples:', + ' php occ launchpad:dashboard:show a1b2c3d4-e5f6-4789-abcd-ef1234567890', + ' php occ launchpad:dashboard:show a1b2c3d4-e5f6-4789-abcd-ef1234567890 --json', + ] + ) + ) + ->addArgument( + name: 'uuid', + mode: InputArgument::REQUIRED, + description: 'The dashboard UUID.' + ); + }//end configureCommand() - /** - * Execute the show. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * - * @return int - * - * @spec openspec/specs/cli-commands/spec.md - */ - protected function handle( - InputInterface $input, - OutputInterface $output - ): int { - $uuid = (string) $input->getArgument(name: 'uuid'); + /** + * Execute the show. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * + * @return int + * + * @spec openspec/specs/cli-commands/spec.md + */ + protected function handle( + InputInterface $input, + OutputInterface $output, + ): int { + $uuid = (string)$input->getArgument(name: 'uuid'); - if (preg_match(pattern: self::UUID_REGEX, subject: $uuid) !== 1) { - return $this->emitError( - input: $input, - output: $output, - exitCode: CommandService::EXIT_INVALID_ARGS, - code: 'INVALID_ARGUMENT', - message: "Invalid UUID format: '".$uuid."'", - context: ['field' => 'uuid', 'providedValue' => $uuid] - ); - } + if (preg_match(pattern: self::UUID_REGEX, subject: $uuid) !== 1) { + return $this->emitError( + input: $input, + output: $output, + exitCode: CommandService::EXIT_INVALID_ARGS, + code: 'INVALID_ARGUMENT', + message: "Invalid UUID format: '" . $uuid . "'", + context: ['field' => 'uuid', 'providedValue' => $uuid] + ); + } - try { - $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); - } catch (DoesNotExistException) { - return $this->emitError( - input: $input, - output: $output, - exitCode: CommandService::EXIT_NOT_FOUND, - code: 'NOT_FOUND', - message: 'Dashboard not found', - context: ['uuid' => $uuid] - ); - } + try { + $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); + } catch (DoesNotExistException) { + return $this->emitError( + input: $input, + output: $output, + exitCode: CommandService::EXIT_NOT_FOUND, + code: 'NOT_FOUND', + message: 'Dashboard not found', + context: ['uuid' => $uuid] + ); + } - $placements = array_map( - callback: static function (WidgetPlacement $placement): array { - return $placement->jsonSerialize(); - }, - array: $this->placementMapper->findByDashboardId(dashboardId: (int) $dashboard->getId()) - ); + $placements = array_map( + callback: static function (WidgetPlacement $placement): array { + return $placement->jsonSerialize(); + }, + array: $this->placementMapper->findByDashboardId(dashboardId: (int)$dashboard->getId()) + ); - $payload = [ - 'dashboard' => $dashboard->jsonSerialize(), - 'widgets' => $placements, - ]; + $payload = [ + 'dashboard' => $dashboard->jsonSerialize(), + 'widgets' => $placements, + ]; - if ($this->isJson(input: $input) === true) { - $this->emitSuccess(input: $input, output: $output, data: $payload); - return CommandService::EXIT_SUCCESS; - } + if ($this->isJson(input: $input) === true) { + $this->emitSuccess(input: $input, output: $output, data: $payload); + return CommandService::EXIT_SUCCESS; + } - if ($this->isQuiet(input: $input) === false) { - $output->writeln( - messages: (string) json_encode( - value: $payload, - flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) - ) - ); - } + if ($this->isQuiet(input: $input) === false) { + $output->writeln( + messages: (string)json_encode( + value: $payload, + flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ) + ); + } - return CommandService::EXIT_SUCCESS; - }//end handle() + return CommandService::EXIT_SUCCESS; + }//end handle() }//end class diff --git a/lib/Command/DemoShowcasesInstallCommand.php b/lib/Command/DemoShowcasesInstallCommand.php index b2644cb0..f3d6aba6 100644 --- a/lib/Command/DemoShowcasesInstallCommand.php +++ b/lib/Command/DemoShowcasesInstallCommand.php @@ -37,101 +37,99 @@ /** * `launchpad:demo-showcases:install` console command. */ -class DemoShowcasesInstallCommand extends Command -{ - /** - * Constructor. - * - * @param DemoShowcasesService $showcases Showcase service. - */ - public function __construct( - private readonly DemoShowcasesService $showcases, - ) { - parent::__construct(); - }//end __construct() +class DemoShowcasesInstallCommand extends Command { + /** + * Constructor. + * + * @param DemoShowcasesService $showcases Showcase service. + */ + public function __construct( + private readonly DemoShowcasesService $showcases, + ) { + parent::__construct(); + }//end __construct() - /** - * Configure CLI options. - * - * @return void - * - * @spec openspec/specs/demo-data-showcases/spec.md - */ - protected function configure(): void - { - $this->setName(name: 'launchpad:demo-showcases:install') - ->setDescription(description: 'Install a bundled LaunchPad demo showcase dashboard.') - ->addArgument( - name: 'id', - mode: InputArgument::REQUIRED, - description: 'Showcase ID (e.g. de-bron, gemeente-duin).' - ) - ->addOption( - name: 'lang', - shortcut: null, - mode: InputOption::VALUE_REQUIRED, - description: 'Locale (forward-compatible; v1 always resolves to nl).', - default: 'nl' - ) - ->addOption( - name: 'force', - shortcut: 'f', - mode: InputOption::VALUE_NONE, - description: 'Reinstall even if the showcase is already installed.' - ); - }//end configure() + /** + * Configure CLI options. + * + * @return void + * + * @spec openspec/specs/demo-data-showcases/spec.md + */ + protected function configure(): void { + $this->setName(name: 'launchpad:demo-showcases:install') + ->setDescription(description: 'Install a bundled LaunchPad demo showcase dashboard.') + ->addArgument( + name: 'id', + mode: InputArgument::REQUIRED, + description: 'Showcase ID (e.g. de-bron, gemeente-duin).' + ) + ->addOption( + name: 'lang', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'Locale (forward-compatible; v1 always resolves to nl).', + default: 'nl' + ) + ->addOption( + name: 'force', + shortcut: 'f', + mode: InputOption::VALUE_NONE, + description: 'Reinstall even if the showcase is already installed.' + ); + }//end configure() - /** - * Execute the command. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * - * @return int Exit code. - * - * @spec openspec/specs/demo-data-showcases/spec.md - */ - protected function execute( - InputInterface $input, - OutputInterface $output - ): int { - $id = (string) $input->getArgument(name: 'id'); - $lang = (string) ($input->getOption(name: 'lang') ?? 'nl'); - $force = (bool) $input->getOption(name: 'force'); + /** + * Execute the command. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * + * @return int Exit code. + * + * @spec openspec/specs/demo-data-showcases/spec.md + */ + protected function execute( + InputInterface $input, + OutputInterface $output, + ): int { + $id = (string)$input->getArgument(name: 'id'); + $lang = (string)($input->getOption(name: 'lang') ?? 'nl'); + $force = (bool)$input->getOption(name: 'force'); - try { - $result = $this->showcases->installShowcase( - showcaseId: $id, - lang: $lang, - force: $force - ); - } catch (ShowcaseNotFoundException) { - $output->writeln(messages: 'Showcase not found: '.$id.''); - return self::FAILURE; - } catch (Throwable $e) { - $output->writeln(messages: 'Installation failed: '.$e->getMessage().''); - return self::FAILURE; - } + try { + $result = $this->showcases->installShowcase( + showcaseId: $id, + lang: $lang, + force: $force + ); + } catch (ShowcaseNotFoundException) { + $output->writeln(messages: 'Showcase not found: ' . $id . ''); + return self::FAILURE; + } catch (Throwable $e) { + $output->writeln(messages: 'Installation failed: ' . $e->getMessage() . ''); + return self::FAILURE; + } - if ($result['alreadyInstalled'] === true) { - $output->writeln( - messages: 'Showcase '.$id.' is already installed (UUID: '.$result['installedDashboardUuid'].').' - ); - $output->writeln(messages: 'Use --force to reinstall.'); - return self::SUCCESS; - } + if ($result['alreadyInstalled'] === true) { + $output->writeln( + messages: 'Showcase ' . $id . ' is already installed (UUID: ' . $result['installedDashboardUuid'] . ').' + ); + $output->writeln(messages: 'Use --force to reinstall.'); + return self::SUCCESS; + } - $output->writeln( - messages: 'Installed dashboard '.$result['installedDashboardUuid'] - ); + $output->writeln( + messages: 'Installed dashboard ' . $result['installedDashboardUuid'] + ); - $skipped = $result['skippedWidgets']; - if ($skipped !== []) { - $output->writeln( - messages: 'Skipped unknown widgets: '.implode(separator: ', ', array: $skipped).'' - ); - } + $skipped = $result['skippedWidgets']; + if ($skipped !== []) { + $output->writeln( + messages: 'Skipped unknown widgets: ' . implode(separator: ', ', array: $skipped) . '' + ); + } - return self::SUCCESS; - }//end execute() + return self::SUCCESS; + }//end execute() }//end class diff --git a/lib/Command/DemoShowcasesListCommand.php b/lib/Command/DemoShowcasesListCommand.php index fce2722e..a0417a60 100644 --- a/lib/Command/DemoShowcasesListCommand.php +++ b/lib/Command/DemoShowcasesListCommand.php @@ -34,85 +34,83 @@ /** * `launchpad:demo-showcases:list` console command. */ -class DemoShowcasesListCommand extends Command -{ - /** - * Constructor. - * - * @param DemoShowcasesService $showcases Showcase service. - */ - public function __construct( - private readonly DemoShowcasesService $showcases, - ) { - parent::__construct(); - }//end __construct() +class DemoShowcasesListCommand extends Command { + /** + * Constructor. + * + * @param DemoShowcasesService $showcases Showcase service. + */ + public function __construct( + private readonly DemoShowcasesService $showcases, + ) { + parent::__construct(); + }//end __construct() - /** - * Configure CLI options. - * - * @return void - * - * @spec openspec/specs/demo-data-showcases/spec.md - */ - protected function configure(): void - { - $this->setName(name: 'launchpad:demo-showcases:list') - ->setDescription(description: 'List every bundled LaunchPad demo showcase.') - ->addOption( - name: 'json', - shortcut: null, - mode: InputOption::VALUE_NONE, - description: 'Emit machine-parseable JSON instead of a table.' - ); - }//end configure() + /** + * Configure CLI options. + * + * @return void + * + * @spec openspec/specs/demo-data-showcases/spec.md + */ + protected function configure(): void { + $this->setName(name: 'launchpad:demo-showcases:list') + ->setDescription(description: 'List every bundled LaunchPad demo showcase.') + ->addOption( + name: 'json', + shortcut: null, + mode: InputOption::VALUE_NONE, + description: 'Emit machine-parseable JSON instead of a table.' + ); + }//end configure() - /** - * Execute the command. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * - * @return int Exit code. - * - * @spec openspec/specs/demo-data-showcases/spec.md - */ - protected function execute( - InputInterface $input, - OutputInterface $output - ): int { - $showcases = $this->showcases->getAvailableShowcases(); + /** + * Execute the command. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * + * @return int Exit code. + * + * @spec openspec/specs/demo-data-showcases/spec.md + */ + protected function execute( + InputInterface $input, + OutputInterface $output, + ): int { + $showcases = $this->showcases->getAvailableShowcases(); - if ((bool) $input->getOption(name: 'json') === true) { - $output->writeln( - messages: (string) json_encode( - value: $showcases, - flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) - ) - ); - return self::SUCCESS; - } + if ((bool)$input->getOption(name: 'json') === true) { + $output->writeln( + messages: (string)json_encode( + value: $showcases, + flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ) + ); + return self::SUCCESS; + } - $table = new Table(output: $output); - $table->setHeaders(headers: ['ID', 'Name', 'Language', 'Status', 'Dashboard UUID']); + $table = new Table(output: $output); + $table->setHeaders(headers: ['ID', 'Name', 'Language', 'Status', 'Dashboard UUID']); - foreach ($showcases as $showcase) { - $status = 'Not installed'; - if ($showcase['isInstalled'] === true) { - $status = 'Installed'; - } + foreach ($showcases as $showcase) { + $status = 'Not installed'; + if ($showcase['isInstalled'] === true) { + $status = 'Installed'; + } - $table->addRow( - row: [ - $showcase['id'], - $showcase['name'], - $showcase['language'], - $status, - (string) ($showcase['installedDashboardUuid'] ?? '-'), - ] - ); - } + $table->addRow( + row: [ + $showcase['id'], + $showcase['name'], + $showcase['language'], + $status, + (string)($showcase['installedDashboardUuid'] ?? '-'), + ] + ); + } - $table->render(); - return self::SUCCESS; - }//end execute() + $table->render(); + return self::SUCCESS; + }//end execute() }//end class diff --git a/lib/Command/ExportCommand.php b/lib/Command/ExportCommand.php index f748e365..15afeba2 100644 --- a/lib/Command/ExportCommand.php +++ b/lib/Command/ExportCommand.php @@ -37,215 +37,213 @@ /** * `launchpad:export` console command. */ -class ExportCommand extends Command -{ - /** - * Constructor. - * - * @param ExportService $exportService Export service. - * @param DashboardMapper $dashboardMapper Dashboard mapper for - * site-scope iteration. - */ - public function __construct( - private readonly ExportService $exportService, - private readonly DashboardMapper $dashboardMapper, - ) { - parent::__construct(); - }//end __construct() - - /** - * Configure CLI options. - * - * @return void - * - * @spec openspec/specs/dashboard-export-import/spec.md - */ - protected function configure(): void - { - $this->setName(name: 'launchpad:export') - ->setDescription(description: 'Export LaunchPad dashboards to a versioned ZIP archive.') - ->addOption( - name: 'scope', - shortcut: null, - mode: InputOption::VALUE_REQUIRED, - description: 'Export scope: "site" (default) or "dashboard".', - default: 'site' - ) - ->addOption( - name: 'dashboard-uuid', - shortcut: null, - mode: InputOption::VALUE_REQUIRED, - description: 'Dashboard UUID, required when --scope=dashboard.' - ) - ->addOption( - name: 'output', - shortcut: 'o', - mode: InputOption::VALUE_REQUIRED, - description: 'Output file path for the ZIP archive.' - ); - }//end configure() - - /** - * Execute the export. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * - * @return int Exit code (0 success, 1 error). - * - * @spec openspec/specs/dashboard-export-import/spec.md - */ - protected function execute( - InputInterface $input, - OutputInterface $output - ): int { - $scope = (string) $input->getOption(name: 'scope'); - $outputPath = (string) ($input->getOption(name: 'output') ?? ''); - $dashboardUid = (string) ($input->getOption(name: 'dashboard-uuid') ?? ''); - - if ($outputPath === '') { - $output->writeln(messages: '--output parameter is required'); - return self::FAILURE; - } - - if (in_array(needle: $scope, haystack: ['site', 'dashboard'], strict: true) === false) { - $output->writeln( - messages: 'Unsupported scope: '.$scope.'. Use "site" or "dashboard".' - ); - return self::FAILURE; - } - - if ($scope === 'dashboard' && $dashboardUid === '') { - $output->writeln( - messages: '--dashboard-uuid is required when --scope=dashboard' - ); - return self::FAILURE; - } - - try { - $count = $this->writeArchive( - scope: $scope, - dashboardUuid: $dashboardUid, - outputPath: $outputPath - ); - } catch (DoesNotExistException) { - $output->writeln(messages: 'Dashboard not found: '.$dashboardUid.''); - return self::FAILURE; - } catch (Throwable $e) { - $output->writeln(messages: 'Export failed: '.$e->getMessage().''); - return self::FAILURE; - } - - $noun = 'dashboards'; - if ($count === 1) { - $noun = 'dashboard'; - } - - $output->writeln( - messages: 'Exported '.(string) $count.' '.$noun.' to '.$outputPath - ); - return self::SUCCESS; - }//end execute() - - /** - * Write the archive to disk by reusing the export service helpers. - * - * The CLI uses the same serializer the HTTP path uses; we copy the - * temporary stream back to the requested on-disk location. - * - * @param string $scope The export scope. - * @param string $dashboardUuid Dashboard UUID (when scope=dashboard). - * @param string $outputPath Destination file path. - * - * @return int The dashboard count written. - * - * @throws DoesNotExistException When the dashboard is not found. - */ - private function writeArchive( - string $scope, - string $dashboardUuid, - string $outputPath - ): int { - $dashboards = $this->collectDashboards( - scope: $scope, - dashboardUuid: $dashboardUuid - ); - - $zip = new ZipArchive(); - if ($zip->open(filename: $outputPath, flags: ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { - throw new RuntimeException(message: 'Could not open ZIP archive at '.$outputPath); - } - - $manifest = $this->exportService->buildManifest( - scope: $scope, - dashboardCount: count($dashboards), - currentUserId: 'cli' - ); - $zip->addFromString( - name: 'manifest.json', - content: (string) json_encode( - value: $manifest, - flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) - ) - ); - - foreach ($dashboards as $dashboard) { - $payload = $this->exportService->serializeDashboard(dashboard: $dashboard); - $uuid = (string) $dashboard->getUuid(); - if ($uuid === '') { - continue; - } - - $zip->addFromString( - name: 'dashboards/'.$uuid.'.json', - content: (string) json_encode(value: $payload, flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) - ); - $zip->addEmptyDir(dirname: 'assets/widgets/'.$uuid.'/'); - } - - $zip->addFromString(name: 'metadata-fields.json', content: '[]'); - $zip->addEmptyDir(dirname: 'assets/icons/'); - - $zip->close(); - - return count($dashboards); - }//end writeArchive() - - /** - * Resolve the dashboard collection for the requested scope. - * - * @param string $scope The export scope. - * @param string $dashboardUuid Dashboard UUID (when scope=dashboard). - * - * @return Dashboard[] The dashboards to export. - * - * @throws DoesNotExistException When the dashboard is not found. - */ - private function collectDashboards( - string $scope, - string $dashboardUuid - ): array { - if ($scope === 'dashboard') { - return [$this->dashboardMapper->findByUuid(uuid: $dashboardUuid)]; - } - - $all = []; - foreach ($this->dashboardMapper->findAdminTemplates() as $tpl) { - $all[] = $tpl; - } - - foreach ($this->dashboardMapper->findByParent(parentUuid: null) as $root) { - $all[] = $root; - $uuid = (string) $root->getUuid(); - if ($uuid === '') { - continue; - } - - foreach ($this->dashboardMapper->findDescendants(ancestorUuid: $uuid) as $child) { - $all[] = $child; - } - } - - return $all; - }//end collectDashboards() +class ExportCommand extends Command { + /** + * Constructor. + * + * @param ExportService $exportService Export service. + * @param DashboardMapper $dashboardMapper Dashboard mapper for + * site-scope iteration. + */ + public function __construct( + private readonly ExportService $exportService, + private readonly DashboardMapper $dashboardMapper, + ) { + parent::__construct(); + }//end __construct() + + /** + * Configure CLI options. + * + * @return void + * + * @spec openspec/specs/dashboard-export-import/spec.md + */ + protected function configure(): void { + $this->setName(name: 'launchpad:export') + ->setDescription(description: 'Export LaunchPad dashboards to a versioned ZIP archive.') + ->addOption( + name: 'scope', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'Export scope: "site" (default) or "dashboard".', + default: 'site' + ) + ->addOption( + name: 'dashboard-uuid', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'Dashboard UUID, required when --scope=dashboard.' + ) + ->addOption( + name: 'output', + shortcut: 'o', + mode: InputOption::VALUE_REQUIRED, + description: 'Output file path for the ZIP archive.' + ); + }//end configure() + + /** + * Execute the export. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * + * @return int Exit code (0 success, 1 error). + * + * @spec openspec/specs/dashboard-export-import/spec.md + */ + protected function execute( + InputInterface $input, + OutputInterface $output, + ): int { + $scope = (string)$input->getOption(name: 'scope'); + $outputPath = (string)($input->getOption(name: 'output') ?? ''); + $dashboardUid = (string)($input->getOption(name: 'dashboard-uuid') ?? ''); + + if ($outputPath === '') { + $output->writeln(messages: '--output parameter is required'); + return self::FAILURE; + } + + if (in_array(needle: $scope, haystack: ['site', 'dashboard'], strict: true) === false) { + $output->writeln( + messages: 'Unsupported scope: ' . $scope . '. Use "site" or "dashboard".' + ); + return self::FAILURE; + } + + if ($scope === 'dashboard' && $dashboardUid === '') { + $output->writeln( + messages: '--dashboard-uuid is required when --scope=dashboard' + ); + return self::FAILURE; + } + + try { + $count = $this->writeArchive( + scope: $scope, + dashboardUuid: $dashboardUid, + outputPath: $outputPath + ); + } catch (DoesNotExistException) { + $output->writeln(messages: 'Dashboard not found: ' . $dashboardUid . ''); + return self::FAILURE; + } catch (Throwable $e) { + $output->writeln(messages: 'Export failed: ' . $e->getMessage() . ''); + return self::FAILURE; + } + + $noun = 'dashboards'; + if ($count === 1) { + $noun = 'dashboard'; + } + + $output->writeln( + messages: 'Exported ' . (string)$count . ' ' . $noun . ' to ' . $outputPath + ); + return self::SUCCESS; + }//end execute() + + /** + * Write the archive to disk by reusing the export service helpers. + * + * The CLI uses the same serializer the HTTP path uses; we copy the + * temporary stream back to the requested on-disk location. + * + * @param string $scope The export scope. + * @param string $dashboardUuid Dashboard UUID (when scope=dashboard). + * @param string $outputPath Destination file path. + * + * @return int The dashboard count written. + * + * @throws DoesNotExistException When the dashboard is not found. + */ + private function writeArchive( + string $scope, + string $dashboardUuid, + string $outputPath, + ): int { + $dashboards = $this->collectDashboards( + scope: $scope, + dashboardUuid: $dashboardUuid + ); + + $zip = new ZipArchive(); + if ($zip->open(filename: $outputPath, flags: ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException(message: 'Could not open ZIP archive at ' . $outputPath); + } + + $manifest = $this->exportService->buildManifest( + scope: $scope, + dashboardCount: count($dashboards), + currentUserId: 'cli' + ); + $zip->addFromString( + name: 'manifest.json', + content: (string)json_encode( + value: $manifest, + flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) + ) + ); + + foreach ($dashboards as $dashboard) { + $payload = $this->exportService->serializeDashboard(dashboard: $dashboard); + $uuid = (string)$dashboard->getUuid(); + if ($uuid === '') { + continue; + } + + $zip->addFromString( + name: 'dashboards/' . $uuid . '.json', + content: (string)json_encode(value: $payload, flags: (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) + ); + $zip->addEmptyDir(dirname: 'assets/widgets/' . $uuid . '/'); + } + + $zip->addFromString(name: 'metadata-fields.json', content: '[]'); + $zip->addEmptyDir(dirname: 'assets/icons/'); + + $zip->close(); + + return count($dashboards); + }//end writeArchive() + + /** + * Resolve the dashboard collection for the requested scope. + * + * @param string $scope The export scope. + * @param string $dashboardUuid Dashboard UUID (when scope=dashboard). + * + * @return Dashboard[] The dashboards to export. + * + * @throws DoesNotExistException When the dashboard is not found. + */ + private function collectDashboards( + string $scope, + string $dashboardUuid, + ): array { + if ($scope === 'dashboard') { + return [$this->dashboardMapper->findByUuid(uuid: $dashboardUuid)]; + } + + $all = []; + foreach ($this->dashboardMapper->findAdminTemplates() as $tpl) { + $all[] = $tpl; + } + + foreach ($this->dashboardMapper->findByParent(parentUuid: null) as $root) { + $all[] = $root; + $uuid = (string)$root->getUuid(); + if ($uuid === '') { + continue; + } + + foreach ($this->dashboardMapper->findDescendants(ancestorUuid: $uuid) as $child) { + $all[] = $child; + } + } + + return $all; + }//end collectDashboards() }//end class diff --git a/lib/Command/I18nCopyNavigationCommand.php b/lib/Command/I18nCopyNavigationCommand.php index 3ee06b5a..45448de6 100644 --- a/lib/Command/I18nCopyNavigationCommand.php +++ b/lib/Command/I18nCopyNavigationCommand.php @@ -34,128 +34,126 @@ /** * `launchpad:i18n:copy-navigation` console command. */ -class I18nCopyNavigationCommand extends CommandBase -{ - /** - * Marker table for the navigation tree. - * - * @var string - */ - private const NAV_TABLE = 'launchpad_navigation'; +class I18nCopyNavigationCommand extends CommandBase { + /** + * Marker table for the navigation tree. + * + * @var string + */ + private const NAV_TABLE = 'launchpad_navigation'; - /** - * Constructor. - * - * @param CommandService $commandService Shared CLI helper. - * @param IUserSession $userSession Caller resolution. - * @param IDBConnection $db Database connection. - */ - public function __construct( - CommandService $commandService, - IUserSession $userSession, - private readonly IDBConnection $db - ) { - parent::__construct(commandService: $commandService, userSession: $userSession); - }//end __construct() + /** + * Constructor. + * + * @param CommandService $commandService Shared CLI helper. + * @param IUserSession $userSession Caller resolution. + * @param IDBConnection $db Database connection. + */ + public function __construct( + CommandService $commandService, + IUserSession $userSession, + private readonly IDBConnection $db, + ) { + parent::__construct(commandService: $commandService, userSession: $userSession); + }//end __construct() - /** - * Wire command name, description, and per-command options. - * - * @return void - * - * @spec openspec/specs/cli-commands/spec.md - */ - protected function configureCommand(): void - { - $this->setName(name: 'launchpad:i18n:copy-navigation') - ->setDescription(description: 'Clone org-navigation between language variants.') - ->setHelp( - help: implode( - separator: "\n", - array: [ - 'Clone the entire org-navigation tree from one language variant to another.', - 'Existing target nodes are NOT overwritten unless --overwrite is supplied.', - '', - 'Examples:', - ' php occ launchpad:i18n:copy-navigation --from=nl --to=en', - ' php occ launchpad:i18n:copy-navigation --from=nl --to=en --overwrite --json', - ] - ) - ) - ->addOption( - name: 'from', - shortcut: null, - mode: InputOption::VALUE_REQUIRED, - description: 'Source language code.' - ) - ->addOption( - name: 'to', - shortcut: null, - mode: InputOption::VALUE_REQUIRED, - description: 'Target language code.' - ) - ->addOption( - name: 'overwrite', - shortcut: null, - mode: InputOption::VALUE_NONE, - description: 'Overwrite existing target nodes that conflict.' - ); - }//end configureCommand() + /** + * Wire command name, description, and per-command options. + * + * @return void + * + * @spec openspec/specs/cli-commands/spec.md + */ + protected function configureCommand(): void { + $this->setName(name: 'launchpad:i18n:copy-navigation') + ->setDescription(description: 'Clone org-navigation between language variants.') + ->setHelp( + help: implode( + separator: "\n", + array: [ + 'Clone the entire org-navigation tree from one language variant to another.', + 'Existing target nodes are NOT overwritten unless --overwrite is supplied.', + '', + 'Examples:', + ' php occ launchpad:i18n:copy-navigation --from=nl --to=en', + ' php occ launchpad:i18n:copy-navigation --from=nl --to=en --overwrite --json', + ] + ) + ) + ->addOption( + name: 'from', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'Source language code.' + ) + ->addOption( + name: 'to', + shortcut: null, + mode: InputOption::VALUE_REQUIRED, + description: 'Target language code.' + ) + ->addOption( + name: 'overwrite', + shortcut: null, + mode: InputOption::VALUE_NONE, + description: 'Overwrite existing target nodes that conflict.' + ); + }//end configureCommand() - /** - * Execute the clone. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * - * @return int - * - * @spec openspec/specs/cli-commands/spec.md - */ - protected function handle( - InputInterface $input, - OutputInterface $output - ): int { - $from = $input->getOption(name: 'from'); - $to = $input->getOption(name: 'to'); + /** + * Execute the clone. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * + * @return int + * + * @spec openspec/specs/cli-commands/spec.md + */ + protected function handle( + InputInterface $input, + OutputInterface $output, + ): int { + $from = $input->getOption(name: 'from'); + $to = $input->getOption(name: 'to'); - if ($from === null || $to === null - || (string) $from === '' || (string) $to === '' - ) { - return $this->emitError( - input: $input, - output: $output, - exitCode: CommandService::EXIT_INVALID_ARGS, - code: 'INVALID_ARGUMENT', - message: 'Both --from and --to are required', - context: ['from' => $from, 'to' => $to] - ); - } + if ($from === null || $to === null + || (string)$from === '' || (string)$to === '' + ) { + return $this->emitError( + input: $input, + output: $output, + exitCode: CommandService::EXIT_INVALID_ARGS, + code: 'INVALID_ARGUMENT', + message: 'Both --from and --to are required', + context: ['from' => $from, 'to' => $to] + ); + } - if ($this->db->tableExists(table: self::NAV_TABLE) === false) { - return $this->emitError( - input: $input, - output: $output, - exitCode: CommandService::EXIT_NOT_FOUND, - code: 'NOT_FOUND', - message: "No navigation tree found for language '".(string) $from."'", - context: ['language' => (string) $from] - ); - } + if ($this->db->tableExists(table: self::NAV_TABLE) === false) { + return $this->emitError( + input: $input, + output: $output, + exitCode: CommandService::EXIT_NOT_FOUND, + code: 'NOT_FOUND', + message: "No navigation tree found for language '" . (string)$from . "'", + context: ['language' => (string)$from] + ); + } - $copied = 0; + $copied = 0; - $this->emitSuccess( - input: $input, - output: $output, - data: [ - 'from' => (string) $from, - 'to' => (string) $to, - 'copied' => $copied, - ], - human: 'Copied '.$copied.' nodes from '.(string) $from.' to '.(string) $to - ); + $this->emitSuccess( + input: $input, + output: $output, + data: [ + 'from' => (string)$from, + 'to' => (string)$to, + 'copied' => $copied, + ], + human: 'Copied ' . $copied . ' nodes from ' . (string)$from . ' to ' . (string)$to + ); - return CommandService::EXIT_SUCCESS; - }//end handle() + return CommandService::EXIT_SUCCESS; + }//end handle() }//end class diff --git a/lib/Command/I18nExportStringsCommand.php b/lib/Command/I18nExportStringsCommand.php index 6a18dc5a..3f83e040 100644 --- a/lib/Command/I18nExportStringsCommand.php +++ b/lib/Command/I18nExportStringsCommand.php @@ -37,180 +37,176 @@ /** * `launchpad:i18n:export-strings` console command. */ -class I18nExportStringsCommand extends CommandBase -{ - /** - * Translation marker patterns. Each entry produces a captured - * single- or double-quoted string literal. - * - * @var list - */ - private const MARKER_PATTERNS = [ - '/->t\(\s*[\'"]([^\'"]+)[\'"]/', - '/\bt\(\s*[\'"]([^\'"]+)[\'"]/', - '/\bn\(\s*[\'"]([^\'"]+)[\'"]/', - ]; - - /** - * Constructor. - * - * @param CommandService $commandService Shared CLI helper. - * @param IUserSession $userSession Caller resolution. - */ - public function __construct( - CommandService $commandService, - IUserSession $userSession - ) { - parent::__construct(commandService: $commandService, userSession: $userSession); - }//end __construct() - - /** - * Wire command name, description, and per-command options. - * - * @return void - * - * @spec openspec/specs/cli-commands/spec.md - */ - protected function configureCommand(): void - { - $this->setName(name: 'launchpad:i18n:export-strings') - ->setDescription(description: 'Extract translatable strings to l10n/launchpad.pot.') - ->setHelp( - help: implode( - separator: "\n", - array: [ - 'Scan lib/ and src/ for translatable strings and write them to l10n/launchpad.pot.', - 'Idempotent — overwrites the existing POT file.', - '', - 'Examples:', - ' php occ launchpad:i18n:export-strings', - ' php occ launchpad:i18n:export-strings --json', - ] - ) - ); - }//end configureCommand() - - /** - * Execute the extraction. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * - * @return int - * - * @spec openspec/specs/cli-commands/spec.md - */ - protected function handle( - InputInterface $input, - OutputInterface $output - ): int { - $appRoot = (string) realpath(path: __DIR__.'/../..'); - if ($appRoot === '') { - return $this->emitError( - input: $input, - output: $output, - exitCode: CommandService::EXIT_ERROR, - code: 'INTERNAL_ERROR', - message: 'Could not resolve app root directory.' - ); - } - - $strings = []; - foreach (['lib', 'src'] as $sub) { - $path = $appRoot.'/'.$sub; - if (is_dir(filename: $path) === false) { - continue; - } - - $this->collectFromDir(directory: $path, sink: $strings); - } - - ksort(array: $strings); - $this->writePot(strings: array_keys(array: $strings), outPath: $appRoot.'/l10n/launchpad.pot'); - - $this->emitSuccess( - input: $input, - output: $output, - data: ['count' => count(value: $strings), 'output' => 'l10n/launchpad.pot'], - human: 'Wrote '.count(value: $strings).' strings to l10n/launchpad.pot' - ); - - return CommandService::EXIT_SUCCESS; - }//end handle() - - /** - * Recursively scan `$directory` for translatable markers and - * accumulate them into `$sink` (using the string as key for - * dedup). - * - * @param string $directory Directory to scan. - * @param array $sink Accumulator (modified by reference). - * - * @return void - */ - private function collectFromDir(string $directory, array &$sink): void - { - $iterator = new RecursiveIteratorIterator( - iterator: new RecursiveDirectoryIterator( - $directory, - RecursiveDirectoryIterator::SKIP_DOTS - ) - ); - - foreach ($iterator as $file) { - if ($file->isFile() === false) { - continue; - } - - $ext = strtolower(string: $file->getExtension()); - if (in_array(needle: $ext, haystack: ['php', 'vue', 'js', 'ts'], strict: true) === false) { - continue; - } - - $contents = (string) file_get_contents(filename: $file->getPathname()); - foreach (self::MARKER_PATTERNS as $pattern) { - $matches = []; - $found = preg_match_all(pattern: $pattern, subject: $contents, matches: $matches); - if ($found === false || $found === 0) { - continue; - } - - foreach ($matches[1] as $string) { - $sink[(string) $string] = true; - } - } - }//end foreach - }//end collectFromDir() - - /** - * Write the POT file at `$outPath`. Missing parent directories - * are created. - * - * @param list $strings Sorted, unique source strings. - * @param string $outPath Target file path. - * - * @return void - */ - private function writePot(array $strings, string $outPath): void - { - $dir = dirname(path: $outPath); - if (is_dir(filename: $dir) === false) { - mkdir(directory: $dir, permissions: 0775, recursive: true); - } - - $lines = []; - $lines[] = '# LaunchPad translatable strings — generated by `launchpad:i18n:export-strings`.'; - $lines[] = 'msgid ""'; - $lines[] = 'msgstr ""'; - $lines[] = '"Content-Type: text/plain; charset=UTF-8\n"'; - $lines[] = ''; - foreach ($strings as $string) { - $escaped = str_replace(search: ['\\', '"'], replace: ['\\\\', '\\"'], subject: $string); - $lines[] = 'msgid "'.$escaped.'"'; - $lines[] = 'msgstr ""'; - $lines[] = ''; - } - - file_put_contents(filename: $outPath, data: implode(separator: "\n", array: $lines)); - }//end writePot() +class I18nExportStringsCommand extends CommandBase { + /** + * Translation marker patterns. Each entry produces a captured + * single- or double-quoted string literal. + * + * @var list + */ + private const MARKER_PATTERNS = [ + '/->t\(\s*[\'"]([^\'"]+)[\'"]/', + '/\bt\(\s*[\'"]([^\'"]+)[\'"]/', + '/\bn\(\s*[\'"]([^\'"]+)[\'"]/', + ]; + + /** + * Constructor. + * + * @param CommandService $commandService Shared CLI helper. + * @param IUserSession $userSession Caller resolution. + */ + public function __construct( + CommandService $commandService, + IUserSession $userSession, + ) { + parent::__construct(commandService: $commandService, userSession: $userSession); + }//end __construct() + + /** + * Wire command name, description, and per-command options. + * + * @return void + * + * @spec openspec/specs/cli-commands/spec.md + */ + protected function configureCommand(): void { + $this->setName(name: 'launchpad:i18n:export-strings') + ->setDescription(description: 'Extract translatable strings to l10n/launchpad.pot.') + ->setHelp( + help: implode( + separator: "\n", + array: [ + 'Scan lib/ and src/ for translatable strings and write them to l10n/launchpad.pot.', + 'Idempotent — overwrites the existing POT file.', + '', + 'Examples:', + ' php occ launchpad:i18n:export-strings', + ' php occ launchpad:i18n:export-strings --json', + ] + ) + ); + }//end configureCommand() + + /** + * Execute the extraction. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * + * @return int + * + * @spec openspec/specs/cli-commands/spec.md + */ + protected function handle( + InputInterface $input, + OutputInterface $output, + ): int { + $appRoot = (string)realpath(path: __DIR__ . '/../..'); + if ($appRoot === '') { + return $this->emitError( + input: $input, + output: $output, + exitCode: CommandService::EXIT_ERROR, + code: 'INTERNAL_ERROR', + message: 'Could not resolve app root directory.' + ); + } + + $strings = []; + foreach (['lib', 'src'] as $sub) { + $path = $appRoot . '/' . $sub; + if (is_dir(filename: $path) === false) { + continue; + } + + $this->collectFromDir(directory: $path, sink: $strings); + } + + ksort(array: $strings); + $this->writePot(strings: array_keys(array: $strings), outPath: $appRoot . '/l10n/launchpad.pot'); + + $this->emitSuccess( + input: $input, + output: $output, + data: ['count' => count(value: $strings), 'output' => 'l10n/launchpad.pot'], + human: 'Wrote ' . count(value: $strings) . ' strings to l10n/launchpad.pot' + ); + + return CommandService::EXIT_SUCCESS; + }//end handle() + + /** + * Recursively scan `$directory` for translatable markers and + * accumulate them into `$sink` (using the string as key for + * dedup). + * + * @param string $directory Directory to scan. + * @param array $sink Accumulator (modified by reference). + * + * @return void + */ + private function collectFromDir(string $directory, array &$sink): void { + $iterator = new RecursiveIteratorIterator( + iterator: new RecursiveDirectoryIterator( + $directory, + RecursiveDirectoryIterator::SKIP_DOTS + ) + ); + + foreach ($iterator as $file) { + if ($file->isFile() === false) { + continue; + } + + $ext = strtolower(string: $file->getExtension()); + if (in_array(needle: $ext, haystack: ['php', 'vue', 'js', 'ts'], strict: true) === false) { + continue; + } + + $contents = (string)file_get_contents(filename: $file->getPathname()); + foreach (self::MARKER_PATTERNS as $pattern) { + $matches = []; + $found = preg_match_all(pattern: $pattern, subject: $contents, matches: $matches); + if ($found === false || $found === 0) { + continue; + } + + foreach ($matches[1] as $string) { + $sink[(string)$string] = true; + } + } + }//end foreach + }//end collectFromDir() + + /** + * Write the POT file at `$outPath`. Missing parent directories + * are created. + * + * @param list $strings Sorted, unique source strings. + * @param string $outPath Target file path. + * + * @return void + */ + private function writePot(array $strings, string $outPath): void { + $dir = dirname(path: $outPath); + if (is_dir(filename: $dir) === false) { + mkdir(directory: $dir, permissions: 0775, recursive: true); + } + + $lines = []; + $lines[] = '# LaunchPad translatable strings — generated by `launchpad:i18n:export-strings`.'; + $lines[] = 'msgid ""'; + $lines[] = 'msgstr ""'; + $lines[] = '"Content-Type: text/plain; charset=UTF-8\n"'; + $lines[] = ''; + foreach ($strings as $string) { + $escaped = str_replace(search: ['\\', '"'], replace: ['\\\\', '\\"'], subject: $string); + $lines[] = 'msgid "' . $escaped . '"'; + $lines[] = 'msgstr ""'; + $lines[] = ''; + } + + file_put_contents(filename: $outPath, data: implode(separator: "\n", array: $lines)); + }//end writePot() }//end class diff --git a/lib/Command/I18nMigrateLanguageStructureCommand.php b/lib/Command/I18nMigrateLanguageStructureCommand.php index 78f8f412..2f14fdf2 100644 --- a/lib/Command/I18nMigrateLanguageStructureCommand.php +++ b/lib/Command/I18nMigrateLanguageStructureCommand.php @@ -38,115 +38,113 @@ /** * `launchpad:i18n:migrate-language-structure` console command. */ -class I18nMigrateLanguageStructureCommand extends CommandBase -{ - /** - * Marker table name owned by the `dashboard-language-content` - * capability — its presence enables the migration path. - * - * @var string - */ - private const TARGET_TABLE = 'launchpad_language_content'; +class I18nMigrateLanguageStructureCommand extends CommandBase { + /** + * Marker table name owned by the `dashboard-language-content` + * capability — its presence enables the migration path. + * + * @var string + */ + private const TARGET_TABLE = 'launchpad_language_content'; - /** - * Constructor. - * - * @param CommandService $commandService Shared CLI helper. - * @param IUserSession $userSession Caller resolution. - * @param IDBConnection $db Database connection. - */ - public function __construct( - CommandService $commandService, - IUserSession $userSession, - private readonly IDBConnection $db - ) { - parent::__construct(commandService: $commandService, userSession: $userSession); - }//end __construct() + /** + * Constructor. + * + * @param CommandService $commandService Shared CLI helper. + * @param IUserSession $userSession Caller resolution. + * @param IDBConnection $db Database connection. + */ + public function __construct( + CommandService $commandService, + IUserSession $userSession, + private readonly IDBConnection $db, + ) { + parent::__construct(commandService: $commandService, userSession: $userSession); + }//end __construct() - /** - * Wire command name, description, and per-command options. - * - * @return void - * - * @spec openspec/specs/cli-commands/spec.md - */ - protected function configureCommand(): void - { - $this->setName(name: 'launchpad:i18n:migrate-language-structure') - ->setDescription(description: 'Migrate flat-language rows to per-language tables.') - ->setHelp( - help: implode( - separator: "\n", - array: [ - 'One-time migration of legacy flat-language rows to the per-language-table layout.', - 'Requires the `dashboard-language-content` capability to be installed.', - 'Idempotent — already-migrated rows are skipped.', - '', - 'Examples:', - ' php occ launchpad:i18n:migrate-language-structure --no-interaction', - ' php occ launchpad:i18n:migrate-language-structure --json', - ] - ) - ); - }//end configureCommand() + /** + * Wire command name, description, and per-command options. + * + * @return void + * + * @spec openspec/specs/cli-commands/spec.md + */ + protected function configureCommand(): void { + $this->setName(name: 'launchpad:i18n:migrate-language-structure') + ->setDescription(description: 'Migrate flat-language rows to per-language tables.') + ->setHelp( + help: implode( + separator: "\n", + array: [ + 'One-time migration of legacy flat-language rows to the per-language-table layout.', + 'Requires the `dashboard-language-content` capability to be installed.', + 'Idempotent — already-migrated rows are skipped.', + '', + 'Examples:', + ' php occ launchpad:i18n:migrate-language-structure --no-interaction', + ' php occ launchpad:i18n:migrate-language-structure --json', + ] + ) + ); + }//end configureCommand() - /** - * Execute the migration. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * - * @return int - * - * @spec openspec/specs/cli-commands/spec.md - */ - protected function handle( - InputInterface $input, - OutputInterface $output - ): int { - if ($this->db->tableExists(table: self::TARGET_TABLE) === false) { - return $this->emitError( - input: $input, - output: $output, - exitCode: CommandService::EXIT_ERROR, - code: 'CAPABILITY_MISSING', - message: 'dashboard-language-content capability is required', - context: ['expectedTable' => self::TARGET_TABLE] - ); - } + /** + * Execute the migration. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * + * @return int + * + * @spec openspec/specs/cli-commands/spec.md + */ + protected function handle( + InputInterface $input, + OutputInterface $output, + ): int { + if ($this->db->tableExists(table: self::TARGET_TABLE) === false) { + return $this->emitError( + input: $input, + output: $output, + exitCode: CommandService::EXIT_ERROR, + code: 'CAPABILITY_MISSING', + message: 'dashboard-language-content capability is required', + context: ['expectedTable' => self::TARGET_TABLE] + ); + } - if ($this->isNoInteraction(input: $input) === false - && $this->isJson(input: $input) === false - ) { - $helper = new QuestionHelper(); - $question = new ConfirmationQuestion( - question: 'Run the language-structure migration? [y/N] ', - default: false - ); - if ((bool) $helper->ask(input: $input, output: $output, question: $question) === false) { - return $this->emitError( - input: $input, - output: $output, - exitCode: CommandService::EXIT_INVALID_ARGS, - code: 'ABORTED', - message: 'Migration aborted by user.' - ); - } - } + if ($this->isNoInteraction(input: $input) === false + && $this->isJson(input: $input) === false + ) { + $helper = new QuestionHelper(); + $question = new ConfirmationQuestion( + question: 'Run the language-structure migration? [y/N] ', + default: false + ); + if ((bool)$helper->ask(input: $input, output: $output, question: $question) === false) { + return $this->emitError( + input: $input, + output: $output, + exitCode: CommandService::EXIT_INVALID_ARGS, + code: 'ABORTED', + message: 'Migration aborted by user.' + ); + } + } - // The migration body is owned by the language-content capability; - // here we only confirm the marker table exists and report a - // zero-row idempotent run when that capability has not yet - // produced source data. - $migrated = 0; + // The migration body is owned by the language-content capability; + // here we only confirm the marker table exists and report a + // zero-row idempotent run when that capability has not yet + // produced source data. + $migrated = 0; - $this->emitSuccess( - input: $input, - output: $output, - data: ['migrated' => $migrated, 'idempotent' => true], - human: 'Migration finished — '.$migrated.' rows migrated.' - ); + $this->emitSuccess( + input: $input, + output: $output, + data: ['migrated' => $migrated, 'idempotent' => true], + human: 'Migration finished — ' . $migrated . ' rows migrated.' + ); - return CommandService::EXIT_SUCCESS; - }//end handle() + return CommandService::EXIT_SUCCESS; + }//end handle() }//end class diff --git a/lib/Command/ImportCommand.php b/lib/Command/ImportCommand.php index c52b23a9..f801c900 100644 --- a/lib/Command/ImportCommand.php +++ b/lib/Command/ImportCommand.php @@ -33,118 +33,116 @@ /** * `launchpad:import` console command. */ -class ImportCommand extends Command -{ - /** - * Constructor. - * - * @param ImportService $importService Import service. - */ - public function __construct( - private readonly ImportService $importService, - ) { - parent::__construct(); - }//end __construct() - - /** - * Configure CLI options. - * - * @return void - * - * @spec openspec/specs/dashboard-export-import/spec.md - */ - protected function configure(): void - { - $this->setName(name: 'launchpad:import') - ->setDescription(description: 'Import LaunchPad dashboards from a versioned ZIP archive.') - ->addOption( - name: 'file', - shortcut: 'f', - mode: InputOption::VALUE_REQUIRED, - description: 'Path to the ZIP archive to import.' - ) - ->addOption( - name: 'preserve-uuids', - shortcut: null, - mode: InputOption::VALUE_NONE, - description: 'Preserve dashboard UUIDs (fail on collision).' - ) - ->addOption( - name: 'user', - shortcut: 'u', - mode: InputOption::VALUE_REQUIRED, - description: 'User ID to attribute the import to (defaults to "cli").', - default: 'cli' - ); - }//end configure() - - /** - * Execute the import. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * - * @return int Exit code (0 success, 1 error). - * - * @spec openspec/specs/dashboard-export-import/spec.md - */ - protected function execute( - InputInterface $input, - OutputInterface $output - ): int { - $file = (string) ($input->getOption(name: 'file') ?? ''); - $preserveUuids = (bool) $input->getOption(name: 'preserve-uuids'); - $user = (string) ($input->getOption(name: 'user') ?? 'cli'); - - if ($file === '') { - $output->writeln(messages: '--file parameter is required'); - return self::FAILURE; - } - - if (file_exists(filename: $file) === false) { - $output->writeln(messages: 'File not found: '.$file.''); - return self::FAILURE; - } - - try { - $result = $this->importService->import( - zipPath: $file, - preserveUuids: $preserveUuids, - currentUserId: $user - ); - } catch (InvalidArgumentException $e) { - $output->writeln(messages: ''.$e->getMessage().''); - return self::FAILURE; - } catch (Throwable $e) { - $output->writeln(messages: 'Import failed: '.$e->getMessage().''); - return self::FAILURE; - } - - if ($result['status'] === ImportService::ERR_UUID_COLLISION) { - $output->writeln(messages: 'UUID collisions detected (--preserve-uuids):'); - foreach ($result['errors'] as $err) { - $msg = (string) ($err['message'] ?? 'collision'); - $output->writeln(messages: ' - '.$msg); - } - - return self::FAILURE; - } - - $imported = $result['importedDashboardCount']; - $skipped = $result['skippedDashboardCount']; - $errors = $result['errors']; - - $head = 'Imported '.(string) $imported.' dashboards, '; - $tail = 'skipped '.(string) $skipped.', errors: '.(string) count($errors); - $output->writeln(messages: ($head.$tail)); - - foreach ($errors as $err) { - $msg = (string) ($err['message'] ?? ''); - if ($msg !== '') { - $output->writeln(messages: ' - '.$msg); - } - } - - return self::SUCCESS; - }//end execute() +class ImportCommand extends Command { + /** + * Constructor. + * + * @param ImportService $importService Import service. + */ + public function __construct( + private readonly ImportService $importService, + ) { + parent::__construct(); + }//end __construct() + + /** + * Configure CLI options. + * + * @return void + * + * @spec openspec/specs/dashboard-export-import/spec.md + */ + protected function configure(): void { + $this->setName(name: 'launchpad:import') + ->setDescription(description: 'Import LaunchPad dashboards from a versioned ZIP archive.') + ->addOption( + name: 'file', + shortcut: 'f', + mode: InputOption::VALUE_REQUIRED, + description: 'Path to the ZIP archive to import.' + ) + ->addOption( + name: 'preserve-uuids', + shortcut: null, + mode: InputOption::VALUE_NONE, + description: 'Preserve dashboard UUIDs (fail on collision).' + ) + ->addOption( + name: 'user', + shortcut: 'u', + mode: InputOption::VALUE_REQUIRED, + description: 'User ID to attribute the import to (defaults to "cli").', + default: 'cli' + ); + }//end configure() + + /** + * Execute the import. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * + * @return int Exit code (0 success, 1 error). + * + * @spec openspec/specs/dashboard-export-import/spec.md + */ + protected function execute( + InputInterface $input, + OutputInterface $output, + ): int { + $file = (string)($input->getOption(name: 'file') ?? ''); + $preserveUuids = (bool)$input->getOption(name: 'preserve-uuids'); + $user = (string)($input->getOption(name: 'user') ?? 'cli'); + + if ($file === '') { + $output->writeln(messages: '--file parameter is required'); + return self::FAILURE; + } + + if (file_exists(filename: $file) === false) { + $output->writeln(messages: 'File not found: ' . $file . ''); + return self::FAILURE; + } + + try { + $result = $this->importService->import( + zipPath: $file, + preserveUuids: $preserveUuids, + currentUserId: $user + ); + } catch (InvalidArgumentException $e) { + $output->writeln(messages: '' . $e->getMessage() . ''); + return self::FAILURE; + } catch (Throwable $e) { + $output->writeln(messages: 'Import failed: ' . $e->getMessage() . ''); + return self::FAILURE; + } + + if ($result['status'] === ImportService::ERR_UUID_COLLISION) { + $output->writeln(messages: 'UUID collisions detected (--preserve-uuids):'); + foreach ($result['errors'] as $err) { + $msg = (string)($err['message'] ?? 'collision'); + $output->writeln(messages: ' - ' . $msg); + } + + return self::FAILURE; + } + + $imported = $result['importedDashboardCount']; + $skipped = $result['skippedDashboardCount']; + $errors = $result['errors']; + + $head = 'Imported ' . (string)$imported . ' dashboards, '; + $tail = 'skipped ' . (string)$skipped . ', errors: ' . (string)count($errors); + $output->writeln(messages: ($head . $tail)); + + foreach ($errors as $err) { + $msg = (string)($err['message'] ?? ''); + if ($msg !== '') { + $output->writeln(messages: ' - ' . $msg); + } + } + + return self::SUCCESS; + }//end execute() }//end class diff --git a/lib/Command/ImportConfluenceCommand.php b/lib/Command/ImportConfluenceCommand.php index 871222cd..48925ac8 100644 --- a/lib/Command/ImportConfluenceCommand.php +++ b/lib/Command/ImportConfluenceCommand.php @@ -35,192 +35,189 @@ /** * `launchpad:import:confluence` console command. */ -class ImportConfluenceCommand extends Command -{ - /** - * Constructor. - * - * @param ConfluenceImportService $importService Importer. - * @param DashboardTreeService $treeService Path resolver. - */ - public function __construct( - private readonly ConfluenceImportService $importService, - private readonly DashboardTreeService $treeService, - ) { - parent::__construct(); - }//end __construct() - - /** - * Configure CLI options. - * - * @return void - * - * @spec openspec/specs/confluence-html-import/spec.md - */ - protected function configure(): void - { - $this->setName(name: 'launchpad:import:confluence') - ->setDescription(description: 'Import a Confluence HTML export ZIP into LaunchPad dashboards.') - ->addOption( - name: 'file', - shortcut: 'f', - mode: InputOption::VALUE_REQUIRED, - description: 'Path to the Confluence HTML export ZIP archive.' - ) - ->addOption( - name: 'parent-path', - shortcut: 'p', - mode: InputOption::VALUE_REQUIRED, - description: 'Slug-chain path under which root pages should be slotted.' - ) - ->addOption( - name: 'user', - shortcut: 'u', - mode: InputOption::VALUE_REQUIRED, - description: 'User ID to attribute the imported dashboards to.', - default: 'cli' - ) - ->addOption( - name: 'dry-run', - shortcut: null, - mode: InputOption::VALUE_NONE, - description: 'Inspect the archive without creating any dashboards.' - ); - }//end configure() - - /** - * Execute the command. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * - * @return int Exit code (0 success, 1 failure). - * - * @spec openspec/specs/confluence-html-import/spec.md - */ - protected function execute( - InputInterface $input, - OutputInterface $output - ): int { - $file = (string) ($input->getOption(name: 'file') ?? ''); - $parentPath = (string) ($input->getOption(name: 'parent-path') ?? ''); - $userId = (string) ($input->getOption(name: 'user') ?? 'cli'); - $isDryRun = (bool) $input->getOption(name: 'dry-run'); - - if ($file === '') { - $output->writeln(messages: '--file parameter is required'); - return self::FAILURE; - } - - if (file_exists(filename: $file) === false) { - $output->writeln(messages: 'File not found: '.$file.''); - return self::FAILURE; - } - - $parentUuid = null; - if ($parentPath !== '') { - $parent = $this->treeService->resolvePath(path: $parentPath); - if ($parent === null) { - $output->writeln( - messages: 'Parent path not found: '.$parentPath.'' - ); - return self::FAILURE; - } - - $parentUuid = $parent->getUuid(); - } - - try { - if ($isDryRun === true) { - return $this->runDryRun(file: $file, output: $output); - } - - return $this->runImport( - file: $file, - userId: $userId, - parentUuid: $parentUuid, - output: $output - ); - } catch (InvalidArgumentException $e) { - $output->writeln(messages: ''.$e->getMessage().''); - return self::FAILURE; - } catch (Throwable $e) { - $output->writeln(messages: 'Import failed: '.$e->getMessage().''); - return self::FAILURE; - } - }//end execute() - - /** - * Run a dry-run preview. - * - * @param string $file ZIP path. - * @param OutputInterface $output CLI output. - * - * @return int Exit code. - */ - private function runDryRun(string $file, OutputInterface $output): int - { - $result = $this->importService->dryRun(zipPath: $file); - - $summary = sprintf( - 'Pages: %d, attachments: %d, estimated dashboards: %d, asset folder: %s', - (int) $result['pageCount'], - (int) $result['attachmentCount'], - (int) $result['estimatedDashboards'], - (string) $result['assetFolder'] - ); - - $output->writeln(messages: $summary); - - foreach ($result['warnings'] as $warning) { - $output->writeln(messages: 'warning: '.$warning.''); - } - - return self::SUCCESS; - }//end runDryRun() - - /** - * Run a full import. - * - * @param string $file ZIP path. - * @param string $userId Importing user UID. - * @param string|null $parentUuid Optional parent dashboard UUID. - * @param OutputInterface $output CLI output. - * - * @return int Exit code. - */ - private function runImport( - string $file, - string $userId, - ?string $parentUuid, - OutputInterface $output - ): int { - $result = $this->importService->import( - zipPath: $file, - currentUserId: $userId, - parentUuid: $parentUuid - ); - - $summary = sprintf( - 'Imported %d dashboards, skipped %d, errors: %d, asset folder: %s', - (int) $result['createdDashboardCount'], - (int) $result['skippedPageCount'], - count(value: $result['errors']), - (string) $result['assetFolder'] - ); - - $output->writeln(messages: $summary); - - foreach ($result['errors'] as $err) { - $output->writeln( - messages: ' - '.$err['pageId'].': '.$err['reason'] - ); - } - - foreach ($result['warnings'] as $warning) { - $output->writeln(messages: 'warning: '.$warning.''); - } - - return self::SUCCESS; - }//end runImport() +class ImportConfluenceCommand extends Command { + /** + * Constructor. + * + * @param ConfluenceImportService $importService Importer. + * @param DashboardTreeService $treeService Path resolver. + */ + public function __construct( + private readonly ConfluenceImportService $importService, + private readonly DashboardTreeService $treeService, + ) { + parent::__construct(); + }//end __construct() + + /** + * Configure CLI options. + * + * @return void + * + * @spec openspec/specs/confluence-html-import/spec.md + */ + protected function configure(): void { + $this->setName(name: 'launchpad:import:confluence') + ->setDescription(description: 'Import a Confluence HTML export ZIP into LaunchPad dashboards.') + ->addOption( + name: 'file', + shortcut: 'f', + mode: InputOption::VALUE_REQUIRED, + description: 'Path to the Confluence HTML export ZIP archive.' + ) + ->addOption( + name: 'parent-path', + shortcut: 'p', + mode: InputOption::VALUE_REQUIRED, + description: 'Slug-chain path under which root pages should be slotted.' + ) + ->addOption( + name: 'user', + shortcut: 'u', + mode: InputOption::VALUE_REQUIRED, + description: 'User ID to attribute the imported dashboards to.', + default: 'cli' + ) + ->addOption( + name: 'dry-run', + shortcut: null, + mode: InputOption::VALUE_NONE, + description: 'Inspect the archive without creating any dashboards.' + ); + }//end configure() + + /** + * Execute the command. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * + * @return int Exit code (0 success, 1 failure). + * + * @spec openspec/specs/confluence-html-import/spec.md + */ + protected function execute( + InputInterface $input, + OutputInterface $output, + ): int { + $file = (string)($input->getOption(name: 'file') ?? ''); + $parentPath = (string)($input->getOption(name: 'parent-path') ?? ''); + $userId = (string)($input->getOption(name: 'user') ?? 'cli'); + $isDryRun = (bool)$input->getOption(name: 'dry-run'); + + if ($file === '') { + $output->writeln(messages: '--file parameter is required'); + return self::FAILURE; + } + + if (file_exists(filename: $file) === false) { + $output->writeln(messages: 'File not found: ' . $file . ''); + return self::FAILURE; + } + + $parentUuid = null; + if ($parentPath !== '') { + $parent = $this->treeService->resolvePath(path: $parentPath); + if ($parent === null) { + $output->writeln( + messages: 'Parent path not found: ' . $parentPath . '' + ); + return self::FAILURE; + } + + $parentUuid = $parent->getUuid(); + } + + try { + if ($isDryRun === true) { + return $this->runDryRun(file: $file, output: $output); + } + + return $this->runImport( + file: $file, + userId: $userId, + parentUuid: $parentUuid, + output: $output + ); + } catch (InvalidArgumentException $e) { + $output->writeln(messages: '' . $e->getMessage() . ''); + return self::FAILURE; + } catch (Throwable $e) { + $output->writeln(messages: 'Import failed: ' . $e->getMessage() . ''); + return self::FAILURE; + } + }//end execute() + + /** + * Run a dry-run preview. + * + * @param string $file ZIP path. + * @param OutputInterface $output CLI output. + * + * @return int Exit code. + */ + private function runDryRun(string $file, OutputInterface $output): int { + $result = $this->importService->dryRun(zipPath: $file); + + $summary = sprintf( + 'Pages: %d, attachments: %d, estimated dashboards: %d, asset folder: %s', + (int)$result['pageCount'], + (int)$result['attachmentCount'], + (int)$result['estimatedDashboards'], + (string)$result['assetFolder'] + ); + + $output->writeln(messages: $summary); + + foreach ($result['warnings'] as $warning) { + $output->writeln(messages: 'warning: ' . $warning . ''); + } + + return self::SUCCESS; + }//end runDryRun() + + /** + * Run a full import. + * + * @param string $file ZIP path. + * @param string $userId Importing user UID. + * @param string|null $parentUuid Optional parent dashboard UUID. + * @param OutputInterface $output CLI output. + * + * @return int Exit code. + */ + private function runImport( + string $file, + string $userId, + ?string $parentUuid, + OutputInterface $output, + ): int { + $result = $this->importService->import( + zipPath: $file, + currentUserId: $userId, + parentUuid: $parentUuid + ); + + $summary = sprintf( + 'Imported %d dashboards, skipped %d, errors: %d, asset folder: %s', + (int)$result['createdDashboardCount'], + (int)$result['skippedPageCount'], + count(value: $result['errors']), + (string)$result['assetFolder'] + ); + + $output->writeln(messages: $summary); + + foreach ($result['errors'] as $err) { + $output->writeln( + messages: ' - ' . $err['pageId'] . ': ' . $err['reason'] + ); + } + + foreach ($result['warnings'] as $warning) { + $output->writeln(messages: 'warning: ' . $warning . ''); + } + + return self::SUCCESS; + }//end runImport() }//end class diff --git a/lib/Command/SetupCommand.php b/lib/Command/SetupCommand.php index 668ac4e3..f462f93d 100644 --- a/lib/Command/SetupCommand.php +++ b/lib/Command/SetupCommand.php @@ -39,233 +39,229 @@ /** * `launchpad:setup` console command. */ -class SetupCommand extends Command -{ - /** - * Constructor. - * - * @param SetupWizardService $wizardService Wizard orchestrator. - * @param AdminSettingsService $settings Group-order persistence - * (Step 3 in the YAML - * schema). - */ - public function __construct( - private readonly SetupWizardService $wizardService, - private readonly AdminSettingsService $settings, - ) { - parent::__construct(); - }//end __construct() +class SetupCommand extends Command { + /** + * Constructor. + * + * @param SetupWizardService $wizardService Wizard orchestrator. + * @param AdminSettingsService $settings Group-order persistence + * (Step 3 in the YAML + * schema). + */ + public function __construct( + private readonly SetupWizardService $wizardService, + private readonly AdminSettingsService $settings, + ) { + parent::__construct(); + }//end __construct() - /** - * Configure CLI options. - * - * @return void - * - * @spec openspec/specs/setup-wizard/spec.md - */ - protected function configure(): void - { - $this->setName(name: 'launchpad:setup') - ->setDescription( - description: 'Run the LaunchPad setup wizard non-interactively from a YAML config (REQ-WIZ-010).' - ) - ->addOption( - name: 'config', - shortcut: 'c', - mode: InputOption::VALUE_REQUIRED, - description: 'Path to the YAML config file describing every step.' - ); - }//end configure() + /** + * Configure CLI options. + * + * @return void + * + * @spec openspec/specs/setup-wizard/spec.md + */ + protected function configure(): void { + $this->setName(name: 'launchpad:setup') + ->setDescription( + description: 'Run the LaunchPad setup wizard non-interactively from a YAML config (REQ-WIZ-010).' + ) + ->addOption( + name: 'config', + shortcut: 'c', + mode: InputOption::VALUE_REQUIRED, + description: 'Path to the YAML config file describing every step.' + ); + }//end configure() - /** - * Execute the wizard from a YAML file. - * - * @param InputInterface $input CLI input. - * @param OutputInterface $output CLI output. - * - * @return int Exit code (0 success, 1 error). - * - * @spec openspec/specs/setup-wizard/spec.md - */ - protected function execute( - InputInterface $input, - OutputInterface $output - ): int { - $configPath = (string) ($input->getOption(name: 'config') ?? ''); + /** + * Execute the wizard from a YAML file. + * + * @param InputInterface $input CLI input. + * @param OutputInterface $output CLI output. + * + * @return int Exit code (0 success, 1 error). + * + * @spec openspec/specs/setup-wizard/spec.md + */ + protected function execute( + InputInterface $input, + OutputInterface $output, + ): int { + $configPath = (string)($input->getOption(name: 'config') ?? ''); - if ($configPath === '') { - $output->writeln(messages: '--config parameter is required'); - return self::FAILURE; - } + if ($configPath === '') { + $output->writeln(messages: '--config parameter is required'); + return self::FAILURE; + } - if (file_exists(filename: $configPath) === false) { - $output->writeln(messages: 'File not found: '.$configPath.''); - return self::FAILURE; - } + if (file_exists(filename: $configPath) === false) { + $output->writeln(messages: 'File not found: ' . $configPath . ''); + return self::FAILURE; + } - try { - $config = Yaml::parseFile(filename: $configPath); - } catch (ParseException $e) { - $output->writeln( - messages: 'Invalid setup.yaml: '.$e->getMessage().'' - ); - return self::FAILURE; - } + try { + $config = Yaml::parseFile(filename: $configPath); + } catch (ParseException $e) { + $output->writeln( + messages: 'Invalid setup.yaml: ' . $e->getMessage() . '' + ); + return self::FAILURE; + } - if (is_array($config) === false) { - $output->writeln( - messages: 'Invalid setup.yaml: top-level structure must be a map.' - ); - return self::FAILURE; - } + if (is_array($config) === false) { + $output->writeln( + messages: 'Invalid setup.yaml: top-level structure must be a map.' + ); + return self::FAILURE; + } - if (isset($config['storage_backend']) === false - || is_string($config['storage_backend']) === false - ) { - $output->writeln( - messages: "Invalid setup.yaml: missing field 'storage_backend'" - ); - return self::FAILURE; - } + if (isset($config['storage_backend']) === false + || is_string($config['storage_backend']) === false + ) { + $output->writeln( + messages: "Invalid setup.yaml: missing field 'storage_backend'" + ); + return self::FAILURE; + } - try { - $this->applySteps(config: $config, output: $output); - } catch (InvalidArgumentException $e) { - $output->writeln(messages: ''.$e->getMessage().''); - return self::FAILURE; - } catch (Throwable $e) { - $output->writeln( - messages: 'Setup failed: '.$e->getMessage().'' - ); - return self::FAILURE; - } + try { + $this->applySteps(config: $config, output: $output); + } catch (InvalidArgumentException $e) { + $output->writeln(messages: '' . $e->getMessage() . ''); + return self::FAILURE; + } catch (Throwable $e) { + $output->writeln( + messages: 'Setup failed: ' . $e->getMessage() . '' + ); + return self::FAILURE; + } - $this->wizardService->markWizardComplete(); - $output->writeln(messages: 'Setup wizard completed successfully.'); - return self::SUCCESS; - }//end execute() + $this->wizardService->markWizardComplete(); + $output->writeln(messages: 'Setup wizard completed successfully.'); + return self::SUCCESS; + }//end execute() - /** - * Apply each non-Welcome step in order, logging progress + idempotency. - * - * @param array $config Parsed YAML config. - * @param OutputInterface $output CLI output for progress logging. - * - * @return void - */ - private function applySteps(array $config, OutputInterface $output): void - { - $output->writeln(messages: 'Step 1: Welcome... done'); + /** + * Apply each non-Welcome step in order, logging progress + idempotency. + * + * @param array $config Parsed YAML config. + * @param OutputInterface $output CLI output for progress logging. + * + * @return void + */ + private function applySteps(array $config, OutputInterface $output): void { + $output->writeln(messages: 'Step 1: Welcome... done'); - $this->applyStorageStep(config: $config, output: $output); - $this->applyGroupOrderStep(config: $config, output: $output); - $this->skipUnimplementedStep( - stepNumber: 4, - stepName: 'Demo data', - present: array_key_exists(key: 'demo_packages', array: $config), - output: $output - ); - $this->skipUnimplementedStep( - stepNumber: 5, - stepName: 'Admin roles', - present: array_key_exists(key: 'admin_role_group', array: $config), - output: $output - ); - $this->skipUnimplementedStep( - stepNumber: 6, - stepName: 'Footer config', - present: array_key_exists(key: 'footer_config', array: $config), - output: $output - ); + $this->applyStorageStep(config: $config, output: $output); + $this->applyGroupOrderStep(config: $config, output: $output); + $this->skipUnimplementedStep( + stepNumber: 4, + stepName: 'Demo data', + present: array_key_exists(key: 'demo_packages', array: $config), + output: $output + ); + $this->skipUnimplementedStep( + stepNumber: 5, + stepName: 'Admin roles', + present: array_key_exists(key: 'admin_role_group', array: $config), + output: $output + ); + $this->skipUnimplementedStep( + stepNumber: 6, + stepName: 'Footer config', + present: array_key_exists(key: 'footer_config', array: $config), + output: $output + ); - $output->writeln(messages: 'Step 7: Done... done'); - }//end applySteps() + $output->writeln(messages: 'Step 7: Done... done'); + }//end applySteps() - /** - * Apply Step 2 — storage backend (REQ-WIZ-003). - * - * @param array $config Parsed YAML. - * @param OutputInterface $output CLI output. - * - * @return void - */ - private function applyStorageStep(array $config, OutputInterface $output): void - { - $current = $this->wizardService->getContentStorage(); - $target = (string) $config['storage_backend']; + /** + * Apply Step 2 — storage backend (REQ-WIZ-003). + * + * @param array $config Parsed YAML. + * @param OutputInterface $output CLI output. + * + * @return void + */ + private function applyStorageStep(array $config, OutputInterface $output): void { + $current = $this->wizardService->getContentStorage(); + $target = (string)$config['storage_backend']; - if ($current === $target) { - $output->writeln( - messages: 'Step 2: Storage backend... already configured, skipping' - ); - return; - } + if ($current === $target) { + $output->writeln( + messages: 'Step 2: Storage backend... already configured, skipping' + ); + return; + } - $this->wizardService->setContentStorage(value: $target); - $output->writeln(messages: 'Step 2: Storage backend... done'); - }//end applyStorageStep() + $this->wizardService->setContentStorage(value: $target); + $output->writeln(messages: 'Step 2: Storage backend... done'); + }//end applyStorageStep() - /** - * Apply Step 3 — group priority order (REQ-WIZ-004). - * - * @param array $config Parsed YAML. - * @param OutputInterface $output CLI output. - * - * @return void - */ - private function applyGroupOrderStep( - array $config, - OutputInterface $output - ): void { - if (array_key_exists(key: 'group_priority_order', array: $config) === false) { - $output->writeln(messages: 'Step 3: Group order... skipped (not in config)'); - return; - } + /** + * Apply Step 3 — group priority order (REQ-WIZ-004). + * + * @param array $config Parsed YAML. + * @param OutputInterface $output CLI output. + * + * @return void + */ + private function applyGroupOrderStep( + array $config, + OutputInterface $output, + ): void { + if (array_key_exists(key: 'group_priority_order', array: $config) === false) { + $output->writeln(messages: 'Step 3: Group order... skipped (not in config)'); + return; + } - $groups = $config['group_priority_order']; - if (is_array($groups) === false) { - throw new InvalidArgumentException( - message: "Invalid setup.yaml: 'group_priority_order' must be a list of strings" - ); - } + $groups = $config['group_priority_order']; + if (is_array($groups) === false) { + throw new InvalidArgumentException( + message: "Invalid setup.yaml: 'group_priority_order' must be a list of strings" + ); + } - $current = $this->settings->getGroupOrder(); - if ($current === array_values(array: $groups)) { - $output->writeln( - messages: 'Step 3: Group order... already configured, skipping' - ); - return; - } + $current = $this->settings->getGroupOrder(); + if ($current === array_values(array: $groups)) { + $output->writeln( + messages: 'Step 3: Group order... already configured, skipping' + ); + return; + } - $this->settings->setGroupOrder(groupIds: $groups); - $output->writeln(messages: 'Step 3: Group order... done'); - }//end applyGroupOrderStep() + $this->settings->setGroupOrder(groupIds: $groups); + $output->writeln(messages: 'Step 3: Group order... done'); + }//end applyGroupOrderStep() - /** - * Log a placeholder for steps whose sibling capabilities ship later. - * - * @param int $stepNumber Step index for the log line. - * @param string $stepName Human-readable step name. - * @param bool $present Whether the YAML key was provided. - * @param OutputInterface $output CLI output. - * - * @return void - */ - private function skipUnimplementedStep( - int $stepNumber, - string $stepName, - bool $present, - OutputInterface $output - ): void { - if ($present === false) { - $output->writeln( - messages: 'Step '.$stepNumber.': '.$stepName.'... skipped (not in config)' - ); - return; - } + /** + * Log a placeholder for steps whose sibling capabilities ship later. + * + * @param int $stepNumber Step index for the log line. + * @param string $stepName Human-readable step name. + * @param bool $present Whether the YAML key was provided. + * @param OutputInterface $output CLI output. + * + * @return void + */ + private function skipUnimplementedStep( + int $stepNumber, + string $stepName, + bool $present, + OutputInterface $output, + ): void { + if ($present === false) { + $output->writeln( + messages: 'Step ' . $stepNumber . ': ' . $stepName . '... skipped (not in config)' + ); + return; + } - $output->writeln( - messages: 'Step '.$stepNumber.': '.$stepName.'... skipped (capability pending)' - ); - }//end skipUnimplementedStep() + $output->writeln( + messages: 'Step ' . $stepNumber . ': ' . $stepName . '... skipped (capability pending)' + ); + }//end skipUnimplementedStep() }//end class diff --git a/lib/Controller/AcknowledgementController.php b/lib/Controller/AcknowledgementController.php index 7d54e4eb..c9c9a128 100644 --- a/lib/Controller/AcknowledgementController.php +++ b/lib/Controller/AcknowledgementController.php @@ -51,289 +51,281 @@ * * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md */ -class AcknowledgementController extends Controller -{ - /** - * Constructor - * - * @param IRequest $request The request. - * @param AcknowledgementService $acknowledgementService The service. - * @param RoleService $roleService LaunchPad role gate. - * @param IGroupManager $groupManager NC admin check. - * @param LoggerInterface $logger PSR logger. - * @param string|null $userId Acting user ID. - */ - public function __construct( - IRequest $request, - private readonly AcknowledgementService $acknowledgementService, - private readonly RoleService $roleService, - private readonly IGroupManager $groupManager, - private readonly LoggerInterface $logger, - private readonly ?string $userId, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() +class AcknowledgementController extends Controller { + /** + * Constructor + * + * @param IRequest $request The request. + * @param AcknowledgementService $acknowledgementService The service. + * @param RoleService $roleService LaunchPad role gate. + * @param IGroupManager $groupManager NC admin check. + * @param LoggerInterface $logger PSR logger. + * @param string|null $userId Acting user ID. + */ + public function __construct( + IRequest $request, + private readonly AcknowledgementService $acknowledgementService, + private readonly RoleService $roleService, + private readonly IGroupManager $groupManager, + private readonly LoggerInterface $logger, + private readonly ?string $userId, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() - /** - * POST /api/acknowledgements — record the calling user's receipt. - * Idempotent (REQ-ACK-003). A body `userId` that names another user is - * rejected with 403 (no IDOR, ADR-005 / REQ-ACK-003). - * - * @return JSONResponse The stored receipt. - * - * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md - */ - #[NoAdminRequired] - public function acknowledge(): JSONResponse - { - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } + /** + * POST /api/acknowledgements — record the calling user's receipt. + * Idempotent (REQ-ACK-003). A body `userId` that names another user is + * rejected with 403 (no IDOR, ADR-005 / REQ-ACK-003). + * + * @return JSONResponse The stored receipt. + * + * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md + */ + #[NoAdminRequired] + public function acknowledge(): JSONResponse { + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } - $announcementKey = (string) $this->request->getParam(key: 'announcementKey', default: ''); - if ($announcementKey === '') { - return new JSONResponse( - data: ['error' => 'announcementKey is required'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } + $announcementKey = (string)$this->request->getParam(key: 'announcementKey', default: ''); + if ($announcementKey === '') { + return new JSONResponse( + data: ['error' => 'announcementKey is required'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } - // Reject any attempt to acknowledge on behalf of another user - // (REQ-ACK-003 scenario "A user cannot acknowledge on behalf of - // another user"). - $bodyUserId = $this->request->getParam(key: 'userId'); - if ($bodyUserId !== null && (string) $bodyUserId !== $this->userId) { - return ResponseHelper::forbidden( - message: 'Cannot acknowledge on behalf of another user' - ); - } + // Reject any attempt to acknowledge on behalf of another user + // (REQ-ACK-003 scenario "A user cannot acknowledge on behalf of + // another user"). + $bodyUserId = $this->request->getParam(key: 'userId'); + if ($bodyUserId !== null && (string)$bodyUserId !== $this->userId) { + return ResponseHelper::forbidden( + message: 'Cannot acknowledge on behalf of another user' + ); + } - $contentVersion = (int) $this->request->getParam(key: 'contentVersion', default: 1); - if ($contentVersion < 1) { - $contentVersion = 1; - } + $contentVersion = (int)$this->request->getParam(key: 'contentVersion', default: 1); + if ($contentVersion < 1) { + $contentVersion = 1; + } - try { - $receipt = $this->acknowledgementService->acknowledge( - announcementKey: $announcementKey, - userId: $this->userId, - contentVersion: $contentVersion - ); - } catch (Throwable $e) { - $this->logger->error( - message: 'acknowledge failed: '.$e->getMessage(), - context: ['exception' => $e] - ); - return new JSONResponse( - data: ['error' => 'Operation failed'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - } + try { + $receipt = $this->acknowledgementService->acknowledge( + announcementKey: $announcementKey, + userId: $this->userId, + contentVersion: $contentVersion + ); + } catch (Throwable $e) { + $this->logger->error( + message: 'acknowledge failed: ' . $e->getMessage(), + context: ['exception' => $e] + ); + return new JSONResponse( + data: ['error' => 'Operation failed'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + } - return ResponseHelper::success(data: $receipt->jsonSerialize()); - }//end acknowledge() + return ResponseHelper::success(data: $receipt->jsonSerialize()); + }//end acknowledge() - /** - * GET /api/acknowledgements/pending — the current user's outstanding - * mandatory items and count. REQ-ACK-002. - * - * @return JSONResponse The `{count, items}` payload. - * - * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md - */ - #[NoAdminRequired] - public function pending(): JSONResponse - { - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } + /** + * GET /api/acknowledgements/pending — the current user's outstanding + * mandatory items and count. REQ-ACK-002. + * + * @return JSONResponse The `{count, items}` payload. + * + * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md + */ + #[NoAdminRequired] + public function pending(): JSONResponse { + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } - try { - $result = $this->acknowledgementService->getPending(userId: $this->userId); - } catch (Throwable $e) { - $this->logger->error( - message: 'pending failed: '.$e->getMessage(), - context: ['exception' => $e] - ); - return new JSONResponse( - data: ['error' => 'Operation failed'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - } + try { + $result = $this->acknowledgementService->getPending(userId: $this->userId); + } catch (Throwable $e) { + $this->logger->error( + message: 'pending failed: ' . $e->getMessage(), + context: ['exception' => $e] + ); + return new JSONResponse( + data: ['error' => 'Operation failed'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + } - return ResponseHelper::success(data: $result); - }//end pending() + return ResponseHelper::success(data: $result); + }//end pending() - /** - * GET /api/acknowledgements/report/{announcementKey} — the - * audience-scoped read-receipt report. Admin / template owner only - * (REQ-ACK-004). - * - * @param string $announcementKey The announcement identity. - * - * @return JSONResponse The report payload or 403 / 404. - * - * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md - */ - #[NoAdminRequired] - public function report(string $announcementKey): JSONResponse - { - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } + /** + * GET /api/acknowledgements/report/{announcementKey} — the + * audience-scoped read-receipt report. Admin / template owner only + * (REQ-ACK-004). + * + * @param string $announcementKey The announcement identity. + * + * @return JSONResponse The report payload or 403 / 404. + * + * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md + */ + #[NoAdminRequired] + public function report(string $announcementKey): JSONResponse { + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } - if ($this->isManager(announcementKey: $announcementKey) === false) { - return ResponseHelper::forbidden( - message: 'Only an admin or the template owner may read this report' - ); - } + if ($this->isManager(announcementKey: $announcementKey) === false) { + return ResponseHelper::forbidden( + message: 'Only an admin or the template owner may read this report' + ); + } - try { - $report = $this->acknowledgementService->report( - announcementKey: $announcementKey - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Unknown announcement'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (Throwable $e) { - $this->logger->error( - message: 'report failed: '.$e->getMessage(), - context: ['exception' => $e] - ); - return new JSONResponse( - data: ['error' => 'Operation failed'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - } + try { + $report = $this->acknowledgementService->report( + announcementKey: $announcementKey + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Unknown announcement'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (Throwable $e) { + $this->logger->error( + message: 'report failed: ' . $e->getMessage(), + context: ['exception' => $e] + ); + return new JSONResponse( + data: ['error' => 'Operation failed'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + } - return ResponseHelper::success(data: $report); - }//end report() + return ResponseHelper::success(data: $report); + }//end report() - /** - * GET /api/acknowledgements/report/{announcementKey}/csv — the report - * as a downloadable CSV compliance file. Admin / template owner only - * (REQ-ACK-004 / REQ-ACK-006). - * - * @param string $announcementKey The announcement identity. - * - * @return DataDownloadResponse|JSONResponse The CSV download or 403 / 404. - * - * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md - */ - #[NoAdminRequired] - #[NoCSRFRequired] - public function reportCsv(string $announcementKey) - { - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } + /** + * GET /api/acknowledgements/report/{announcementKey}/csv — the report + * as a downloadable CSV compliance file. Admin / template owner only + * (REQ-ACK-004 / REQ-ACK-006). + * + * @param string $announcementKey The announcement identity. + * + * @return DataDownloadResponse|JSONResponse The CSV download or 403 / 404. + * + * @spec openspec/changes/dashboard-acknowledgements/specs/dashboard-acknowledgements/spec.md + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function reportCsv(string $announcementKey) { + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } - if ($this->isManager(announcementKey: $announcementKey) === false) { - return ResponseHelper::forbidden( - message: 'Only an admin or the template owner may export this report' - ); - } + if ($this->isManager(announcementKey: $announcementKey) === false) { + return ResponseHelper::forbidden( + message: 'Only an admin or the template owner may export this report' + ); + } - try { - $report = $this->acknowledgementService->report( - announcementKey: $announcementKey - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Unknown announcement'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (Throwable $e) { - $this->logger->error( - message: 'reportCsv failed: '.$e->getMessage(), - context: ['exception' => $e] - ); - return new JSONResponse( - data: ['error' => 'Operation failed'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - } + try { + $report = $this->acknowledgementService->report( + announcementKey: $announcementKey + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Unknown announcement'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (Throwable $e) { + $this->logger->error( + message: 'reportCsv failed: ' . $e->getMessage(), + context: ['exception' => $e] + ); + return new JSONResponse( + data: ['error' => 'Operation failed'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + } - $csv = $this->buildCsv(report: $report); + $csv = $this->buildCsv(report: $report); - return new DataDownloadResponse( - data: $csv, - filename: 'acknowledgement-report-'.$announcementKey.'.csv', - contentType: 'text/csv' - ); - }//end reportCsv() + return new DataDownloadResponse( + data: $csv, + filename: 'acknowledgement-report-' . $announcementKey . '.csv', + contentType: 'text/csv' + ); + }//end reportCsv() - /** - * Build the CSV body from a report payload — one row per audience - * member with status and (for acknowledged rows) timestamp. - * REQ-ACK-006. - * - * @param array $report The report payload from the service. - * - * @return string The CSV text. - */ - private function buildCsv(array $report): string - { - $lines = []; - $lines[] = 'user_id,status,acknowledged_at'; - foreach (($report['rows'] ?? []) as $row) { - $lines[] = implode( - separator: ',', - array: [ - $this->csvField(value: (string) ($row['userId'] ?? '')), - $this->csvField(value: (string) ($row['status'] ?? '')), - $this->csvField(value: (string) ($row['acknowledgedAt'] ?? '')), - ] - ); - } + /** + * Build the CSV body from a report payload — one row per audience + * member with status and (for acknowledged rows) timestamp. + * REQ-ACK-006. + * + * @param array $report The report payload from the service. + * + * @return string The CSV text. + */ + private function buildCsv(array $report): string { + $lines = []; + $lines[] = 'user_id,status,acknowledged_at'; + foreach (($report['rows'] ?? []) as $row) { + $lines[] = implode( + separator: ',', + array: [ + $this->csvField(value: (string)($row['userId'] ?? '')), + $this->csvField(value: (string)($row['status'] ?? '')), + $this->csvField(value: (string)($row['acknowledgedAt'] ?? '')), + ] + ); + } - return implode(separator: "\r\n", array: $lines)."\r\n"; - }//end buildCsv() + return implode(separator: "\r\n", array: $lines) . "\r\n"; + }//end buildCsv() - /** - * Quote and escape a single CSV field per RFC 4180. - * - * @param string $value The raw field value. - * - * @return string The quoted field. - */ - private function csvField(string $value): string - { - return '"'.str_replace(search: '"', replace: '""', subject: $value).'"'; - }//end csvField() + /** + * Quote and escape a single CSV field per RFC 4180. + * + * @param string $value The raw field value. + * + * @return string The quoted field. + */ + private function csvField(string $value): string { + return '"' . str_replace(search: '"', replace: '""', subject: $value) . '"'; + }//end csvField() - /** - * Whether the current user may manage (report on) the announcement — - * a Nextcloud admin, a LaunchPad admin, or the announcement's template - * owner (REQ-ACK-004, design "Authorization"). - * - * @param string $announcementKey The announcement identity. - * - * @return bool True when authorized. - */ - private function isManager(string $announcementKey): bool - { - if ($this->userId === null) { - return false; - } + /** + * Whether the current user may manage (report on) the announcement — + * a Nextcloud admin, a LaunchPad admin, or the announcement's template + * owner (REQ-ACK-004, design "Authorization"). + * + * @param string $announcementKey The announcement identity. + * + * @return bool True when authorized. + */ + private function isManager(string $announcementKey): bool { + if ($this->userId === null) { + return false; + } - if ($this->groupManager->isAdmin(userId: $this->userId) === true) { - return true; - } + if ($this->groupManager->isAdmin(userId: $this->userId) === true) { + return true; + } - if ($this->roleService->isAdmin(userId: $this->userId) === true) { - return true; - } + if ($this->roleService->isAdmin(userId: $this->userId) === true) { + return true; + } - $owner = $this->acknowledgementService->resolveOwnerUserId( - announcementKey: $announcementKey - ); + $owner = $this->acknowledgementService->resolveOwnerUserId( + announcementKey: $announcementKey + ); - return $owner !== null && $owner === $this->userId; - }//end isManager() + return $owner !== null && $owner === $this->userId; + }//end isManager() }//end class diff --git a/lib/Controller/ActionMatrixController.php b/lib/Controller/ActionMatrixController.php index ea8294a6..0a818755 100644 --- a/lib/Controller/ActionMatrixController.php +++ b/lib/Controller/ActionMatrixController.php @@ -37,134 +37,128 @@ * * @spec openspec/architecture/adr-023-action-authorization.md */ -class ActionMatrixController extends Controller -{ - private const SEED_PATH = __DIR__.'/../actions.seed.json'; - - /** - * Constructor. - * - * @param IRequest $request The request. - * @param ActionAuthService $actionAuth The action authorization service. - * @param IGroupManager $groupManager The group manager. - */ - public function __construct( - IRequest $request, - private readonly ActionAuthService $actionAuth, - private readonly IGroupManager $groupManager, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * Get the full action matrix, the complete action key list, and all groups. - * - * The action key list is the union of the keys currently in the matrix and - * the keys declared in the seed file, so the admin sees every declared - * action even before any customization. - * - * @return JSONResponse The matrix, action keys, and group IDs. - * - * @spec openspec/architecture/adr-023-action-authorization.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function getMatrix(): JSONResponse - { - $matrix = $this->actionAuth->getMatrix(); - - $actionKeys = array_keys($matrix); - foreach ($this->seedActionKeys() as $key) { - if (in_array($key, $actionKeys, true) === false) { - $actionKeys[] = $key; - } - } - - sort($actionKeys); - - $groups = []; - foreach ($this->groupManager->search('') as $group) { - $groups[] = $group->getGID(); - } - - return new JSONResponse( - [ - 'matrix' => $matrix, - 'actions' => $actionKeys, - 'groups' => $groups, - ] - ); - - }//end getMatrix() - - /** - * Persist the action matrix. - * - * Reads the `matrix` parameter from the request body and writes it through - * the action authorization service (which normalizes the shape). - * - * @return JSONResponse The normalized matrix after the write. - * - * @spec openspec/architecture/adr-023-action-authorization.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function setMatrix(): JSONResponse - { - $matrix = $this->request->getParam('matrix'); - if (is_array($matrix) === false) { - $matrix = []; - } - - try { - $this->actionAuth->setMatrix($matrix); - } catch (\JsonException $e) { - return new JSONResponse( - ['error' => 'Could not encode the action matrix: '.$e->getMessage()], - \OCP\AppFramework\Http::STATUS_BAD_REQUEST - ); - } - - return new JSONResponse(['matrix' => $this->actionAuth->getMatrix()]); - - }//end setMatrix() - - /** - * Read the action keys declared in the seed file. - * - * @return array - */ - private function seedActionKeys(): array - { - if (file_exists(self::SEED_PATH) === false) { - return []; - } - - $raw = file_get_contents(self::SEED_PATH); - if ($raw === false) { - return []; - } - - try { - $parsed = json_decode($raw, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - return []; - } - - $actions = ($parsed['actions'] ?? null); - if (is_array($actions) === false) { - return []; - } - - $keys = []; - foreach (array_keys($actions) as $key) { - if (is_string($key) === true) { - $keys[] = $key; - } - } - - return $keys; - - }//end seedActionKeys() +class ActionMatrixController extends Controller { + private const SEED_PATH = __DIR__ . '/../actions.seed.json'; + + /** + * Constructor. + * + * @param IRequest $request The request. + * @param ActionAuthService $actionAuth The action authorization service. + * @param IGroupManager $groupManager The group manager. + */ + public function __construct( + IRequest $request, + private readonly ActionAuthService $actionAuth, + private readonly IGroupManager $groupManager, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * Get the full action matrix, the complete action key list, and all groups. + * + * The action key list is the union of the keys currently in the matrix and + * the keys declared in the seed file, so the admin sees every declared + * action even before any customization. + * + * @return JSONResponse The matrix, action keys, and group IDs. + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function getMatrix(): JSONResponse { + $matrix = $this->actionAuth->getMatrix(); + + $actionKeys = array_keys($matrix); + foreach ($this->seedActionKeys() as $key) { + if (in_array($key, $actionKeys, true) === false) { + $actionKeys[] = $key; + } + } + + sort($actionKeys); + + $groups = []; + foreach ($this->groupManager->search('') as $group) { + $groups[] = $group->getGID(); + } + + return new JSONResponse( + [ + 'matrix' => $matrix, + 'actions' => $actionKeys, + 'groups' => $groups, + ] + ); + + }//end getMatrix() + + /** + * Persist the action matrix. + * + * Reads the `matrix` parameter from the request body and writes it through + * the action authorization service (which normalizes the shape). + * + * @return JSONResponse The normalized matrix after the write. + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function setMatrix(): JSONResponse { + $matrix = $this->request->getParam('matrix'); + if (is_array($matrix) === false) { + $matrix = []; + } + + try { + $this->actionAuth->setMatrix($matrix); + } catch (\JsonException $e) { + return new JSONResponse( + ['error' => 'Could not encode the action matrix: ' . $e->getMessage()], + \OCP\AppFramework\Http::STATUS_BAD_REQUEST + ); + } + + return new JSONResponse(['matrix' => $this->actionAuth->getMatrix()]); + }//end setMatrix() + + /** + * Read the action keys declared in the seed file. + * + * @return array + */ + private function seedActionKeys(): array { + if (file_exists(self::SEED_PATH) === false) { + return []; + } + + $raw = file_get_contents(self::SEED_PATH); + if ($raw === false) { + return []; + } + + try { + $parsed = json_decode($raw, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + return []; + } + + $actions = ($parsed['actions'] ?? null); + if (is_array($actions) === false) { + return []; + } + + $keys = []; + foreach (array_keys($actions) as $key) { + if (is_string($key) === true) { + $keys[] = $key; + } + } + + return $keys; + }//end seedActionKeys() }//end class diff --git a/lib/Controller/AdminBulkController.php b/lib/Controller/AdminBulkController.php index 2df10359..26fa654f 100644 --- a/lib/Controller/AdminBulkController.php +++ b/lib/Controller/AdminBulkController.php @@ -48,349 +48,345 @@ /** * Bulk admin endpoints for dashboards (REQ-BULK-001..011). */ -class AdminBulkController extends Controller -{ - /** - * Constructor. - * - * @param IRequest $request The current request. - * @param BulkOperationService $bulkService The bulk service. - * @param IUserSession $userSession The user session. - * @param IGroupManager $groupManager NC group manager for inline admin guard. - */ - public function __construct( - IRequest $request, - private readonly BulkOperationService $bulkService, - private readonly IUserSession $userSession, - private readonly IGroupManager $groupManager, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * Inline admin guard — returns a 401/403 JSONResponse when the caller - * is not authenticated or not an NC admin, or null when the guard passes. - * - * @return JSONResponse|null Non-null means the request must be rejected. - */ - private function assertAdmin(): ?JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse( - data: ['error' => 'Not authenticated'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } - - if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) { - return new JSONResponse( - data: ['error' => 'Admin required'], - statusCode: Http::STATUS_FORBIDDEN - ); - } - - return null; - }//end assertAdmin() - - /** - * `POST /api/admin/dashboards/bulk-delete` — REQ-BULK-001. - * - * @param mixed $dashboardUuids The UUID array. - * @param bool|null $dryRun When true, preview only. - * @param bool|null $cascade When true, cascade into children. - * - * @return JSONResponse The bulk-delete envelope. - * - * @spec openspec/specs/dashboard-bulk-operations/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function bulkDelete( - mixed $dashboardUuids=null, - ?bool $dryRun=null, - ?bool $cascade=null - ): JSONResponse { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - $uuids = $this->extractUuids(value: $dashboardUuids); - if ($uuids === null) { - return new JSONResponse( - data: ['error' => 'dashboardUuids must be an array of strings'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - $userId = (string) $this->userSession->getUser()?->getUID(); - $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun'); - $doCascade = $this->resolveBool(value: $cascade, queryKey: 'cascade'); - - try { - $result = $this->bulkService->bulkDelete( - dashboardUuids: $uuids, - userId: $userId, - dryRun: $isDryRun, - cascade: $doCascade - ); - } catch (PermissionDeniedException $e) { - return new JSONResponse( - data: [ - 'error' => $e->getMessage(), - 'deniedUuids' => $e->getDeniedUuids(), - ], - statusCode: Http::STATUS_FORBIDDEN - ); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); - }//end try - - return new JSONResponse(data: $result, statusCode: Http::STATUS_OK); - }//end bulkDelete() - - /** - * `POST /api/admin/dashboards/bulk-move` — REQ-BULK-002. - * - * @param mixed $dashboardUuids The UUID array. - * @param string|null $parentUuid The new parent UUID - * (NULL ⇒ root). - * @param bool|null $dryRun When true, preview only. - * - * @return JSONResponse The bulk-move envelope. - * - * @spec openspec/specs/dashboard-bulk-operations/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function bulkMove( - mixed $dashboardUuids=null, - ?string $parentUuid=null, - ?bool $dryRun=null - ): JSONResponse { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - $uuids = $this->extractUuids(value: $dashboardUuids); - if ($uuids === null) { - return new JSONResponse( - data: ['error' => 'dashboardUuids must be an array of strings'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - $userId = (string) $this->userSession->getUser()?->getUID(); - $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun'); - - try { - $result = $this->bulkService->bulkMove( - dashboardUuids: $uuids, - parentUuid: $parentUuid, - userId: $userId, - dryRun: $isDryRun - ); - } catch (PermissionDeniedException $e) { - return new JSONResponse( - data: [ - 'error' => $e->getMessage(), - 'deniedUuids' => $e->getDeniedUuids(), - ], - statusCode: Http::STATUS_FORBIDDEN - ); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); - }//end try - - return new JSONResponse(data: $result, statusCode: Http::STATUS_OK); - }//end bulkMove() - - /** - * `POST /api/admin/dashboards/bulk-status` — REQ-BULK-003. - * - * @param mixed $dashboardUuids The UUID array. - * @param string|null $publicationStatus The target status enum value. - * @param string|null $publishAt Future ISO-8601 timestamp. - * @param bool|null $dryRun When true, preview only. - * - * @return JSONResponse The bulk-status envelope. - * - * @spec openspec/specs/dashboard-bulk-operations/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function bulkStatus( - mixed $dashboardUuids=null, - ?string $publicationStatus=null, - ?string $publishAt=null, - ?bool $dryRun=null - ): JSONResponse { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - $uuids = $this->extractUuids(value: $dashboardUuids); - if ($uuids === null) { - return new JSONResponse( - data: ['error' => 'dashboardUuids must be an array of strings'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - if ($publicationStatus === null || trim($publicationStatus) === '') { - return new JSONResponse( - data: ['error' => 'publicationStatus is required'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - $userId = (string) $this->userSession->getUser()?->getUID(); - $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun'); - - try { - $result = $this->bulkService->bulkStatus( - dashboardUuids: $uuids, - publicationStatus: $publicationStatus, - publishAt: $publishAt, - userId: $userId, - dryRun: $isDryRun - ); - } catch (PermissionDeniedException $e) { - return new JSONResponse( - data: [ - 'error' => $e->getMessage(), - 'deniedUuids' => $e->getDeniedUuids(), - ], - statusCode: Http::STATUS_FORBIDDEN - ); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); - }//end try - - return new JSONResponse(data: $result, statusCode: Http::STATUS_OK); - }//end bulkStatus() - - /** - * `POST /api/admin/dashboards/bulk-reindex` — REQ-BULK-004. - * - * @param mixed $dashboardUuids The UUID array. - * @param bool|null $dryRun When true, preview only. - * - * @return JSONResponse The bulk-reindex envelope. - * - * @spec openspec/specs/dashboard-bulk-operations/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function bulkReindex( - mixed $dashboardUuids=null, - ?bool $dryRun=null - ): JSONResponse { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - $uuids = $this->extractUuids(value: $dashboardUuids); - if ($uuids === null) { - return new JSONResponse( - data: ['error' => 'dashboardUuids must be an array of strings'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - $userId = (string) $this->userSession->getUser()?->getUID(); - $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun'); - - try { - $result = $this->bulkService->bulkReindex( - dashboardUuids: $uuids, - userId: $userId, - dryRun: $isDryRun - ); - } catch (PermissionDeniedException $e) { - return new JSONResponse( - data: [ - 'error' => $e->getMessage(), - 'deniedUuids' => $e->getDeniedUuids(), - ], - statusCode: Http::STATUS_FORBIDDEN - ); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - return new JSONResponse(data: $result, statusCode: Http::STATUS_OK); - }//end bulkReindex() - - /** - * Validate and unwrap a `dashboardUuids` body field into a string - * list. Returns null when the value is not a list of strings. - * - * @param mixed $value The raw decoded value. - * - * @return string[]|null The extracted UUID list, or null on - * validation failure. - */ - private function extractUuids(mixed $value): ?array - { - if (is_array($value) === false) { - return null; - } - - $uuids = []; - foreach ($value as $item) { - if (is_string($item) === false) { - return null; - } - - $trimmed = trim($item); - if ($trimmed === '') { - return null; - } - - $uuids[] = $trimmed; - } - - return $uuids; - }//end extractUuids() - - /** - * Resolve a boolean parameter that may arrive either in the body - * (`bool`) or via the query string (`?dryRun=true`). The query - * string takes precedence when the body parameter is null. - * - * @param bool|null $value The body-parsed value. - * @param string $queryKey The query string key. - * - * @return bool The resolved boolean. - */ - private function resolveBool(?bool $value, string $queryKey): bool - { - if ($value !== null) { - return $value; - } - - $raw = $this->request->getParam(key: $queryKey); - if ($raw === null || $raw === '') { - return false; - } - - $lower = strtolower((string) $raw); - return in_array(needle: $lower, haystack: ['1', 'true', 'yes', 'on'], strict: true); - }//end resolveBool() +class AdminBulkController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The current request. + * @param BulkOperationService $bulkService The bulk service. + * @param IUserSession $userSession The user session. + * @param IGroupManager $groupManager NC group manager for inline admin guard. + */ + public function __construct( + IRequest $request, + private readonly BulkOperationService $bulkService, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * Inline admin guard — returns a 401/403 JSONResponse when the caller + * is not authenticated or not an NC admin, or null when the guard passes. + * + * @return JSONResponse|null Non-null means the request must be rejected. + */ + private function assertAdmin(): ?JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse( + data: ['error' => 'Not authenticated'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } + + if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) { + return new JSONResponse( + data: ['error' => 'Admin required'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + return null; + }//end assertAdmin() + + /** + * `POST /api/admin/dashboards/bulk-delete` — REQ-BULK-001. + * + * @param mixed $dashboardUuids The UUID array. + * @param bool|null $dryRun When true, preview only. + * @param bool|null $cascade When true, cascade into children. + * + * @return JSONResponse The bulk-delete envelope. + * + * @spec openspec/specs/dashboard-bulk-operations/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function bulkDelete( + mixed $dashboardUuids = null, + ?bool $dryRun = null, + ?bool $cascade = null, + ): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + $uuids = $this->extractUuids(value: $dashboardUuids); + if ($uuids === null) { + return new JSONResponse( + data: ['error' => 'dashboardUuids must be an array of strings'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + $userId = (string)$this->userSession->getUser()?->getUID(); + $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun'); + $doCascade = $this->resolveBool(value: $cascade, queryKey: 'cascade'); + + try { + $result = $this->bulkService->bulkDelete( + dashboardUuids: $uuids, + userId: $userId, + dryRun: $isDryRun, + cascade: $doCascade + ); + } catch (PermissionDeniedException $e) { + return new JSONResponse( + data: [ + 'error' => $e->getMessage(), + 'deniedUuids' => $e->getDeniedUuids(), + ], + statusCode: Http::STATUS_FORBIDDEN + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + }//end try + + return new JSONResponse(data: $result, statusCode: Http::STATUS_OK); + }//end bulkDelete() + + /** + * `POST /api/admin/dashboards/bulk-move` — REQ-BULK-002. + * + * @param mixed $dashboardUuids The UUID array. + * @param string|null $parentUuid The new parent UUID + * (NULL ⇒ root). + * @param bool|null $dryRun When true, preview only. + * + * @return JSONResponse The bulk-move envelope. + * + * @spec openspec/specs/dashboard-bulk-operations/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function bulkMove( + mixed $dashboardUuids = null, + ?string $parentUuid = null, + ?bool $dryRun = null, + ): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + $uuids = $this->extractUuids(value: $dashboardUuids); + if ($uuids === null) { + return new JSONResponse( + data: ['error' => 'dashboardUuids must be an array of strings'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + $userId = (string)$this->userSession->getUser()?->getUID(); + $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun'); + + try { + $result = $this->bulkService->bulkMove( + dashboardUuids: $uuids, + parentUuid: $parentUuid, + userId: $userId, + dryRun: $isDryRun + ); + } catch (PermissionDeniedException $e) { + return new JSONResponse( + data: [ + 'error' => $e->getMessage(), + 'deniedUuids' => $e->getDeniedUuids(), + ], + statusCode: Http::STATUS_FORBIDDEN + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + }//end try + + return new JSONResponse(data: $result, statusCode: Http::STATUS_OK); + }//end bulkMove() + + /** + * `POST /api/admin/dashboards/bulk-status` — REQ-BULK-003. + * + * @param mixed $dashboardUuids The UUID array. + * @param string|null $publicationStatus The target status enum value. + * @param string|null $publishAt Future ISO-8601 timestamp. + * @param bool|null $dryRun When true, preview only. + * + * @return JSONResponse The bulk-status envelope. + * + * @spec openspec/specs/dashboard-bulk-operations/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function bulkStatus( + mixed $dashboardUuids = null, + ?string $publicationStatus = null, + ?string $publishAt = null, + ?bool $dryRun = null, + ): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + $uuids = $this->extractUuids(value: $dashboardUuids); + if ($uuids === null) { + return new JSONResponse( + data: ['error' => 'dashboardUuids must be an array of strings'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + if ($publicationStatus === null || trim($publicationStatus) === '') { + return new JSONResponse( + data: ['error' => 'publicationStatus is required'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + $userId = (string)$this->userSession->getUser()?->getUID(); + $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun'); + + try { + $result = $this->bulkService->bulkStatus( + dashboardUuids: $uuids, + publicationStatus: $publicationStatus, + publishAt: $publishAt, + userId: $userId, + dryRun: $isDryRun + ); + } catch (PermissionDeniedException $e) { + return new JSONResponse( + data: [ + 'error' => $e->getMessage(), + 'deniedUuids' => $e->getDeniedUuids(), + ], + statusCode: Http::STATUS_FORBIDDEN + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + }//end try + + return new JSONResponse(data: $result, statusCode: Http::STATUS_OK); + }//end bulkStatus() + + /** + * `POST /api/admin/dashboards/bulk-reindex` — REQ-BULK-004. + * + * @param mixed $dashboardUuids The UUID array. + * @param bool|null $dryRun When true, preview only. + * + * @return JSONResponse The bulk-reindex envelope. + * + * @spec openspec/specs/dashboard-bulk-operations/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function bulkReindex( + mixed $dashboardUuids = null, + ?bool $dryRun = null, + ): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + $uuids = $this->extractUuids(value: $dashboardUuids); + if ($uuids === null) { + return new JSONResponse( + data: ['error' => 'dashboardUuids must be an array of strings'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + $userId = (string)$this->userSession->getUser()?->getUID(); + $isDryRun = $this->resolveBool(value: $dryRun, queryKey: 'dryRun'); + + try { + $result = $this->bulkService->bulkReindex( + dashboardUuids: $uuids, + userId: $userId, + dryRun: $isDryRun + ); + } catch (PermissionDeniedException $e) { + return new JSONResponse( + data: [ + 'error' => $e->getMessage(), + 'deniedUuids' => $e->getDeniedUuids(), + ], + statusCode: Http::STATUS_FORBIDDEN + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + return new JSONResponse(data: $result, statusCode: Http::STATUS_OK); + }//end bulkReindex() + + /** + * Validate and unwrap a `dashboardUuids` body field into a string + * list. Returns null when the value is not a list of strings. + * + * @param mixed $value The raw decoded value. + * + * @return string[]|null The extracted UUID list, or null on + * validation failure. + */ + private function extractUuids(mixed $value): ?array { + if (is_array($value) === false) { + return null; + } + + $uuids = []; + foreach ($value as $item) { + if (is_string($item) === false) { + return null; + } + + $trimmed = trim($item); + if ($trimmed === '') { + return null; + } + + $uuids[] = $trimmed; + } + + return $uuids; + }//end extractUuids() + + /** + * Resolve a boolean parameter that may arrive either in the body + * (`bool`) or via the query string (`?dryRun=true`). The query + * string takes precedence when the body parameter is null. + * + * @param bool|null $value The body-parsed value. + * @param string $queryKey The query string key. + * + * @return bool The resolved boolean. + */ + private function resolveBool(?bool $value, string $queryKey): bool { + if ($value !== null) { + return $value; + } + + $raw = $this->request->getParam(key: $queryKey); + if ($raw === null || $raw === '') { + return false; + } + + $lower = strtolower((string)$raw); + return in_array(needle: $lower, haystack: ['1', 'true', 'yes', 'on'], strict: true); + }//end resolveBool() }//end class diff --git a/lib/Controller/AdminCleanupController.php b/lib/Controller/AdminCleanupController.php index 6756d936..dfde0f41 100644 --- a/lib/Controller/AdminCleanupController.php +++ b/lib/Controller/AdminCleanupController.php @@ -47,199 +47,194 @@ /** * Admin endpoints for scan + purge. */ -class AdminCleanupController extends Controller -{ - /** - * Constructor. - * - * @param IRequest $request The request. - * @param OrphanedDataCleanupService $cleanupService The orchestrator. - * @param CategoryRegistryService $registry Category registry - * (for unknown-name - * error messages). - * @param IUserSession $userSession Current user. - * @param IGroupManager $groupManager Admin check. - */ - public function __construct( - IRequest $request, - private readonly OrphanedDataCleanupService $cleanupService, - private readonly CategoryRegistryService $registry, - private readonly IUserSession $userSession, - private readonly IGroupManager $groupManager, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * Inline admin guard. - * - * @return JSONResponse|null Non-null = caller must be rejected. - */ - private function assertAdmin(): ?JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse( - data: ['error' => 'Not authenticated'], - statusCode: Http::STATUS_FORBIDDEN - ); - } - - if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) { - return new JSONResponse( - data: ['error' => 'Admin required'], - statusCode: Http::STATUS_FORBIDDEN - ); - } - - return null; - }//end assertAdmin() - - /** - * `GET /api/admin/cleanup/scan` — REQ-CLN-004. - * - * Returns a JSON envelope describing the per-category orphan - * counts. Reads from the distributed cache when available - * (REQ-CLN-010) and surfaces `cached`/`cachedAt` hints so the UI - * can display "last refreshed" badges. - * - * @return JSONResponse The scan result. - * - * @spec openspec/specs/orphaned-data-cleanup/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function scan(): JSONResponse - { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - $cached = $this->cleanupService->getCachedScanResult(); - if ($cached !== null) { - return new JSONResponse( - data: array_merge( - $cached->jsonSerialize(), - [ - 'cached' => true, - 'cachedAt' => $cached->getScannedAt(), - ] - ), - statusCode: Http::STATUS_OK - ); - } - - $result = $this->cleanupService->scan(); - - return new JSONResponse( - data: array_merge( - $result->jsonSerialize(), - [ - 'cached' => false, - 'cachedAt' => null, - ] - ), - statusCode: Http::STATUS_OK - ); - }//end scan() - - /** - * `POST /api/admin/cleanup/purge` — REQ-CLN-005. - * - * Body shape: - * { - * "categories": ["expired_locks", ...], // optional, []=all - * "dryRun": true|false // optional, false default - * } - * - * Returns the per-category breakdown plus total, duration, and - * dryRun flag. Unknown categories receive HTTP 400 with the list - * of valid names so the caller can correct the request. - * - * @param array|null $categories Per-category filter. - * @param bool|null $dryRun Dry-run flag. - * - * @return JSONResponse The purge result. - * - * @spec openspec/specs/orphaned-data-cleanup/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function purge(?array $categories=null, ?bool $dryRun=null): JSONResponse - { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - $names = $this->normaliseCategories(input: ($categories ?? [])); - if ($names === null) { - return new JSONResponse( - data: [ - 'error' => 'Unknown cleanup category in request', - 'validCategories' => $this->registry->getCategoryNames(), - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - $userId = ''; - $user = $this->userSession->getUser(); - if ($user !== null) { - $userId = $user->getUID(); - } - - $result = $this->cleanupService->purge( - categoryNames: $names, - dryRun: ($dryRun ?? false), - userId: $userId, - source: 'api', - ); - - return new JSONResponse( - data: [ - 'purgedByCategory' => $result->getByCategory(), - 'totalRows' => $result->getTotalRows(), - 'durationMs' => $result->getDurationMs(), - 'dryRun' => $result->isDryRun(), - 'skipped' => $result->getSkipped(), - ], - statusCode: Http::STATUS_OK - ); - }//end purge() - - /** - * Normalise the API-supplied categories list. - * - * Filters non-string entries silently (defence in depth — the - * controller is reached after framework JSON parsing) and - * returns `null` if any of the remaining names are not registered. - * An empty list is returned as `[]` (which the orchestrator - * treats as "all categories"). - * - * @param array $input The raw input list. - * - * @return array|null The validated names or null. - */ - private function normaliseCategories(array $input): ?array - { - $known = $this->registry->getCategoryNames(); - $normalised = []; - - foreach ($input as $value) { - if (is_string(value: $value) === false || $value === '') { - continue; - } - - if (in_array(needle: $value, haystack: $known, strict: true) === false) { - return null; - } - - $normalised[] = $value; - } - - return $normalised; - }//end normaliseCategories() +class AdminCleanupController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The request. + * @param OrphanedDataCleanupService $cleanupService The orchestrator. + * @param CategoryRegistryService $registry Category registry + * (for unknown-name + * error messages). + * @param IUserSession $userSession Current user. + * @param IGroupManager $groupManager Admin check. + */ + public function __construct( + IRequest $request, + private readonly OrphanedDataCleanupService $cleanupService, + private readonly CategoryRegistryService $registry, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * Inline admin guard. + * + * @return JSONResponse|null Non-null = caller must be rejected. + */ + private function assertAdmin(): ?JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse( + data: ['error' => 'Not authenticated'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) { + return new JSONResponse( + data: ['error' => 'Admin required'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + return null; + }//end assertAdmin() + + /** + * `GET /api/admin/cleanup/scan` — REQ-CLN-004. + * + * Returns a JSON envelope describing the per-category orphan + * counts. Reads from the distributed cache when available + * (REQ-CLN-010) and surfaces `cached`/`cachedAt` hints so the UI + * can display "last refreshed" badges. + * + * @return JSONResponse The scan result. + * + * @spec openspec/specs/orphaned-data-cleanup/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function scan(): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + $cached = $this->cleanupService->getCachedScanResult(); + if ($cached !== null) { + return new JSONResponse( + data: array_merge( + $cached->jsonSerialize(), + [ + 'cached' => true, + 'cachedAt' => $cached->getScannedAt(), + ] + ), + statusCode: Http::STATUS_OK + ); + } + + $result = $this->cleanupService->scan(); + + return new JSONResponse( + data: array_merge( + $result->jsonSerialize(), + [ + 'cached' => false, + 'cachedAt' => null, + ] + ), + statusCode: Http::STATUS_OK + ); + }//end scan() + + /** + * `POST /api/admin/cleanup/purge` — REQ-CLN-005. + * + * Body shape: + * { + * "categories": ["expired_locks", ...], // optional, []=all + * "dryRun": true|false // optional, false default + * } + * + * Returns the per-category breakdown plus total, duration, and + * dryRun flag. Unknown categories receive HTTP 400 with the list + * of valid names so the caller can correct the request. + * + * @param array|null $categories Per-category filter. + * @param bool|null $dryRun Dry-run flag. + * + * @return JSONResponse The purge result. + * + * @spec openspec/specs/orphaned-data-cleanup/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function purge(?array $categories = null, ?bool $dryRun = null): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + $names = $this->normaliseCategories(input: ($categories ?? [])); + if ($names === null) { + return new JSONResponse( + data: [ + 'error' => 'Unknown cleanup category in request', + 'validCategories' => $this->registry->getCategoryNames(), + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + $userId = ''; + $user = $this->userSession->getUser(); + if ($user !== null) { + $userId = $user->getUID(); + } + + $result = $this->cleanupService->purge( + categoryNames: $names, + dryRun: ($dryRun ?? false), + userId: $userId, + source: 'api', + ); + + return new JSONResponse( + data: [ + 'purgedByCategory' => $result->getByCategory(), + 'totalRows' => $result->getTotalRows(), + 'durationMs' => $result->getDurationMs(), + 'dryRun' => $result->isDryRun(), + 'skipped' => $result->getSkipped(), + ], + statusCode: Http::STATUS_OK + ); + }//end purge() + + /** + * Normalise the API-supplied categories list. + * + * Filters non-string entries silently (defence in depth — the + * controller is reached after framework JSON parsing) and + * returns `null` if any of the remaining names are not registered. + * An empty list is returned as `[]` (which the orchestrator + * treats as "all categories"). + * + * @param array $input The raw input list. + * + * @return array|null The validated names or null. + */ + private function normaliseCategories(array $input): ?array { + $known = $this->registry->getCategoryNames(); + $normalised = []; + + foreach ($input as $value) { + if (is_string(value: $value) === false || $value === '') { + continue; + } + + if (in_array(needle: $value, haystack: $known, strict: true) === false) { + return null; + } + + $normalised[] = $value; + } + + return $normalised; + }//end normaliseCategories() }//end class diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index 7ef12e64..51657756 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -97,1042 +97,1027 @@ * any single method. * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-4 */ -class AdminController extends Controller -{ - /** - * Constructor - * - * @param IRequest $request The request. - * @param AdminTemplateService $templateService The admin template service. - * @param AdminSettingsService $settingsService The admin settings service. - * @param IGroupManager $groupManager The Nextcloud group manager. - * @param IUserSession $userSession The current user session. - * @param ExportService $exportService ZIP export service - * (REQ-EXIM-001..003). - * @param ImportService $importService ZIP import service - * (REQ-EXIM-004..008). - * @param RoleService $roleService The LaunchPad role service - * (REQ-ROLE-001..011). - * @param FeedRefreshService $feedRefresh The background feed - * refresh service - * used by the - * on-demand admin - * `refreshFeeds` - * action - * (REQ-BGJOB-FEED-005). - * @param FooterService $footerService Global footer settings + sanitiser - * (REQ-FTR-001..010). - * @param SetupWizardService $setupWizardService Setup-wizard - * orchestrator - * (REQ-WIZ-001..011). - * @param ActionAuthService $actionAuth ADR-023 action authorization. - * @param TemplateResyncService $resyncService Admin template - * re-sync - * orchestrator - * (REQ-RESYNC-001..005). - */ - public function __construct( - IRequest $request, - private readonly AdminTemplateService $templateService, - private readonly AdminSettingsService $settingsService, - private readonly IGroupManager $groupManager, - private readonly IUserSession $userSession, - private readonly ExportService $exportService, - private readonly ImportService $importService, - private readonly RoleService $roleService, - private readonly FeedRefreshService $feedRefresh, - private readonly FooterService $footerService, - private readonly SetupWizardService $setupWizardService, - private readonly ActionAuthService $actionAuth, - private readonly TemplateResyncService $resyncService, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * Inline admin guard — checks session and group membership. - * - * Returns a 401/403 JSONResponse when the caller is not authenticated or - * not an NC admin, or null when the guard passes. - * - * @return JSONResponse|null Non-null means the request must be rejected. - */ - private function assertAdmin(): ?JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse( - data: ['error' => 'Not authenticated'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } - - if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) { - return new JSONResponse( - data: ['error' => 'Admin required'], - statusCode: Http::STATUS_FORBIDDEN - ); - } - - return null; - }//end assertAdmin() - - /** - * List all admin dashboard templates. - * - * @return JSONResponse The list of templates. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-4 - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function listTemplates(): JSONResponse - { - $templates = $this->templateService->listTemplates(); - - return ResponseHelper::success( - data: ResponseHelper::serializeList(entities: $templates) - ); - }//end listTemplates() - - /** - * Get a specific admin template. - * - * @param int $id The template ID. - * - * @return JSONResponse The template data. - * - * @spec openspec/specs/admin-templates/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function getTemplate(int $id): JSONResponse - { - try { - $result = $this->templateService->getTemplateWithPlacements( - id: $id - ); - $placements = ResponseHelper::serializeList( - entities: $result['placements'] - ); - - return ResponseHelper::success( - data: [ - 'template' => $result['template']->jsonSerialize(), - 'placements' => $placements, - ] - ); - } catch (\Exception $e) { - return ResponseHelper::error( - exception: $e, - statusCode: Http::STATUS_NOT_FOUND - ); - }//end try - }//end getTemplate() - - /** - * Create a new admin template. - * - * @param string $name The template name. - * @param string|null $description The description. - * @param array|null $targetGroups The target groups. - * @param string $permissionLevel The permission level. - * @param bool $isDefault Whether default. - * - * @return JSONResponse The created template. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-3 - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function createTemplate( - string $name, - ?string $description=null, - ?array $targetGroups=null, - string $permissionLevel=Dashboard::PERMISSION_ADD_ONLY, - bool $isDefault=false - ): JSONResponse { - try { - $template = $this->templateService->createTemplate( - name: $name, - description: $description, - targetGroups: $targetGroups, - permissionLevel: $permissionLevel, - isDefault: $isDefault - ); - - return ResponseHelper::success( - data: $template->jsonSerialize(), - statusCode: Http::STATUS_CREATED - ); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - }//end try - }//end createTemplate() - - /** - * Update an admin template. - * - * @param int $id The template ID. - * @param string|null $name The name. - * @param string|null $description The description. - * @param array|null $targetGroups The target groups. - * @param string|null $permissionLevel The permission level. - * @param bool|null $isDefault Whether default. - * @param int|null $gridColumns The grid columns. - * - * @return JSONResponse The updated template. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-5 - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function updateTemplate( - int $id, - ?string $name=null, - ?string $description=null, - ?array $targetGroups=null, - ?string $permissionLevel=null, - ?bool $isDefault=null, - ?int $gridColumns=null - ): JSONResponse { - try { - $data = $this->buildUpdateData( - name: $name, - description: $description, - targetGroups: $targetGroups, - permissionLevel: $permissionLevel, - isDefault: $isDefault, - gridColumns: $gridColumns - ); - - $template = $this->templateService->updateTemplate( - id: $id, - data: $data - ); - - return ResponseHelper::success( - data: $template->jsonSerialize() - ); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - }//end try - }//end updateTemplate() - - /** - * Delete an admin template. - * - * @param int $id The template ID. - * - * @return JSONResponse The deletion confirmation. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-6 - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function deleteTemplate(int $id): JSONResponse - { - try { - $this->templateService->deleteTemplate(id: $id); - - return ResponseHelper::success(data: ['status' => 'ok']); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - }//end try - }//end deleteTemplate() - - /** - * Push an updated admin template to its already-provisioned user - * copies (REQ-RESYNC-001). - * - * Body: `{strategy: "overwrite"|"merge", dryRun: bool}`. Dry-run - * (the default) computes and returns the plan — affected copies plus - * per-copy add/update/remove/preserve counts — without mutating - * anything. A real run (`dryRun: false`) applies inline for small - * target groups or enqueues {@see \OCA\LaunchPad\BackgroundJob\TemplateResyncJob} - * for large ones, writes one audit record, and notifies every - * affected user. - * - * Admin-guarded twice over — the `AuthorizedAdminSetting` attribute - * plus the explicit {@see self::assertAdmin()} guard — matching this - * controller's other mutating admin actions (export/import/footer). - * - * @param int $id The admin template's dashboard ID. - * @param string $strategy `'overwrite'` or `'merge'`. - * @param bool $dryRun When true (default), report without - * mutating. - * - * @return JSONResponse The plan, the applied result, or the - * async-accepted envelope. 400 on an invalid - * strategy or a non-template dashboard; 401/403 - * on guard failure. - * - * @spec openspec/specs/admin-templates/spec.md#requirement-req-resync-001-re-sync-action-pushes-template-updates-to-existing-copies - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function resyncTemplate( - int $id, - string $strategy='', - bool $dryRun=true - ): JSONResponse { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - $user = $this->userSession->getUser(); - $actingAdminId = ''; - if ($user !== null) { - $actingAdminId = $user->getUID(); - } - - try { - $result = $this->resyncService->resync( - templateId: $id, - strategy: $strategy, - dryRun: $dryRun, - actingAdminId: $actingAdminId - ); - - return ResponseHelper::success(data: $result); - } catch (InvalidArgumentException $e) { - return ResponseHelper::error( - exception: $e, - statusCode: Http::STATUS_BAD_REQUEST, - message: $e->getMessage() - ); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - }//end try - }//end resyncTemplate() - - /** - * Get admin settings. - * - * @return JSONResponse The admin settings. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-1 - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function getSettings(): JSONResponse - { - return ResponseHelper::success( - data: $this->settingsService->getSettings() - ); - }//end getSettings() - - /** - * Update admin settings. - * - * @param string|null $defaultPermLevel Default permission level. - * @param bool|null $allowUserDash Allow user dashboards. - * @param bool|null $allowMultiDash Allow multiple dashboards. - * @param int|null $defaultGridCols Default grid columns. - * @param array|null $linkCreateFileExts link-button-widget createFile - * extension allow-list - * (REQ-LBN-004). - * @param string|null $launchpadContentStorage Content storage backend - * (`database` or - * `groupfolder`). - * REQ-GFSB-006. - * @param string|null $defaultSharePermissionLevel Org-wide default share - * permission level - * (dashboard-sharing spec). - * @param array|null $forcedShareGroups Groups every new dashboard - * is force-shared with - * (dashboard-sharing spec). - * @param bool|null $legacyWidgetBridgeEnabled Enable / disable the - * legacy widget bridge - * (legacy-widget-bridge - * spec). - * @param int|null $maxDashboardsPerUser Maximum personal - * dashboards per user - * (`0` = unlimited). - * dashboard-quota-limits - * REQ-QUOTA-001. - * @param int|null $maxWidgetsPerDashboard Maximum placements per - * dashboard (`0` = - * unlimited). - * dashboard-quota-limits - * REQ-QUOTA-001. - * @param string|null $quicksearchFallbackTarget On-dashboard quick-search - * no-match fallback: - * `'none'`, - * `'unified-search'`, or - * an `https` URL - * template containing - * `{query}`. - * tile-quick-search - * REQ-QSEARCH-004. - * - * @return JSONResponse The update confirmation. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-2 - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function updateSettings( - ?string $defaultPermLevel=null, - ?bool $allowUserDash=null, - ?bool $allowMultiDash=null, - ?int $defaultGridCols=null, - ?array $linkCreateFileExts=null, - ?string $launchpadContentStorage=null, - ?string $defaultSharePermissionLevel=null, - ?array $forcedShareGroups=null, - ?bool $legacyWidgetBridgeEnabled=null, - ?int $maxDashboardsPerUser=null, - ?int $maxWidgetsPerDashboard=null, - ?string $quicksearchFallbackTarget=null - ): JSONResponse { - try { - $this->settingsService->updateSettings( - defaultPermLevel: $defaultPermLevel, - allowUserDash: $allowUserDash, - allowMultiDash: $allowMultiDash, - defaultGridCols: $defaultGridCols, - linkCreateFileExts: $linkCreateFileExts, - contentStorage: $launchpadContentStorage, - defaultSharePermissionLevel: $defaultSharePermissionLevel, - forcedShareGroups: $forcedShareGroups, - legacyWidgetBridgeEnabled: $legacyWidgetBridgeEnabled, - maxDashboardsPerUser: $maxDashboardsPerUser, - maxWidgetsPerDashboard: $maxWidgetsPerDashboard, - quicksearchFallbackTarget: $quicksearchFallbackTarget - ); - - return ResponseHelper::success(data: ['status' => 'ok']); - } catch (\InvalidArgumentException $e) { - return new JSONResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - }//end try - }//end updateSettings() - - /** - * Read the global footer settings (REQ-FTR-001, REQ-FTR-010). - * - * Returns the five footer keys as a flat camelCase object so the - * admin UI can render the form with one round-trip. Admin-only — - * non-admins receive HTTP 403 because even the read path discloses - * potentially-sensitive draft footer copy. - * - * @return JSONResponse The settings object, or 401/403 on guard failure. - * - * @spec openspec/specs/admin-templates/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function getFooterSettings(): JSONResponse - { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - return ResponseHelper::success( - data: $this->footerService->getGlobalSettings() - ); - }//end getFooterSettings() - - /** - * Patch the global footer settings (REQ-FTR-001..003, REQ-FTR-009, - * REQ-FTR-010). - * - * Body: any subset of `{footerEnabled, footerHtml, footerConfig, - * footerBackgroundColor, footerTextColor}`. The service sanitises - * HTML, validates the structured-config schema, and validates hex - * colour strings before persistence. Validation failures map to - * HTTP 400 (or 413 when the HTML exceeds the 8 KB cap). - * - * @param bool|null $footerEnabled Master toggle. - * @param string|array|null $footerHtml Raw HTML or - * language-variant - * map. - * @param array|null $footerConfig Structured config. - * @param string|null $footerBackgroundColor Hex (#rrggbb) or null. - * @param string|null $footerTextColor Hex (#rrggbb) or null. - * - * @return JSONResponse Status 200 on success, 400/413 on validation, - * 401/403 on guard failure. - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) — NC reads params from route declaration; - * body uses getParams() for array_key_exists semantics. - * - * @spec openspec/specs/admin-templates/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function updateFooterSettings( - ?bool $footerEnabled=null, - mixed $footerHtml=null, - ?array $footerConfig=null, - ?string $footerBackgroundColor=null, - ?string $footerTextColor=null - ): JSONResponse { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - // Build the patch from only those args that the caller actually - // supplied — `array_key_exists` semantics on the body let admins - // explicitly clear a colour by sending `null`. - $body = $this->request->getParams(); - $patch = []; - foreach (['footerEnabled', 'footerHtml', 'footerConfig', 'footerBackgroundColor', 'footerTextColor'] as $key) { - if (array_key_exists(key: $key, array: $body) === true) { - $patch[$key] = $body[$key]; - } - } - - try { - $this->footerService->updateGlobalSettings(patch: $patch); - } catch (InvalidArgumentException $e) { - $isOversize = str_contains( - haystack: $e->getMessage(), - needle: '8 KB limit' - ); - $status = Http::STATUS_BAD_REQUEST; - if ($isOversize === true) { - $status = Http::STATUS_REQUEST_ENTITY_TOO_LARGE; - } - - return new JSONResponse( - data: ['error' => $e->getMessage()], - statusCode: $status - ); - } - - return ResponseHelper::success(data: ['status' => 'ok']); - }//end updateFooterSettings() - - /** - * Export a single dashboard or the entire site as a ZIP archive. - * - * Implements REQ-EXIM-002 (single-dashboard export) and REQ-EXIM-003 - * (site export). Admin-only — non-admins receive HTTP 403. - * - * Query parameters: - * - `scope` (string, required): `dashboard` or `site`. - * - `dashboardUuid` (string, required when scope=dashboard). - * - * @param string $scope The export scope. - * @param string|null $dashboardUuid The dashboard UUID for scope=dashboard. - * - * @return StreamResponse|JSONResponse The streamed ZIP, or a JSON error. - * - * @spec openspec/specs/admin-templates/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function export( - string $scope='site', - ?string $dashboardUuid=null - ): StreamResponse|JSONResponse { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - if (in_array(needle: $scope, haystack: ['site', 'dashboard'], strict: true) === false) { - return new JSONResponse( - data: ['error' => 'Unsupported scope: '.$scope], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - $userId = (string) $this->userSession->getUser()?->getUID(); - - if ($scope === 'site') { - return $this->exportService->exportSite(currentUserId: $userId); - } - - if ($dashboardUuid === null || $dashboardUuid === '') { - return new JSONResponse( - data: ['error' => 'dashboardUuid parameter is required when scope=dashboard'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - if (preg_match(pattern: '/^[A-Za-z0-9\-]{8,}$/', subject: $dashboardUuid) !== 1) { - return new JSONResponse( - data: ['error' => 'Invalid dashboard UUID format'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - try { - return $this->exportService->exportDashboard( - dashboardUuid: $dashboardUuid, - currentUserId: $userId - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } - }//end export() - - /** - * Import a previously-exported ZIP archive. - * - * Implements REQ-EXIM-004..008. Admin-only. - * - * Multipart body: a `file` field containing the ZIP archive. - * Query parameter: `preserveUuids` (default false). - * - * @param bool $preserveUuids When true, fail on UUID collision. - * - * @return JSONResponse The import summary, or an error response. - * - * @spec openspec/specs/admin-templates/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function import(bool $preserveUuids=false): JSONResponse - { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - // Multipart uploads bind to $_FILES; PHP only populates this for - // POST requests, which is what the route declares (REQ-EXIM-004). - $upload = $_FILES['file'] ?? null; - if (is_array($upload) === false - || isset($upload['tmp_name']) === false - || (string) $upload['tmp_name'] === '' - ) { - return new JSONResponse( - data: ['error' => 'No file uploaded under field "file".'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - $tmpName = (string) $upload['tmp_name']; - - $userId = (string) $this->userSession->getUser()?->getUID(); - - try { - $result = $this->importService->import( - zipPath: $tmpName, - preserveUuids: $preserveUuids, - currentUserId: $userId - ); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - if ($result['status'] === ImportService::ERR_UUID_COLLISION) { - return new JSONResponse( - data: [ - 'importedDashboardCount' => 0, - 'skippedDashboardCount' => 0, - 'errors' => $result['errors'], - ], - statusCode: Http::STATUS_CONFLICT - ); - } - - return ResponseHelper::success( - data: [ - 'importedDashboardCount' => $result['importedDashboardCount'], - 'skippedDashboardCount' => $result['skippedDashboardCount'], - 'errors' => $result['errors'], - ] - ); - }//end import() - - /** - * List every role assignment in the system (REQ-ROLE-006). NC-admin only. - * - * Returns a JSON array of role-assignment rows with their persisted - * fields (id, userId, groupId, role, assignedBy, assignedAt). The - * caller MUST be a Nextcloud admin; non-admins receive HTTP 403. - * - * @return JSONResponse The list of role assignments, or 401/403. - * - * @spec openspec/specs/admin-templates/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function listRoles(): JSONResponse - { - - $assignments = $this->roleService->listAssignments(); - - return ResponseHelper::success( - data: ResponseHelper::serializeList(entities: $assignments) - ); - }//end listRoles() - - /** - * Create a new role assignment (REQ-ROLE-004). NC-admin only. - * - * Accepts a JSON body `{userId?: string, groupId?: string, role: string}`. - * Exactly one of `userId` / `groupId` MUST be set. Returns the new - * assignment with HTTP 201 on success. Returns 400 on structural - * failure, 409 on duplicate, 401/403 on auth failure. - * - * @param string|null $userId The target user ID (XOR with groupId). - * @param string|null $groupId The target group ID (XOR with userId). - * @param string|null $role The role name (admin / editor / viewer). - * - * @return JSONResponse The created assignment, or an error envelope. - * - * @spec openspec/specs/admin-templates/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function createRole( - ?string $userId=null, - ?string $groupId=null, - ?string $role=null - ): JSONResponse { - - $assignedBy = (string) $this->userSession->getUser()->getUID(); - - try { - $assignment = $this->roleService->assignRole( - userId: $userId, - groupId: $groupId, - role: (string) $role, - assignedBy: $assignedBy - ); - } catch (DuplicateRoleAssignmentException $e) { - return new JSONResponse( - data: [ - 'error' => $e->getDisplayMessage(), - 'errorCode' => $e->getErrorCode(), - ], - statusCode: Http::STATUS_CONFLICT - ); - } catch (InvalidRoleAssignmentException $e) { - return new JSONResponse( - data: [ - 'error' => $e->getDisplayMessage(), - 'errorCode' => $e->getErrorCode(), - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - }//end try - - return ResponseHelper::success( - data: $assignment->jsonSerialize(), - statusCode: Http::STATUS_CREATED - ); - }//end createRole() - - /** - * Delete a role assignment by ID (REQ-ROLE-004). NC-admin only. - * - * Returns 204 on success, 404 when no row matches, 401/403 on auth. - * - * @param int $id The role assignment ID. - * - * @return JSONResponse Empty success or error envelope. - * - * @spec openspec/specs/admin-templates/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function deleteRole(int $id): JSONResponse - { - - try { - $this->roleService->removeRole(id: $id); - } catch (DoesNotExistException) { - return ResponseHelper::forbidden( - message: 'Role assignment not found' - )->setStatus(status: Http::STATUS_NOT_FOUND); - } - - return new JSONResponse( - data: [], - statusCode: Http::STATUS_NO_CONTENT - ); - }//end deleteRole() - - /** - * Return the calling user's effective LaunchPad role and source - * (REQ-ROLE-006). Available to any authenticated user. - * - * Response shape: `{role: string|null, source: string|null}`. - * - * @return JSONResponse The role / source envelope, or 401. - * - * @spec openspec/specs/admin-templates/spec.md - */ - #[NoAdminRequired] - public function getMyRole(): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'admin.get-my-role'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - $userId = (string) $user->getUID(); - - return ResponseHelper::success( - data: [ - 'role' => $this->roleService->getEffectiveRole(userId: $userId), - 'source' => $this->roleService->getRoleSource(userId: $userId), - ] - ); - }//end getMyRole() - - /** - * Trigger an immediate background feed refresh (REQ-FRJ-010). - * - * Admin-only — guarded by {@see self::requireAdmin()}. Optionally - * scope the refresh to a single feed URL (must be HTTP/HTTPS). - * Returns `{processedCount, successCount, failureCount, durationMs}`. - * - * @param string|null $feedUrl Optional single URL to refresh. - * - * @return JSONResponse The aggregate refresh summary. - * - * @spec openspec/specs/admin-templates/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function refreshFeedsNow(?string $feedUrl=null): JSONResponse - { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - if ($feedUrl !== null && $feedUrl !== '') { - $scheme = strtolower( - string: (string) parse_url( - url: $feedUrl, - component: PHP_URL_SCHEME - ) - ); - if (in_array(needle: $scheme, haystack: ['http', 'https'], strict: true) === false) { - return new JSONResponse( - data: [ - 'error' => 'feedUrl must use http:// or https:// scheme.', - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - } - - $summary = $this->feedRefresh->refreshAll(onlyUrl: $feedUrl); - - return new JSONResponse(data: $summary, statusCode: Http::STATUS_OK); - }//end refreshFeedsNow() - - /** - * `POST /api/admin/templates/{uuid}/preview-image` — admin-only - * preview-image upload (REQ-TMPL-017). - * - * Body (JSON): `{base64: 'data:image/;base64,'}`. The - * payload is delegated to {@see ResourceService::upload()} (the - * "custom-icon-upload pattern"); the returned URL is written to the - * template's `templatePreviewImage` column. Allowed image types: - * PNG, JPG, GIF, WebP, SVG (sanitised). Maximum decoded size: 5 MB. - * - * @param string $uuid The template UUID. - * @param string $base64 The base64 data URL. - * - * @return JSONResponse `{status: 'success', previewImage: '...'}` - * on success. - * - * @spec openspec/specs/admin-templates/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function uploadTemplatePreviewImage( - string $uuid, - string $base64='' - ): JSONResponse { - - if ($base64 === '') { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'invalid_payload', - 'message' => 'Field "base64" is required', - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - try { - $url = $this->templateService->uploadPreviewImage( - templateUuid: $uuid, - base64DataUrl: $base64 - ); - } catch (DoesNotExistException $e) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'not_found', - 'message' => 'Template not found', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (ResourceException $e) { - // Catches every typed ResourceException subclass (bad data URL, - // disallowed image format, oversized payload, SVG sanitiser - // rejection, storage failure) returned by ResourceService::upload - // — all collapse to a single 400 envelope per REQ-TMPL-017. - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'invalid_image', - 'message' => $e->getMessage(), - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - }//end try - - return new JSONResponse( - data: [ - 'status' => 'success', - 'previewImage' => $url, - ], - statusCode: Http::STATUS_OK - ); - }//end uploadTemplatePreviewImage() - - /** - * Get the setup-wizard state (REQ-WIZ-008). - * - * Admin-only — non-admins receive HTTP 403. Returns - * `{complete, currentRecommendedStep, stepStatuses}`. - * - * @return JSONResponse The wizard state, or 401/403. - * - * @spec openspec/specs/admin-templates/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function getWizardState(): JSONResponse - { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - return ResponseHelper::success( - data: $this->setupWizardService->getWizardState() - ); - }//end getWizardState() - - /** - * Mark the setup-wizard complete (REQ-WIZ-009). - * - * Idempotent — calling on a completed instance returns 200 with the - * same payload. Admin-only. - * - * @return JSONResponse The post-completion wizard state, or 401/403. - * - * @spec openspec/specs/admin-templates/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function completeWizard(): JSONResponse - { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - return ResponseHelper::success( - data: $this->setupWizardService->markWizardComplete() - ); - }//end completeWizard() - - /** - * Persist the storage backend choice from Step 2 (REQ-WIZ-003). - * - * Validates the selection and writes `launchpad.content_storage`. The - * GroupFolder option is server-side gated by the `groupfolders` app - * dependency — selecting it without the app installed returns 400. - * Admin-only. - * - * @param string|null $storage The chosen backend. - * - * @return JSONResponse The post-write wizard state, or 400/401/403. - * - * @spec openspec/specs/admin-templates/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function setWizardStorage(?string $storage=null): JSONResponse - { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - if ($storage === null || $storage === '') { - return new JSONResponse( - data: ['error' => 'Field "storage" is required.'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - if ($storage === SetupWizardService::STORAGE_GROUPFOLDER - && $this->setupWizardService->hasGroupfolderApp() === false - ) { - return new JSONResponse( - data: ['error' => 'GroupFolder app is not installed.'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - try { - $this->setupWizardService->setContentStorage(value: $storage); - } catch (InvalidArgumentException) { - return new JSONResponse( - data: ['error' => 'Unsupported storage backend.'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - return ResponseHelper::success( - data: $this->setupWizardService->getWizardState() - ); - }//end setWizardStorage() - - /** - * Build the update data array from nullable parameters. - * - * @param string|null $name The name. - * @param string|null $description The description. - * @param array|null $targetGroups The target groups. - * @param string|null $permissionLevel The permission level. - * @param bool|null $isDefault Whether default. - * @param int|null $gridColumns The grid columns. - * - * @return array The non-null update data. - */ - private function buildUpdateData( - ?string $name, - ?string $description, - ?array $targetGroups, - ?string $permissionLevel, - ?bool $isDefault, - ?int $gridColumns - ): array { - $fields = [ - 'name' => $name, - 'description' => $description, - 'targetGroups' => $targetGroups, - 'permissionLevel' => $permissionLevel, - 'isDefault' => $isDefault, - 'gridColumns' => $gridColumns, - ]; - - return array_filter( - array: $fields, - callback: function ($value) { - return $value !== null; - } - ); - }//end buildUpdateData() +class AdminController extends Controller { + /** + * Constructor + * + * @param IRequest $request The request. + * @param AdminTemplateService $templateService The admin template service. + * @param AdminSettingsService $settingsService The admin settings service. + * @param IGroupManager $groupManager The Nextcloud group manager. + * @param IUserSession $userSession The current user session. + * @param ExportService $exportService ZIP export service + * (REQ-EXIM-001..003). + * @param ImportService $importService ZIP import service + * (REQ-EXIM-004..008). + * @param RoleService $roleService The LaunchPad role service + * (REQ-ROLE-001..011). + * @param FeedRefreshService $feedRefresh The background feed + * refresh service + * used by the + * on-demand admin + * `refreshFeeds` + * action + * (REQ-BGJOB-FEED-005). + * @param FooterService $footerService Global footer settings + sanitiser + * (REQ-FTR-001..010). + * @param SetupWizardService $setupWizardService Setup-wizard + * orchestrator + * (REQ-WIZ-001..011). + * @param ActionAuthService $actionAuth ADR-023 action authorization. + * @param TemplateResyncService $resyncService Admin template + * re-sync + * orchestrator + * (REQ-RESYNC-001..005). + */ + public function __construct( + IRequest $request, + private readonly AdminTemplateService $templateService, + private readonly AdminSettingsService $settingsService, + private readonly IGroupManager $groupManager, + private readonly IUserSession $userSession, + private readonly ExportService $exportService, + private readonly ImportService $importService, + private readonly RoleService $roleService, + private readonly FeedRefreshService $feedRefresh, + private readonly FooterService $footerService, + private readonly SetupWizardService $setupWizardService, + private readonly ActionAuthService $actionAuth, + private readonly TemplateResyncService $resyncService, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * Inline admin guard — checks session and group membership. + * + * Returns a 401/403 JSONResponse when the caller is not authenticated or + * not an NC admin, or null when the guard passes. + * + * @return JSONResponse|null Non-null means the request must be rejected. + */ + private function assertAdmin(): ?JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse( + data: ['error' => 'Not authenticated'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } + + if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) { + return new JSONResponse( + data: ['error' => 'Admin required'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + return null; + }//end assertAdmin() + + /** + * List all admin dashboard templates. + * + * @return JSONResponse The list of templates. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-4 + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function listTemplates(): JSONResponse { + $templates = $this->templateService->listTemplates(); + + return ResponseHelper::success( + data: ResponseHelper::serializeList(entities: $templates) + ); + }//end listTemplates() + + /** + * Get a specific admin template. + * + * @param int $id The template ID. + * + * @return JSONResponse The template data. + * + * @spec openspec/specs/admin-templates/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function getTemplate(int $id): JSONResponse { + try { + $result = $this->templateService->getTemplateWithPlacements( + id: $id + ); + $placements = ResponseHelper::serializeList( + entities: $result['placements'] + ); + + return ResponseHelper::success( + data: [ + 'template' => $result['template']->jsonSerialize(), + 'placements' => $placements, + ] + ); + } catch (\Exception $e) { + return ResponseHelper::error( + exception: $e, + statusCode: Http::STATUS_NOT_FOUND + ); + }//end try + }//end getTemplate() + + /** + * Create a new admin template. + * + * @param string $name The template name. + * @param string|null $description The description. + * @param array|null $targetGroups The target groups. + * @param string $permissionLevel The permission level. + * @param bool $isDefault Whether default. + * + * @return JSONResponse The created template. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-3 + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function createTemplate( + string $name, + ?string $description = null, + ?array $targetGroups = null, + string $permissionLevel = Dashboard::PERMISSION_ADD_ONLY, + bool $isDefault = false, + ): JSONResponse { + try { + $template = $this->templateService->createTemplate( + name: $name, + description: $description, + targetGroups: $targetGroups, + permissionLevel: $permissionLevel, + isDefault: $isDefault + ); + + return ResponseHelper::success( + data: $template->jsonSerialize(), + statusCode: Http::STATUS_CREATED + ); + } catch (\Exception $e) { + return ResponseHelper::error(exception: $e); + }//end try + }//end createTemplate() + + /** + * Update an admin template. + * + * @param int $id The template ID. + * @param string|null $name The name. + * @param string|null $description The description. + * @param array|null $targetGroups The target groups. + * @param string|null $permissionLevel The permission level. + * @param bool|null $isDefault Whether default. + * @param int|null $gridColumns The grid columns. + * + * @return JSONResponse The updated template. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-5 + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function updateTemplate( + int $id, + ?string $name = null, + ?string $description = null, + ?array $targetGroups = null, + ?string $permissionLevel = null, + ?bool $isDefault = null, + ?int $gridColumns = null, + ): JSONResponse { + try { + $data = $this->buildUpdateData( + name: $name, + description: $description, + targetGroups: $targetGroups, + permissionLevel: $permissionLevel, + isDefault: $isDefault, + gridColumns: $gridColumns + ); + + $template = $this->templateService->updateTemplate( + id: $id, + data: $data + ); + + return ResponseHelper::success( + data: $template->jsonSerialize() + ); + } catch (\Exception $e) { + return ResponseHelper::error(exception: $e); + }//end try + }//end updateTemplate() + + /** + * Delete an admin template. + * + * @param int $id The template ID. + * + * @return JSONResponse The deletion confirmation. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-6 + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function deleteTemplate(int $id): JSONResponse { + try { + $this->templateService->deleteTemplate(id: $id); + + return ResponseHelper::success(data: ['status' => 'ok']); + } catch (\Exception $e) { + return ResponseHelper::error(exception: $e); + }//end try + }//end deleteTemplate() + + /** + * Push an updated admin template to its already-provisioned user + * copies (REQ-RESYNC-001). + * + * Body: `{strategy: "overwrite"|"merge", dryRun: bool}`. Dry-run + * (the default) computes and returns the plan — affected copies plus + * per-copy add/update/remove/preserve counts — without mutating + * anything. A real run (`dryRun: false`) applies inline for small + * target groups or enqueues {@see \OCA\LaunchPad\BackgroundJob\TemplateResyncJob} + * for large ones, writes one audit record, and notifies every + * affected user. + * + * Admin-guarded twice over — the `AuthorizedAdminSetting` attribute + * plus the explicit {@see self::assertAdmin()} guard — matching this + * controller's other mutating admin actions (export/import/footer). + * + * @param int $id The admin template's dashboard ID. + * @param string $strategy `'overwrite'` or `'merge'`. + * @param bool $dryRun When true (default), report without + * mutating. + * + * @return JSONResponse The plan, the applied result, or the + * async-accepted envelope. 400 on an invalid + * strategy or a non-template dashboard; 401/403 + * on guard failure. + * + * @spec openspec/specs/admin-templates/spec.md#requirement-req-resync-001-re-sync-action-pushes-template-updates-to-existing-copies + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function resyncTemplate( + int $id, + string $strategy = '', + bool $dryRun = true, + ): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + $user = $this->userSession->getUser(); + $actingAdminId = ''; + if ($user !== null) { + $actingAdminId = $user->getUID(); + } + + try { + $result = $this->resyncService->resync( + templateId: $id, + strategy: $strategy, + dryRun: $dryRun, + actingAdminId: $actingAdminId + ); + + return ResponseHelper::success(data: $result); + } catch (InvalidArgumentException $e) { + return ResponseHelper::error( + exception: $e, + statusCode: Http::STATUS_BAD_REQUEST, + message: $e->getMessage() + ); + } catch (\Exception $e) { + return ResponseHelper::error(exception: $e); + }//end try + }//end resyncTemplate() + + /** + * Get admin settings. + * + * @return JSONResponse The admin settings. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-1 + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function getSettings(): JSONResponse { + return ResponseHelper::success( + data: $this->settingsService->getSettings() + ); + }//end getSettings() + + /** + * Update admin settings. + * + * @param string|null $defaultPermLevel Default permission level. + * @param bool|null $allowUserDash Allow user dashboards. + * @param bool|null $allowMultiDash Allow multiple dashboards. + * @param int|null $defaultGridCols Default grid columns. + * @param array|null $linkCreateFileExts link-button-widget createFile + * extension allow-list + * (REQ-LBN-004). + * @param string|null $launchpadContentStorage Content storage backend + * (`database` or + * `groupfolder`). + * REQ-GFSB-006. + * @param string|null $defaultSharePermissionLevel Org-wide default share + * permission level + * (dashboard-sharing spec). + * @param array|null $forcedShareGroups Groups every new dashboard + * is force-shared with + * (dashboard-sharing spec). + * @param bool|null $legacyWidgetBridgeEnabled Enable / disable the + * legacy widget bridge + * (legacy-widget-bridge + * spec). + * @param int|null $maxDashboardsPerUser Maximum personal + * dashboards per user + * (`0` = unlimited). + * dashboard-quota-limits + * REQ-QUOTA-001. + * @param int|null $maxWidgetsPerDashboard Maximum placements per + * dashboard (`0` = + * unlimited). + * dashboard-quota-limits + * REQ-QUOTA-001. + * @param string|null $quicksearchFallbackTarget On-dashboard quick-search + * no-match fallback: + * `'none'`, + * `'unified-search'`, or + * an `https` URL + * template containing + * `{query}`. + * tile-quick-search + * REQ-QSEARCH-004. + * + * @return JSONResponse The update confirmation. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-2 + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function updateSettings( + ?string $defaultPermLevel = null, + ?bool $allowUserDash = null, + ?bool $allowMultiDash = null, + ?int $defaultGridCols = null, + ?array $linkCreateFileExts = null, + ?string $launchpadContentStorage = null, + ?string $defaultSharePermissionLevel = null, + ?array $forcedShareGroups = null, + ?bool $legacyWidgetBridgeEnabled = null, + ?int $maxDashboardsPerUser = null, + ?int $maxWidgetsPerDashboard = null, + ?string $quicksearchFallbackTarget = null, + ): JSONResponse { + try { + $this->settingsService->updateSettings( + defaultPermLevel: $defaultPermLevel, + allowUserDash: $allowUserDash, + allowMultiDash: $allowMultiDash, + defaultGridCols: $defaultGridCols, + linkCreateFileExts: $linkCreateFileExts, + contentStorage: $launchpadContentStorage, + defaultSharePermissionLevel: $defaultSharePermissionLevel, + forcedShareGroups: $forcedShareGroups, + legacyWidgetBridgeEnabled: $legacyWidgetBridgeEnabled, + maxDashboardsPerUser: $maxDashboardsPerUser, + maxWidgetsPerDashboard: $maxWidgetsPerDashboard, + quicksearchFallbackTarget: $quicksearchFallbackTarget + ); + + return ResponseHelper::success(data: ['status' => 'ok']); + } catch (\InvalidArgumentException $e) { + return new JSONResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + } catch (\Exception $e) { + return ResponseHelper::error(exception: $e); + }//end try + }//end updateSettings() + + /** + * Read the global footer settings (REQ-FTR-001, REQ-FTR-010). + * + * Returns the five footer keys as a flat camelCase object so the + * admin UI can render the form with one round-trip. Admin-only — + * non-admins receive HTTP 403 because even the read path discloses + * potentially-sensitive draft footer copy. + * + * @return JSONResponse The settings object, or 401/403 on guard failure. + * + * @spec openspec/specs/admin-templates/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function getFooterSettings(): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + return ResponseHelper::success( + data: $this->footerService->getGlobalSettings() + ); + }//end getFooterSettings() + + /** + * Patch the global footer settings (REQ-FTR-001..003, REQ-FTR-009, + * REQ-FTR-010). + * + * Body: any subset of `{footerEnabled, footerHtml, footerConfig, + * footerBackgroundColor, footerTextColor}`. The service sanitises + * HTML, validates the structured-config schema, and validates hex + * colour strings before persistence. Validation failures map to + * HTTP 400 (or 413 when the HTML exceeds the 8 KB cap). + * + * @param bool|null $footerEnabled Master toggle. + * @param string|array|null $footerHtml Raw HTML or + * language-variant + * map. + * @param array|null $footerConfig Structured config. + * @param string|null $footerBackgroundColor Hex (#rrggbb) or null. + * @param string|null $footerTextColor Hex (#rrggbb) or null. + * + * @return JSONResponse Status 200 on success, 400/413 on validation, + * 401/403 on guard failure. + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) — NC reads params from route declaration; + * body uses getParams() for array_key_exists semantics. + * + * @spec openspec/specs/admin-templates/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function updateFooterSettings( + ?bool $footerEnabled = null, + mixed $footerHtml = null, + ?array $footerConfig = null, + ?string $footerBackgroundColor = null, + ?string $footerTextColor = null, + ): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + // Build the patch from only those args that the caller actually + // supplied — `array_key_exists` semantics on the body let admins + // explicitly clear a colour by sending `null`. + $body = $this->request->getParams(); + $patch = []; + foreach (['footerEnabled', 'footerHtml', 'footerConfig', 'footerBackgroundColor', 'footerTextColor'] as $key) { + if (array_key_exists(key: $key, array: $body) === true) { + $patch[$key] = $body[$key]; + } + } + + try { + $this->footerService->updateGlobalSettings(patch: $patch); + } catch (InvalidArgumentException $e) { + $isOversize = str_contains( + haystack: $e->getMessage(), + needle: '8 KB limit' + ); + $status = Http::STATUS_BAD_REQUEST; + if ($isOversize === true) { + $status = Http::STATUS_REQUEST_ENTITY_TOO_LARGE; + } + + return new JSONResponse( + data: ['error' => $e->getMessage()], + statusCode: $status + ); + } + + return ResponseHelper::success(data: ['status' => 'ok']); + }//end updateFooterSettings() + + /** + * Export a single dashboard or the entire site as a ZIP archive. + * + * Implements REQ-EXIM-002 (single-dashboard export) and REQ-EXIM-003 + * (site export). Admin-only — non-admins receive HTTP 403. + * + * Query parameters: + * - `scope` (string, required): `dashboard` or `site`. + * - `dashboardUuid` (string, required when scope=dashboard). + * + * @param string $scope The export scope. + * @param string|null $dashboardUuid The dashboard UUID for scope=dashboard. + * + * @return StreamResponse|JSONResponse The streamed ZIP, or a JSON error. + * + * @spec openspec/specs/admin-templates/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function export( + string $scope = 'site', + ?string $dashboardUuid = null, + ): StreamResponse|JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + if (in_array(needle: $scope, haystack: ['site', 'dashboard'], strict: true) === false) { + return new JSONResponse( + data: ['error' => 'Unsupported scope: ' . $scope], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + $userId = (string)$this->userSession->getUser()?->getUID(); + + if ($scope === 'site') { + return $this->exportService->exportSite(currentUserId: $userId); + } + + if ($dashboardUuid === null || $dashboardUuid === '') { + return new JSONResponse( + data: ['error' => 'dashboardUuid parameter is required when scope=dashboard'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + if (preg_match(pattern: '/^[A-Za-z0-9\-]{8,}$/', subject: $dashboardUuid) !== 1) { + return new JSONResponse( + data: ['error' => 'Invalid dashboard UUID format'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + try { + return $this->exportService->exportDashboard( + dashboardUuid: $dashboardUuid, + currentUserId: $userId + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + }//end export() + + /** + * Import a previously-exported ZIP archive. + * + * Implements REQ-EXIM-004..008. Admin-only. + * + * Multipart body: a `file` field containing the ZIP archive. + * Query parameter: `preserveUuids` (default false). + * + * @param bool $preserveUuids When true, fail on UUID collision. + * + * @return JSONResponse The import summary, or an error response. + * + * @spec openspec/specs/admin-templates/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function import(bool $preserveUuids = false): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + // Multipart uploads bind to $_FILES; PHP only populates this for + // POST requests, which is what the route declares (REQ-EXIM-004). + $upload = $_FILES['file'] ?? null; + if (is_array($upload) === false + || isset($upload['tmp_name']) === false + || (string)$upload['tmp_name'] === '' + ) { + return new JSONResponse( + data: ['error' => 'No file uploaded under field "file".'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + $tmpName = (string)$upload['tmp_name']; + + $userId = (string)$this->userSession->getUser()?->getUID(); + + try { + $result = $this->importService->import( + zipPath: $tmpName, + preserveUuids: $preserveUuids, + currentUserId: $userId + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + if ($result['status'] === ImportService::ERR_UUID_COLLISION) { + return new JSONResponse( + data: [ + 'importedDashboardCount' => 0, + 'skippedDashboardCount' => 0, + 'errors' => $result['errors'], + ], + statusCode: Http::STATUS_CONFLICT + ); + } + + return ResponseHelper::success( + data: [ + 'importedDashboardCount' => $result['importedDashboardCount'], + 'skippedDashboardCount' => $result['skippedDashboardCount'], + 'errors' => $result['errors'], + ] + ); + }//end import() + + /** + * List every role assignment in the system (REQ-ROLE-006). NC-admin only. + * + * Returns a JSON array of role-assignment rows with their persisted + * fields (id, userId, groupId, role, assignedBy, assignedAt). The + * caller MUST be a Nextcloud admin; non-admins receive HTTP 403. + * + * @return JSONResponse The list of role assignments, or 401/403. + * + * @spec openspec/specs/admin-templates/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function listRoles(): JSONResponse { + + $assignments = $this->roleService->listAssignments(); + + return ResponseHelper::success( + data: ResponseHelper::serializeList(entities: $assignments) + ); + }//end listRoles() + + /** + * Create a new role assignment (REQ-ROLE-004). NC-admin only. + * + * Accepts a JSON body `{userId?: string, groupId?: string, role: string}`. + * Exactly one of `userId` / `groupId` MUST be set. Returns the new + * assignment with HTTP 201 on success. Returns 400 on structural + * failure, 409 on duplicate, 401/403 on auth failure. + * + * @param string|null $userId The target user ID (XOR with groupId). + * @param string|null $groupId The target group ID (XOR with userId). + * @param string|null $role The role name (admin / editor / viewer). + * + * @return JSONResponse The created assignment, or an error envelope. + * + * @spec openspec/specs/admin-templates/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function createRole( + ?string $userId = null, + ?string $groupId = null, + ?string $role = null, + ): JSONResponse { + + $assignedBy = (string)$this->userSession->getUser()->getUID(); + + try { + $assignment = $this->roleService->assignRole( + userId: $userId, + groupId: $groupId, + role: (string)$role, + assignedBy: $assignedBy + ); + } catch (DuplicateRoleAssignmentException $e) { + return new JSONResponse( + data: [ + 'error' => $e->getDisplayMessage(), + 'errorCode' => $e->getErrorCode(), + ], + statusCode: Http::STATUS_CONFLICT + ); + } catch (InvalidRoleAssignmentException $e) { + return new JSONResponse( + data: [ + 'error' => $e->getDisplayMessage(), + 'errorCode' => $e->getErrorCode(), + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + }//end try + + return ResponseHelper::success( + data: $assignment->jsonSerialize(), + statusCode: Http::STATUS_CREATED + ); + }//end createRole() + + /** + * Delete a role assignment by ID (REQ-ROLE-004). NC-admin only. + * + * Returns 204 on success, 404 when no row matches, 401/403 on auth. + * + * @param int $id The role assignment ID. + * + * @return JSONResponse Empty success or error envelope. + * + * @spec openspec/specs/admin-templates/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function deleteRole(int $id): JSONResponse { + + try { + $this->roleService->removeRole(id: $id); + } catch (DoesNotExistException) { + return ResponseHelper::forbidden( + message: 'Role assignment not found' + )->setStatus(status: Http::STATUS_NOT_FOUND); + } + + return new JSONResponse( + data: [], + statusCode: Http::STATUS_NO_CONTENT + ); + }//end deleteRole() + + /** + * Return the calling user's effective LaunchPad role and source + * (REQ-ROLE-006). Available to any authenticated user. + * + * Response shape: `{role: string|null, source: string|null}`. + * + * @return JSONResponse The role / source envelope, or 401. + * + * @spec openspec/specs/admin-templates/spec.md + */ + #[NoAdminRequired] + public function getMyRole(): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'admin.get-my-role'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + $userId = (string)$user->getUID(); + + return ResponseHelper::success( + data: [ + 'role' => $this->roleService->getEffectiveRole(userId: $userId), + 'source' => $this->roleService->getRoleSource(userId: $userId), + ] + ); + }//end getMyRole() + + /** + * Trigger an immediate background feed refresh (REQ-FRJ-010). + * + * Admin-only — guarded by {@see self::requireAdmin()}. Optionally + * scope the refresh to a single feed URL (must be HTTP/HTTPS). + * Returns `{processedCount, successCount, failureCount, durationMs}`. + * + * @param string|null $feedUrl Optional single URL to refresh. + * + * @return JSONResponse The aggregate refresh summary. + * + * @spec openspec/specs/admin-templates/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function refreshFeedsNow(?string $feedUrl = null): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + if ($feedUrl !== null && $feedUrl !== '') { + $scheme = strtolower( + string: (string)parse_url( + url: $feedUrl, + component: PHP_URL_SCHEME + ) + ); + if (in_array(needle: $scheme, haystack: ['http', 'https'], strict: true) === false) { + return new JSONResponse( + data: [ + 'error' => 'feedUrl must use http:// or https:// scheme.', + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + } + + $summary = $this->feedRefresh->refreshAll(onlyUrl: $feedUrl); + + return new JSONResponse(data: $summary, statusCode: Http::STATUS_OK); + }//end refreshFeedsNow() + + /** + * `POST /api/admin/templates/{uuid}/preview-image` — admin-only + * preview-image upload (REQ-TMPL-017). + * + * Body (JSON): `{base64: 'data:image/;base64,'}`. The + * payload is delegated to {@see ResourceService::upload()} (the + * "custom-icon-upload pattern"); the returned URL is written to the + * template's `templatePreviewImage` column. Allowed image types: + * PNG, JPG, GIF, WebP, SVG (sanitised). Maximum decoded size: 5 MB. + * + * @param string $uuid The template UUID. + * @param string $base64 The base64 data URL. + * + * @return JSONResponse `{status: 'success', previewImage: '...'}` + * on success. + * + * @spec openspec/specs/admin-templates/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function uploadTemplatePreviewImage( + string $uuid, + string $base64 = '', + ): JSONResponse { + + if ($base64 === '') { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'invalid_payload', + 'message' => 'Field "base64" is required', + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + try { + $url = $this->templateService->uploadPreviewImage( + templateUuid: $uuid, + base64DataUrl: $base64 + ); + } catch (DoesNotExistException $e) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'not_found', + 'message' => 'Template not found', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (ResourceException $e) { + // Catches every typed ResourceException subclass (bad data URL, + // disallowed image format, oversized payload, SVG sanitiser + // rejection, storage failure) returned by ResourceService::upload + // — all collapse to a single 400 envelope per REQ-TMPL-017. + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'invalid_image', + 'message' => $e->getMessage(), + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + }//end try + + return new JSONResponse( + data: [ + 'status' => 'success', + 'previewImage' => $url, + ], + statusCode: Http::STATUS_OK + ); + }//end uploadTemplatePreviewImage() + + /** + * Get the setup-wizard state (REQ-WIZ-008). + * + * Admin-only — non-admins receive HTTP 403. Returns + * `{complete, currentRecommendedStep, stepStatuses}`. + * + * @return JSONResponse The wizard state, or 401/403. + * + * @spec openspec/specs/admin-templates/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function getWizardState(): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + return ResponseHelper::success( + data: $this->setupWizardService->getWizardState() + ); + }//end getWizardState() + + /** + * Mark the setup-wizard complete (REQ-WIZ-009). + * + * Idempotent — calling on a completed instance returns 200 with the + * same payload. Admin-only. + * + * @return JSONResponse The post-completion wizard state, or 401/403. + * + * @spec openspec/specs/admin-templates/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function completeWizard(): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + return ResponseHelper::success( + data: $this->setupWizardService->markWizardComplete() + ); + }//end completeWizard() + + /** + * Persist the storage backend choice from Step 2 (REQ-WIZ-003). + * + * Validates the selection and writes `launchpad.content_storage`. The + * GroupFolder option is server-side gated by the `groupfolders` app + * dependency — selecting it without the app installed returns 400. + * Admin-only. + * + * @param string|null $storage The chosen backend. + * + * @return JSONResponse The post-write wizard state, or 400/401/403. + * + * @spec openspec/specs/admin-templates/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function setWizardStorage(?string $storage = null): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + if ($storage === null || $storage === '') { + return new JSONResponse( + data: ['error' => 'Field "storage" is required.'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + if ($storage === SetupWizardService::STORAGE_GROUPFOLDER + && $this->setupWizardService->hasGroupfolderApp() === false + ) { + return new JSONResponse( + data: ['error' => 'GroupFolder app is not installed.'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + try { + $this->setupWizardService->setContentStorage(value: $storage); + } catch (InvalidArgumentException) { + return new JSONResponse( + data: ['error' => 'Unsupported storage backend.'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + return ResponseHelper::success( + data: $this->setupWizardService->getWizardState() + ); + }//end setWizardStorage() + + /** + * Build the update data array from nullable parameters. + * + * @param string|null $name The name. + * @param string|null $description The description. + * @param array|null $targetGroups The target groups. + * @param string|null $permissionLevel The permission level. + * @param bool|null $isDefault Whether default. + * @param int|null $gridColumns The grid columns. + * + * @return array The non-null update data. + */ + private function buildUpdateData( + ?string $name, + ?string $description, + ?array $targetGroups, + ?string $permissionLevel, + ?bool $isDefault, + ?int $gridColumns, + ): array { + $fields = [ + 'name' => $name, + 'description' => $description, + 'targetGroups' => $targetGroups, + 'permissionLevel' => $permissionLevel, + 'isDefault' => $isDefault, + 'gridColumns' => $gridColumns, + ]; + + return array_filter( + array: $fields, + callback: function ($value) { + return $value !== null; + } + ); + }//end buildUpdateData() }//end class diff --git a/lib/Controller/AdminDemoShowcasesController.php b/lib/Controller/AdminDemoShowcasesController.php index 74151c73..2d0cb62c 100644 --- a/lib/Controller/AdminDemoShowcasesController.php +++ b/lib/Controller/AdminDemoShowcasesController.php @@ -48,180 +48,176 @@ /** * Admin endpoints for managing bundled demo showcase dashboards. */ -class AdminDemoShowcasesController extends Controller -{ - /** - * Constructor. - * - * @param IRequest $request The HTTP request. - * @param DemoShowcasesService $showcasesSvc Showcase service. - * @param IUserSession $userSession Active user session. - * @param IGroupManager $groupManager Admin check. - * @param LoggerInterface $logger PSR-3 logger. - */ - public function __construct( - IRequest $request, - private readonly DemoShowcasesService $showcasesSvc, - private readonly IUserSession $userSession, - private readonly IGroupManager $groupManager, - private readonly LoggerInterface $logger, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * Inline admin guard. - * - * @return JSONResponse|null Non-null = caller must be rejected. - */ - private function assertAdmin(): ?JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse( - data: ['error' => 'Not authenticated'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } - - if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) { - return new JSONResponse( - data: ['error' => 'Admin required'], - statusCode: Http::STATUS_FORBIDDEN - ); - } - - return null; - }//end assertAdmin() - - /** - * List bundled showcases with installation status (REQ-DEMO-002). - * - * @return JSONResponse Showcase descriptors, or 401/403. - * - * @spec openspec/specs/demo-data-showcases/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function index(): JSONResponse - { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - return ResponseHelper::success( - data: $this->showcasesSvc->getAvailableShowcases() - ); - }//end index() - - /** - * Install a bundled showcase (REQ-DEMO-003, REQ-DEMO-004). - * - * Always returns the dashboard UUID — when the showcase is - * already installed and `force` is unset, the existing UUID is - * returned with `alreadyInstalled: true` so callers can render an - * informational banner. - * - * @param string $id The showcase ID (path segment). - * @param string $lang Optional locale (always resolves to `nl` - * in v1; REQ-DEMO-007). - * @param bool $force Force reinstallation, removing the existing - * dashboard if any. - * - * @return JSONResponse The install result, or an error. - * - * @spec openspec/specs/demo-data-showcases/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function install( - string $id, - string $lang='nl', - bool $force=false - ): JSONResponse { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - try { - $result = $this->showcasesSvc->installShowcase( - showcaseId: $id, - lang: $lang, - force: $force - ); - } catch (ShowcaseNotFoundException $e) { - return new JSONResponse( - data: ['error' => 'Showcase not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (Throwable $e) { - $this->logger->error( - message: 'Showcase install failed', - context: [ - 'showcaseId' => $id, - 'exception' => $e, - ] - ); - return new JSONResponse( - data: ['error' => 'Showcase installation failed'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - }//end try - - $statusCode = Http::STATUS_CREATED; - if ($result['alreadyInstalled'] === true) { - $statusCode = Http::STATUS_OK; - } - - return new JSONResponse( - data: [ - 'installedDashboardUuid' => $result['installedDashboardUuid'], - 'skippedWidgets' => $result['skippedWidgets'], - 'alreadyInstalled' => $result['alreadyInstalled'], - ], - statusCode: $statusCode - ); - }//end install() - - /** - * Uninstall a previously-installed showcase (REQ-DEMO-006). - * - * Idempotent — returns 204 even when the showcase is not currently - * installed. - * - * @param string $id The showcase ID (path segment). - * - * @return JSONResponse Empty 204, or 401/403. - * - * @spec openspec/specs/demo-data-showcases/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function destroy(string $id): JSONResponse - { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - try { - $this->showcasesSvc->uninstallShowcase(showcaseId: $id); - } catch (Throwable $e) { - $this->logger->error( - message: 'Showcase uninstall failed', - context: [ - 'showcaseId' => $id, - 'exception' => $e, - ] - ); - return new JSONResponse( - data: ['error' => 'Showcase uninstall failed'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - } - - return new JSONResponse(data: [], statusCode: Http::STATUS_NO_CONTENT); - }//end destroy() +class AdminDemoShowcasesController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The HTTP request. + * @param DemoShowcasesService $showcasesSvc Showcase service. + * @param IUserSession $userSession Active user session. + * @param IGroupManager $groupManager Admin check. + * @param LoggerInterface $logger PSR-3 logger. + */ + public function __construct( + IRequest $request, + private readonly DemoShowcasesService $showcasesSvc, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + private readonly LoggerInterface $logger, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * Inline admin guard. + * + * @return JSONResponse|null Non-null = caller must be rejected. + */ + private function assertAdmin(): ?JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse( + data: ['error' => 'Not authenticated'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } + + if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) { + return new JSONResponse( + data: ['error' => 'Admin required'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + return null; + }//end assertAdmin() + + /** + * List bundled showcases with installation status (REQ-DEMO-002). + * + * @return JSONResponse Showcase descriptors, or 401/403. + * + * @spec openspec/specs/demo-data-showcases/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function index(): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + return ResponseHelper::success( + data: $this->showcasesSvc->getAvailableShowcases() + ); + }//end index() + + /** + * Install a bundled showcase (REQ-DEMO-003, REQ-DEMO-004). + * + * Always returns the dashboard UUID — when the showcase is + * already installed and `force` is unset, the existing UUID is + * returned with `alreadyInstalled: true` so callers can render an + * informational banner. + * + * @param string $id The showcase ID (path segment). + * @param string $lang Optional locale (always resolves to `nl` + * in v1; REQ-DEMO-007). + * @param bool $force Force reinstallation, removing the existing + * dashboard if any. + * + * @return JSONResponse The install result, or an error. + * + * @spec openspec/specs/demo-data-showcases/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function install( + string $id, + string $lang = 'nl', + bool $force = false, + ): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + try { + $result = $this->showcasesSvc->installShowcase( + showcaseId: $id, + lang: $lang, + force: $force + ); + } catch (ShowcaseNotFoundException $e) { + return new JSONResponse( + data: ['error' => 'Showcase not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (Throwable $e) { + $this->logger->error( + message: 'Showcase install failed', + context: [ + 'showcaseId' => $id, + 'exception' => $e, + ] + ); + return new JSONResponse( + data: ['error' => 'Showcase installation failed'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + + $statusCode = Http::STATUS_CREATED; + if ($result['alreadyInstalled'] === true) { + $statusCode = Http::STATUS_OK; + } + + return new JSONResponse( + data: [ + 'installedDashboardUuid' => $result['installedDashboardUuid'], + 'skippedWidgets' => $result['skippedWidgets'], + 'alreadyInstalled' => $result['alreadyInstalled'], + ], + statusCode: $statusCode + ); + }//end install() + + /** + * Uninstall a previously-installed showcase (REQ-DEMO-006). + * + * Idempotent — returns 204 even when the showcase is not currently + * installed. + * + * @param string $id The showcase ID (path segment). + * + * @return JSONResponse Empty 204, or 401/403. + * + * @spec openspec/specs/demo-data-showcases/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function destroy(string $id): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + try { + $this->showcasesSvc->uninstallShowcase(showcaseId: $id); + } catch (Throwable $e) { + $this->logger->error( + message: 'Showcase uninstall failed', + context: [ + 'showcaseId' => $id, + 'exception' => $e, + ] + ); + return new JSONResponse( + data: ['error' => 'Showcase uninstall failed'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + + return new JSONResponse(data: [], statusCode: Http::STATUS_NO_CONTENT); + }//end destroy() }//end class diff --git a/lib/Controller/AdminOrgNavigationController.php b/lib/Controller/AdminOrgNavigationController.php index f95a679d..1df3eba1 100644 --- a/lib/Controller/AdminOrgNavigationController.php +++ b/lib/Controller/AdminOrgNavigationController.php @@ -52,289 +52,282 @@ /** * Org-wide navigation editor REST surface. */ -class AdminOrgNavigationController extends Controller -{ - /** - * Setting key for the global navigation rail position - * (REQ-ONAV-004). Stored in `launchpad_admin_settings` rather than - * `IAppData` because it is a scalar enum, not a tree. - * - * @var string - */ - public const SETTING_KEY_POSITION = 'org_navigation_position'; - - /** - * Allowed values for the position setting (REQ-ONAV-004). - * - * @var array - */ - public const ALLOWED_POSITIONS = ['left', 'right', 'top', 'hidden']; - - /** - * Default position when the setting is unset (REQ-ONAV-004). - * - * @var string - */ - public const DEFAULT_POSITION = 'hidden'; - - /** - * Constructor. - * - * @param IRequest $request Inbound request. - * @param OrgNavigationService $service Tree storage + filter - * service. - * @param AdminSettingMapper $settings Persistence layer for - * the position scalar. - * @param IUserSession $userSession Current user session. - * @param IGroupManager $groupManager Admin check for write endpoints. - * @param ActionAuthService $actionAuth ADR-023 action authorization. - */ - public function __construct( - IRequest $request, - private readonly OrgNavigationService $service, - private readonly AdminSettingMapper $settings, - private readonly IUserSession $userSession, - private readonly IGroupManager $groupManager, - private readonly ActionAuthService $actionAuth, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * Inline admin guard (returns null when the caller is an NC admin). - * - * @return JSONResponse|null Non-null = caller must be rejected. - */ - private function assertAdmin(): ?JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse( - data: ['error' => 'Not authenticated'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } - - if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) { - return new JSONResponse( - data: ['error' => 'Admin required'], - statusCode: Http::STATUS_FORBIDDEN - ); - } - - return null; - }//end assertAdmin() - - /** - * Read the org-navigation tree filtered for the current user. - * - * Accessible to any logged-in user (REQ-ONAV-002). - * - * @param string $lang Language code (defaults to `nl`). - * - * @return JSONResponse The filtered tree under `tree` plus the - * effective `language`. - * - * @spec openspec/specs/navigation-editor-org/spec.md - */ - #[NoAdminRequired] - public function getOrgNavigation(string $lang=OrgNavigationService::DEFAULT_LANGUAGE): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'admin-org-navigation.get-org-navigation'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - $language = $this->validateLanguage(language: $lang); - if ($language === null) { - return new JSONResponse( - data: ['error' => 'Unsupported language'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - $tree = $this->service->getTree(language: $language); - $filtered = $this->service->filterTreeByUserGroups( - tree: $tree, - userId: $user->getUID() - ); - - return ResponseHelper::success( - data: [ - 'tree' => $filtered, - 'language' => $language, - ] - ); - }//end getOrgNavigation() - - /** - * Replace the org-navigation tree for the given language. - * - * Admin-only (REQ-ONAV-003); validates and persists in one go. - * - * @param array|null $tree The full replacement tree. - * @param string $lang Language code (defaults to `nl`). - * - * @return JSONResponse The persisted tree (unchanged) on success. - * - * @spec openspec/specs/navigation-editor-org/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function updateOrgNavigation( - ?array $tree=null, - string $lang=OrgNavigationService::DEFAULT_LANGUAGE - ): JSONResponse { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - $language = $this->validateLanguage(language: $lang); - if ($language === null) { - return new JSONResponse( - data: ['error' => 'Unsupported language'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - if (is_array($tree) === false) { - return new JSONResponse( - data: ['error' => 'tree must be an array of node objects'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - try { - $this->service->setTree(tree: $tree, language: $language); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - return ResponseHelper::success( - data: [ - 'tree' => $tree, - 'language' => $language, - ] - ); - }//end updateOrgNavigation() - - /** - * Read the global rail-position setting (REQ-ONAV-004). - * - * @return JSONResponse The current effective position. - * - * @spec openspec/specs/navigation-editor-org/spec.md - */ - #[NoAdminRequired] - public function getPosition(): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'admin-org-navigation.get-position'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - return ResponseHelper::success( - data: ['position' => $this->readPosition()] - ); - }//end getPosition() - - /** - * Replace the global rail-position setting (REQ-ONAV-004). - * - * Admin-only. Accepts `{position: 'left'|'right'|'top'|'hidden'}`. - * - * @param string|null $position The desired position. - * - * @return JSONResponse The persisted position on success. - * - * @spec openspec/specs/navigation-editor-org/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function updatePosition(?string $position=null): JSONResponse - { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - if ($position === null - || in_array(needle: $position, haystack: self::ALLOWED_POSITIONS, strict: true) === false - ) { - return new JSONResponse( - data: ['error' => 'position must be one of: '.implode(separator: ', ', array: self::ALLOWED_POSITIONS)], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - $this->settings->setSetting( - key: self::SETTING_KEY_POSITION, - value: $position - ); - - return ResponseHelper::success( - data: ['position' => $position] - ); - }//end updatePosition() - - /** - * Validate that a language code is one LaunchPad supports in v1. - * - * @param string $language The candidate language code. - * - * @return string|null The normalised language code, or `null` - * when the value is not supported. - */ - private function validateLanguage(string $language): ?string - { - $lower = strtolower(string: trim(string: $language)); - if (in_array( - needle: $lower, - haystack: OrgNavigationService::SUPPORTED_LANGUAGES, - strict: true - ) === false - ) { - return null; - } - - return $lower; - }//end validateLanguage() - - /** - * Read the persisted position with a default fallback. - * - * @return string Always one of {@see self::ALLOWED_POSITIONS}. - */ - private function readPosition(): string - { - $raw = $this->settings->getValue( - key: self::SETTING_KEY_POSITION, - default: self::DEFAULT_POSITION - ); - - if (is_string($raw) === false - || in_array(needle: $raw, haystack: self::ALLOWED_POSITIONS, strict: true) === false - ) { - return self::DEFAULT_POSITION; - } - - return $raw; - }//end readPosition() +class AdminOrgNavigationController extends Controller { + /** + * Setting key for the global navigation rail position + * (REQ-ONAV-004). Stored in `launchpad_admin_settings` rather than + * `IAppData` because it is a scalar enum, not a tree. + * + * @var string + */ + public const SETTING_KEY_POSITION = 'org_navigation_position'; + + /** + * Allowed values for the position setting (REQ-ONAV-004). + * + * @var array + */ + public const ALLOWED_POSITIONS = ['left', 'right', 'top', 'hidden']; + + /** + * Default position when the setting is unset (REQ-ONAV-004). + * + * @var string + */ + public const DEFAULT_POSITION = 'hidden'; + + /** + * Constructor. + * + * @param IRequest $request Inbound request. + * @param OrgNavigationService $service Tree storage + filter + * service. + * @param AdminSettingMapper $settings Persistence layer for + * the position scalar. + * @param IUserSession $userSession Current user session. + * @param IGroupManager $groupManager Admin check for write endpoints. + * @param ActionAuthService $actionAuth ADR-023 action authorization. + */ + public function __construct( + IRequest $request, + private readonly OrgNavigationService $service, + private readonly AdminSettingMapper $settings, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + private readonly ActionAuthService $actionAuth, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * Inline admin guard (returns null when the caller is an NC admin). + * + * @return JSONResponse|null Non-null = caller must be rejected. + */ + private function assertAdmin(): ?JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse( + data: ['error' => 'Not authenticated'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } + + if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) { + return new JSONResponse( + data: ['error' => 'Admin required'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + return null; + }//end assertAdmin() + + /** + * Read the org-navigation tree filtered for the current user. + * + * Accessible to any logged-in user (REQ-ONAV-002). + * + * @param string $lang Language code (defaults to `nl`). + * + * @return JSONResponse The filtered tree under `tree` plus the + * effective `language`. + * + * @spec openspec/specs/navigation-editor-org/spec.md + */ + #[NoAdminRequired] + public function getOrgNavigation(string $lang = OrgNavigationService::DEFAULT_LANGUAGE): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'admin-org-navigation.get-org-navigation'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + $language = $this->validateLanguage(language: $lang); + if ($language === null) { + return new JSONResponse( + data: ['error' => 'Unsupported language'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + $tree = $this->service->getTree(language: $language); + $filtered = $this->service->filterTreeByUserGroups( + tree: $tree, + userId: $user->getUID() + ); + + return ResponseHelper::success( + data: [ + 'tree' => $filtered, + 'language' => $language, + ] + ); + }//end getOrgNavigation() + + /** + * Replace the org-navigation tree for the given language. + * + * Admin-only (REQ-ONAV-003); validates and persists in one go. + * + * @param array|null $tree The full replacement tree. + * @param string $lang Language code (defaults to `nl`). + * + * @return JSONResponse The persisted tree (unchanged) on success. + * + * @spec openspec/specs/navigation-editor-org/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function updateOrgNavigation( + ?array $tree = null, + string $lang = OrgNavigationService::DEFAULT_LANGUAGE, + ): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + $language = $this->validateLanguage(language: $lang); + if ($language === null) { + return new JSONResponse( + data: ['error' => 'Unsupported language'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + if (is_array($tree) === false) { + return new JSONResponse( + data: ['error' => 'tree must be an array of node objects'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + try { + $this->service->setTree(tree: $tree, language: $language); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + return ResponseHelper::success( + data: [ + 'tree' => $tree, + 'language' => $language, + ] + ); + }//end updateOrgNavigation() + + /** + * Read the global rail-position setting (REQ-ONAV-004). + * + * @return JSONResponse The current effective position. + * + * @spec openspec/specs/navigation-editor-org/spec.md + */ + #[NoAdminRequired] + public function getPosition(): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'admin-org-navigation.get-position'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + return ResponseHelper::success( + data: ['position' => $this->readPosition()] + ); + }//end getPosition() + + /** + * Replace the global rail-position setting (REQ-ONAV-004). + * + * Admin-only. Accepts `{position: 'left'|'right'|'top'|'hidden'}`. + * + * @param string|null $position The desired position. + * + * @return JSONResponse The persisted position on success. + * + * @spec openspec/specs/navigation-editor-org/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function updatePosition(?string $position = null): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + if ($position === null + || in_array(needle: $position, haystack: self::ALLOWED_POSITIONS, strict: true) === false + ) { + return new JSONResponse( + data: ['error' => 'position must be one of: ' . implode(separator: ', ', array: self::ALLOWED_POSITIONS)], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + $this->settings->setSetting( + key: self::SETTING_KEY_POSITION, + value: $position + ); + + return ResponseHelper::success( + data: ['position' => $position] + ); + }//end updatePosition() + + /** + * Validate that a language code is one LaunchPad supports in v1. + * + * @param string $language The candidate language code. + * + * @return string|null The normalised language code, or `null` + * when the value is not supported. + */ + private function validateLanguage(string $language): ?string { + $lower = strtolower(string: trim(string: $language)); + if (in_array( + needle: $lower, + haystack: OrgNavigationService::SUPPORTED_LANGUAGES, + strict: true + ) === false + ) { + return null; + } + + return $lower; + }//end validateLanguage() + + /** + * Read the persisted position with a default fallback. + * + * @return string Always one of {@see self::ALLOWED_POSITIONS}. + */ + private function readPosition(): string { + $raw = $this->settings->getValue( + key: self::SETTING_KEY_POSITION, + default: self::DEFAULT_POSITION + ); + + if (is_string($raw) === false + || in_array(needle: $raw, haystack: self::ALLOWED_POSITIONS, strict: true) === false + ) { + return self::DEFAULT_POSITION; + } + + return $raw; + }//end readPosition() }//end class diff --git a/lib/Controller/AdminSettingsController.php b/lib/Controller/AdminSettingsController.php index c0d2c66a..6c807754 100644 --- a/lib/Controller/AdminSettingsController.php +++ b/lib/Controller/AdminSettingsController.php @@ -50,178 +50,174 @@ /** * Admin-only controller for the group-priority order setting. */ -class AdminSettingsController extends Controller -{ - /** - * Constructor. - * - * @param IRequest $request The HTTP request. - * @param AdminSettingsService $settingsService Persisted-settings service. - * @param IGroupManager $groupManager Group manager (admin check + listing). - * @param IUserSession $userSession Active session accessor. - */ - public function __construct( - IRequest $request, - private readonly AdminSettingsService $settingsService, - private readonly IGroupManager $groupManager, - private readonly IUserSession $userSession, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * Handle `GET /api/admin/groups` — REQ-ASET-013. - * - * Returns the disjoint exhaustive split `{active, inactive, allKnown}`: - * - `active` — the persisted `group_order` list, in admin-chosen - * order. Stale IDs (no longer in Nextcloud) remain so admin can - * see and remove them. - * - `inactive` — every Nextcloud group ID NOT in `active`, sorted - * by displayName (case-insensitive). - * - `allKnown` — full `{id, displayName}` list for the UI to render - * display names without a second round-trip. Stale IDs MUST NOT - * appear here (no display name available). - * - * @return JSONResponse Either the success payload or HTTP 403 when - * the caller is not an administrator. - * - * @spec openspec/specs/admin-settings/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function listGroups(): JSONResponse - { - $forbidden = $this->assertAdmin(); - if ($forbidden !== null) { - return $forbidden; - } - - $allKnown = []; - $allKnownIds = []; - foreach ($this->groupManager->search(search: '') as $group) { - $id = $group->getGID(); - $allKnownIds[] = $id; - $allKnown[] = [ - 'id' => $id, - 'displayName' => $group->getDisplayName(), - ]; - } - - $active = $this->settingsService->getGroupOrder(); - - // `inactive` = allKnown - active (stale active IDs MUST NOT - // appear in inactive — REQ-ASET-013 disjoint scenario). - $activeSet = array_flip(array: $active); - $inactive = []; - foreach ($allKnownIds as $id) { - if (array_key_exists(key: $id, array: $activeSet) === false) { - $inactive[] = $id; - } - } - - // Sort `inactive` by displayName (case-insensitive). Build a - // lookup so stable sort by name is cheap. - $displayNameById = []; - foreach ($allKnown as $row) { - $displayNameById[$row['id']] = $row['displayName']; - } - - usort( - array: $inactive, - callback: static function (string $aId, string $bId) use ($displayNameById): int { - $aName = strtolower(string: $displayNameById[$aId] ?? $aId); - $bName = strtolower(string: $displayNameById[$bId] ?? $bId); - return strcmp(string1: $aName, string2: $bName); - } - ); - - return ResponseHelper::success( - data: [ - 'active' => $active, - 'inactive' => $inactive, - 'allKnown' => $allKnown, - ] - ); - }//end listGroups() - - /** - * Handle `POST /api/admin/groups` — REQ-ASET-012, REQ-ASET-014. - * - * Body: `{"groups": ["id", ...]}`. Replaces the persisted - * `group_order` setting wholesale. Validation: - * - `groups` MUST be present and an array. - * - Every element MUST be a non-empty string. - * - Duplicate IDs are deduplicated (first occurrence kept). - * - Unknown (not currently in Nextcloud) IDs are tolerated — they - * remain in the persisted setting per REQ-ASET-014. - * - * @param mixed $groups The raw `groups` payload from the request body. - * - * @return JSONResponse HTTP 200 with `{status: 'ok'}` on success, - * 400 on validation failure, 403 for non-admins. - * - * @spec openspec/specs/admin-settings/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function updateGroupOrder(mixed $groups=null): JSONResponse - { - $forbidden = $this->assertAdmin(); - if ($forbidden !== null) { - return $forbidden; - } - - if (is_array($groups) === false) { - return new JSONResponse( - data: ['error' => 'groups must be an array'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - try { - $this->settingsService->setGroupOrder(groupIds: $groups); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - return ResponseHelper::success( - data: [ - 'status' => 'ok', - 'groupOrder' => $this->settingsService->getGroupOrder(), - ] - ); - }//end updateGroupOrder() - - /** - * Assert that the active session belongs to an administrator. - * - * Both endpoints are admin-only because the inactive list reveals - * every group on the system (REQ-ASET-014). The base controller - * routing already requires authentication; this guard only adds the - * admin check on top. - * - * @return JSONResponse|null `null` when the caller is an admin, or - * a 403 response that the calling action - * should return verbatim. - */ - private function assertAdmin(): ?JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse( - data: ['error' => 'Not authenticated'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } - - if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) { - return ResponseHelper::forbidden(); - } - - return null; - }//end assertAdmin() +class AdminSettingsController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The HTTP request. + * @param AdminSettingsService $settingsService Persisted-settings service. + * @param IGroupManager $groupManager Group manager (admin check + listing). + * @param IUserSession $userSession Active session accessor. + */ + public function __construct( + IRequest $request, + private readonly AdminSettingsService $settingsService, + private readonly IGroupManager $groupManager, + private readonly IUserSession $userSession, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * Handle `GET /api/admin/groups` — REQ-ASET-013. + * + * Returns the disjoint exhaustive split `{active, inactive, allKnown}`: + * - `active` — the persisted `group_order` list, in admin-chosen + * order. Stale IDs (no longer in Nextcloud) remain so admin can + * see and remove them. + * - `inactive` — every Nextcloud group ID NOT in `active`, sorted + * by displayName (case-insensitive). + * - `allKnown` — full `{id, displayName}` list for the UI to render + * display names without a second round-trip. Stale IDs MUST NOT + * appear here (no display name available). + * + * @return JSONResponse Either the success payload or HTTP 403 when + * the caller is not an administrator. + * + * @spec openspec/specs/admin-settings/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function listGroups(): JSONResponse { + $forbidden = $this->assertAdmin(); + if ($forbidden !== null) { + return $forbidden; + } + + $allKnown = []; + $allKnownIds = []; + foreach ($this->groupManager->search(search: '') as $group) { + $id = $group->getGID(); + $allKnownIds[] = $id; + $allKnown[] = [ + 'id' => $id, + 'displayName' => $group->getDisplayName(), + ]; + } + + $active = $this->settingsService->getGroupOrder(); + + // `inactive` = allKnown - active (stale active IDs MUST NOT + // appear in inactive — REQ-ASET-013 disjoint scenario). + $activeSet = array_flip(array: $active); + $inactive = []; + foreach ($allKnownIds as $id) { + if (array_key_exists(key: $id, array: $activeSet) === false) { + $inactive[] = $id; + } + } + + // Sort `inactive` by displayName (case-insensitive). Build a + // lookup so stable sort by name is cheap. + $displayNameById = []; + foreach ($allKnown as $row) { + $displayNameById[$row['id']] = $row['displayName']; + } + + usort( + array: $inactive, + callback: static function (string $aId, string $bId) use ($displayNameById): int { + $aName = strtolower(string: $displayNameById[$aId] ?? $aId); + $bName = strtolower(string: $displayNameById[$bId] ?? $bId); + return strcmp(string1: $aName, string2: $bName); + } + ); + + return ResponseHelper::success( + data: [ + 'active' => $active, + 'inactive' => $inactive, + 'allKnown' => $allKnown, + ] + ); + }//end listGroups() + + /** + * Handle `POST /api/admin/groups` — REQ-ASET-012, REQ-ASET-014. + * + * Body: `{"groups": ["id", ...]}`. Replaces the persisted + * `group_order` setting wholesale. Validation: + * - `groups` MUST be present and an array. + * - Every element MUST be a non-empty string. + * - Duplicate IDs are deduplicated (first occurrence kept). + * - Unknown (not currently in Nextcloud) IDs are tolerated — they + * remain in the persisted setting per REQ-ASET-014. + * + * @param mixed $groups The raw `groups` payload from the request body. + * + * @return JSONResponse HTTP 200 with `{status: 'ok'}` on success, + * 400 on validation failure, 403 for non-admins. + * + * @spec openspec/specs/admin-settings/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function updateGroupOrder(mixed $groups = null): JSONResponse { + $forbidden = $this->assertAdmin(); + if ($forbidden !== null) { + return $forbidden; + } + + if (is_array($groups) === false) { + return new JSONResponse( + data: ['error' => 'groups must be an array'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + try { + $this->settingsService->setGroupOrder(groupIds: $groups); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + return ResponseHelper::success( + data: [ + 'status' => 'ok', + 'groupOrder' => $this->settingsService->getGroupOrder(), + ] + ); + }//end updateGroupOrder() + + /** + * Assert that the active session belongs to an administrator. + * + * Both endpoints are admin-only because the inactive list reveals + * every group on the system (REQ-ASET-014). The base controller + * routing already requires authentication; this guard only adds the + * admin check on top. + * + * @return JSONResponse|null `null` when the caller is an admin, or + * a 403 response that the calling action + * should return verbatim. + */ + private function assertAdmin(): ?JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse( + data: ['error' => 'Not authenticated'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } + + if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) { + return ResponseHelper::forbidden(); + } + + return null; + }//end assertAdmin() }//end class diff --git a/lib/Controller/AdminWidgetRulesController.php b/lib/Controller/AdminWidgetRulesController.php index 43c78c6a..590644c6 100644 --- a/lib/Controller/AdminWidgetRulesController.php +++ b/lib/Controller/AdminWidgetRulesController.php @@ -35,44 +35,42 @@ * * @spec openspec/specs/conditional-visibility/spec.md */ -class AdminWidgetRulesController extends Controller -{ - /** - * Constructor - * - * @param IRequest $request The request. - * @param ConditionalService $conditionalService The conditional service. - */ - public function __construct( - IRequest $request, - private readonly ConditionalService $conditionalService, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() +class AdminWidgetRulesController extends Controller { + /** + * Constructor + * + * @param IRequest $request The request. + * @param ConditionalService $conditionalService The conditional service. + */ + public function __construct( + IRequest $request, + private readonly ConditionalService $conditionalService, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() - /** - * List every widget placement that carries at least one conditional rule. - * - * Admin-only — the overview discloses every user's dashboard names and - * widget types, so it is gated with `#[AuthorizedAdminSetting]` like the - * rest of the Beheer surface (ADR-005). - * - * @return JSONResponse The overview rows (placement + dashboard + counts). - * - * @spec openspec/specs/conditional-visibility/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function index(): JSONResponse - { - try { - return ResponseHelper::success( - data: $this->conditionalService->listAllRules() - ); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - } - }//end index() + /** + * List every widget placement that carries at least one conditional rule. + * + * Admin-only — the overview discloses every user's dashboard names and + * widget types, so it is gated with `#[AuthorizedAdminSetting]` like the + * rest of the Beheer surface (ADR-005). + * + * @return JSONResponse The overview rows (placement + dashboard + counts). + * + * @spec openspec/specs/conditional-visibility/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function index(): JSONResponse { + try { + return ResponseHelper::success( + data: $this->conditionalService->listAllRules() + ); + } catch (\Exception $e) { + return ResponseHelper::error(exception: $e); + } + }//end index() }//end class diff --git a/lib/Controller/AnalyticsController.php b/lib/Controller/AnalyticsController.php index d9e65702..edeb7eb0 100644 --- a/lib/Controller/AnalyticsController.php +++ b/lib/Controller/AnalyticsController.php @@ -55,214 +55,211 @@ * * @spec openspec/changes/archive/2026-05-02-dashboard-view-analytics/tasks.md */ -class AnalyticsController extends Controller -{ - /** - * Constructor. - * - * @param IRequest $request The HTTP request. - * @param AnalyticsService $analyticsService The analytics reporting service. - * @param ActionAuthService $actionAuth ADR-023 action authorization. - * @param IUserSession $userSession Current user session. - */ - public function __construct( - IRequest $request, - private readonly AnalyticsService $analyticsService, - private readonly ActionAuthService $actionAuth, - private readonly IUserSession $userSession, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() +class AnalyticsController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The HTTP request. + * @param AnalyticsService $analyticsService The analytics reporting service. + * @param ActionAuthService $actionAuth ADR-023 action authorization. + * @param IUserSession $userSession Current user session. + */ + public function __construct( + IRequest $request, + private readonly AnalyticsService $analyticsService, + private readonly ActionAuthService $actionAuth, + private readonly IUserSession $userSession, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() - /** - * Handle `GET /api/admin/analytics/dashboards/top` (REQ-ANLT-006). - * - * @param string $period The period string (`7d`, `30d`, `90d`). - * @param int $limit Maximum rows. - * - * @return JSONResponse The response. - * - * @spec openspec/specs/dashboard-view-analytics/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function topDashboards( - string $period='30d', - int $limit=10 - ): JSONResponse { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + /** + * Handle `GET /api/admin/analytics/dashboards/top` (REQ-ANLT-006). + * + * @param string $period The period string (`7d`, `30d`, `90d`). + * @param int $limit Maximum rows. + * + * @return JSONResponse The response. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function topDashboards( + string $period = '30d', + int $limit = 10, + ): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - try { - $this->actionAuth->requireAction($user, 'analytics.top-dashboards'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } + try { + $this->actionAuth->requireAction($user, 'analytics.top-dashboards'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } - try { - $rows = $this->analyticsService->getTopDashboards( - period: $period, - limit: $limit - ); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'invalid_period', - 'message' => $e->getMessage(), - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - } + try { + $rows = $this->analyticsService->getTopDashboards( + period: $period, + limit: $limit + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'invalid_period', + 'message' => $e->getMessage(), + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } - return ResponseHelper::success(data: $rows); - }//end topDashboards() + return ResponseHelper::success(data: $rows); + }//end topDashboards() - /** - * Handle `GET /api/admin/analytics/dashboards/{uuid}` - * (REQ-ANLT-007). - * - * @param string $uuid The dashboard UUID from the URL. - * @param string $period The period string. - * - * @return JSONResponse The response. - * - * @spec openspec/specs/dashboard-view-analytics/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function dashboardDetail( - string $uuid, - string $period='30d' - ): JSONResponse { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + /** + * Handle `GET /api/admin/analytics/dashboards/{uuid}` + * (REQ-ANLT-007). + * + * @param string $uuid The dashboard UUID from the URL. + * @param string $period The period string. + * + * @return JSONResponse The response. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function dashboardDetail( + string $uuid, + string $period = '30d', + ): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - try { - $this->actionAuth->requireAction($user, 'analytics.dashboard-detail'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } + try { + $this->actionAuth->requireAction($user, 'analytics.dashboard-detail'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } - try { - $rows = $this->analyticsService->getDashboardDetail( - dashboardUuid: $uuid, - period: $period - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'not_found', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'invalid_period', - 'message' => $e->getMessage(), - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - }//end try + try { + $rows = $this->analyticsService->getDashboardDetail( + dashboardUuid: $uuid, + period: $period + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'not_found', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'invalid_period', + 'message' => $e->getMessage(), + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + }//end try - return ResponseHelper::success(data: $rows); - }//end dashboardDetail() + return ResponseHelper::success(data: $rows); + }//end dashboardDetail() - /** - * Handle `GET /api/admin/analytics/summary` (REQ-ANLT-008). - * - * @param string $period The period string. - * - * @return JSONResponse The response. - * - * @spec openspec/specs/dashboard-view-analytics/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function instanceSummary(string $period='30d'): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + /** + * Handle `GET /api/admin/analytics/summary` (REQ-ANLT-008). + * + * @param string $period The period string. + * + * @return JSONResponse The response. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function instanceSummary(string $period = '30d'): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - try { - $this->actionAuth->requireAction($user, 'analytics.instance-summary'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } + try { + $this->actionAuth->requireAction($user, 'analytics.instance-summary'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } - try { - $summary = $this->analyticsService->getInstanceSummary( - period: $period - ); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'invalid_period', - 'message' => $e->getMessage(), - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - } + try { + $summary = $this->analyticsService->getInstanceSummary( + period: $period + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'invalid_period', + 'message' => $e->getMessage(), + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } - return ResponseHelper::success(data: $summary); - }//end instanceSummary() + return ResponseHelper::success(data: $summary); + }//end instanceSummary() - /** - * Handle `GET /api/admin/analytics/export` (REQ-ANLT-010). - * - * Returns a `text/csv` attachment with the filename - * `dashboard-analytics-YYYY-MM-DD.csv` (today's UTC date). - * - * @param string $period The period string. - * - * @return Response The CSV download response or a JSON error - * envelope. - * - * @spec openspec/specs/dashboard-view-analytics/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function exportCsv(string $period='30d'): Response - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + /** + * Handle `GET /api/admin/analytics/export` (REQ-ANLT-010). + * + * Returns a `text/csv` attachment with the filename + * `dashboard-analytics-YYYY-MM-DD.csv` (today's UTC date). + * + * @param string $period The period string. + * + * @return Response The CSV download response or a JSON error + * envelope. + * + * @spec openspec/specs/dashboard-view-analytics/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function exportCsv(string $period = '30d'): Response { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - try { - $this->actionAuth->requireAction($user, 'analytics.export-csv'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } + try { + $this->actionAuth->requireAction($user, 'analytics.export-csv'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } - try { - $csv = $this->analyticsService->generateCsvExport( - period: $period - ); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'invalid_period', - 'message' => $e->getMessage(), - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - } + try { + $csv = $this->analyticsService->generateCsvExport( + period: $period + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'invalid_period', + 'message' => $e->getMessage(), + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } - return new DataDownloadResponse( - data: $csv, - filename: $this->analyticsService->csvExportFilename(), - contentType: 'text/csv' - ); - }//end exportCsv() + return new DataDownloadResponse( + data: $csv, + filename: $this->analyticsService->csvExportFilename(), + contentType: 'text/csv' + ); + }//end exportCsv() }//end class diff --git a/lib/Controller/ConfluenceImportController.php b/lib/Controller/ConfluenceImportController.php index eaafd722..03da94eb 100644 --- a/lib/Controller/ConfluenceImportController.php +++ b/lib/Controller/ConfluenceImportController.php @@ -42,122 +42,118 @@ * `$_FILES` is the only multipart entry point under Nextcloud. * @spec openspec/specs/confluence-html-import/spec.md */ -class ConfluenceImportController extends Controller -{ - /** - * Constructor. - * - * @param IRequest $request Request handle. - * @param ConfluenceImportService $importService The import orchestrator. - * @param IUserSession $userSession Current session. - */ - public function __construct( - IRequest $request, - private readonly ConfluenceImportService $importService, - private readonly IUserSession $userSession, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() +class ConfluenceImportController extends Controller { + /** + * Constructor. + * + * @param IRequest $request Request handle. + * @param ConfluenceImportService $importService The import orchestrator. + * @param IUserSession $userSession Current session. + */ + public function __construct( + IRequest $request, + private readonly ConfluenceImportService $importService, + private readonly IUserSession $userSession, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() - /** - * `POST /api/admin/import/confluence/dry-run` — REQ-CFLI-007. - * - * @return JSONResponse The dry-run preview, or an error response. - * - * @spec openspec/specs/confluence-html-import/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function dryRun(): JSONResponse - { - $tmpName = $this->resolveUpload(); - if ($tmpName instanceof JSONResponse) { - return $tmpName; - } + /** + * `POST /api/admin/import/confluence/dry-run` — REQ-CFLI-007. + * + * @return JSONResponse The dry-run preview, or an error response. + * + * @spec openspec/specs/confluence-html-import/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function dryRun(): JSONResponse { + $tmpName = $this->resolveUpload(); + if ($tmpName instanceof JSONResponse) { + return $tmpName; + } - try { - $result = $this->importService->dryRun(zipPath: $tmpName); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); - } catch (Throwable $e) { - return new JSONResponse( - data: ['error' => 'Confluence dry-run failed: '.$e->getMessage()], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - } + try { + $result = $this->importService->dryRun(zipPath: $tmpName); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + } catch (Throwable $e) { + return new JSONResponse( + data: ['error' => 'Confluence dry-run failed: ' . $e->getMessage()], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + } - return new JSONResponse(data: $result); - }//end dryRun() + return new JSONResponse(data: $result); + }//end dryRun() - /** - * `POST /api/admin/import/confluence` — REQ-CFLI-001..006, 009, 012. - * - * @param string|null $parentUuid Optional parent dashboard UUID - * under which root pages will be slotted. - * - * @return JSONResponse The import summary, or an error response. - * - * @spec openspec/specs/confluence-html-import/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function import(?string $parentUuid=null): JSONResponse - { - $tmpName = $this->resolveUpload(); - if ($tmpName instanceof JSONResponse) { - return $tmpName; - } + /** + * `POST /api/admin/import/confluence` — REQ-CFLI-001..006, 009, 012. + * + * @param string|null $parentUuid Optional parent dashboard UUID + * under which root pages will be slotted. + * + * @return JSONResponse The import summary, or an error response. + * + * @spec openspec/specs/confluence-html-import/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function import(?string $parentUuid = null): JSONResponse { + $tmpName = $this->resolveUpload(); + if ($tmpName instanceof JSONResponse) { + return $tmpName; + } - $userId = (string) $this->userSession->getUser()?->getUID(); + $userId = (string)$this->userSession->getUser()?->getUID(); - $resolvedParent = null; - if ($parentUuid !== null && $parentUuid !== '') { - $resolvedParent = $parentUuid; - } + $resolvedParent = null; + if ($parentUuid !== null && $parentUuid !== '') { + $resolvedParent = $parentUuid; + } - try { - $result = $this->importService->import( - zipPath: $tmpName, - currentUserId: $userId, - parentUuid: $resolvedParent - ); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); - } catch (Throwable $e) { - return new JSONResponse( - data: ['error' => 'Confluence import failed: '.$e->getMessage()], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - } + try { + $result = $this->importService->import( + zipPath: $tmpName, + currentUserId: $userId, + parentUuid: $resolvedParent + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + } catch (Throwable $e) { + return new JSONResponse( + data: ['error' => 'Confluence import failed: ' . $e->getMessage()], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + } - return new JSONResponse(data: $result); - }//end import() + return new JSONResponse(data: $result); + }//end import() - /** - * Locate and validate the uploaded ZIP, returning its tmp path. - * - * @return string|JSONResponse Either the tmp path or a 400 response. - */ - private function resolveUpload(): string|JSONResponse - { - $upload = $_FILES['file'] ?? null; - if (is_array($upload) === false - || isset($upload['tmp_name']) === false - || (string) $upload['tmp_name'] === '' - ) { - return new JSONResponse( - data: ['error' => 'No file uploaded under field "file".'], - statusCode: Http::STATUS_BAD_REQUEST - ); - } + /** + * Locate and validate the uploaded ZIP, returning its tmp path. + * + * @return string|JSONResponse Either the tmp path or a 400 response. + */ + private function resolveUpload(): string|JSONResponse { + $upload = $_FILES['file'] ?? null; + if (is_array($upload) === false + || isset($upload['tmp_name']) === false + || (string)$upload['tmp_name'] === '' + ) { + return new JSONResponse( + data: ['error' => 'No file uploaded under field "file".'], + statusCode: Http::STATUS_BAD_REQUEST + ); + } - return (string) $upload['tmp_name']; - }//end resolveUpload() + return (string)$upload['tmp_name']; + }//end resolveUpload() }//end class diff --git a/lib/Controller/DashboardApiController.php b/lib/Controller/DashboardApiController.php index a8cefc1d..fc71006e 100644 --- a/lib/Controller/DashboardApiController.php +++ b/lib/Controller/DashboardApiController.php @@ -80,1877 +80,1856 @@ * surface. * @spec openspec/specs/dashboards/spec.md */ -class DashboardApiController extends Controller -{ - /** - * Constructor - * - * @param IRequest $request The request. - * @param DashboardService $dashboardService The dashboard service. - * @param PermissionService $permissionService The permission service. - * @param DashboardTreeService $treeService The tree service that - * owns hierarchy - * queries, cycle - * detection, slug - * uniqueness, path - * resolution, and the - * cascade-delete walker - * (REQ-DASH-023..030). - * @param DashboardVersionService $versionService Snapshot service - * (REQ-VERS-001) — - * automatic - * snapshots fire - * after every - * successful PUT - * via the - * debounced - * `captureSnapshot` - * helper. - * @param AnalyticsService $analyticsService The view-analytics - * service used by the - * `viewEvent` endpoint - * (REQ-ANLT-002). - * @param LoggerInterface $logger PSR logger (used by - * fork to report - * unexpected errors - * — REQ-DASH-021). - * @param IUserSession $userSession The user session, used - * to resolve the - * authenticated IUser for - * ADR-023 action checks. - * @param ActionAuthService $actionAuth The ADR-023 action - * authorization service. - * @param string|null $userId The user ID. - * @param QuotaService|null $quotaService The quota-enforcement - * service used to gate - * dashboard creation - * (dashboard-quota-limits). - */ - public function __construct( - IRequest $request, - private readonly DashboardService $dashboardService, - private readonly PermissionService $permissionService, - private readonly DashboardTreeService $treeService, - private readonly DashboardVersionService $versionService, - private readonly AnalyticsService $analyticsService, - private readonly LoggerInterface $logger, - private readonly IUserSession $userSession, - private readonly ActionAuthService $actionAuth, - private readonly ?string $userId, - private readonly ?QuotaService $quotaService=null, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * List all personal dashboards for the current user. - * - * Backward compatible — this endpoint never returns group-shared - * dashboards (REQ-DASH-014). Use {@see self::visible()} for the - * unioned listing. - * - * @return JSONResponse The list of dashboards. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-17 - */ - #[NoAdminRequired] - public function list(): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.list'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - $dashboards = $this->dashboardService->getUserDashboards( - userId: $this->userId - ); - - $serialized = ResponseHelper::serializeList(entities: $dashboards); - - // Dashboard-quota-limits REQ-QUOTA-006: additive quota envelope on - // the personal dashboards list. Response shape is - // `{items: [...], quota: {...}}`. When the quota service is absent - // (legacy test doubles) fall back to the bare-array contract. - if ($this->quotaService === null) { - return ResponseHelper::success(data: $serialized); - } - - return ResponseHelper::success( - data: [ - 'items' => $serialized, - 'quota' => $this->quotaService->getQuotaStatus( - userId: $this->userId - ), - ] - ); - }//end list() - - /** - * List the deduplicated union of dashboards visible to the user. - * - * Returns personal + group-matching + default-group dashboards, each - * tagged with `source` (`'user'`, `'group'`, `'default'`). - * REQ-DASH-013. - * - * @return JSONResponse The visible dashboards. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[NoAdminRequired] - public function visible(): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.visible'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - $items = $this->dashboardService->getVisibleToUser( - userId: $this->userId - ); - - $serialized = []; - foreach ($items as $entry) { - $row = $entry['dashboard']->jsonSerialize(); - $row['source'] = $entry['source']; - // Tag ownership so the frontend can route activation correctly: - // only personal `user`-type rows owned by the caller take the - // legacy id-based `is_active` path; group/default rows (user_id - // NULL) are activated via the UUID preference instead. - $row['isOwner'] = ($entry['dashboard']->getUserId() === $this->userId); - $serialized[] = $row; - } - - // Dashboard-quota-limits REQ-QUOTA-006: carry the additive quota - // envelope on the unioned listing the store consumes, so the - // frontend can disable create affordances at the limit without an - // extra round-trip. The response shape is now - // `{items: [...], quota: {...}}`; clients that read the bare array - // are handled by the store's shape-tolerant unwrap. When the quota - // service is absent (legacy test doubles) fall back to the - // bare-array contract. - if ($this->quotaService === null) { - return ResponseHelper::success(data: $serialized); - } - - return ResponseHelper::success( - data: [ - 'items' => $serialized, - 'quota' => $this->quotaService->getQuotaStatus( - userId: $this->userId - ), - ] - ); - }//end visible() - - /** - * Get the user's active dashboard with placements. - * - * @return JSONResponse The active dashboard data. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-18 - */ - #[NoAdminRequired] - public function getActive(): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.get-active'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - $result = $this->dashboardService->getEffectiveDashboard( - userId: $this->userId - ); - - if ($result === null) { - return ResponseHelper::success( - data: ['error' => 'No dashboard available'], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - // The effective dashboard can now be a group/default (showcase) - // dashboard the user does not own (resolved via the last-used - // preference), so tag ownership the same way show() does rather - // than letting the client assume the caller owns it. - $activeDashboard = $result['dashboard']; - $isOwner = ($activeDashboard->getUserId() === $this->userId); - - $sharedBy = null; - if ($isOwner === false) { - $sharedBy = $activeDashboard->getUserId(); - } - - return ResponseHelper::success( - data: [ - 'dashboard' => $activeDashboard->jsonSerialize(), - 'placements' => ResponseHelper::serializeList( - entities: $result['placements'] - ), - 'permissionLevel' => $result['permissionLevel'], - 'isOwner' => $isOwner, - 'sharedBy' => $sharedBy, - ] - ); - }//end getActive() - - /** - * Get a single dashboard by id with its placements + permission level. - * - * Powers the front-end's `switchDashboard` flow: clicking a row in the - * sidebar issues `GET /api/dashboard/{id}` and the response is the - * same envelope shape as {@see self::getActive()}, so the store can - * write `activeDashboard`, `widgetPlacements`, and `permissionLevel` - * with no per-source branching. - * - * Returns 404 (not 403) when the dashboard exists but is not visible - * to the caller — this matches the `getVisibleToUser` policy and - * intentionally does not leak existence (REQ-DASH-020 scenario - * "Cannot see what you cannot read"). - * - * @param int $id The dashboard ID. - * - * @return JSONResponse The dashboard envelope (200) or - * `{'error': 'Not found'}` (404). - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-21 - */ - #[NoAdminRequired] - public function show(int $id): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.show'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - $result = $this->dashboardService->getDashboardForUser( - dashboardId: $id, - userId: $this->userId - ); - - if ($result === null) { - return ResponseHelper::success( - data: ['error' => 'Not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - $dashboard = $result['dashboard']; - $isOwner = ($dashboard->getUserId() === $this->userId); - $sharedBy = null; - if ($isOwner === false) { - $sharedBy = $dashboard->getUserId(); - } - - return ResponseHelper::success( - data: [ - 'dashboard' => $dashboard->jsonSerialize(), - 'placements' => ResponseHelper::serializeList( - entities: $result['placements'] - ), - 'permissionLevel' => $result['permissionLevel'], - 'isOwner' => $isOwner, - 'sharedBy' => $sharedBy, - ] - ); - }//end show() - - /** - * Create a new dashboard. - * - * @param mixed $name The dashboard name. - * @param string|null $description The description. - * @param string|null $icon The icon registry key (or NULL/empty to use the default). - * @param string|null $parentUuid Optional parent dashboard UUID - * (REQ-DASH-023). NULL ⇒ root. - * @param string|null $slug Optional caller-supplied slug - * (REQ-DASH-024). NULL ⇒ derive from - * the name. - * @param int|null $sortOrder Optional sibling sort order - * (REQ-DASH-029). NULL ⇒ 0. - * - * @return JSONResponse The created dashboard. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-16 - */ - #[NoAdminRequired] - public function create( - $name=null, - ?string $description=null, - ?string $icon=null, - ?string $parentUuid=null, - ?string $slug=null, - ?int $sortOrder=null - ): JSONResponse { - $denial = $this->denyCreate(); - if ($denial !== null) { - return $denial; - } - - $resolved = $this->resolveCreateParams( - name: $name, - description: $description, - icon: $icon, - parentUuid: $parentUuid, - slug: $slug, - sortOrder: $sortOrder - ); - - $permError = $this->checkCreatePermissions( - userId: $this->userId - ); - if ($permError !== null) { - return $permError; - } - - try { - $dashboard = $this->dashboardService->createDashboard( - userId: $this->userId, - name: $resolved['name'], - description: $resolved['description'], - icon: $resolved['icon'], - parentUuid: $resolved['parentUuid'], - slug: $resolved['slug'], - sortOrder: $resolved['sortOrder'], - seedDefaults: true - ); - - // The newly-created dashboard ships with a default widget - // bundle (Conduction + Sendent + Nextcloud tiles + a Files - // widget) seeded by the service. Returning the placements - // here matches the `getActive()` envelope so the store can - // populate `widgetPlacements` without an extra round-trip. - $placements = $this->dashboardService->findPlacements( - dashboardId: $dashboard->getId() - ); - - return ResponseHelper::success( - data: [ - 'dashboard' => $dashboard->jsonSerialize(), - 'placements' => ResponseHelper::serializeList( - entities: $placements - ), - ], - statusCode: Http::STATUS_CREATED - ); - } catch (QuotaExceededException $e) { - // Dashboard-quota-limits REQ-QUOTA-002: the user is at their - // dashboard limit — HTTP 409 with the structured body. - return ResponseHelper::quotaExceeded(exception: $e); - } catch (InvalidArgumentException $e) { - // REQ-DASH-023..029: parent / slug / depth / cycle violations - // surface as HTTP 400 with the validation message verbatim. - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'invalid_argument', - 'message' => $e->getMessage(), - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - }//end try - }//end create() - - /** - * Resolve the authentication / authorisation guard chain for - * {@see self::create()}. - * - * Order is load-bearing. REQ-ASET-003 (extended): the admin gating - * runs BEFORE any request-body handling so the response envelope is - * the stable `personal_dashboards_disabled` shape no matter what the - * body looked like. - * - * @return JSONResponse|NULL The refusal, or NULL to proceed. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-16 - */ - private function denyCreate(): ?JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - // L3: wire the create action so the matrix entry is enforced — - // consistent with all other mutation endpoints (ADR-023). - $this->actionAuth->requireAction($user, 'dashboard.create'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - try { - $this->dashboardService->assertPersonalDashboardsAllowed(); - } catch (PersonalDashboardsDisabledException $e) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => $e->getErrorCode(), - 'message' => $e->getMessage(), - ], - statusCode: Http::STATUS_FORBIDDEN - ); - } - - return null; - }//end denyCreate() - - /** - * Update a dashboard. - * - * @param int $id The dashboard ID. - * @param string|null $name The name. - * @param string|null $description The description. - * @param array|null $placements The placements. - * @param string|null $icon The icon registry key, URL, or NULL to leave unchanged. - * @param string|null $parentUuid Optional new parent UUID (REQ-DASH-023); - * explicit empty string clears the - * parent (re-roots the dashboard). - * @param string|null $slug Optional new slug (REQ-DASH-024). - * @param int|null $sortOrder Optional new sort order (REQ-DASH-029). - * - * @return JSONResponse The updated dashboard. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-19 - */ - #[NoAdminRequired] - public function update( - int $id, - ?string $name=null, - ?string $description=null, - ?array $placements=null, - ?string $icon=null, - ?string $parentUuid=null, - ?string $slug=null, - ?int $sortOrder=null - ): JSONResponse { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.update'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - // REQ-PERM-007: Metadata-only updates (name, description, icon) are - // allowed for all permission levels. Widget/tile/layout changes - // require add_only or full permission. - $isMetadataOnly = $placements === null; - if ($isMetadataOnly === true - && $this->permissionService->canEditDashboardMetadata( - userId: $this->userId, - dashboardId: $id - ) === false - ) { - return ResponseHelper::forbidden(); - } - - if ($isMetadataOnly === false - && $this->permissionService->canEditDashboard( - userId: $this->userId, - dashboardId: $id - ) === false - ) { - return ResponseHelper::forbidden(); - } - - try { - $data = $this->buildUpdateData( - name: $name, - description: $description, - placements: $placements, - icon: $icon, - parentUuid: $parentUuid, - slug: $slug, - sortOrder: $sortOrder - ); - - $dashboard = $this->dashboardService->updateDashboard( - dashboardId: $id, - userId: $this->userId, - data: $data - ); - - // REQ-VERS-001: capture an automatic snapshot after the - // PUT succeeds. The version service enforces its own - // debounce window (60 s) so rapid drag-and-drop edits do - // not flood the table. Failures are swallowed so they do - // not surface to the dashboard PUT response. - $this->captureAutomaticSnapshot(dashboard: $dashboard); - - return ResponseHelper::success( - data: ['dashboard' => $dashboard->jsonSerialize()] - ); - } catch (InvalidArgumentException $e) { - // REQ-DASH-023..029: parent / slug / depth / cycle violations - // surface as HTTP 400 with the validation message verbatim. - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'invalid_argument', - 'message' => $e->getMessage(), - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - }//end try - }//end update() - - /** - * Delete a dashboard. - * - * Honours the cascade-delete guard from REQ-DASH-030: when the - * dashboard has children the request MUST include `?cascade=true` - * (case-insensitive) — otherwise the response is HTTP 409 with the - * child count so the UI can surface a confirmation. - * - * @param int $id The dashboard ID. - * - * @return JSONResponse The deletion confirmation. - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-20 - */ - #[NoAdminRequired] - public function delete(int $id): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.delete'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - $cascade = $this->resolveCascadeFlag(); - - try { - $this->dashboardService->deleteDashboard( - dashboardId: $id, - userId: $this->userId, - cascade: $cascade - ); - - return ResponseHelper::success(data: ['status' => 'ok']); - } catch (DashboardHasChildrenException $e) { - // REQ-DASH-030: stable 409 envelope with the child count so - // the frontend can render "Delete N children?" before - // retrying with cascade=true. - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => DashboardHasChildrenException::ERROR_CODE, - 'message' => $e->getMessage(), - 'childCount' => $e->getChildCount(), - ], - statusCode: Http::STATUS_CONFLICT - ); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - }//end try - }//end delete() - - /** - * GET /api/dashboards/tree — return the nested dashboard tree scoped - * to the calling user's visible dashboards (REQ-DASH-026). - * - * Each node carries `{uuid, name, slug, sortOrder, children: [...]}`. - * Only nodes for dashboards that `DashboardService::getVisibleToUser` - * resolves for the caller are included — personal drafts owned by - * other users are not enumerable (C1 fix: REQ-DASH-026 + REQ-PERM-001). - * - * @return JSONResponse The nested tree. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[NoAdminRequired] - public function tree(): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.tree'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - // C1 fix: build the visibility set for the calling user, then ask - // the tree service for the structural tree filtered to those UUIDs. - // This prevents cross-user IDOR via UUID enumeration through the tree. - $visible = $this->dashboardService->getVisibleToUser( - userId: $this->userId - ); - $visibleUuids = []; - foreach ($visible as $entry) { - $uuid = $entry['dashboard']->getUuid(); - if ($uuid !== null && $uuid !== '') { - $visibleUuids[$uuid] = true; - } - } - - $tree = $this->treeService->getFilteredTree( - visibleUuids: $visibleUuids - ); - - return ResponseHelper::success(data: $tree); - }//end tree() - - /** - * GET /api/dashboards/by-path/{path} — resolve a slug-chain path - * (REQ-DASH-027). - * - * Returns the matching dashboard with its computed `path` and - * `breadcrumbs` (REQ-DASH-025) attached. Responds with 404 (not 403) - * on any miss — including visibility misses — to avoid confirming that - * a given slug exists to an unauthorised caller. - * - * C2 fix (REQ-DASH-027 + REQ-PERM-001): after slug resolution the - * resolved dashboard is checked via PermissionService; callers with no - * view access receive the same 404 they would get for an unknown slug. - * - * @param string $path The slug-joined path captured from the URL - * (the `{path}` placeholder is regex-allowed - * to include slashes — see `appinfo/routes.php`). - * - * @return JSONResponse The dashboard payload, or a 404 envelope. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[NoAdminRequired] - public function byPath(string $path=''): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.by-path'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - if ($path === '') { - $path = (string) $this->request->getParam(key: 'path', default: ''); - } - - $dashboard = $this->treeService->resolvePath(path: $path); - if ($dashboard === null) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'not_found', - 'message' => 'Dashboard not found at path', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - // C2 fix: verify the caller can see this dashboard. Return 404 - // (not 403) to avoid leaking that the slug exists at all. - $dashboardId = (int) $dashboard->getId(); - if ($this->permissionService->canViewDashboard( - userId: $this->userId, - dashboardId: $dashboardId - ) === false - ) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'not_found', - 'message' => 'Dashboard not found at path', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - $uuid = (string) $dashboard->getUuid(); - $serialised = $dashboard->jsonSerialize(); - $serialised['path'] = $this->treeService->computePath(uuid: $uuid); - $serialised['breadcrumbs'] = $this->treeService->computeBreadcrumbs( - uuid: $uuid - ); - - return ResponseHelper::success( - data: ['dashboard' => $serialised] - ); - }//end byPath() - - /** - * GET /api/dashboards/{uuid}/path — return a dashboard's canonical - * slug-chain path. - * - * Used by the frontend after every sidebar switch to keep the - * browser URL in sync with the active dashboard. The path is the - * leading-slash slug-chain returned by - * {@see DashboardTreeService::computePath()}; an empty string means - * the UUID does not resolve OR the dashboard has no slug (legal — - * NULL slugs are simply unaddressable by path), and the frontend - * treats either case as "leave the URL alone". - * - * @param string $uuid Dashboard UUID captured from the URL. - * - * @return JSONResponse `{path: string}` envelope (always 200 when - * authorised — the empty-path case is a valid - * response shape the caller distinguishes - * client-side). - * - * @spec openspec/specs/dashboards/spec.md - */ - #[NoAdminRequired] - public function computePath(string $uuid=''): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.compute-path'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - if ($uuid === '') { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'missing_uuid', - 'message' => 'UUID is required', - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - return ResponseHelper::success( - data: ['path' => $this->treeService->computePath(uuid: $uuid)] - ); - }//end computePath() - - /** - * Activate a dashboard. - * - * @param int $id The dashboard ID. - * - * @return JSONResponse The activated dashboard. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[NoAdminRequired] - public function activate(int $id): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.activate'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - try { - $dashboard = $this->dashboardService->activateDashboard( - dashboardId: $id, - userId: $this->userId - ); - - return ResponseHelper::success( - data: ['dashboard' => $dashboard->jsonSerialize()] - ); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - } - }//end activate() - - /** - * List the group-shared dashboards in a single group. - * - * Any logged-in user may list. REQ-DASH-014. - * - * @param string $groupId The group ID. - * - * @return JSONResponse The list of group-shared dashboards. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[NoAdminRequired] - public function listGroup(string $groupId): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.list-group'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - // H1: verify the caller is a member of the requested group (or - // admin) before returning its dashboards — mirrors the group- - // membership check in PermissionService::resolveAccessLevel. - if ($this->dashboardService->userCanAccessGroup( - userId: $this->userId, - groupId: $groupId - ) === false - ) { - return ResponseHelper::forbidden(); - } - - $dashboards = $this->dashboardService->listGroupDashboards( - groupId: $groupId - ); - - // M5: strip internal identity fields (userId, groupId, targetGroups) - // from group-shared dashboard payloads returned to non-owner viewers. - $viewerData = array_map( - static fn ($dashboard) => $dashboard->toViewerArray(), - $dashboards - ); - - return ResponseHelper::success(data: $viewerData); - }//end listGroup() - - /** - * Create a new group-shared dashboard. - * - * Admin-only — enforced by the `#[AuthorizedAdminSetting]` attribute - * (gate-route-auth / gate-semantic-auth both pass since the - * framework-level check is the actual authorization point). - * REQ-DASH-014. - * - * @param string $groupId The group ID. - * @param mixed $name The dashboard name (or {name,...} - * dict as the body). - * @param string|null $description The dashboard description. - * - * @return JSONResponse The created dashboard. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function createGroup( - string $groupId, - $name=null, - ?string $description=null - ): JSONResponse { - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - $resolved = $this->resolveCreateParams( - name: $name, - description: $description - ); - - try { - $dashboard = $this->dashboardService->createGroupShared( - actorUserId: $this->userId, - groupId: $groupId, - name: $resolved['name'], - description: $resolved['description'] - ); - - return ResponseHelper::success( - data: ['dashboard' => $dashboard->jsonSerialize()], - statusCode: Http::STATUS_CREATED - ); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - } - }//end createGroup() - - /** - * Get a single group-shared dashboard with placements. - * - * @param string $groupId The group ID from the URL. - * @param string $uuid The dashboard UUID from the URL. - * - * @return JSONResponse The dashboard payload. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[NoAdminRequired] - public function getGroup( - string $groupId, - string $uuid - ): JSONResponse { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.get-group'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - // H1: verify the caller is a member of the requested group (or - // admin) before fetching the dashboard payload. - if ($this->dashboardService->userCanAccessGroup( - userId: $this->userId, - groupId: $groupId - ) === false - ) { - return ResponseHelper::forbidden(); - } - - try { - $dashboard = $this->dashboardService->findGroupDashboard( - groupId: $groupId, - uuid: $uuid - ); - } catch (DoesNotExistException) { - // ADR-005: do not leak raw exception messages to clients. - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - // M5: strip internal identity fields from viewer-facing payload. - return ResponseHelper::success( - data: ['dashboard' => $dashboard->toViewerArray()] - ); - }//end getGroup() - - /** - * Update a group-shared dashboard. Admin-only. - * - * @param string $groupId The group ID from the URL. - * @param string $uuid The dashboard UUID from the URL. - * @param string|null $name The new name. - * @param string|null $description The new description. - * @param int|null $gridColumns The new grid column count. - * @param array|null $placements Updated placements. - * - * @return JSONResponse The updated dashboard. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function updateGroup( - string $groupId, - string $uuid, - ?string $name=null, - ?string $description=null, - ?int $gridColumns=null, - ?array $placements=null - ): JSONResponse { - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - $patch = $this->buildGroupUpdateData( - name: $name, - description: $description, - gridColumns: $gridColumns, - placements: $placements - ); - - try { - $dashboard = $this->dashboardService->updateGroupShared( - actorUserId: $this->userId, - groupId: $groupId, - uuid: $uuid, - patch: $patch - ); - - return ResponseHelper::success( - data: ['dashboard' => $dashboard->jsonSerialize()] - ); - } catch (DoesNotExistException) { - // ADR-005: do not leak raw exception messages to clients. - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - }//end try - }//end updateGroup() - - /** - * Delete a group-shared dashboard. Admin-only. - * - * Returns HTTP 400 when the last-in-group guard rejects the delete - * (REQ-DASH-014). - * - * @param string $groupId The group ID from the URL. - * @param string $uuid The dashboard UUID from the URL. - * - * @return JSONResponse The status payload. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function deleteGroup( - string $groupId, - string $uuid - ): JSONResponse { - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - try { - $this->dashboardService->deleteGroupShared( - actorUserId: $this->userId, - groupId: $groupId, - uuid: $uuid - ); - - return ResponseHelper::success(data: ['status' => 'ok']); - } catch (DoesNotExistException) { - // ADR-005: do not leak raw exception messages to clients. - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - }//end try - }//end deleteGroup() - - /** - * Promote a single group-shared dashboard to the group's default. - * - * Admin-only — enforced by the `#[AuthorizedAdminSetting]` attribute. - * The body payload is `{"uuid": "..."}`. Returns 404 when the uuid - * does not belong to the given groupId. REQ-DASH-015. - * - * @param string $groupId The group ID from the URL. - * @param string|null $uuid The dashboard UUID from the body. - * - * @return JSONResponse The status payload. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function setGroupDefault( - string $groupId, - ?string $uuid=null - ): JSONResponse { - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - if ($uuid === null || $uuid === '') { - return ResponseHelper::error( - exception: new InvalidArgumentException( - 'Missing required field: uuid' - ), - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - try { - $this->dashboardService->setGroupDefault( - actorUserId: $this->userId, - groupId: $groupId, - uuid: $uuid - ); - - return ResponseHelper::success( - data: [ - 'status' => 'ok', - 'groupId' => $groupId, - 'uuid' => $uuid, - ] - ); - } catch (DoesNotExistException) { - // ADR-005: do not leak raw exception messages to clients. - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (\Exception $e) { - return ResponseHelper::error(exception: $e); - }//end try - }//end setGroupDefault() - - /** - * Persist the user's active-dashboard preference. - * - * Accepts any UUID string (including non-existent UUIDs — the resolver's - * stale-pref path handles invalid values on next render). Empty string - * clears the preference. REQ-DASH-019. - * - * @param string|null $uuid The dashboard UUID from the request body, or - * empty string to clear. - * - * @return JSONResponse HTTP 200 `{status: 'success'}` on success; 401 - * when the session has no user. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[NoAdminRequired] - public function setActiveDashboard(?string $uuid=null): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.set-active-dashboard'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - $this->dashboardService->setActivePreference( - userId: $this->userId, - uuid: ($uuid ?? '') - ); - - return ResponseHelper::success(data: ['status' => 'success']); - }//end setActiveDashboard() - - /** - * Pin (or clear) the user's EXPLICIT default-dashboard choice - * (wave3.7). - * - * Distinct from {@see self::setActiveDashboard()} — this pref is - * only ever written when the user explicitly clicks "Set as - * default" on a row's cog menu, and is NOT auto-overwritten on - * every switch. The resolver checks it before the active pref so - * the pin survives across switches. - * - * Body shape: `{uuid: string}` — empty string clears the pin. - * - * @param string|null $uuid The dashboard UUID, or empty string to clear. - * - * @return JSONResponse 200 `{status: 'success'}` on success; 401 - * when the session has no user. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[NoAdminRequired] - public function setDefaultDashboard(?string $uuid=null): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.set-default-dashboard'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - $this->dashboardService->setDefaultPreference( - userId: $this->userId, - uuid: ($uuid ?? '') - ); - - return ResponseHelper::success(data: ['status' => 'success']); - }//end setDefaultDashboard() - - /** - * Read the user's EXPLICIT default-dashboard pin (wave3.7). - * - * @return JSONResponse 200 `{uuid: string}` — empty string when no - * pin set; 401 when the session has no user. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[NoAdminRequired] - public function getDefaultDashboard(): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.get-default-dashboard'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - return ResponseHelper::success( - data: [ - 'uuid' => $this->dashboardService->getDefaultPreference( - userId: $this->userId - ), - ] - ); - }//end getDefaultDashboard() - - /** - * Fork any visible dashboard into a brand-new personal copy. - * - * REQ-DASH-020 / REQ-DASH-021 / REQ-DASH-022. Body shape: - * `{name?: string}` — when `name` is absent the system applies the - * default `t('My copy of {name}', source.name)` translated via the - * caller's active language. - * - * Status mapping: - * - HTTP 201 with the full new dashboard payload on success. - * - HTTP 401 when the session has no user. - * - HTTP 403 with stable error code `personal_dashboards_disabled` - * when the admin flag `allow_user_dashboards` is off — REQ-ASET-003 - * runtime gating runs FIRST so the envelope shape is stable - * regardless of body contents. - * - HTTP 404 when the source UUID is not visible to the caller — - * do not leak existence (REQ-DASH-020 scenario "Cannot fork a - * dashboard you cannot read"). - * - HTTP 500 when a partial-failure rollback fires — REQ-DASH-021. - * ADR-005: the response carries a stable error code and a generic - * user-facing message; the underlying exception is logged for ops. - * - * @param string $uuid The source dashboard UUID from the URL. - * @param string|null $name Optional explicit fork name from the body. - * - * @return JSONResponse The new dashboard payload (201) or an - * appropriate error envelope. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[NoAdminRequired] - public function fork( - string $uuid, - ?string $name=null - ): JSONResponse { - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - try { - $fork = $this->dashboardService->forkAsPersonal( - userId: $this->userId, - sourceUuid: $uuid, - name: $name - ); - - return new JSONResponse( - data: [ - 'status' => 'success', - 'dashboard' => $fork->jsonSerialize(), - ], - statusCode: Http::STATUS_CREATED - ); - } catch (PersonalDashboardsDisabledException $e) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => $e->getErrorCode(), - 'message' => $e->getMessage(), - ], - statusCode: Http::STATUS_FORBIDDEN - ); - } catch (QuotaExceededException $e) { - // Dashboard-quota-limits REQ-QUOTA-002: a fork is bound by the - // per-user dashboard quota — HTTP 409 with the structured body. - return ResponseHelper::quotaExceeded(exception: $e); - } catch (DoesNotExistException) { - // REQ-DASH-020: source not visible — 404 without leaking - // existence (use the canonical message rather than echoing - // the exception detail). - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'not_found', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (\Throwable $t) { - // REQ-DASH-021 + ADR-005: log the real cause, return a - // stable, generic envelope to the client. - $this->logger->error( - message: 'launchpad: fork failed for user {user}: {message}', - context: [ - 'user' => $this->userId, - 'message' => $t->getMessage(), - ] - ); - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'internal_error', - 'message' => 'An unexpected error occurred', - ], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - }//end try - }//end fork() - - /** - * Publish a dashboard. REQ-DASH-032. - * - * Owner-or-admin gated at the service boundary; the route attribute - * is `#[NoAdminRequired]` because the in-body owner check is the - * actual authorization point (gate-semantic-auth). - * - * @param string $uuid The dashboard UUID from the URL. - * - * @return JSONResponse The updated dashboard payload. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[NoAdminRequired] - public function publish(string $uuid): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.publish'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - try { - $dashboard = $this->dashboardService->publishDashboard( - uuid: $uuid, - userId: $this->userId - ); - - return ResponseHelper::success( - data: ['dashboard' => $dashboard->jsonSerialize()] - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'not_found', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (\Exception $e) { - return $this->mapPublicationError(exception: $e); - }//end try - }//end publish() - - /** - * Unpublish a dashboard. REQ-DASH-033. - * - * @param string $uuid The dashboard UUID from the URL. - * - * @return JSONResponse The updated dashboard payload. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[NoAdminRequired] - public function unpublish(string $uuid): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.unpublish'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - try { - $dashboard = $this->dashboardService->unpublish( - uuid: $uuid, - userId: $this->userId - ); - - return ResponseHelper::success( - data: ['dashboard' => $dashboard->jsonSerialize()] - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'not_found', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (\Exception $e) { - return $this->mapPublicationError(exception: $e); - }//end try - }//end unpublish() - - /** - * Schedule a dashboard for automatic publication. REQ-DASH-034. - * - * Body: `{"publishAt": "2026-04-01T10:00:00Z"}`. Returns 400 with an - * i18n-friendly error message when `publishAt` is missing, - * unparseable, or in the past; 403 when the actor is neither owner - * nor admin. - * - * @param string $uuid The dashboard UUID from the URL. - * @param string|null $publishAt The future ISO-8601 timestamp from - * the request body. - * - * @return JSONResponse The updated dashboard payload. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[NoAdminRequired] - public function schedule( - string $uuid, - ?string $publishAt=null - ): JSONResponse { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.schedule'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - if ($publishAt === null || $publishAt === '') { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'invalid_argument', - 'message' => DashboardService::ERR_SCHEDULE_PAST_DATE, - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - try { - $dashboard = $this->dashboardService->schedule( - uuid: $uuid, - publishAt: $publishAt, - userId: $this->userId - ); - - return ResponseHelper::success( - data: ['dashboard' => $dashboard->jsonSerialize()] - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'not_found', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'invalid_argument', - 'message' => $e->getMessage(), - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - } catch (\Exception $e) { - return $this->mapPublicationError(exception: $e); - }//end try - }//end schedule() - - /** - * Record a dashboard view event (REQ-ANLT-002). - * - * Authenticated users only — POST `{}` body. Returns HTTP 204 - * after the daily counter has been incremented. Short-circuits - * silently to 204 when the user has opted out (REQ-ANLT-004) or - * when global analytics is disabled (REQ-ANLT-005). Returns 404 - * when the dashboard does not exist. - * - * @param string $uuid The dashboard UUID from the URL. - * - * @return JSONResponse An empty 204 response on success, 401 - * when unauthenticated, 404 when the - * dashboard does not exist. - * - * @spec openspec/specs/dashboards/spec.md - */ - #[NoAdminRequired] - public function viewEvent(string $uuid): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); - } - - $this->actionAuth->requireAction($user, 'dashboard.view-event'); - - if ($this->userId === null) { - return ResponseHelper::unauthorized(); - } - - // H4: resolve the dashboard and assert the caller can view it - // before recording any counter increment (REQ-ANLT-002). - try { - $dashboard = $this->dashboardService->findByUuid(uuid: $uuid); - } catch (DoesNotExistException) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'not_found', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - if ($this->permissionService->canViewDashboard( - userId: $this->userId, - dashboardId: $dashboard->getId() - ) === false - ) { - return ResponseHelper::forbidden(); - } - - try { - $this->analyticsService->recordViewEvent( - dashboardUuid: $uuid, - userId: $this->userId - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'not_found', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - return new JSONResponse( - data: [], - statusCode: Http::STATUS_NO_CONTENT - ); - }//end viewEvent() - - /** - * Map publication-related service exceptions onto the right HTTP - * status. REQ-DASH-032..034. - * - * The service raises `Exception` with the sentinel message - * {@see DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN} when - * the actor is not allowed; everything else falls through to the - * generic ResponseHelper::error path. - * - * @param \Exception $exception The thrown exception. - * - * @return JSONResponse The mapped error envelope. - */ - private function mapPublicationError(\Exception $exception): JSONResponse - { - if ($exception->getMessage() === DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN - ) { - return ResponseHelper::forbidden( - message: DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN - ); - } - - return ResponseHelper::error(exception: $exception); - }//end mapPublicationError() - - /** - * Resolve create parameters from JSON body or individual params. - * - * Forwards the new hierarchy fields (`parentUuid`, `slug`, - * `sortOrder`) introduced by REQ-DASH-023..029. When the caller - * sends a JSON body the helper inspects the array form first; when - * positional / typed params come via the framework binding it - * falls back to those. - * - * @param mixed $name The name parameter. - * @param string|null $description The description parameter. - * @param string|null $icon The icon parameter. - * @param string|null $parentUuid Optional parent UUID. - * @param string|null $slug Optional caller-supplied slug. - * @param int|null $sortOrder Optional sort order. - * - * @return array{name: string, description: ?string, icon: ?string, parentUuid: ?string, slug: ?string, sortOrder: int} - * The resolved values. - */ - private function resolveCreateParams( - $name, - ?string $description, - ?string $icon=null, - ?string $parentUuid=null, - ?string $slug=null, - ?int $sortOrder=null - ): array { - if (is_array($name) === true) { - $bodyIcon = ($name['icon'] ?? null); - $resolvedIcon = null; - if (is_string($bodyIcon) === true) { - $resolvedIcon = $bodyIcon; - } - - $bodyParent = ($name['parentUuid'] ?? null); - $bodySlug = ($name['slug'] ?? null); - $bodySort = ($name['sortOrder'] ?? null); - $resolvedParent = null; - if (is_string($bodyParent) === true) { - $resolvedParent = $bodyParent; - } - - $resolvedSlug = null; - if (is_string($bodySlug) === true) { - $resolvedSlug = $bodySlug; - } - - $resolvedSort = 0; - if (is_numeric($bodySort) === true) { - $resolvedSort = (int) $bodySort; - } - - return [ - 'name' => $name['name'] ?? 'My Dashboard', - 'description' => $name['description'] ?? null, - 'icon' => $resolvedIcon, - 'parentUuid' => $resolvedParent, - 'slug' => $resolvedSlug, - 'sortOrder' => $resolvedSort, - ]; - }//end if - - return [ - 'name' => $name ?? 'My Dashboard', - 'description' => $description, - 'icon' => $icon, - 'parentUuid' => $parentUuid, - 'slug' => $slug, - 'sortOrder' => ($sortOrder ?? 0), - ]; - }//end resolveCreateParams() - - /** - * Read the case-insensitive `cascade` query param (REQ-DASH-030). - * - * `?cascade=true|TRUE|True|1|yes|on|cascade` → true; anything else - * (including the param being absent) → false. - * - * @return bool Whether cascade-delete was explicitly requested. - */ - private function resolveCascadeFlag(): bool - { - $raw = $this->request->getParam(key: 'cascade'); - if ($raw === null) { - return false; - } - - $lower = strtolower((string) $raw); - return in_array( - $lower, - ['true', '1', 'yes', 'on', 'cascade'], - true - ); - }//end resolveCascadeFlag() - - /** - * Check creation permissions and return error if denied. - * - * @param string $userId The user ID. - * - * @return JSONResponse|null Error response or null if allowed. - */ - private function checkCreatePermissions(string $userId): ?JSONResponse - { - if ($this->permissionService->canCreateDashboard( - userId: $userId - ) === false - ) { - return ResponseHelper::forbidden( - message: 'Dashboard creation not allowed' - ); - } - - $existing = $this->dashboardService->getUserDashboards( - userId: $userId - ); - if (empty($existing) === false - && $this->permissionService->canHaveMultipleDashboards() === false - ) { - return ResponseHelper::forbidden( - message: 'Multiple dashboards not allowed' - ); - } - - return null; - }//end checkCreatePermissions() - - /** - * Build update data from nullable parameters. - * - * @param string|null $name The name. - * @param string|null $description The description. - * @param array|null $placements The placements. - * @param string|null $icon The icon registry key, URL, or NULL/empty. - * @param string|null $parentUuid Optional new parent UUID - * (REQ-DASH-023). The literal sentinel - * `__null__` clears the parent - * (re-roots the dashboard) — needed - * because the framework cannot - * distinguish "not in payload" from - * "explicit NULL" with typed-string - * binding. - * @param string|null $slug Optional new slug (REQ-DASH-024). - * @param int|null $sortOrder Optional new sort order - * (REQ-DASH-029). - * - * @return array The non-null update data. - */ - private function buildUpdateData( - ?string $name, - ?string $description, - ?array $placements, - ?string $icon=null, - ?string $parentUuid=null, - ?string $slug=null, - ?int $sortOrder=null - ): array { - $fields = [ - 'name' => $name, - 'description' => $description, - 'placements' => $placements, - ]; - - $data = array_filter( - array: $fields, - callback: function ($value) { - return $value !== null; - } - ); - - // Icon explicitly supports NULL/empty (resets to the default - // glyph), so it must be merged separately from the array_filter - // above. Caller distinguishes "not in payload" via the default - // null sentinel. - if ($icon !== null) { - $data['icon'] = $icon; - } - - // REQ-DASH-023: `parentUuid = '__null__'` is the agreed sentinel - // for "re-root this dashboard" because the framework's typed - // string binding cannot represent an explicit NULL. Anything - // else (non-null string) is forwarded verbatim — including the - // empty string, which the service treats as a NULL parent. - if ($parentUuid !== null) { - $data['parentUuid'] = $parentUuid; - if ($parentUuid === '__null__' || $parentUuid === '') { - $data['parentUuid'] = null; - } - } - - if ($slug !== null) { - $data['slug'] = $slug; - } - - if ($sortOrder !== null) { - $data['sortOrder'] = $sortOrder; - } - - return $data; - }//end buildUpdateData() - - /** - * Build the patch payload for the group-shared update endpoint. - * - * @param string|null $name The new name. - * @param string|null $description The new description. - * @param int|null $gridColumns The new grid columns. - * @param array|null $placements Updated placements. - * - * @return array The non-null patch fields. - */ - private function buildGroupUpdateData( - ?string $name, - ?string $description, - ?int $gridColumns, - ?array $placements - ): array { - $fields = [ - 'name' => $name, - 'description' => $description, - 'gridColumns' => $gridColumns, - 'placements' => $placements, - ]; - - return array_filter( - array: $fields, - callback: function ($value) { - return $value !== null; - } - ); - }//end buildGroupUpdateData() - - /** - * Capture an automatic version snapshot after a successful update - * (REQ-VERS-001). The version service enforces a 60-second debounce - * window so a flurry of drag-and-drop saves does not flood the - * table. - * - * Failures here MUST NOT surface to the dashboard PUT response — - * the user's edit succeeded; missing one snapshot is a quality of - * life regression, not a data-integrity bug. We log + swallow. - * - * @param \OCA\LaunchPad\Db\Dashboard $dashboard The dashboard that was - * just updated. - * - * @return void - */ - private function captureAutomaticSnapshot( - \OCA\LaunchPad\Db\Dashboard $dashboard - ): void { - if ($this->userId === null) { - return; - } - - try { - $this->versionService->captureSnapshot( - dashboard: $dashboard, - snapshotJson: null, - createdBy: $this->userId, - note: null, - explicit: false - ); - } catch (\Throwable $t) { - $this->logger->warning( - message: 'launchpad: automatic version snapshot failed', - context: ['exception' => $t] - ); - } - }//end captureAutomaticSnapshot() +class DashboardApiController extends Controller { + /** + * Constructor + * + * @param IRequest $request The request. + * @param DashboardService $dashboardService The dashboard service. + * @param PermissionService $permissionService The permission service. + * @param DashboardTreeService $treeService The tree service that + * owns hierarchy + * queries, cycle + * detection, slug + * uniqueness, path + * resolution, and the + * cascade-delete walker + * (REQ-DASH-023..030). + * @param DashboardVersionService $versionService Snapshot service + * (REQ-VERS-001) — + * automatic + * snapshots fire + * after every + * successful PUT + * via the + * debounced + * `captureSnapshot` + * helper. + * @param AnalyticsService $analyticsService The view-analytics + * service used by the + * `viewEvent` endpoint + * (REQ-ANLT-002). + * @param LoggerInterface $logger PSR logger (used by + * fork to report + * unexpected errors + * — REQ-DASH-021). + * @param IUserSession $userSession The user session, used + * to resolve the + * authenticated IUser for + * ADR-023 action checks. + * @param ActionAuthService $actionAuth The ADR-023 action + * authorization service. + * @param string|null $userId The user ID. + * @param QuotaService|null $quotaService The quota-enforcement + * service used to gate + * dashboard creation + * (dashboard-quota-limits). + */ + public function __construct( + IRequest $request, + private readonly DashboardService $dashboardService, + private readonly PermissionService $permissionService, + private readonly DashboardTreeService $treeService, + private readonly DashboardVersionService $versionService, + private readonly AnalyticsService $analyticsService, + private readonly LoggerInterface $logger, + private readonly IUserSession $userSession, + private readonly ActionAuthService $actionAuth, + private readonly ?string $userId, + private readonly ?QuotaService $quotaService = null, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * List all personal dashboards for the current user. + * + * Backward compatible — this endpoint never returns group-shared + * dashboards (REQ-DASH-014). Use {@see self::visible()} for the + * unioned listing. + * + * @return JSONResponse The list of dashboards. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-17 + */ + #[NoAdminRequired] + public function list(): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.list'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + $dashboards = $this->dashboardService->getUserDashboards( + userId: $this->userId + ); + + $serialized = ResponseHelper::serializeList(entities: $dashboards); + + // Dashboard-quota-limits REQ-QUOTA-006: additive quota envelope on + // the personal dashboards list. Response shape is + // `{items: [...], quota: {...}}`. When the quota service is absent + // (legacy test doubles) fall back to the bare-array contract. + if ($this->quotaService === null) { + return ResponseHelper::success(data: $serialized); + } + + return ResponseHelper::success( + data: [ + 'items' => $serialized, + 'quota' => $this->quotaService->getQuotaStatus( + userId: $this->userId + ), + ] + ); + }//end list() + + /** + * List the deduplicated union of dashboards visible to the user. + * + * Returns personal + group-matching + default-group dashboards, each + * tagged with `source` (`'user'`, `'group'`, `'default'`). + * REQ-DASH-013. + * + * @return JSONResponse The visible dashboards. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[NoAdminRequired] + public function visible(): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.visible'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + $items = $this->dashboardService->getVisibleToUser( + userId: $this->userId + ); + + $serialized = []; + foreach ($items as $entry) { + $row = $entry['dashboard']->jsonSerialize(); + $row['source'] = $entry['source']; + // Tag ownership so the frontend can route activation correctly: + // only personal `user`-type rows owned by the caller take the + // legacy id-based `is_active` path; group/default rows (user_id + // NULL) are activated via the UUID preference instead. + $row['isOwner'] = ($entry['dashboard']->getUserId() === $this->userId); + $serialized[] = $row; + } + + // Dashboard-quota-limits REQ-QUOTA-006: carry the additive quota + // envelope on the unioned listing the store consumes, so the + // frontend can disable create affordances at the limit without an + // extra round-trip. The response shape is now + // `{items: [...], quota: {...}}`; clients that read the bare array + // are handled by the store's shape-tolerant unwrap. When the quota + // service is absent (legacy test doubles) fall back to the + // bare-array contract. + if ($this->quotaService === null) { + return ResponseHelper::success(data: $serialized); + } + + return ResponseHelper::success( + data: [ + 'items' => $serialized, + 'quota' => $this->quotaService->getQuotaStatus( + userId: $this->userId + ), + ] + ); + }//end visible() + + /** + * Get the user's active dashboard with placements. + * + * @return JSONResponse The active dashboard data. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-18 + */ + #[NoAdminRequired] + public function getActive(): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.get-active'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + $result = $this->dashboardService->getEffectiveDashboard( + userId: $this->userId + ); + + if ($result === null) { + return ResponseHelper::success( + data: ['error' => 'No dashboard available'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + // The effective dashboard can now be a group/default (showcase) + // dashboard the user does not own (resolved via the last-used + // preference), so tag ownership the same way show() does rather + // than letting the client assume the caller owns it. + $activeDashboard = $result['dashboard']; + $isOwner = ($activeDashboard->getUserId() === $this->userId); + + $sharedBy = null; + if ($isOwner === false) { + $sharedBy = $activeDashboard->getUserId(); + } + + return ResponseHelper::success( + data: [ + 'dashboard' => $activeDashboard->jsonSerialize(), + 'placements' => ResponseHelper::serializeList( + entities: $result['placements'] + ), + 'permissionLevel' => $result['permissionLevel'], + 'isOwner' => $isOwner, + 'sharedBy' => $sharedBy, + ] + ); + }//end getActive() + + /** + * Get a single dashboard by id with its placements + permission level. + * + * Powers the front-end's `switchDashboard` flow: clicking a row in the + * sidebar issues `GET /api/dashboard/{id}` and the response is the + * same envelope shape as {@see self::getActive()}, so the store can + * write `activeDashboard`, `widgetPlacements`, and `permissionLevel` + * with no per-source branching. + * + * Returns 404 (not 403) when the dashboard exists but is not visible + * to the caller — this matches the `getVisibleToUser` policy and + * intentionally does not leak existence (REQ-DASH-020 scenario + * "Cannot see what you cannot read"). + * + * @param int $id The dashboard ID. + * + * @return JSONResponse The dashboard envelope (200) or + * `{'error': 'Not found'}` (404). + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-21 + */ + #[NoAdminRequired] + public function show(int $id): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.show'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + $result = $this->dashboardService->getDashboardForUser( + dashboardId: $id, + userId: $this->userId + ); + + if ($result === null) { + return ResponseHelper::success( + data: ['error' => 'Not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + $dashboard = $result['dashboard']; + $isOwner = ($dashboard->getUserId() === $this->userId); + $sharedBy = null; + if ($isOwner === false) { + $sharedBy = $dashboard->getUserId(); + } + + return ResponseHelper::success( + data: [ + 'dashboard' => $dashboard->jsonSerialize(), + 'placements' => ResponseHelper::serializeList( + entities: $result['placements'] + ), + 'permissionLevel' => $result['permissionLevel'], + 'isOwner' => $isOwner, + 'sharedBy' => $sharedBy, + ] + ); + }//end show() + + /** + * Create a new dashboard. + * + * @param mixed $name The dashboard name. + * @param string|null $description The description. + * @param string|null $icon The icon registry key (or NULL/empty to use the default). + * @param string|null $parentUuid Optional parent dashboard UUID + * (REQ-DASH-023). NULL ⇒ root. + * @param string|null $slug Optional caller-supplied slug + * (REQ-DASH-024). NULL ⇒ derive from + * the name. + * @param int|null $sortOrder Optional sibling sort order + * (REQ-DASH-029). NULL ⇒ 0. + * + * @return JSONResponse The created dashboard. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-16 + */ + #[NoAdminRequired] + public function create( + $name = null, + ?string $description = null, + ?string $icon = null, + ?string $parentUuid = null, + ?string $slug = null, + ?int $sortOrder = null, + ): JSONResponse { + $denial = $this->denyCreate(); + if ($denial !== null) { + return $denial; + } + + $resolved = $this->resolveCreateParams( + name: $name, + description: $description, + icon: $icon, + parentUuid: $parentUuid, + slug: $slug, + sortOrder: $sortOrder + ); + + $permError = $this->checkCreatePermissions( + userId: $this->userId + ); + if ($permError !== null) { + return $permError; + } + + try { + $dashboard = $this->dashboardService->createDashboard( + userId: $this->userId, + name: $resolved['name'], + description: $resolved['description'], + icon: $resolved['icon'], + parentUuid: $resolved['parentUuid'], + slug: $resolved['slug'], + sortOrder: $resolved['sortOrder'], + seedDefaults: true + ); + + // The newly-created dashboard ships with a default widget + // bundle (Conduction + Sendent + Nextcloud tiles + a Files + // widget) seeded by the service. Returning the placements + // here matches the `getActive()` envelope so the store can + // populate `widgetPlacements` without an extra round-trip. + $placements = $this->dashboardService->findPlacements( + dashboardId: $dashboard->getId() + ); + + return ResponseHelper::success( + data: [ + 'dashboard' => $dashboard->jsonSerialize(), + 'placements' => ResponseHelper::serializeList( + entities: $placements + ), + ], + statusCode: Http::STATUS_CREATED + ); + } catch (QuotaExceededException $e) { + // Dashboard-quota-limits REQ-QUOTA-002: the user is at their + // dashboard limit — HTTP 409 with the structured body. + return ResponseHelper::quotaExceeded(exception: $e); + } catch (InvalidArgumentException $e) { + // REQ-DASH-023..029: parent / slug / depth / cycle violations + // surface as HTTP 400 with the validation message verbatim. + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'invalid_argument', + 'message' => $e->getMessage(), + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } catch (\Exception $e) { + return ResponseHelper::error(exception: $e); + }//end try + }//end create() + + /** + * Resolve the authentication / authorisation guard chain for + * {@see self::create()}. + * + * Order is load-bearing. REQ-ASET-003 (extended): the admin gating + * runs BEFORE any request-body handling so the response envelope is + * the stable `personal_dashboards_disabled` shape no matter what the + * body looked like. + * + * @return JSONResponse|NULL The refusal, or NULL to proceed. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-16 + */ + private function denyCreate(): ?JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + // L3: wire the create action so the matrix entry is enforced — + // consistent with all other mutation endpoints (ADR-023). + $this->actionAuth->requireAction($user, 'dashboard.create'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + try { + $this->dashboardService->assertPersonalDashboardsAllowed(); + } catch (PersonalDashboardsDisabledException $e) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => $e->getErrorCode(), + 'message' => $e->getMessage(), + ], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + return null; + }//end denyCreate() + + /** + * Update a dashboard. + * + * @param int $id The dashboard ID. + * @param string|null $name The name. + * @param string|null $description The description. + * @param array|null $placements The placements. + * @param string|null $icon The icon registry key, URL, or NULL to leave unchanged. + * @param string|null $parentUuid Optional new parent UUID (REQ-DASH-023); + * explicit empty string clears the + * parent (re-roots the dashboard). + * @param string|null $slug Optional new slug (REQ-DASH-024). + * @param int|null $sortOrder Optional new sort order (REQ-DASH-029). + * + * @return JSONResponse The updated dashboard. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-19 + */ + #[NoAdminRequired] + public function update( + int $id, + ?string $name = null, + ?string $description = null, + ?array $placements = null, + ?string $icon = null, + ?string $parentUuid = null, + ?string $slug = null, + ?int $sortOrder = null, + ): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.update'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + // REQ-PERM-007: Metadata-only updates (name, description, icon) are + // allowed for all permission levels. Widget/tile/layout changes + // require add_only or full permission. + $isMetadataOnly = $placements === null; + if ($isMetadataOnly === true + && $this->permissionService->canEditDashboardMetadata( + userId: $this->userId, + dashboardId: $id + ) === false + ) { + return ResponseHelper::forbidden(); + } + + if ($isMetadataOnly === false + && $this->permissionService->canEditDashboard( + userId: $this->userId, + dashboardId: $id + ) === false + ) { + return ResponseHelper::forbidden(); + } + + try { + $data = $this->buildUpdateData( + name: $name, + description: $description, + placements: $placements, + icon: $icon, + parentUuid: $parentUuid, + slug: $slug, + sortOrder: $sortOrder + ); + + $dashboard = $this->dashboardService->updateDashboard( + dashboardId: $id, + userId: $this->userId, + data: $data + ); + + // REQ-VERS-001: capture an automatic snapshot after the + // PUT succeeds. The version service enforces its own + // debounce window (60 s) so rapid drag-and-drop edits do + // not flood the table. Failures are swallowed so they do + // not surface to the dashboard PUT response. + $this->captureAutomaticSnapshot(dashboard: $dashboard); + + return ResponseHelper::success( + data: ['dashboard' => $dashboard->jsonSerialize()] + ); + } catch (InvalidArgumentException $e) { + // REQ-DASH-023..029: parent / slug / depth / cycle violations + // surface as HTTP 400 with the validation message verbatim. + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'invalid_argument', + 'message' => $e->getMessage(), + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } catch (\Exception $e) { + return ResponseHelper::error(exception: $e); + }//end try + }//end update() + + /** + * Delete a dashboard. + * + * Honours the cascade-delete guard from REQ-DASH-030: when the + * dashboard has children the request MUST include `?cascade=true` + * (case-insensitive) — otherwise the response is HTTP 409 with the + * child count so the UI can surface a confirmation. + * + * @param int $id The dashboard ID. + * + * @return JSONResponse The deletion confirmation. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-launchpad/tasks.md#task-20 + */ + #[NoAdminRequired] + public function delete(int $id): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.delete'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + $cascade = $this->resolveCascadeFlag(); + + try { + $this->dashboardService->deleteDashboard( + dashboardId: $id, + userId: $this->userId, + cascade: $cascade + ); + + return ResponseHelper::success(data: ['status' => 'ok']); + } catch (DashboardHasChildrenException $e) { + // REQ-DASH-030: stable 409 envelope with the child count so + // the frontend can render "Delete N children?" before + // retrying with cascade=true. + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => DashboardHasChildrenException::ERROR_CODE, + 'message' => $e->getMessage(), + 'childCount' => $e->getChildCount(), + ], + statusCode: Http::STATUS_CONFLICT + ); + } catch (\Exception $e) { + return ResponseHelper::error(exception: $e); + }//end try + }//end delete() + + /** + * GET /api/dashboards/tree — return the nested dashboard tree scoped + * to the calling user's visible dashboards (REQ-DASH-026). + * + * Each node carries `{uuid, name, slug, sortOrder, children: [...]}`. + * Only nodes for dashboards that `DashboardService::getVisibleToUser` + * resolves for the caller are included — personal drafts owned by + * other users are not enumerable (C1 fix: REQ-DASH-026 + REQ-PERM-001). + * + * @return JSONResponse The nested tree. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[NoAdminRequired] + public function tree(): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.tree'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + // C1 fix: build the visibility set for the calling user, then ask + // the tree service for the structural tree filtered to those UUIDs. + // This prevents cross-user IDOR via UUID enumeration through the tree. + $visible = $this->dashboardService->getVisibleToUser( + userId: $this->userId + ); + $visibleUuids = []; + foreach ($visible as $entry) { + $uuid = $entry['dashboard']->getUuid(); + if ($uuid !== null && $uuid !== '') { + $visibleUuids[$uuid] = true; + } + } + + $tree = $this->treeService->getFilteredTree( + visibleUuids: $visibleUuids + ); + + return ResponseHelper::success(data: $tree); + }//end tree() + + /** + * GET /api/dashboards/by-path/{path} — resolve a slug-chain path + * (REQ-DASH-027). + * + * Returns the matching dashboard with its computed `path` and + * `breadcrumbs` (REQ-DASH-025) attached. Responds with 404 (not 403) + * on any miss — including visibility misses — to avoid confirming that + * a given slug exists to an unauthorised caller. + * + * C2 fix (REQ-DASH-027 + REQ-PERM-001): after slug resolution the + * resolved dashboard is checked via PermissionService; callers with no + * view access receive the same 404 they would get for an unknown slug. + * + * @param string $path The slug-joined path captured from the URL + * (the `{path}` placeholder is regex-allowed + * to include slashes — see `appinfo/routes.php`). + * + * @return JSONResponse The dashboard payload, or a 404 envelope. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[NoAdminRequired] + public function byPath(string $path = ''): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.by-path'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + if ($path === '') { + $path = (string)$this->request->getParam(key: 'path', default: ''); + } + + $dashboard = $this->treeService->resolvePath(path: $path); + if ($dashboard === null) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'not_found', + 'message' => 'Dashboard not found at path', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + // C2 fix: verify the caller can see this dashboard. Return 404 + // (not 403) to avoid leaking that the slug exists at all. + $dashboardId = (int)$dashboard->getId(); + if ($this->permissionService->canViewDashboard( + userId: $this->userId, + dashboardId: $dashboardId + ) === false + ) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'not_found', + 'message' => 'Dashboard not found at path', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + $uuid = (string)$dashboard->getUuid(); + $serialised = $dashboard->jsonSerialize(); + $serialised['path'] = $this->treeService->computePath(uuid: $uuid); + $serialised['breadcrumbs'] = $this->treeService->computeBreadcrumbs( + uuid: $uuid + ); + + return ResponseHelper::success( + data: ['dashboard' => $serialised] + ); + }//end byPath() + + /** + * GET /api/dashboards/{uuid}/path — return a dashboard's canonical + * slug-chain path. + * + * Used by the frontend after every sidebar switch to keep the + * browser URL in sync with the active dashboard. The path is the + * leading-slash slug-chain returned by + * {@see DashboardTreeService::computePath()}; an empty string means + * the UUID does not resolve OR the dashboard has no slug (legal — + * NULL slugs are simply unaddressable by path), and the frontend + * treats either case as "leave the URL alone". + * + * @param string $uuid Dashboard UUID captured from the URL. + * + * @return JSONResponse `{path: string}` envelope (always 200 when + * authorised — the empty-path case is a valid + * response shape the caller distinguishes + * client-side). + * + * @spec openspec/specs/dashboards/spec.md + */ + #[NoAdminRequired] + public function computePath(string $uuid = ''): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.compute-path'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + if ($uuid === '') { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'missing_uuid', + 'message' => 'UUID is required', + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + return ResponseHelper::success( + data: ['path' => $this->treeService->computePath(uuid: $uuid)] + ); + }//end computePath() + + /** + * Activate a dashboard. + * + * @param int $id The dashboard ID. + * + * @return JSONResponse The activated dashboard. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[NoAdminRequired] + public function activate(int $id): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.activate'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + try { + $dashboard = $this->dashboardService->activateDashboard( + dashboardId: $id, + userId: $this->userId + ); + + return ResponseHelper::success( + data: ['dashboard' => $dashboard->jsonSerialize()] + ); + } catch (\Exception $e) { + return ResponseHelper::error(exception: $e); + } + }//end activate() + + /** + * List the group-shared dashboards in a single group. + * + * Any logged-in user may list. REQ-DASH-014. + * + * @param string $groupId The group ID. + * + * @return JSONResponse The list of group-shared dashboards. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[NoAdminRequired] + public function listGroup(string $groupId): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.list-group'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + // H1: verify the caller is a member of the requested group (or + // admin) before returning its dashboards — mirrors the group- + // membership check in PermissionService::resolveAccessLevel. + if ($this->dashboardService->userCanAccessGroup( + userId: $this->userId, + groupId: $groupId + ) === false + ) { + return ResponseHelper::forbidden(); + } + + $dashboards = $this->dashboardService->listGroupDashboards( + groupId: $groupId + ); + + // M5: strip internal identity fields (userId, groupId, targetGroups) + // from group-shared dashboard payloads returned to non-owner viewers. + $viewerData = array_map( + static fn ($dashboard) => $dashboard->toViewerArray(), + $dashboards + ); + + return ResponseHelper::success(data: $viewerData); + }//end listGroup() + + /** + * Create a new group-shared dashboard. + * + * Admin-only — enforced by the `#[AuthorizedAdminSetting]` attribute + * (gate-route-auth / gate-semantic-auth both pass since the + * framework-level check is the actual authorization point). + * REQ-DASH-014. + * + * @param string $groupId The group ID. + * @param mixed $name The dashboard name (or {name,...} + * dict as the body). + * @param string|null $description The dashboard description. + * + * @return JSONResponse The created dashboard. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function createGroup( + string $groupId, + $name = null, + ?string $description = null, + ): JSONResponse { + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + $resolved = $this->resolveCreateParams( + name: $name, + description: $description + ); + + try { + $dashboard = $this->dashboardService->createGroupShared( + actorUserId: $this->userId, + groupId: $groupId, + name: $resolved['name'], + description: $resolved['description'] + ); + + return ResponseHelper::success( + data: ['dashboard' => $dashboard->jsonSerialize()], + statusCode: Http::STATUS_CREATED + ); + } catch (\Exception $e) { + return ResponseHelper::error(exception: $e); + } + }//end createGroup() + + /** + * Get a single group-shared dashboard with placements. + * + * @param string $groupId The group ID from the URL. + * @param string $uuid The dashboard UUID from the URL. + * + * @return JSONResponse The dashboard payload. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[NoAdminRequired] + public function getGroup( + string $groupId, + string $uuid, + ): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.get-group'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + // H1: verify the caller is a member of the requested group (or + // admin) before fetching the dashboard payload. + if ($this->dashboardService->userCanAccessGroup( + userId: $this->userId, + groupId: $groupId + ) === false + ) { + return ResponseHelper::forbidden(); + } + + try { + $dashboard = $this->dashboardService->findGroupDashboard( + groupId: $groupId, + uuid: $uuid + ); + } catch (DoesNotExistException) { + // ADR-005: do not leak raw exception messages to clients. + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + // M5: strip internal identity fields from viewer-facing payload. + return ResponseHelper::success( + data: ['dashboard' => $dashboard->toViewerArray()] + ); + }//end getGroup() + + /** + * Update a group-shared dashboard. Admin-only. + * + * @param string $groupId The group ID from the URL. + * @param string $uuid The dashboard UUID from the URL. + * @param string|null $name The new name. + * @param string|null $description The new description. + * @param int|null $gridColumns The new grid column count. + * @param array|null $placements Updated placements. + * + * @return JSONResponse The updated dashboard. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function updateGroup( + string $groupId, + string $uuid, + ?string $name = null, + ?string $description = null, + ?int $gridColumns = null, + ?array $placements = null, + ): JSONResponse { + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + $patch = $this->buildGroupUpdateData( + name: $name, + description: $description, + gridColumns: $gridColumns, + placements: $placements + ); + + try { + $dashboard = $this->dashboardService->updateGroupShared( + actorUserId: $this->userId, + groupId: $groupId, + uuid: $uuid, + patch: $patch + ); + + return ResponseHelper::success( + data: ['dashboard' => $dashboard->jsonSerialize()] + ); + } catch (DoesNotExistException) { + // ADR-005: do not leak raw exception messages to clients. + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (\Exception $e) { + return ResponseHelper::error(exception: $e); + }//end try + }//end updateGroup() + + /** + * Delete a group-shared dashboard. Admin-only. + * + * Returns HTTP 400 when the last-in-group guard rejects the delete + * (REQ-DASH-014). + * + * @param string $groupId The group ID from the URL. + * @param string $uuid The dashboard UUID from the URL. + * + * @return JSONResponse The status payload. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function deleteGroup( + string $groupId, + string $uuid, + ): JSONResponse { + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + try { + $this->dashboardService->deleteGroupShared( + actorUserId: $this->userId, + groupId: $groupId, + uuid: $uuid + ); + + return ResponseHelper::success(data: ['status' => 'ok']); + } catch (DoesNotExistException) { + // ADR-005: do not leak raw exception messages to clients. + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (\Exception $e) { + return ResponseHelper::error(exception: $e); + }//end try + }//end deleteGroup() + + /** + * Promote a single group-shared dashboard to the group's default. + * + * Admin-only — enforced by the `#[AuthorizedAdminSetting]` attribute. + * The body payload is `{"uuid": "..."}`. Returns 404 when the uuid + * does not belong to the given groupId. REQ-DASH-015. + * + * @param string $groupId The group ID from the URL. + * @param string|null $uuid The dashboard UUID from the body. + * + * @return JSONResponse The status payload. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function setGroupDefault( + string $groupId, + ?string $uuid = null, + ): JSONResponse { + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + if ($uuid === null || $uuid === '') { + return ResponseHelper::error( + exception: new InvalidArgumentException( + 'Missing required field: uuid' + ), + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + try { + $this->dashboardService->setGroupDefault( + actorUserId: $this->userId, + groupId: $groupId, + uuid: $uuid + ); + + return ResponseHelper::success( + data: [ + 'status' => 'ok', + 'groupId' => $groupId, + 'uuid' => $uuid, + ] + ); + } catch (DoesNotExistException) { + // ADR-005: do not leak raw exception messages to clients. + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (\Exception $e) { + return ResponseHelper::error(exception: $e); + }//end try + }//end setGroupDefault() + + /** + * Persist the user's active-dashboard preference. + * + * Accepts any UUID string (including non-existent UUIDs — the resolver's + * stale-pref path handles invalid values on next render). Empty string + * clears the preference. REQ-DASH-019. + * + * @param string|null $uuid The dashboard UUID from the request body, or + * empty string to clear. + * + * @return JSONResponse HTTP 200 `{status: 'success'}` on success; 401 + * when the session has no user. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[NoAdminRequired] + public function setActiveDashboard(?string $uuid = null): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.set-active-dashboard'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + $this->dashboardService->setActivePreference( + userId: $this->userId, + uuid: ($uuid ?? '') + ); + + return ResponseHelper::success(data: ['status' => 'success']); + }//end setActiveDashboard() + + /** + * Pin (or clear) the user's EXPLICIT default-dashboard choice + * (wave3.7). + * + * Distinct from {@see self::setActiveDashboard()} — this pref is + * only ever written when the user explicitly clicks "Set as + * default" on a row's cog menu, and is NOT auto-overwritten on + * every switch. The resolver checks it before the active pref so + * the pin survives across switches. + * + * Body shape: `{uuid: string}` — empty string clears the pin. + * + * @param string|null $uuid The dashboard UUID, or empty string to clear. + * + * @return JSONResponse 200 `{status: 'success'}` on success; 401 + * when the session has no user. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[NoAdminRequired] + public function setDefaultDashboard(?string $uuid = null): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.set-default-dashboard'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + $this->dashboardService->setDefaultPreference( + userId: $this->userId, + uuid: ($uuid ?? '') + ); + + return ResponseHelper::success(data: ['status' => 'success']); + }//end setDefaultDashboard() + + /** + * Read the user's EXPLICIT default-dashboard pin (wave3.7). + * + * @return JSONResponse 200 `{uuid: string}` — empty string when no + * pin set; 401 when the session has no user. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[NoAdminRequired] + public function getDefaultDashboard(): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.get-default-dashboard'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + return ResponseHelper::success( + data: [ + 'uuid' => $this->dashboardService->getDefaultPreference( + userId: $this->userId + ), + ] + ); + }//end getDefaultDashboard() + + /** + * Fork any visible dashboard into a brand-new personal copy. + * + * REQ-DASH-020 / REQ-DASH-021 / REQ-DASH-022. Body shape: + * `{name?: string}` — when `name` is absent the system applies the + * default `t('My copy of {name}', source.name)` translated via the + * caller's active language. + * + * Status mapping: + * - HTTP 201 with the full new dashboard payload on success. + * - HTTP 401 when the session has no user. + * - HTTP 403 with stable error code `personal_dashboards_disabled` + * when the admin flag `allow_user_dashboards` is off — REQ-ASET-003 + * runtime gating runs FIRST so the envelope shape is stable + * regardless of body contents. + * - HTTP 404 when the source UUID is not visible to the caller — + * do not leak existence (REQ-DASH-020 scenario "Cannot fork a + * dashboard you cannot read"). + * - HTTP 500 when a partial-failure rollback fires — REQ-DASH-021. + * ADR-005: the response carries a stable error code and a generic + * user-facing message; the underlying exception is logged for ops. + * + * @param string $uuid The source dashboard UUID from the URL. + * @param string|null $name Optional explicit fork name from the body. + * + * @return JSONResponse The new dashboard payload (201) or an + * appropriate error envelope. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[NoAdminRequired] + public function fork( + string $uuid, + ?string $name = null, + ): JSONResponse { + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + try { + $fork = $this->dashboardService->forkAsPersonal( + userId: $this->userId, + sourceUuid: $uuid, + name: $name + ); + + return new JSONResponse( + data: [ + 'status' => 'success', + 'dashboard' => $fork->jsonSerialize(), + ], + statusCode: Http::STATUS_CREATED + ); + } catch (PersonalDashboardsDisabledException $e) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => $e->getErrorCode(), + 'message' => $e->getMessage(), + ], + statusCode: Http::STATUS_FORBIDDEN + ); + } catch (QuotaExceededException $e) { + // Dashboard-quota-limits REQ-QUOTA-002: a fork is bound by the + // per-user dashboard quota — HTTP 409 with the structured body. + return ResponseHelper::quotaExceeded(exception: $e); + } catch (DoesNotExistException) { + // REQ-DASH-020: source not visible — 404 without leaking + // existence (use the canonical message rather than echoing + // the exception detail). + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'not_found', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (\Throwable $t) { + // REQ-DASH-021 + ADR-005: log the real cause, return a + // stable, generic envelope to the client. + $this->logger->error( + message: 'launchpad: fork failed for user {user}: {message}', + context: [ + 'user' => $this->userId, + 'message' => $t->getMessage(), + ] + ); + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'internal_error', + 'message' => 'An unexpected error occurred', + ], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + }//end fork() + + /** + * Publish a dashboard. REQ-DASH-032. + * + * Owner-or-admin gated at the service boundary; the route attribute + * is `#[NoAdminRequired]` because the in-body owner check is the + * actual authorization point (gate-semantic-auth). + * + * @param string $uuid The dashboard UUID from the URL. + * + * @return JSONResponse The updated dashboard payload. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[NoAdminRequired] + public function publish(string $uuid): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.publish'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + try { + $dashboard = $this->dashboardService->publishDashboard( + uuid: $uuid, + userId: $this->userId + ); + + return ResponseHelper::success( + data: ['dashboard' => $dashboard->jsonSerialize()] + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'not_found', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (\Exception $e) { + return $this->mapPublicationError(exception: $e); + }//end try + }//end publish() + + /** + * Unpublish a dashboard. REQ-DASH-033. + * + * @param string $uuid The dashboard UUID from the URL. + * + * @return JSONResponse The updated dashboard payload. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[NoAdminRequired] + public function unpublish(string $uuid): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.unpublish'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + try { + $dashboard = $this->dashboardService->unpublish( + uuid: $uuid, + userId: $this->userId + ); + + return ResponseHelper::success( + data: ['dashboard' => $dashboard->jsonSerialize()] + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'not_found', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (\Exception $e) { + return $this->mapPublicationError(exception: $e); + }//end try + }//end unpublish() + + /** + * Schedule a dashboard for automatic publication. REQ-DASH-034. + * + * Body: `{"publishAt": "2026-04-01T10:00:00Z"}`. Returns 400 with an + * i18n-friendly error message when `publishAt` is missing, + * unparseable, or in the past; 403 when the actor is neither owner + * nor admin. + * + * @param string $uuid The dashboard UUID from the URL. + * @param string|null $publishAt The future ISO-8601 timestamp from + * the request body. + * + * @return JSONResponse The updated dashboard payload. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[NoAdminRequired] + public function schedule( + string $uuid, + ?string $publishAt = null, + ): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.schedule'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + if ($publishAt === null || $publishAt === '') { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'invalid_argument', + 'message' => DashboardService::ERR_SCHEDULE_PAST_DATE, + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + try { + $dashboard = $this->dashboardService->schedule( + uuid: $uuid, + publishAt: $publishAt, + userId: $this->userId + ); + + return ResponseHelper::success( + data: ['dashboard' => $dashboard->jsonSerialize()] + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'not_found', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'invalid_argument', + 'message' => $e->getMessage(), + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } catch (\Exception $e) { + return $this->mapPublicationError(exception: $e); + }//end try + }//end schedule() + + /** + * Record a dashboard view event (REQ-ANLT-002). + * + * Authenticated users only — POST `{}` body. Returns HTTP 204 + * after the daily counter has been incremented. Short-circuits + * silently to 204 when the user has opted out (REQ-ANLT-004) or + * when global analytics is disabled (REQ-ANLT-005). Returns 404 + * when the dashboard does not exist. + * + * @param string $uuid The dashboard UUID from the URL. + * + * @return JSONResponse An empty 204 response on success, 401 + * when unauthenticated, 404 when the + * dashboard does not exist. + * + * @spec openspec/specs/dashboards/spec.md + */ + #[NoAdminRequired] + public function viewEvent(string $uuid): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.view-event'); + + if ($this->userId === null) { + return ResponseHelper::unauthorized(); + } + + // H4: resolve the dashboard and assert the caller can view it + // before recording any counter increment (REQ-ANLT-002). + try { + $dashboard = $this->dashboardService->findByUuid(uuid: $uuid); + } catch (DoesNotExistException) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'not_found', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + if ($this->permissionService->canViewDashboard( + userId: $this->userId, + dashboardId: $dashboard->getId() + ) === false + ) { + return ResponseHelper::forbidden(); + } + + try { + $this->analyticsService->recordViewEvent( + dashboardUuid: $uuid, + userId: $this->userId + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'not_found', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + return new JSONResponse( + data: [], + statusCode: Http::STATUS_NO_CONTENT + ); + }//end viewEvent() + + /** + * Map publication-related service exceptions onto the right HTTP + * status. REQ-DASH-032..034. + * + * The service raises `Exception` with the sentinel message + * {@see DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN} when + * the actor is not allowed; everything else falls through to the + * generic ResponseHelper::error path. + * + * @param \Exception $exception The thrown exception. + * + * @return JSONResponse The mapped error envelope. + */ + private function mapPublicationError(\Exception $exception): JSONResponse { + if ($exception->getMessage() === DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN + ) { + return ResponseHelper::forbidden( + message: DashboardService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN + ); + } + + return ResponseHelper::error(exception: $exception); + }//end mapPublicationError() + + /** + * Resolve create parameters from JSON body or individual params. + * + * Forwards the new hierarchy fields (`parentUuid`, `slug`, + * `sortOrder`) introduced by REQ-DASH-023..029. When the caller + * sends a JSON body the helper inspects the array form first; when + * positional / typed params come via the framework binding it + * falls back to those. + * + * @param mixed $name The name parameter. + * @param string|null $description The description parameter. + * @param string|null $icon The icon parameter. + * @param string|null $parentUuid Optional parent UUID. + * @param string|null $slug Optional caller-supplied slug. + * @param int|null $sortOrder Optional sort order. + * + * @return array{name: string, description: ?string, icon: ?string, parentUuid: ?string, slug: ?string, sortOrder: int} + * The resolved values. + */ + private function resolveCreateParams( + $name, + ?string $description, + ?string $icon = null, + ?string $parentUuid = null, + ?string $slug = null, + ?int $sortOrder = null, + ): array { + if (is_array($name) === true) { + $bodyIcon = ($name['icon'] ?? null); + $resolvedIcon = null; + if (is_string($bodyIcon) === true) { + $resolvedIcon = $bodyIcon; + } + + $bodyParent = ($name['parentUuid'] ?? null); + $bodySlug = ($name['slug'] ?? null); + $bodySort = ($name['sortOrder'] ?? null); + $resolvedParent = null; + if (is_string($bodyParent) === true) { + $resolvedParent = $bodyParent; + } + + $resolvedSlug = null; + if (is_string($bodySlug) === true) { + $resolvedSlug = $bodySlug; + } + + $resolvedSort = 0; + if (is_numeric($bodySort) === true) { + $resolvedSort = (int)$bodySort; + } + + return [ + 'name' => $name['name'] ?? 'My Dashboard', + 'description' => $name['description'] ?? null, + 'icon' => $resolvedIcon, + 'parentUuid' => $resolvedParent, + 'slug' => $resolvedSlug, + 'sortOrder' => $resolvedSort, + ]; + }//end if + + return [ + 'name' => $name ?? 'My Dashboard', + 'description' => $description, + 'icon' => $icon, + 'parentUuid' => $parentUuid, + 'slug' => $slug, + 'sortOrder' => ($sortOrder ?? 0), + ]; + }//end resolveCreateParams() + + /** + * Read the case-insensitive `cascade` query param (REQ-DASH-030). + * + * `?cascade=true|TRUE|True|1|yes|on|cascade` → true; anything else + * (including the param being absent) → false. + * + * @return bool Whether cascade-delete was explicitly requested. + */ + private function resolveCascadeFlag(): bool { + $raw = $this->request->getParam(key: 'cascade'); + if ($raw === null) { + return false; + } + + $lower = strtolower((string)$raw); + return in_array( + $lower, + ['true', '1', 'yes', 'on', 'cascade'], + true + ); + }//end resolveCascadeFlag() + + /** + * Check creation permissions and return error if denied. + * + * @param string $userId The user ID. + * + * @return JSONResponse|null Error response or null if allowed. + */ + private function checkCreatePermissions(string $userId): ?JSONResponse { + if ($this->permissionService->canCreateDashboard( + userId: $userId + ) === false + ) { + return ResponseHelper::forbidden( + message: 'Dashboard creation not allowed' + ); + } + + $existing = $this->dashboardService->getUserDashboards( + userId: $userId + ); + if (empty($existing) === false + && $this->permissionService->canHaveMultipleDashboards() === false + ) { + return ResponseHelper::forbidden( + message: 'Multiple dashboards not allowed' + ); + } + + return null; + }//end checkCreatePermissions() + + /** + * Build update data from nullable parameters. + * + * @param string|null $name The name. + * @param string|null $description The description. + * @param array|null $placements The placements. + * @param string|null $icon The icon registry key, URL, or NULL/empty. + * @param string|null $parentUuid Optional new parent UUID + * (REQ-DASH-023). The literal sentinel + * `__null__` clears the parent + * (re-roots the dashboard) — needed + * because the framework cannot + * distinguish "not in payload" from + * "explicit NULL" with typed-string + * binding. + * @param string|null $slug Optional new slug (REQ-DASH-024). + * @param int|null $sortOrder Optional new sort order + * (REQ-DASH-029). + * + * @return array The non-null update data. + */ + private function buildUpdateData( + ?string $name, + ?string $description, + ?array $placements, + ?string $icon = null, + ?string $parentUuid = null, + ?string $slug = null, + ?int $sortOrder = null, + ): array { + $fields = [ + 'name' => $name, + 'description' => $description, + 'placements' => $placements, + ]; + + $data = array_filter( + array: $fields, + callback: function ($value) { + return $value !== null; + } + ); + + // Icon explicitly supports NULL/empty (resets to the default + // glyph), so it must be merged separately from the array_filter + // above. Caller distinguishes "not in payload" via the default + // null sentinel. + if ($icon !== null) { + $data['icon'] = $icon; + } + + // REQ-DASH-023: `parentUuid = '__null__'` is the agreed sentinel + // for "re-root this dashboard" because the framework's typed + // string binding cannot represent an explicit NULL. Anything + // else (non-null string) is forwarded verbatim — including the + // empty string, which the service treats as a NULL parent. + if ($parentUuid !== null) { + $data['parentUuid'] = $parentUuid; + if ($parentUuid === '__null__' || $parentUuid === '') { + $data['parentUuid'] = null; + } + } + + if ($slug !== null) { + $data['slug'] = $slug; + } + + if ($sortOrder !== null) { + $data['sortOrder'] = $sortOrder; + } + + return $data; + }//end buildUpdateData() + + /** + * Build the patch payload for the group-shared update endpoint. + * + * @param string|null $name The new name. + * @param string|null $description The new description. + * @param int|null $gridColumns The new grid columns. + * @param array|null $placements Updated placements. + * + * @return array The non-null patch fields. + */ + private function buildGroupUpdateData( + ?string $name, + ?string $description, + ?int $gridColumns, + ?array $placements, + ): array { + $fields = [ + 'name' => $name, + 'description' => $description, + 'gridColumns' => $gridColumns, + 'placements' => $placements, + ]; + + return array_filter( + array: $fields, + callback: function ($value) { + return $value !== null; + } + ); + }//end buildGroupUpdateData() + + /** + * Capture an automatic version snapshot after a successful update + * (REQ-VERS-001). The version service enforces a 60-second debounce + * window so a flurry of drag-and-drop saves does not flood the + * table. + * + * Failures here MUST NOT surface to the dashboard PUT response — + * the user's edit succeeded; missing one snapshot is a quality of + * life regression, not a data-integrity bug. We log + swallow. + * + * @param \OCA\LaunchPad\Db\Dashboard $dashboard The dashboard that was + * just updated. + * + * @return void + */ + private function captureAutomaticSnapshot( + \OCA\LaunchPad\Db\Dashboard $dashboard, + ): void { + if ($this->userId === null) { + return; + } + + try { + $this->versionService->captureSnapshot( + dashboard: $dashboard, + snapshotJson: null, + createdBy: $this->userId, + note: null, + explicit: false + ); + } catch (\Throwable $t) { + $this->logger->warning( + message: 'launchpad: automatic version snapshot failed', + context: ['exception' => $t] + ); + } + }//end captureAutomaticSnapshot() }//end class diff --git a/lib/Controller/DashboardLockApiController.php b/lib/Controller/DashboardLockApiController.php index ec4629ab..0009d107 100644 --- a/lib/Controller/DashboardLockApiController.php +++ b/lib/Controller/DashboardLockApiController.php @@ -54,329 +54,323 @@ /** * Controller for dashboard editing-lock endpoints (REQ-LOCK-001..008). */ -class DashboardLockApiController extends Controller -{ - /** - * Constructor - * - * @param IRequest $request The HTTP request. - * @param DashboardLockService $lockService The lock service. - * @param PermissionService $permissionService Dashboard permission resolver. - * @param DashboardMapper $dashboardMapper UUID → id lookup. - * @param ActionAuthService $actionAuth ADR-023 action authorization. - * @param IUserSession $userSession User session (IUser resolution). - * @param string|null $userId The calling user ID. - */ - public function __construct( - IRequest $request, - private readonly DashboardLockService $lockService, - private readonly PermissionService $permissionService, - private readonly DashboardMapper $dashboardMapper, - private readonly ActionAuthService $actionAuth, - private readonly IUserSession $userSession, - private readonly ?string $userId, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() +class DashboardLockApiController extends Controller { + /** + * Constructor + * + * @param IRequest $request The HTTP request. + * @param DashboardLockService $lockService The lock service. + * @param PermissionService $permissionService Dashboard permission resolver. + * @param DashboardMapper $dashboardMapper UUID → id lookup. + * @param ActionAuthService $actionAuth ADR-023 action authorization. + * @param IUserSession $userSession User session (IUser resolution). + * @param string|null $userId The calling user ID. + */ + public function __construct( + IRequest $request, + private readonly DashboardLockService $lockService, + private readonly PermissionService $permissionService, + private readonly DashboardMapper $dashboardMapper, + private readonly ActionAuthService $actionAuth, + private readonly IUserSession $userSession, + private readonly ?string $userId, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() - /** - * Acquire (or refresh) the lock for the given dashboard. - * - * Re-entrant for the same user — a second tab MUST receive HTTP - * 200 with the refreshed lock instead of HTTP 409 (REQ-LOCK-001). - * - * @param string $uuid The dashboard UUID. - * - * @return JSONResponse 200 with the lock object on success, - * 404 when the dashboard UUID is unknown, - * 409 with the existing lock on conflict. - * - * @spec openspec/specs/dashboard-locking/spec.md - */ - #[NoAdminRequired] - public function acquire(string $uuid): JSONResponse - { - if ($this->userId === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + /** + * Acquire (or refresh) the lock for the given dashboard. + * + * Re-entrant for the same user — a second tab MUST receive HTTP + * 200 with the refreshed lock instead of HTTP 409 (REQ-LOCK-001). + * + * @param string $uuid The dashboard UUID. + * + * @return JSONResponse 200 with the lock object on success, + * 404 when the dashboard UUID is unknown, + * 409 with the existing lock on conflict. + * + * @spec openspec/specs/dashboard-locking/spec.md + */ + #[NoAdminRequired] + public function acquire(string $uuid): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - try { - $this->actionAuth->requireAction($user, 'dashboard-lock.acquire'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } + try { + $this->actionAuth->requireAction($user, 'dashboard-lock.acquire'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } - try { - $lock = $this->lockService->acquireLock( - dashboardUuid: $uuid, - userId: $this->userId - ); - return new JSONResponse( - data: $lock->jsonSerialize(), - statusCode: Http::STATUS_OK - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (LockForbiddenException $e) { - // C3 fix: caller lacks view access to this dashboard. - return new JSONResponse( - data: [ - 'error' => $e->getMessage(), - 'code' => LockForbiddenException::ERROR_CODE, - ], - statusCode: Http::STATUS_FORBIDDEN - ); - } catch (LockConflictException $e) { - // M2: strip userId from conflict response — callers need the - // displayName to show "X is editing" but should not receive - // the internal user identifier of a third party. - return new JSONResponse( - data: [ - 'error' => $e->getMessage(), - 'code' => LockConflictException::ERROR_CODE, - 'lock' => $e->getExistingLock()->jsonSerializeConflict(), - ], - statusCode: Http::STATUS_CONFLICT - ); - }//end try - }//end acquire() + try { + $lock = $this->lockService->acquireLock( + dashboardUuid: $uuid, + userId: $this->userId + ); + return new JSONResponse( + data: $lock->jsonSerialize(), + statusCode: Http::STATUS_OK + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (LockForbiddenException $e) { + // C3 fix: caller lacks view access to this dashboard. + return new JSONResponse( + data: [ + 'error' => $e->getMessage(), + 'code' => LockForbiddenException::ERROR_CODE, + ], + statusCode: Http::STATUS_FORBIDDEN + ); + } catch (LockConflictException $e) { + // M2: strip userId from conflict response — callers need the + // displayName to show "X is editing" but should not receive + // the internal user identifier of a third party. + return new JSONResponse( + data: [ + 'error' => $e->getMessage(), + 'code' => LockConflictException::ERROR_CODE, + 'lock' => $e->getExistingLock()->jsonSerializeConflict(), + ], + statusCode: Http::STATUS_CONFLICT + ); + }//end try + }//end acquire() - /** - * Refresh the lock (heartbeat). Owner-only. - * - * @param string $uuid The dashboard UUID. - * - * @return JSONResponse 200 with the refreshed lock; 404 when no - * active lock exists; 403 on owner mismatch. - * - * @spec openspec/specs/dashboard-locking/spec.md - */ - #[NoAdminRequired] - public function heartbeat(string $uuid): JSONResponse - { - if ($this->userId === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + /** + * Refresh the lock (heartbeat). Owner-only. + * + * @param string $uuid The dashboard UUID. + * + * @return JSONResponse 200 with the refreshed lock; 404 when no + * active lock exists; 403 on owner mismatch. + * + * @spec openspec/specs/dashboard-locking/spec.md + */ + #[NoAdminRequired] + public function heartbeat(string $uuid): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - try { - $this->actionAuth->requireAction($user, 'dashboard-lock.heartbeat'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } + try { + $this->actionAuth->requireAction($user, 'dashboard-lock.heartbeat'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } - try { - $lock = $this->lockService->heartbeat( - dashboardUuid: $uuid, - userId: $this->userId - ); - return new JSONResponse( - data: $lock->jsonSerialize(), - statusCode: Http::STATUS_OK - ); - } catch (LockNotFoundException $e) { - return new JSONResponse( - data: [ - 'error' => $e->getMessage(), - 'code' => LockNotFoundException::ERROR_CODE, - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (LockForbiddenException $e) { - return new JSONResponse( - data: [ - 'error' => $e->getMessage(), - 'code' => LockForbiddenException::ERROR_CODE, - ], - statusCode: Http::STATUS_FORBIDDEN - ); - }//end try - }//end heartbeat() + try { + $lock = $this->lockService->heartbeat( + dashboardUuid: $uuid, + userId: $this->userId + ); + return new JSONResponse( + data: $lock->jsonSerialize(), + statusCode: Http::STATUS_OK + ); + } catch (LockNotFoundException $e) { + return new JSONResponse( + data: [ + 'error' => $e->getMessage(), + 'code' => LockNotFoundException::ERROR_CODE, + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (LockForbiddenException $e) { + return new JSONResponse( + data: [ + 'error' => $e->getMessage(), + 'code' => LockForbiddenException::ERROR_CODE, + ], + statusCode: Http::STATUS_FORBIDDEN + ); + }//end try + }//end heartbeat() - /** - * Release the lock. Owner-or-admin. - * - * Idempotent — releasing a non-existent lock returns 204 (the - * caller's intent "no longer holding the lock" is satisfied). - * - * @param string $uuid The dashboard UUID. - * - * @return JSONResponse 204 on success; 403 on permission mismatch. - * - * @spec openspec/specs/dashboard-locking/spec.md - */ - #[NoAdminRequired] - public function release(string $uuid): JSONResponse - { - if ($this->userId === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + /** + * Release the lock. Owner-or-admin. + * + * Idempotent — releasing a non-existent lock returns 204 (the + * caller's intent "no longer holding the lock" is satisfied). + * + * @param string $uuid The dashboard UUID. + * + * @return JSONResponse 204 on success; 403 on permission mismatch. + * + * @spec openspec/specs/dashboard-locking/spec.md + */ + #[NoAdminRequired] + public function release(string $uuid): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - try { - $this->actionAuth->requireAction($user, 'dashboard-lock.release'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } + try { + $this->actionAuth->requireAction($user, 'dashboard-lock.release'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } - try { - $this->lockService->releaseLock( - dashboardUuid: $uuid, - userId: $this->userId, - allowAdminOverride: true - ); - return new JSONResponse( - data: [], - statusCode: Http::STATUS_NO_CONTENT - ); - } catch (LockForbiddenException $e) { - return new JSONResponse( - data: [ - 'error' => $e->getMessage(), - 'code' => LockForbiddenException::ERROR_CODE, - ], - statusCode: Http::STATUS_FORBIDDEN - ); - } - }//end release() + try { + $this->lockService->releaseLock( + dashboardUuid: $uuid, + userId: $this->userId, + allowAdminOverride: true + ); + return new JSONResponse( + data: [], + statusCode: Http::STATUS_NO_CONTENT + ); + } catch (LockForbiddenException $e) { + return new JSONResponse( + data: [ + 'error' => $e->getMessage(), + 'code' => LockForbiddenException::ERROR_CODE, + ], + statusCode: Http::STATUS_FORBIDDEN + ); + } + }//end release() - /** - * Query the current lock state. - * - * Returns the lock object when active, or HTTP 404 when none - * exists. Stale rows are scrubbed inline by the service before - * the response. - * - * @param string $uuid The dashboard UUID. - * - * @return JSONResponse 200 with the lock or 404 when none. - * - * @spec openspec/specs/dashboard-locking/spec.md - */ - #[NoAdminRequired] - public function get(string $uuid): JSONResponse - { - if ($this->userId === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + /** + * Query the current lock state. + * + * Returns the lock object when active, or HTTP 404 when none + * exists. Stale rows are scrubbed inline by the service before + * the response. + * + * @param string $uuid The dashboard UUID. + * + * @return JSONResponse 200 with the lock or 404 when none. + * + * @spec openspec/specs/dashboard-locking/spec.md + */ + #[NoAdminRequired] + public function get(string $uuid): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - try { - $this->actionAuth->requireAction($user, 'dashboard-lock.get'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } + try { + $this->actionAuth->requireAction($user, 'dashboard-lock.get'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } - // H1: guard against identity leak — any authed user could enumerate - // lock holders for arbitrary UUIDs; return 404 on no-view-access - // (same shape as "no lock") to avoid leaking dashboard existence. - try { - $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Lock not found', 'code' => LockNotFoundException::ERROR_CODE], - statusCode: Http::STATUS_NOT_FOUND - ); - } + // H1: guard against identity leak — any authed user could enumerate + // lock holders for arbitrary UUIDs; return 404 on no-view-access + // (same shape as "no lock") to avoid leaking dashboard existence. + try { + $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Lock not found', 'code' => LockNotFoundException::ERROR_CODE], + statusCode: Http::STATUS_NOT_FOUND + ); + } - if ($this->permissionService->canViewDashboard( - userId: $this->userId, - dashboardId: (int) $dashboard->getId() - ) === false - ) { - // Return 404 not 403 to avoid leaking dashboard existence. - return new JSONResponse( - data: ['error' => 'Lock not found', 'code' => LockNotFoundException::ERROR_CODE], - statusCode: Http::STATUS_NOT_FOUND - ); - } + if ($this->permissionService->canViewDashboard( + userId: $this->userId, + dashboardId: (int)$dashboard->getId() + ) === false + ) { + // Return 404 not 403 to avoid leaking dashboard existence. + return new JSONResponse( + data: ['error' => 'Lock not found', 'code' => LockNotFoundException::ERROR_CODE], + statusCode: Http::STATUS_NOT_FOUND + ); + } - $lock = $this->lockService->getLockState(dashboardUuid: $uuid); - if ($lock === null) { - return new JSONResponse( - data: [ - 'error' => 'Lock not found', - 'code' => LockNotFoundException::ERROR_CODE, - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } + $lock = $this->lockService->getLockState(dashboardUuid: $uuid); + if ($lock === null) { + return new JSONResponse( + data: [ + 'error' => 'Lock not found', + 'code' => LockNotFoundException::ERROR_CODE, + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } - return new JSONResponse( - data: $lock->jsonSerialize(), - statusCode: Http::STATUS_OK - ); - }//end get() + return new JSONResponse( + data: $lock->jsonSerialize(), + statusCode: Http::STATUS_OK + ); + }//end get() - /** - * Admin-only: force-release any user's lock (REQ-LOCK-006, design - * D4). The admin may then `acquire` normally if they want to take - * the lock themselves. - * - * @param string $uuid The dashboard UUID. - * - * @return JSONResponse 200 on success; 403 when caller is not admin. - * - * @spec openspec/specs/dashboard-locking/spec.md - */ - #[NoAdminRequired] - public function forceRelease(string $uuid): JSONResponse - { - if ($this->userId === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + /** + * Admin-only: force-release any user's lock (REQ-LOCK-006, design + * D4). The admin may then `acquire` normally if they want to take + * the lock themselves. + * + * @param string $uuid The dashboard UUID. + * + * @return JSONResponse 200 on success; 403 when caller is not admin. + * + * @spec openspec/specs/dashboard-locking/spec.md + */ + #[NoAdminRequired] + public function forceRelease(string $uuid): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - try { - $this->actionAuth->requireAction($user, 'dashboard-lock.force-release'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } + try { + $this->actionAuth->requireAction($user, 'dashboard-lock.force-release'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } - try { - $this->lockService->forceRelease( - dashboardUuid: $uuid, - adminUserId: $this->userId - ); - return new JSONResponse( - data: ['status' => 'ok'], - statusCode: Http::STATUS_OK - ); - } catch (LockForbiddenException $e) { - return new JSONResponse( - data: [ - 'error' => $e->getMessage(), - 'code' => LockForbiddenException::ERROR_CODE, - ], - statusCode: Http::STATUS_FORBIDDEN - ); - } - }//end forceRelease() + try { + $this->lockService->forceRelease( + dashboardUuid: $uuid, + adminUserId: $this->userId + ); + return new JSONResponse( + data: ['status' => 'ok'], + statusCode: Http::STATUS_OK + ); + } catch (LockForbiddenException $e) { + return new JSONResponse( + data: [ + 'error' => $e->getMessage(), + 'code' => LockForbiddenException::ERROR_CODE, + ], + statusCode: Http::STATUS_FORBIDDEN + ); + } + }//end forceRelease() }//end class diff --git a/lib/Controller/DashboardMetadataController.php b/lib/Controller/DashboardMetadataController.php index 1f298cee..b290490b 100644 --- a/lib/Controller/DashboardMetadataController.php +++ b/lib/Controller/DashboardMetadataController.php @@ -48,171 +48,167 @@ * All access decisions are delegated to PermissionService — the single * source of truth for dashboard ACL (H5, REQ-MDFL-008). */ -class DashboardMetadataController extends Controller -{ - /** - * Constructor. - * - * @param IRequest $request The HTTP request. - * @param MetadataService $metadataService The metadata service facade. - * @param DashboardMapper $dashboardMapper For dashboard lookup. - * @param PermissionService $permissionService Authoritative ACL service - * (replaces inline canRead / - * canWrite helpers — H5). - * @param ActionAuthService $actionAuth ADR-023 action authorization. - * @param IUserSession $userSession User session (IUser resolution). - * @param string|null $userId The active user id. - */ - public function __construct( - IRequest $request, - private readonly MetadataService $metadataService, - private readonly DashboardMapper $dashboardMapper, - private readonly PermissionService $permissionService, - private readonly ActionAuthService $actionAuth, - private readonly IUserSession $userSession, - private readonly ?string $userId, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * `GET /api/dashboards/{uuid}/metadata` — REQ-MDFL-004 / REQ-MDFL-008. - * - * @param string $uuid The dashboard UUID. - * - * @return JSONResponse 200 + flat metadata, 404 when missing, - * 403 when the caller cannot see the dashboard. - * - * @spec openspec/specs/dashboard-metadata-fields/spec.md - */ - #[NoAdminRequired] - public function getMetadata(string $uuid): JSONResponse - { - if ($this->userId === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'dashboard-metadata.get-metadata'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - $dashboard = $this->loadDashboard(uuid: $uuid); - if ($dashboard === null) { - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - // H5: delegate to PermissionService — the single ACL source of truth. - if ($this->permissionService->canViewDashboard( - userId: $this->userId, - dashboardId: $dashboard->getId() - ) === false - ) { - return ResponseHelper::forbidden(); - } - - $metadata = $this->metadataService->getMetadataForDashboard( - dashboardUuid: $uuid - ); - - return ResponseHelper::success(data: $metadata); - }//end getMetadata() - - /** - * `PUT /api/dashboards/{uuid}/metadata` — REQ-MDFL-005 / REQ-MDFL-008. - * - * Body: flat key-value object. Omitted keys are NOT removed; only - * keys present in the payload are upserted. - * - * @param string $uuid The dashboard UUID. - * @param array $metadata The patch payload. - * - * @return JSONResponse 200 + updated metadata, 400 on validation - * failure, 404 when missing, 403 otherwise. - * - * @spec openspec/specs/dashboard-metadata-fields/spec.md - */ - #[NoAdminRequired] - public function setMetadata(string $uuid, array $metadata=[]): JSONResponse - { - if ($this->userId === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'dashboard-metadata.set-metadata'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - $dashboard = $this->loadDashboard(uuid: $uuid); - if ($dashboard === null) { - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - // H5: delegate to PermissionService — canEditDashboardMetadata is - // owner-only for personal dashboards; admin-only for admin templates. - // This replaces the previous inline canWrite() which incorrectly - // allowed any group member to write group-shared metadata. - if ($this->permissionService->canEditDashboardMetadata( - userId: $this->userId, - dashboardId: $dashboard->getId() - ) === false - ) { - return ResponseHelper::forbidden(); - } - - try { - $updated = $this->metadataService->setMetadataForDashboard( - dashboardUuid: $uuid, - keyValues: $metadata - ); - } catch (InvalidMetadataFieldException $exception) { - return new JSONResponse( - data: [ - 'error' => InvalidMetadataFieldException::ERROR_CODE, - 'message' => $exception->getMessage(), - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - - return ResponseHelper::success(data: $updated); - }//end setMetadata() - - /** - * Resolve a UUID to a dashboard or null. - * - * @param string $uuid The UUID. - * - * @return Dashboard|null The dashboard or null. - */ - private function loadDashboard(string $uuid): ?Dashboard - { - try { - return $this->dashboardMapper->findByUuid(uuid: $uuid); - } catch (DoesNotExistException) { - return null; - } - }//end loadDashboard() +class DashboardMetadataController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The HTTP request. + * @param MetadataService $metadataService The metadata service facade. + * @param DashboardMapper $dashboardMapper For dashboard lookup. + * @param PermissionService $permissionService Authoritative ACL service + * (replaces inline canRead / + * canWrite helpers — H5). + * @param ActionAuthService $actionAuth ADR-023 action authorization. + * @param IUserSession $userSession User session (IUser resolution). + * @param string|null $userId The active user id. + */ + public function __construct( + IRequest $request, + private readonly MetadataService $metadataService, + private readonly DashboardMapper $dashboardMapper, + private readonly PermissionService $permissionService, + private readonly ActionAuthService $actionAuth, + private readonly IUserSession $userSession, + private readonly ?string $userId, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * `GET /api/dashboards/{uuid}/metadata` — REQ-MDFL-004 / REQ-MDFL-008. + * + * @param string $uuid The dashboard UUID. + * + * @return JSONResponse 200 + flat metadata, 404 when missing, + * 403 when the caller cannot see the dashboard. + * + * @spec openspec/specs/dashboard-metadata-fields/spec.md + */ + #[NoAdminRequired] + public function getMetadata(string $uuid): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'dashboard-metadata.get-metadata'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + $dashboard = $this->loadDashboard(uuid: $uuid); + if ($dashboard === null) { + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + // H5: delegate to PermissionService — the single ACL source of truth. + if ($this->permissionService->canViewDashboard( + userId: $this->userId, + dashboardId: $dashboard->getId() + ) === false + ) { + return ResponseHelper::forbidden(); + } + + $metadata = $this->metadataService->getMetadataForDashboard( + dashboardUuid: $uuid + ); + + return ResponseHelper::success(data: $metadata); + }//end getMetadata() + + /** + * `PUT /api/dashboards/{uuid}/metadata` — REQ-MDFL-005 / REQ-MDFL-008. + * + * Body: flat key-value object. Omitted keys are NOT removed; only + * keys present in the payload are upserted. + * + * @param string $uuid The dashboard UUID. + * @param array $metadata The patch payload. + * + * @return JSONResponse 200 + updated metadata, 400 on validation + * failure, 404 when missing, 403 otherwise. + * + * @spec openspec/specs/dashboard-metadata-fields/spec.md + */ + #[NoAdminRequired] + public function setMetadata(string $uuid, array $metadata = []): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'dashboard-metadata.set-metadata'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + $dashboard = $this->loadDashboard(uuid: $uuid); + if ($dashboard === null) { + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + // H5: delegate to PermissionService — canEditDashboardMetadata is + // owner-only for personal dashboards; admin-only for admin templates. + // This replaces the previous inline canWrite() which incorrectly + // allowed any group member to write group-shared metadata. + if ($this->permissionService->canEditDashboardMetadata( + userId: $this->userId, + dashboardId: $dashboard->getId() + ) === false + ) { + return ResponseHelper::forbidden(); + } + + try { + $updated = $this->metadataService->setMetadataForDashboard( + dashboardUuid: $uuid, + keyValues: $metadata + ); + } catch (InvalidMetadataFieldException $exception) { + return new JSONResponse( + data: [ + 'error' => InvalidMetadataFieldException::ERROR_CODE, + 'message' => $exception->getMessage(), + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + + return ResponseHelper::success(data: $updated); + }//end setMetadata() + + /** + * Resolve a UUID to a dashboard or null. + * + * @param string $uuid The UUID. + * + * @return Dashboard|null The dashboard or null. + */ + private function loadDashboard(string $uuid): ?Dashboard { + try { + return $this->dashboardMapper->findByUuid(uuid: $uuid); + } catch (DoesNotExistException) { + return null; + } + }//end loadDashboard() }//end class diff --git a/lib/Controller/DashboardReactionApiController.php b/lib/Controller/DashboardReactionApiController.php index c6625b4d..18598ce6 100644 --- a/lib/Controller/DashboardReactionApiController.php +++ b/lib/Controller/DashboardReactionApiController.php @@ -30,8 +30,8 @@ use OCA\LaunchPad\AppInfo\Application; use OCA\LaunchPad\Service\ActionAuthService; use OCA\LaunchPad\Service\PermissionDeniedException; -use OCA\LaunchPad\Service\ReactionService; use OCA\LaunchPad\Service\ReactionsDisabledException; +use OCA\LaunchPad\Service\ReactionService; use OCP\AppFramework\Controller; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -56,238 +56,234 @@ * and four exception types * across four routes. */ -class DashboardReactionApiController extends Controller -{ - /** - * Constructor - * - * @param IRequest $request The request. - * @param ReactionService $reactionService The reaction service. - * @param ActionAuthService $actionAuth ADR-023 action authorization. - * @param IUserSession $userSession The current user session. - * @param LoggerInterface $logger PSR logger. - * @param string|null $userId The acting user ID. - */ - public function __construct( - IRequest $request, - private readonly ReactionService $reactionService, - private readonly ActionAuthService $actionAuth, - private readonly IUserSession $userSession, - private readonly LoggerInterface $logger, - private readonly ?string $userId, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() +class DashboardReactionApiController extends Controller { + /** + * Constructor + * + * @param IRequest $request The request. + * @param ReactionService $reactionService The reaction service. + * @param ActionAuthService $actionAuth ADR-023 action authorization. + * @param IUserSession $userSession The current user session. + * @param LoggerInterface $logger PSR logger. + * @param string|null $userId The acting user ID. + */ + public function __construct( + IRequest $request, + private readonly ReactionService $reactionService, + private readonly ActionAuthService $actionAuth, + private readonly IUserSession $userSession, + private readonly LoggerInterface $logger, + private readonly ?string $userId, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() - /** - * GET /api/dashboards/{uuid}/reactions — return the - * `{counts, mine, enabled}` summary. REQ-RXN-003. - * - * @param string $uuid The dashboard UUID. - * - * @return JSONResponse The summary. - * - * @spec openspec/specs/dashboard-reactions/spec.md - */ - #[NoAdminRequired] - public function getReactions(string $uuid): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + /** + * GET /api/dashboards/{uuid}/reactions — return the + * `{counts, mine, enabled}` summary. REQ-RXN-003. + * + * @param string $uuid The dashboard UUID. + * + * @return JSONResponse The summary. + * + * @spec openspec/specs/dashboard-reactions/spec.md + */ + #[NoAdminRequired] + public function getReactions(string $uuid): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - $this->actionAuth->requireAction($user, 'dashboard-reaction.get-reactions'); + $this->actionAuth->requireAction($user, 'dashboard-reaction.get-reactions'); - try { - $summary = $this->reactionService->getReactionsSummary( - dashboardUuid: $uuid, - userId: $this->userId - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (PermissionDeniedException $e) { - return ResponseHelper::forbidden(message: $e->getMessage()); - } catch (Throwable $e) { - $this->logger->error( - message: 'getReactions failed: '.$e->getMessage(), - context: ['exception' => $e] - ); - return new JSONResponse( - data: ['error' => 'Operation failed'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - }//end try + try { + $summary = $this->reactionService->getReactionsSummary( + dashboardUuid: $uuid, + userId: $this->userId + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (PermissionDeniedException $e) { + return ResponseHelper::forbidden(message: $e->getMessage()); + } catch (Throwable $e) { + $this->logger->error( + message: 'getReactions failed: ' . $e->getMessage(), + context: ['exception' => $e] + ); + return new JSONResponse( + data: ['error' => 'Operation failed'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try - return ResponseHelper::success(data: $summary); - }//end getReactions() + return ResponseHelper::success(data: $summary); + }//end getReactions() - /** - * POST /api/dashboards/{uuid}/reactions — add the calling user's - * reaction. Idempotent (REQ-RXN-001 scenario "User re-posts the - * same emoji"). - * - * @param string $uuid The dashboard UUID. - * @param string $emoji The emoji to add (request body field). - * - * @return JSONResponse The updated summary. - * - * @spec openspec/specs/dashboard-reactions/spec.md - */ - #[NoAdminRequired] - public function addReaction(string $uuid, string $emoji=''): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + /** + * POST /api/dashboards/{uuid}/reactions — add the calling user's + * reaction. Idempotent (REQ-RXN-001 scenario "User re-posts the + * same emoji"). + * + * @param string $uuid The dashboard UUID. + * @param string $emoji The emoji to add (request body field). + * + * @return JSONResponse The updated summary. + * + * @spec openspec/specs/dashboard-reactions/spec.md + */ + #[NoAdminRequired] + public function addReaction(string $uuid, string $emoji = ''): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - $this->actionAuth->requireAction($user, 'dashboard-reaction.add-reaction'); + $this->actionAuth->requireAction($user, 'dashboard-reaction.add-reaction'); - try { - $summary = $this->reactionService->addReaction( - dashboardUuid: $uuid, - userId: $this->userId, - emoji: $emoji - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (PermissionDeniedException $e) { - return ResponseHelper::forbidden(message: $e->getMessage()); - } catch (ReactionsDisabledException $e) { - return ResponseHelper::forbidden(message: $e->getMessage()); - } catch (InvalidArgumentException $e) { - return new JSONResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); - } catch (Throwable $e) { - $this->logger->error( - message: 'addReaction failed: '.$e->getMessage(), - context: ['exception' => $e] - ); - return new JSONResponse( - data: ['error' => 'Operation failed'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - }//end try + try { + $summary = $this->reactionService->addReaction( + dashboardUuid: $uuid, + userId: $this->userId, + emoji: $emoji + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (PermissionDeniedException $e) { + return ResponseHelper::forbidden(message: $e->getMessage()); + } catch (ReactionsDisabledException $e) { + return ResponseHelper::forbidden(message: $e->getMessage()); + } catch (InvalidArgumentException $e) { + return new JSONResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + } catch (Throwable $e) { + $this->logger->error( + message: 'addReaction failed: ' . $e->getMessage(), + context: ['exception' => $e] + ); + return new JSONResponse( + data: ['error' => 'Operation failed'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try - return ResponseHelper::success(data: $summary); - }//end addReaction() + return ResponseHelper::success(data: $summary); + }//end addReaction() - /** - * DELETE /api/dashboards/{uuid}/reactions/{emoji} — remove the - * calling user's reaction. Idempotent (REQ-RXN-002). - * - * @param string $uuid The dashboard UUID. - * @param string $emoji The emoji to remove. - * - * @return JSONResponse Empty 204 response. - * - * @spec openspec/specs/dashboard-reactions/spec.md - */ - #[NoAdminRequired] - public function removeReaction(string $uuid, string $emoji): JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + /** + * DELETE /api/dashboards/{uuid}/reactions/{emoji} — remove the + * calling user's reaction. Idempotent (REQ-RXN-002). + * + * @param string $uuid The dashboard UUID. + * @param string $emoji The emoji to remove. + * + * @return JSONResponse Empty 204 response. + * + * @spec openspec/specs/dashboard-reactions/spec.md + */ + #[NoAdminRequired] + public function removeReaction(string $uuid, string $emoji): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - $this->actionAuth->requireAction($user, 'dashboard-reaction.remove-reaction'); + $this->actionAuth->requireAction($user, 'dashboard-reaction.remove-reaction'); - try { - $this->reactionService->removeReaction( - dashboardUuid: $uuid, - userId: $this->userId, - emoji: $emoji - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (PermissionDeniedException $e) { - return ResponseHelper::forbidden(message: $e->getMessage()); - } catch (Throwable $e) { - $this->logger->error( - message: 'removeReaction failed: '.$e->getMessage(), - context: ['exception' => $e] - ); - return new JSONResponse( - data: ['error' => 'Operation failed'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - }//end try + try { + $this->reactionService->removeReaction( + dashboardUuid: $uuid, + userId: $this->userId, + emoji: $emoji + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (PermissionDeniedException $e) { + return ResponseHelper::forbidden(message: $e->getMessage()); + } catch (Throwable $e) { + $this->logger->error( + message: 'removeReaction failed: ' . $e->getMessage(), + context: ['exception' => $e] + ); + return new JSONResponse( + data: ['error' => 'Operation failed'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try - // 204 No Content — JSONResponse with empty body and explicit - // status (the framework still emits headers/body shape, but - // the contract is "204 always" per REQ-RXN-002). - return new JSONResponse( - data: [], - statusCode: Http::STATUS_NO_CONTENT - ); - }//end removeReaction() + // 204 No Content — JSONResponse with empty body and explicit + // status (the framework still emits headers/body shape, but + // the contract is "204 always" per REQ-RXN-002). + return new JSONResponse( + data: [], + statusCode: Http::STATUS_NO_CONTENT + ); + }//end removeReaction() - /** - * GET /api/dashboards/{uuid}/reactions/{emoji}/users — return the - * paginated list of reactors. REQ-RXN-004. - * - * @param string $uuid The dashboard UUID. - * @param string $emoji The emoji. - * @param string|null $cursor Optional opaque cursor (offset). - * - * @return JSONResponse The reactors page. - * - * @spec openspec/specs/dashboard-reactions/spec.md - */ - #[NoAdminRequired] - public function getReactorsByEmoji( - string $uuid, - string $emoji, - ?string $cursor=null - ): JSONResponse { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } + /** + * GET /api/dashboards/{uuid}/reactions/{emoji}/users — return the + * paginated list of reactors. REQ-RXN-004. + * + * @param string $uuid The dashboard UUID. + * @param string $emoji The emoji. + * @param string|null $cursor Optional opaque cursor (offset). + * + * @return JSONResponse The reactors page. + * + * @spec openspec/specs/dashboard-reactions/spec.md + */ + #[NoAdminRequired] + public function getReactorsByEmoji( + string $uuid, + string $emoji, + ?string $cursor = null, + ): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } - $this->actionAuth->requireAction($user, 'dashboard-reaction.get-reactors-by-emoji'); + $this->actionAuth->requireAction($user, 'dashboard-reaction.get-reactors-by-emoji'); - try { - $page = $this->reactionService->getReactorsByEmoji( - dashboardUuid: $uuid, - emoji: $emoji, - userId: $this->userId, - cursor: $cursor - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (PermissionDeniedException $e) { - return ResponseHelper::forbidden(message: $e->getMessage()); - } catch (Throwable $e) { - $this->logger->error( - message: 'getReactorsByEmoji failed: '.$e->getMessage(), - context: ['exception' => $e] - ); - return new JSONResponse( - data: ['error' => 'Operation failed'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - }//end try + try { + $page = $this->reactionService->getReactorsByEmoji( + dashboardUuid: $uuid, + emoji: $emoji, + userId: $this->userId, + cursor: $cursor + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (PermissionDeniedException $e) { + return ResponseHelper::forbidden(message: $e->getMessage()); + } catch (Throwable $e) { + $this->logger->error( + message: 'getReactorsByEmoji failed: ' . $e->getMessage(), + context: ['exception' => $e] + ); + return new JSONResponse( + data: ['error' => 'Operation failed'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try - return ResponseHelper::success(data: $page); - }//end getReactorsByEmoji() + return ResponseHelper::success(data: $page); + }//end getReactorsByEmoji() }//end class diff --git a/lib/Controller/DashboardShareApiController.php b/lib/Controller/DashboardShareApiController.php index bbaabcd3..8d3145ef 100644 --- a/lib/Controller/DashboardShareApiController.php +++ b/lib/Controller/DashboardShareApiController.php @@ -44,308 +44,303 @@ * * @spec openspec/specs/dashboard-sharing/spec.md */ -class DashboardShareApiController extends Controller -{ - /** - * Constructor - * - * @param IRequest $request The request. - * @param DashboardShareService $shareService The share service. - * @param IUserManager $userManager Nextcloud user manager (sharee lookup). - * @param IGroupManager $groupManager Nextcloud group manager (sharee lookup). - * @param string|null $userId The calling user ID. - */ - public function __construct( - IRequest $request, - private readonly DashboardShareService $shareService, - private readonly IUserManager $userManager, - private readonly IGroupManager $groupManager, - private readonly ?string $userId, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() +class DashboardShareApiController extends Controller { + /** + * Constructor + * + * @param IRequest $request The request. + * @param DashboardShareService $shareService The share service. + * @param IUserManager $userManager Nextcloud user manager (sharee lookup). + * @param IGroupManager $groupManager Nextcloud group manager (sharee lookup). + * @param string|null $userId The calling user ID. + */ + public function __construct( + IRequest $request, + private readonly DashboardShareService $shareService, + private readonly IUserManager $userManager, + private readonly IGroupManager $groupManager, + private readonly ?string $userId, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() - /** - * List all shares for a dashboard. - * - * @param int $id The dashboard ID. - * - * @return DataResponse The list of shares. - * - * @spec openspec/specs/dashboard-sharing/spec.md - */ - #[NoAdminRequired] - public function index(int $id): DataResponse - { - if ($this->userId === null) { - return new DataResponse( - data: ['error' => 'Not logged in'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } + /** + * List all shares for a dashboard. + * + * @param int $id The dashboard ID. + * + * @return DataResponse The list of shares. + * + * @spec openspec/specs/dashboard-sharing/spec.md + */ + #[NoAdminRequired] + public function index(int $id): DataResponse { + if ($this->userId === null) { + return new DataResponse( + data: ['error' => 'Not logged in'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } - try { - $shares = $this->shareService->listShares( - dashboardId: $id, - userId: $this->userId - ); - $serialized = array_map( - callback: static fn($share) => $share->jsonSerialize(), - array: $shares - ); - return new DataResponse(data: $serialized); - } catch (DoesNotExistException) { - return new DataResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (Exception $e) { - return new DataResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_FORBIDDEN - ); - }//end try - }//end index() + try { + $shares = $this->shareService->listShares( + dashboardId: $id, + userId: $this->userId + ); + $serialized = array_map( + callback: static fn ($share) => $share->jsonSerialize(), + array: $shares + ); + return new DataResponse(data: $serialized); + } catch (DoesNotExistException) { + return new DataResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (Exception $e) { + return new DataResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_FORBIDDEN + ); + }//end try + }//end index() - /** - * Add or upsert a single share. REQ-SHARE-001. - * - * @param int $id The dashboard ID. - * @param string|null $shareType The share type. - * @param string|null $shareWith The recipient. - * @param string|null $permissionLevel The permission level. - * - * @return DataResponse The created/updated share. - * - * @spec openspec/specs/dashboard-sharing/spec.md - */ - #[NoAdminRequired] - public function create( - int $id, - ?string $shareType=null, - ?string $shareWith=null, - ?string $permissionLevel=null - ): DataResponse { - if ($this->userId === null) { - return new DataResponse( - data: ['error' => 'Not logged in'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } + /** + * Add or upsert a single share. REQ-SHARE-001. + * + * @param int $id The dashboard ID. + * @param string|null $shareType The share type. + * @param string|null $shareWith The recipient. + * @param string|null $permissionLevel The permission level. + * + * @return DataResponse The created/updated share. + * + * @spec openspec/specs/dashboard-sharing/spec.md + */ + #[NoAdminRequired] + public function create( + int $id, + ?string $shareType = null, + ?string $shareWith = null, + ?string $permissionLevel = null, + ): DataResponse { + if ($this->userId === null) { + return new DataResponse( + data: ['error' => 'Not logged in'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } - try { - $share = $this->shareService->addShare( - dashboardId: $id, - shareType: (string) $shareType, - shareWith: (string) $shareWith, - permissionLevel: (string) $permissionLevel, - callerId: $this->userId - ); - return new DataResponse( - data: $share->jsonSerialize(), - statusCode: Http::STATUS_CREATED - ); - } catch (InvalidArgumentException $e) { - return new DataResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); - } catch (DoesNotExistException) { - return new DataResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (Exception $e) { - return new DataResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_FORBIDDEN - ); - }//end try - }//end create() + try { + $share = $this->shareService->addShare( + dashboardId: $id, + shareType: (string)$shareType, + shareWith: (string)$shareWith, + permissionLevel: (string)$permissionLevel, + callerId: $this->userId + ); + return new DataResponse( + data: $share->jsonSerialize(), + statusCode: Http::STATUS_CREATED + ); + } catch (InvalidArgumentException $e) { + return new DataResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + } catch (DoesNotExistException) { + return new DataResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (Exception $e) { + return new DataResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_FORBIDDEN + ); + }//end try + }//end create() - /** - * Remove a share by ID. REQ-SHARE-001. - * - * @param int $shareId The share ID. - * - * @return DataResponse Empty 204 on success. - * - * @spec openspec/specs/dashboard-sharing/spec.md - */ - #[NoAdminRequired] - public function destroy(int $shareId): DataResponse - { - if ($this->userId === null) { - return new DataResponse( - data: ['error' => 'Not logged in'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } + /** + * Remove a share by ID. REQ-SHARE-001. + * + * @param int $shareId The share ID. + * + * @return DataResponse Empty 204 on success. + * + * @spec openspec/specs/dashboard-sharing/spec.md + */ + #[NoAdminRequired] + public function destroy(int $shareId): DataResponse { + if ($this->userId === null) { + return new DataResponse( + data: ['error' => 'Not logged in'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } - try { - $this->shareService->removeShare( - shareId: $shareId, - callerId: $this->userId - ); - return new DataResponse(data: [], statusCode: Http::STATUS_NO_CONTENT); - } catch (DoesNotExistException) { - return new DataResponse( - data: ['error' => 'Share not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (Exception $e) { - return new DataResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_FORBIDDEN - ); - }//end try - }//end destroy() + try { + $this->shareService->removeShare( + shareId: $shareId, + callerId: $this->userId + ); + return new DataResponse(data: [], statusCode: Http::STATUS_NO_CONTENT); + } catch (DoesNotExistException) { + return new DataResponse( + data: ['error' => 'Share not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (Exception $e) { + return new DataResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_FORBIDDEN + ); + }//end try + }//end destroy() - /** - * Atomically replace all shares for a dashboard. REQ-SHARE-009. - * - * @param int $id The dashboard ID. - * @param array|null $shares The new share list. - * - * @return DataResponse The new full share list. - * - * @spec openspec/specs/dashboard-sharing/spec.md - */ - #[NoAdminRequired] - public function replace(int $id, ?array $shares=null): DataResponse - { - if ($this->userId === null) { - return new DataResponse( - data: ['error' => 'Not logged in'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } + /** + * Atomically replace all shares for a dashboard. REQ-SHARE-009. + * + * @param int $id The dashboard ID. + * @param array|null $shares The new share list. + * + * @return DataResponse The new full share list. + * + * @spec openspec/specs/dashboard-sharing/spec.md + */ + #[NoAdminRequired] + public function replace(int $id, ?array $shares = null): DataResponse { + if ($this->userId === null) { + return new DataResponse( + data: ['error' => 'Not logged in'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } - if ($shares === null) { - $shares = []; - } + if ($shares === null) { + $shares = []; + } - try { - $newShares = $this->shareService->replaceShares( - dashboardId: $id, - shares: $shares, - userId: $this->userId - ); - $serialized = array_map( - callback: static fn($share) => $share->jsonSerialize(), - array: $newShares - ); - return new DataResponse(data: $serialized); - } catch (InvalidArgumentException $e) { - return new DataResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); - } catch (DoesNotExistException) { - return new DataResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (Exception $e) { - return new DataResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_FORBIDDEN - ); - }//end try - }//end replace() + try { + $newShares = $this->shareService->replaceShares( + dashboardId: $id, + shares: $shares, + userId: $this->userId + ); + $serialized = array_map( + callback: static fn ($share) => $share->jsonSerialize(), + array: $newShares + ); + return new DataResponse(data: $serialized); + } catch (InvalidArgumentException $e) { + return new DataResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + } catch (DoesNotExistException) { + return new DataResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (Exception $e) { + return new DataResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_FORBIDDEN + ); + }//end try + }//end replace() - /** - * Revoke all shares the caller has granted to a specific recipient. - * REQ-SHARE-010. - * - * @param string $shareType The share type. - * @param string $shareWith The recipient user/group ID. - * - * @return DataResponse The count of deleted rows. - * - * @spec openspec/specs/dashboard-sharing/spec.md - */ - #[NoAdminRequired] - public function revokeForRecipient( - string $shareType, - string $shareWith - ): DataResponse { - if ($this->userId === null) { - return new DataResponse( - data: ['error' => 'Not logged in'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } + /** + * Revoke all shares the caller has granted to a specific recipient. + * REQ-SHARE-010. + * + * @param string $shareType The share type. + * @param string $shareWith The recipient user/group ID. + * + * @return DataResponse The count of deleted rows. + * + * @spec openspec/specs/dashboard-sharing/spec.md + */ + #[NoAdminRequired] + public function revokeForRecipient( + string $shareType, + string $shareWith, + ): DataResponse { + if ($this->userId === null) { + return new DataResponse( + data: ['error' => 'Not logged in'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } - try { - $count = $this->shareService->revokeAllForRecipient( - shareType: $shareType, - shareWith: $shareWith, - callerId: $this->userId - ); - return new DataResponse(data: ['deleted' => $count]); - } catch (InvalidArgumentException $e) { - return new DataResponse( - data: ['error' => $e->getMessage()], - statusCode: Http::STATUS_BAD_REQUEST - ); - } - }//end revokeForRecipient() + try { + $count = $this->shareService->revokeAllForRecipient( + shareType: $shareType, + shareWith: $shareWith, + callerId: $this->userId + ); + return new DataResponse(data: ['deleted' => $count]); + } catch (InvalidArgumentException $e) { + return new DataResponse( + data: ['error' => $e->getMessage()], + statusCode: Http::STATUS_BAD_REQUEST + ); + } + }//end revokeForRecipient() - /** - * Search users and groups for the share autocomplete picker. - * REQ-SHARE-006. - * - * @param string $query The search query. - * - * @return DataResponse The matching users and groups. - * - * @spec openspec/specs/dashboard-sharing/spec.md - */ - #[NoAdminRequired] - public function searchSharees(string $query=''): DataResponse - { - if ($this->userId === null) { - return new DataResponse( - data: ['error' => 'Not logged in'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } + /** + * Search users and groups for the share autocomplete picker. + * REQ-SHARE-006. + * + * @param string $query The search query. + * + * @return DataResponse The matching users and groups. + * + * @spec openspec/specs/dashboard-sharing/spec.md + */ + #[NoAdminRequired] + public function searchSharees(string $query = ''): DataResponse { + if ($this->userId === null) { + return new DataResponse( + data: ['error' => 'Not logged in'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } - $trimmed = trim(string: $query); - // M3: single-character a..z sweeps stay blocked (directory - // enumeration guard, consistent with NC share picker) — but an - // EMPTY query returns a bounded suggestion list so the picker is - // never blank on focus (parity with the core share dialog). - if (strlen(string: $trimmed) === 1) { - return new DataResponse(data: ['users' => [], 'groups' => []]); - } + $trimmed = trim(string: $query); + // M3: single-character a..z sweeps stay blocked (directory + // enumeration guard, consistent with NC share picker) — but an + // EMPTY query returns a bounded suggestion list so the picker is + // never blank on focus (parity with the core share dialog). + if (strlen(string: $trimmed) === 1) { + return new DataResponse(data: ['users' => [], 'groups' => []]); + } - $users = []; - foreach ($this->userManager->search(pattern: $trimmed, limit: 10) as $user) { - if ($user->getUID() === $this->userId) { - continue; - } + $users = []; + foreach ($this->userManager->search(pattern: $trimmed, limit: 10) as $user) { + if ($user->getUID() === $this->userId) { + continue; + } - $users[] = [ - 'id' => $user->getUID(), - 'displayName' => $user->getDisplayName(), - ]; - } + $users[] = [ + 'id' => $user->getUID(), + 'displayName' => $user->getDisplayName(), + ]; + } - $groups = []; - foreach ($this->groupManager->search(search: $trimmed, limit: 10) as $group) { - $groups[] = [ - 'id' => $group->getGID(), - 'displayName' => $group->getDisplayName(), - ]; - } + $groups = []; + foreach ($this->groupManager->search(search: $trimmed, limit: 10) as $group) { + $groups[] = [ + 'id' => $group->getGID(), + 'displayName' => $group->getDisplayName(), + ]; + } - return new DataResponse( - data: ['users' => $users, 'groups' => $groups] - ); - }//end searchSharees() + return new DataResponse( + data: ['users' => $users, 'groups' => $groups] + ); + }//end searchSharees() }//end class diff --git a/lib/Controller/DashboardTranslationApiController.php b/lib/Controller/DashboardTranslationApiController.php index 41faf0c4..7b419f7d 100644 --- a/lib/Controller/DashboardTranslationApiController.php +++ b/lib/Controller/DashboardTranslationApiController.php @@ -46,665 +46,653 @@ * * @spec openspec/specs/dashboard-language-content/spec.md */ -class DashboardTranslationApiController extends Controller -{ - /** - * Constructor - * - * @param IRequest $request The request. - * @param DashboardMapper $dashboardMapper Dashboard mapper - * (used for the - * ownership check - * before any - * translation - * mutation). - * @param DashboardTranslationService $translationService Translation - * service. - * @param ActionAuthService $actionAuth ADR-023 action - * authorization. - * @param IUserSession $userSession User session - * (IUser resolution). - * @param string|null $userId The user ID. - */ - public function __construct( - IRequest $request, - private readonly DashboardMapper $dashboardMapper, - private readonly DashboardTranslationService $translationService, - private readonly ActionAuthService $actionAuth, - private readonly IUserSession $userSession, - private readonly ?string $userId, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * GET /api/dashboards/{uuid}/translations — list every translation - * variant for a dashboard. Returns 403 when the dashboard belongs - * to another user. REQ-DASH-038. - * - * @param string $uuid The dashboard UUID. - * - * @return JSONResponse The list payload. - * - * @spec openspec/specs/dashboard-language-content/spec.md - */ - #[NoAdminRequired] - public function list(string $uuid): JSONResponse - { - $user = $this->userSession->getUser(); - if ($this->userId === null || $user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'dashboard-translation.list'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - $ownerCheck = $this->assertOwner(uuid: $uuid); - if ($ownerCheck !== null) { - return $ownerCheck; - } - - $variants = $this->translationService->listVariants( - dashboardUuid: $uuid - ); - - $serialized = ResponseHelper::serializeList(entities: $variants); - - return ResponseHelper::success( - data: ['translations' => $serialized] - ); - }//end list() - - /** - * POST /api/dashboards/{uuid}/translations — create a new variant. - * - * Body: `{languageCode, name?, description?, widgetTreeJson?, copyFrom?}`. - * Returns 201 with the created entity. Maps duplicate-language - * conflicts to HTTP 409. REQ-DASH-040. - * - * @param string $uuid The dashboard UUID. - * @param string|null $languageCode The language code from the body. - * @param string|null $name The optional name. - * @param string|null $description The optional description. - * @param string|null $widgetTreeJson The optional widget tree JSON. - * @param string|null $copyFrom Optional source language. - * - * @return JSONResponse The created variant. - * - * @spec openspec/specs/dashboard-language-content/spec.md - */ - #[NoAdminRequired] - public function create( - string $uuid, - ?string $languageCode=null, - ?string $name=null, - ?string $description=null, - ?string $widgetTreeJson=null, - ?string $copyFrom=null - ): JSONResponse { - $user = $this->userSession->getUser(); - if ($this->userId === null || $user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'dashboard-translation.create'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - $ownerCheck = $this->assertOwner(uuid: $uuid); - if ($ownerCheck !== null) { - return $ownerCheck; - } - - if ($this->isBlank(value: $languageCode) === true) { - return self::invalidArgument( - message: DashboardTranslationService::ERR_INVALID_LANGUAGE - ); - } - - try { - $variant = $this->translationService->createVariant( - dashboardUuid: $uuid, - languageCode: (string) $languageCode, - name: $name, - description: $description, - widgetTreeJson: $widgetTreeJson, - copyFromLanguage: $copyFrom - ); - - return new JSONResponse( - data: ['translation' => $variant->jsonSerialize()], - statusCode: Http::STATUS_CREATED - ); - } catch (InvalidArgumentException $e) { - return self::invalidArgument(message: $e->getMessage()); - } catch (Exception $e) { - return $this->mapCreateFailure(exception: $e); - }//end try - }//end create() - - /** - * Test whether an optional string parameter carries no value. - * - * A missing body key arrives as `null` and an explicitly blank one as - * `''`; both are rejected identically by the create endpoint. - * - * @param string|null $value The parameter value. - * - * @return bool True when the parameter carries no value. - */ - private function isBlank(?string $value): bool - { - return ($value === null || $value === ''); - }//end isBlank() - - /** - * Build the shared HTTP 400 invalid-argument envelope. - * - * @param string $message The validation message. - * - * @return JSONResponse The 400 response. - */ - private static function invalidArgument(string $message): JSONResponse - { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'invalid_argument', - 'message' => $message, - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - }//end invalidArgument() - - /** - * Map a create-variant failure onto its HTTP envelope. - * - * A duplicate-language collision is the one domain failure with a - * dedicated status (HTTP 409); everything else falls through to the - * generic error envelope. REQ-DASH-040. - * - * @param Exception $exception The failure thrown by the service. - * - * @return JSONResponse The mapped response. - */ - private function mapCreateFailure(Exception $exception): JSONResponse - { - if ($exception->getMessage() === DashboardTranslationService::ERR_LANGUAGE_EXISTS) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'language_exists', - 'message' => $exception->getMessage(), - ], - statusCode: Http::STATUS_CONFLICT - ); - } - - return ResponseHelper::error(exception: $exception); - }//end mapCreateFailure() - - /** - * PUT /api/dashboards/{uuid}/translations/{lang} — update a variant. - * - * Body: `{name?, description?, widgetTreeJson?}`. Returns 200 with - * the updated entity. REQ-DASH-041. - * - * @param string $uuid The dashboard UUID. - * @param string $lang The language code from the URL. - * @param string|null $name Optional new name. - * @param string|null $description Optional new description. - * @param string|null $widgetTreeJson Optional new widget tree JSON. - * - * @return JSONResponse The updated variant. - * - * @spec openspec/specs/dashboard-language-content/spec.md - */ - #[NoAdminRequired] - public function update( - string $uuid, - string $lang, - ?string $name=null, - ?string $description=null, - ?string $widgetTreeJson=null - ): JSONResponse { - $user = $this->userSession->getUser(); - if ($this->userId === null || $user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'dashboard-translation.update'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - $ownerCheck = $this->assertOwner(uuid: $uuid); - if ($ownerCheck !== null) { - return $ownerCheck; - } - - $patch = $this->buildPatch( - name: $name, - description: $description, - widgetTreeJson: $widgetTreeJson - ); - - try { - $variant = $this->translationService->updateVariant( - dashboardUuid: $uuid, - languageCode: $lang, - patch: $patch - ); - - return ResponseHelper::success( - data: ['translation' => $variant->jsonSerialize()] - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'not_found', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (Exception $e) { - return ResponseHelper::error(exception: $e); - }//end try - }//end update() - - /** - * DELETE /api/dashboards/{uuid}/translations/{lang} — delete a - * variant. Maps last-variant / primary-variant guards to HTTP 400. - * REQ-DASH-042. - * - * @param string $uuid The dashboard UUID. - * @param string $lang The language code from the URL. - * - * @return JSONResponse The status payload. - * - * @spec openspec/specs/dashboard-language-content/spec.md - */ - #[NoAdminRequired] - public function destroy(string $uuid, string $lang): JSONResponse - { - $user = $this->userSession->getUser(); - if ($this->userId === null || $user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'dashboard-translation.destroy'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - $ownerCheck = $this->assertOwner(uuid: $uuid); - if ($ownerCheck !== null) { - return $ownerCheck; - } - - try { - $this->translationService->deleteVariant( - dashboardUuid: $uuid, - languageCode: $lang - ); - - return ResponseHelper::success(data: ['status' => 'ok']); - } catch (DoesNotExistException) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'not_found', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (Exception $e) { - $errorCode = 'invalid_state'; - if ($e->getMessage() === DashboardTranslationService::ERR_LAST_VARIANT) { - $errorCode = 'last_variant'; - } else if ($e->getMessage() === DashboardTranslationService::ERR_DELETE_PRIMARY) { - $errorCode = 'primary_variant'; - } - - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => $errorCode, - 'message' => $e->getMessage(), - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - }//end try - }//end destroy() - - /** - * POST /api/dashboards/{uuid}/translations/{lang}/set-primary — - * promote a variant to primary. Idempotent. REQ-DASH-043. - * - * @param string $uuid The dashboard UUID. - * @param string $lang The language code from the URL. - * - * @return JSONResponse The promoted variant. - * - * @spec openspec/specs/dashboard-language-content/spec.md - */ - #[NoAdminRequired] - public function setPrimary(string $uuid, string $lang): JSONResponse - { - $user = $this->userSession->getUser(); - if ($this->userId === null || $user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'dashboard-translation.set-primary'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - $ownerCheck = $this->assertOwner(uuid: $uuid); - if ($ownerCheck !== null) { - return $ownerCheck; - } - - try { - $variant = $this->translationService->promoteVariantToPrimary( - dashboardUuid: $uuid, - languageCode: $lang - ); - - return ResponseHelper::success( - data: ['translation' => $variant->jsonSerialize()] - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'not_found', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (Exception $e) { - return ResponseHelper::error(exception: $e); - } - }//end setPrimary() - - /** - * GET /api/dashboards/{uuid}/resolved — resolve the dashboard's - * content for the viewer's locale. Optional `?lang=` query - * parameter overrides the user's Nextcloud locale; in strict mode - * an unknown explicit lang returns 404 instead of falling back. - * REQ-DASH-039. - * - * Response shape: - * - `dashboard`: the dashboard entity payload - * - `translation`: the matched translation row - * - `availableLanguages`: sorted list of codes - * - `currentLanguage`: the matched code - * - `isFallback`: true when the primary fallback was used - * - * @param string $uuid The dashboard UUID. - * - * @return JSONResponse The resolved payload. - * - * @spec openspec/specs/dashboard-language-content/spec.md - */ - #[NoAdminRequired] - public function resolved(string $uuid): JSONResponse - { - $user = $this->userSession->getUser(); - if ($this->userId === null || $user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'dashboard-translation.resolved'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - try { - $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); - } catch (DoesNotExistException) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'not_found', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - $variant = $this->resolveVariant( - uuid: $uuid, - dashboard: $dashboard, - explicitLang: $this->request->getParam(key: 'lang') - ); - - // Only the strict explicit-lang path yields null; the locale path - // always materialises a variant. REQ-DASH-039 strict scenario. - if ($variant === null) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'language_not_available', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - return ResponseHelper::success( - data: [ - 'dashboard' => $dashboard->jsonSerialize(), - 'translation' => $variant['translation']->jsonSerialize(), - 'availableLanguages' => $this->resolveAvailableLanguages( - uuid: $uuid, - variant: $variant - ), - 'currentLanguage' => $variant['translation']->getLanguageCode(), - 'isFallback' => $variant['isFallback'], - ] - ); - }//end resolved() - - /** - * Resolve the translation variant this request should render. - * - * A usable `?lang=` query parameter selects the strict exact-match - * path (which may report "no such language" by returning null); its - * absence — or a blank / non-string value — selects the viewer's own - * locale, which always yields a variant. REQ-DASH-039. - * - * @param string $uuid The dashboard UUID. - * @param Dashboard $dashboard The dashboard entity (legacy source). - * @param mixed $explicitLang The raw `lang` query parameter. - * - * @return array{translation: mixed, isFallback: bool}|null The variant, - * or null. - */ - private function resolveVariant( - string $uuid, - Dashboard $dashboard, - mixed $explicitLang - ): ?array { - if (is_string($explicitLang) === false || $explicitLang === '') { - return $this->resolveLocaleVariant(uuid: $uuid, dashboard: $dashboard); - } - - return $this->resolveExactVariant(uuid: $uuid, explicitLang: $explicitLang); - }//end resolveVariant() - - /** - * Resolve a variant that matches the requested code exactly. - * - * Strict mode for the explicit `?lang=` parameter — when no exact - * match exists for the requested code the caller must return 404 - * instead of the primary-fallback envelope, so a near-miss (the - * service's own fallback) is reported as "no match" here. - * REQ-DASH-039 strict scenario. - * - * @param string $uuid The dashboard UUID. - * @param string $explicitLang The requested language code. - * - * @return array{translation: mixed, isFallback: bool}|null The exact - * match, or - * null. - */ - private function resolveExactVariant(string $uuid, string $explicitLang): ?array - { - $variant = $this->translationService->resolveForLocale( - dashboardUuid: $uuid, - preferredLanguage: $explicitLang - ); - - $requested = DashboardTranslationMapper::normaliseLanguageCode( - raw: $explicitLang - ); - $matched = null; - if ($variant !== null) { - $matched = (string) $variant['translation']->getLanguageCode(); - } - - if ($matched !== $requested) { - return null; - } - - return $variant; - }//end resolveExactVariant() - - /** - * Resolve the variant for the viewer's own locale. - * - * Legacy fallback — dashboards predating REQ-DASH-038 may have no - * translation rows yet. Materialise an in-memory variant from the - * dashboard's own fields so the response envelope shape stays - * uniform. REQ-DASH-044. - * - * @param string $uuid The dashboard UUID. - * @param Dashboard $dashboard The dashboard entity (legacy source). - * - * @return array{translation: mixed, isFallback: bool} The variant. - */ - private function resolveLocaleVariant(string $uuid, Dashboard $dashboard): array - { - $variant = $this->translationService->resolveForLocale( - dashboardUuid: $uuid, - preferredLanguage: '' - ); - - if ($variant !== null) { - return $variant; - } - - return [ - 'translation' => $this->translationService - ->materialiseLegacyVariant(dashboard: $dashboard), - 'isFallback' => true, - ]; - }//end resolveLocaleVariant() - - /** - * List the language codes offered for this dashboard. - * - * A dashboard with no stored translation rows still advertises the - * one code the resolved variant carries, so the language switcher is - * never empty when content is being shown. - * - * @param string $uuid The dashboard UUID. - * @param array{translation: mixed, isFallback: bool} $variant The resolved variant. - * - * @return array The available codes. - */ - private function resolveAvailableLanguages(string $uuid, array $variant): array - { - $available = $this->translationService->listAvailableLanguages( - dashboardUuid: $uuid - ); - if (count($available) > 0) { - return $available; - } - - $code = (string) $variant['translation']->getLanguageCode(); - if ($code === '') { - return $available; - } - - return [$code]; - }//end resolveAvailableLanguages() - - /** - * Look up the dashboard and verify the current user is the owner. - * - * Returns null when the check passes; an HTTP 403 / 404 envelope - * when it fails. Group-shared dashboards are not addressable via - * the personal-scope translation endpoints — they short-circuit to - * 403 (the group-scoped translation flow lives separately). - * - * @param string $uuid The dashboard UUID. - * - * @return JSONResponse|null The error envelope or null on success. - */ - private function assertOwner(string $uuid): ?JSONResponse - { - try { - $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); - } catch (DoesNotExistException) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'not_found', - ], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - if ($dashboard->getUserId() !== $this->userId) { - return ResponseHelper::forbidden(); - } - - return null; - }//end assertOwner() - - /** - * Build the patch payload from individual nullable parameters. - * - * `null` means "not in payload" (skip the key); anything else (incl. - * the empty string) means "set it explicitly". The service then - * inspects key presence with `array_key_exists`. - * - * @param string|null $name The new name. - * @param string|null $description The new description. - * @param string|null $widgetTreeJson The new widget tree JSON. - * - * @return array The patch payload. - */ - private function buildPatch( - ?string $name, - ?string $description, - ?string $widgetTreeJson - ): array { - $patch = []; - if ($name !== null) { - $patch['name'] = $name; - } - - if ($description !== null) { - $patch['description'] = $description; - } - - if ($widgetTreeJson !== null) { - $patch['widgetTreeJson'] = $widgetTreeJson; - } - - return $patch; - }//end buildPatch() +class DashboardTranslationApiController extends Controller { + /** + * Constructor + * + * @param IRequest $request The request. + * @param DashboardMapper $dashboardMapper Dashboard mapper + * (used for the + * ownership check + * before any + * translation + * mutation). + * @param DashboardTranslationService $translationService Translation + * service. + * @param ActionAuthService $actionAuth ADR-023 action + * authorization. + * @param IUserSession $userSession User session + * (IUser resolution). + * @param string|null $userId The user ID. + */ + public function __construct( + IRequest $request, + private readonly DashboardMapper $dashboardMapper, + private readonly DashboardTranslationService $translationService, + private readonly ActionAuthService $actionAuth, + private readonly IUserSession $userSession, + private readonly ?string $userId, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * GET /api/dashboards/{uuid}/translations — list every translation + * variant for a dashboard. Returns 403 when the dashboard belongs + * to another user. REQ-DASH-038. + * + * @param string $uuid The dashboard UUID. + * + * @return JSONResponse The list payload. + * + * @spec openspec/specs/dashboard-language-content/spec.md + */ + #[NoAdminRequired] + public function list(string $uuid): JSONResponse { + $user = $this->userSession->getUser(); + if ($this->userId === null || $user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'dashboard-translation.list'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + $ownerCheck = $this->assertOwner(uuid: $uuid); + if ($ownerCheck !== null) { + return $ownerCheck; + } + + $variants = $this->translationService->listVariants( + dashboardUuid: $uuid + ); + + $serialized = ResponseHelper::serializeList(entities: $variants); + + return ResponseHelper::success( + data: ['translations' => $serialized] + ); + }//end list() + + /** + * POST /api/dashboards/{uuid}/translations — create a new variant. + * + * Body: `{languageCode, name?, description?, widgetTreeJson?, copyFrom?}`. + * Returns 201 with the created entity. Maps duplicate-language + * conflicts to HTTP 409. REQ-DASH-040. + * + * @param string $uuid The dashboard UUID. + * @param string|null $languageCode The language code from the body. + * @param string|null $name The optional name. + * @param string|null $description The optional description. + * @param string|null $widgetTreeJson The optional widget tree JSON. + * @param string|null $copyFrom Optional source language. + * + * @return JSONResponse The created variant. + * + * @spec openspec/specs/dashboard-language-content/spec.md + */ + #[NoAdminRequired] + public function create( + string $uuid, + ?string $languageCode = null, + ?string $name = null, + ?string $description = null, + ?string $widgetTreeJson = null, + ?string $copyFrom = null, + ): JSONResponse { + $user = $this->userSession->getUser(); + if ($this->userId === null || $user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'dashboard-translation.create'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + $ownerCheck = $this->assertOwner(uuid: $uuid); + if ($ownerCheck !== null) { + return $ownerCheck; + } + + if ($this->isBlank(value: $languageCode) === true) { + return self::invalidArgument( + message: DashboardTranslationService::ERR_INVALID_LANGUAGE + ); + } + + try { + $variant = $this->translationService->createVariant( + dashboardUuid: $uuid, + languageCode: (string)$languageCode, + name: $name, + description: $description, + widgetTreeJson: $widgetTreeJson, + copyFromLanguage: $copyFrom + ); + + return new JSONResponse( + data: ['translation' => $variant->jsonSerialize()], + statusCode: Http::STATUS_CREATED + ); + } catch (InvalidArgumentException $e) { + return self::invalidArgument(message: $e->getMessage()); + } catch (Exception $e) { + return $this->mapCreateFailure(exception: $e); + }//end try + }//end create() + + /** + * Test whether an optional string parameter carries no value. + * + * A missing body key arrives as `null` and an explicitly blank one as + * `''`; both are rejected identically by the create endpoint. + * + * @param string|null $value The parameter value. + * + * @return bool True when the parameter carries no value. + */ + private function isBlank(?string $value): bool { + return ($value === null || $value === ''); + }//end isBlank() + + /** + * Build the shared HTTP 400 invalid-argument envelope. + * + * @param string $message The validation message. + * + * @return JSONResponse The 400 response. + */ + private static function invalidArgument(string $message): JSONResponse { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'invalid_argument', + 'message' => $message, + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + }//end invalidArgument() + + /** + * Map a create-variant failure onto its HTTP envelope. + * + * A duplicate-language collision is the one domain failure with a + * dedicated status (HTTP 409); everything else falls through to the + * generic error envelope. REQ-DASH-040. + * + * @param Exception $exception The failure thrown by the service. + * + * @return JSONResponse The mapped response. + */ + private function mapCreateFailure(Exception $exception): JSONResponse { + if ($exception->getMessage() === DashboardTranslationService::ERR_LANGUAGE_EXISTS) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'language_exists', + 'message' => $exception->getMessage(), + ], + statusCode: Http::STATUS_CONFLICT + ); + } + + return ResponseHelper::error(exception: $exception); + }//end mapCreateFailure() + + /** + * PUT /api/dashboards/{uuid}/translations/{lang} — update a variant. + * + * Body: `{name?, description?, widgetTreeJson?}`. Returns 200 with + * the updated entity. REQ-DASH-041. + * + * @param string $uuid The dashboard UUID. + * @param string $lang The language code from the URL. + * @param string|null $name Optional new name. + * @param string|null $description Optional new description. + * @param string|null $widgetTreeJson Optional new widget tree JSON. + * + * @return JSONResponse The updated variant. + * + * @spec openspec/specs/dashboard-language-content/spec.md + */ + #[NoAdminRequired] + public function update( + string $uuid, + string $lang, + ?string $name = null, + ?string $description = null, + ?string $widgetTreeJson = null, + ): JSONResponse { + $user = $this->userSession->getUser(); + if ($this->userId === null || $user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'dashboard-translation.update'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + $ownerCheck = $this->assertOwner(uuid: $uuid); + if ($ownerCheck !== null) { + return $ownerCheck; + } + + $patch = $this->buildPatch( + name: $name, + description: $description, + widgetTreeJson: $widgetTreeJson + ); + + try { + $variant = $this->translationService->updateVariant( + dashboardUuid: $uuid, + languageCode: $lang, + patch: $patch + ); + + return ResponseHelper::success( + data: ['translation' => $variant->jsonSerialize()] + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'not_found', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (Exception $e) { + return ResponseHelper::error(exception: $e); + }//end try + }//end update() + + /** + * DELETE /api/dashboards/{uuid}/translations/{lang} — delete a + * variant. Maps last-variant / primary-variant guards to HTTP 400. + * REQ-DASH-042. + * + * @param string $uuid The dashboard UUID. + * @param string $lang The language code from the URL. + * + * @return JSONResponse The status payload. + * + * @spec openspec/specs/dashboard-language-content/spec.md + */ + #[NoAdminRequired] + public function destroy(string $uuid, string $lang): JSONResponse { + $user = $this->userSession->getUser(); + if ($this->userId === null || $user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'dashboard-translation.destroy'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + $ownerCheck = $this->assertOwner(uuid: $uuid); + if ($ownerCheck !== null) { + return $ownerCheck; + } + + try { + $this->translationService->deleteVariant( + dashboardUuid: $uuid, + languageCode: $lang + ); + + return ResponseHelper::success(data: ['status' => 'ok']); + } catch (DoesNotExistException) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'not_found', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (Exception $e) { + $errorCode = 'invalid_state'; + if ($e->getMessage() === DashboardTranslationService::ERR_LAST_VARIANT) { + $errorCode = 'last_variant'; + } elseif ($e->getMessage() === DashboardTranslationService::ERR_DELETE_PRIMARY) { + $errorCode = 'primary_variant'; + } + + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => $errorCode, + 'message' => $e->getMessage(), + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + }//end try + }//end destroy() + + /** + * POST /api/dashboards/{uuid}/translations/{lang}/set-primary — + * promote a variant to primary. Idempotent. REQ-DASH-043. + * + * @param string $uuid The dashboard UUID. + * @param string $lang The language code from the URL. + * + * @return JSONResponse The promoted variant. + * + * @spec openspec/specs/dashboard-language-content/spec.md + */ + #[NoAdminRequired] + public function setPrimary(string $uuid, string $lang): JSONResponse { + $user = $this->userSession->getUser(); + if ($this->userId === null || $user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'dashboard-translation.set-primary'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + $ownerCheck = $this->assertOwner(uuid: $uuid); + if ($ownerCheck !== null) { + return $ownerCheck; + } + + try { + $variant = $this->translationService->promoteVariantToPrimary( + dashboardUuid: $uuid, + languageCode: $lang + ); + + return ResponseHelper::success( + data: ['translation' => $variant->jsonSerialize()] + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'not_found', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (Exception $e) { + return ResponseHelper::error(exception: $e); + } + }//end setPrimary() + + /** + * GET /api/dashboards/{uuid}/resolved — resolve the dashboard's + * content for the viewer's locale. Optional `?lang=` query + * parameter overrides the user's Nextcloud locale; in strict mode + * an unknown explicit lang returns 404 instead of falling back. + * REQ-DASH-039. + * + * Response shape: + * - `dashboard`: the dashboard entity payload + * - `translation`: the matched translation row + * - `availableLanguages`: sorted list of codes + * - `currentLanguage`: the matched code + * - `isFallback`: true when the primary fallback was used + * + * @param string $uuid The dashboard UUID. + * + * @return JSONResponse The resolved payload. + * + * @spec openspec/specs/dashboard-language-content/spec.md + */ + #[NoAdminRequired] + public function resolved(string $uuid): JSONResponse { + $user = $this->userSession->getUser(); + if ($this->userId === null || $user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'dashboard-translation.resolved'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + try { + $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); + } catch (DoesNotExistException) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'not_found', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + $variant = $this->resolveVariant( + uuid: $uuid, + dashboard: $dashboard, + explicitLang: $this->request->getParam(key: 'lang') + ); + + // Only the strict explicit-lang path yields null; the locale path + // always materialises a variant. REQ-DASH-039 strict scenario. + if ($variant === null) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'language_not_available', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + return ResponseHelper::success( + data: [ + 'dashboard' => $dashboard->jsonSerialize(), + 'translation' => $variant['translation']->jsonSerialize(), + 'availableLanguages' => $this->resolveAvailableLanguages( + uuid: $uuid, + variant: $variant + ), + 'currentLanguage' => $variant['translation']->getLanguageCode(), + 'isFallback' => $variant['isFallback'], + ] + ); + }//end resolved() + + /** + * Resolve the translation variant this request should render. + * + * A usable `?lang=` query parameter selects the strict exact-match + * path (which may report "no such language" by returning null); its + * absence — or a blank / non-string value — selects the viewer's own + * locale, which always yields a variant. REQ-DASH-039. + * + * @param string $uuid The dashboard UUID. + * @param Dashboard $dashboard The dashboard entity (legacy source). + * @param mixed $explicitLang The raw `lang` query parameter. + * + * @return array{translation: mixed, isFallback: bool}|null The variant, + * or null. + */ + private function resolveVariant( + string $uuid, + Dashboard $dashboard, + mixed $explicitLang, + ): ?array { + if (is_string($explicitLang) === false || $explicitLang === '') { + return $this->resolveLocaleVariant(uuid: $uuid, dashboard: $dashboard); + } + + return $this->resolveExactVariant(uuid: $uuid, explicitLang: $explicitLang); + }//end resolveVariant() + + /** + * Resolve a variant that matches the requested code exactly. + * + * Strict mode for the explicit `?lang=` parameter — when no exact + * match exists for the requested code the caller must return 404 + * instead of the primary-fallback envelope, so a near-miss (the + * service's own fallback) is reported as "no match" here. + * REQ-DASH-039 strict scenario. + * + * @param string $uuid The dashboard UUID. + * @param string $explicitLang The requested language code. + * + * @return array{translation: mixed, isFallback: bool}|null The exact + * match, or + * null. + */ + private function resolveExactVariant(string $uuid, string $explicitLang): ?array { + $variant = $this->translationService->resolveForLocale( + dashboardUuid: $uuid, + preferredLanguage: $explicitLang + ); + + $requested = DashboardTranslationMapper::normaliseLanguageCode( + raw: $explicitLang + ); + $matched = null; + if ($variant !== null) { + $matched = (string)$variant['translation']->getLanguageCode(); + } + + if ($matched !== $requested) { + return null; + } + + return $variant; + }//end resolveExactVariant() + + /** + * Resolve the variant for the viewer's own locale. + * + * Legacy fallback — dashboards predating REQ-DASH-038 may have no + * translation rows yet. Materialise an in-memory variant from the + * dashboard's own fields so the response envelope shape stays + * uniform. REQ-DASH-044. + * + * @param string $uuid The dashboard UUID. + * @param Dashboard $dashboard The dashboard entity (legacy source). + * + * @return array{translation: mixed, isFallback: bool} The variant. + */ + private function resolveLocaleVariant(string $uuid, Dashboard $dashboard): array { + $variant = $this->translationService->resolveForLocale( + dashboardUuid: $uuid, + preferredLanguage: '' + ); + + if ($variant !== null) { + return $variant; + } + + return [ + 'translation' => $this->translationService + ->materialiseLegacyVariant(dashboard: $dashboard), + 'isFallback' => true, + ]; + }//end resolveLocaleVariant() + + /** + * List the language codes offered for this dashboard. + * + * A dashboard with no stored translation rows still advertises the + * one code the resolved variant carries, so the language switcher is + * never empty when content is being shown. + * + * @param string $uuid The dashboard UUID. + * @param array{translation: mixed, isFallback: bool} $variant The resolved variant. + * + * @return array The available codes. + */ + private function resolveAvailableLanguages(string $uuid, array $variant): array { + $available = $this->translationService->listAvailableLanguages( + dashboardUuid: $uuid + ); + if (count($available) > 0) { + return $available; + } + + $code = (string)$variant['translation']->getLanguageCode(); + if ($code === '') { + return $available; + } + + return [$code]; + }//end resolveAvailableLanguages() + + /** + * Look up the dashboard and verify the current user is the owner. + * + * Returns null when the check passes; an HTTP 403 / 404 envelope + * when it fails. Group-shared dashboards are not addressable via + * the personal-scope translation endpoints — they short-circuit to + * 403 (the group-scoped translation flow lives separately). + * + * @param string $uuid The dashboard UUID. + * + * @return JSONResponse|null The error envelope or null on success. + */ + private function assertOwner(string $uuid): ?JSONResponse { + try { + $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); + } catch (DoesNotExistException) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'not_found', + ], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + if ($dashboard->getUserId() !== $this->userId) { + return ResponseHelper::forbidden(); + } + + return null; + }//end assertOwner() + + /** + * Build the patch payload from individual nullable parameters. + * + * `null` means "not in payload" (skip the key); anything else (incl. + * the empty string) means "set it explicitly". The service then + * inspects key presence with `array_key_exists`. + * + * @param string|null $name The new name. + * @param string|null $description The new description. + * @param string|null $widgetTreeJson The new widget tree JSON. + * + * @return array The patch payload. + */ + private function buildPatch( + ?string $name, + ?string $description, + ?string $widgetTreeJson, + ): array { + $patch = []; + if ($name !== null) { + $patch['name'] = $name; + } + + if ($description !== null) { + $patch['description'] = $description; + } + + if ($widgetTreeJson !== null) { + $patch['widgetTreeJson'] = $widgetTreeJson; + } + + return $patch; + }//end buildPatch() }//end class diff --git a/lib/Controller/DashboardVersionApiController.php b/lib/Controller/DashboardVersionApiController.php index 3fd5ab57..79ff6067 100644 --- a/lib/Controller/DashboardVersionApiController.php +++ b/lib/Controller/DashboardVersionApiController.php @@ -48,292 +48,289 @@ /** * Controller for dashboard version endpoints (REQ-VERS-001..009). */ -class DashboardVersionApiController extends Controller -{ - /** - * Constructor - * - * @param IRequest $request NC request. - * @param DashboardMapper $dashboardMapper Dashboard row lookup. - * @param DashboardVersionService $versionService Version service. - * @param ActionAuthService $actionAuth ADR-023 action authorization. - * @param IUserSession $userSession User session (IUser resolution). - * @param LoggerInterface $logger PSR logger. - * @param string|null $userId Current user ID. - */ - public function __construct( - IRequest $request, - private readonly DashboardMapper $dashboardMapper, - private readonly DashboardVersionService $versionService, - private readonly ActionAuthService $actionAuth, - private readonly IUserSession $userSession, - private readonly LoggerInterface $logger, - private readonly ?string $userId, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * List the versions for a dashboard, newest-first (REQ-VERS-003). - * - * @param string $uuid The dashboard UUID. - * - * @return JSONResponse The version list envelope. - * - * @spec openspec/specs/dashboard-versioning/spec.md - */ - #[NoAdminRequired] - public function listVersions(string $uuid): JSONResponse - { - if ($this->userId === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'dashboard-version.list-versions'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - try { - $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - try { - $envelope = $this->versionService->listVersions( - dashboard: $dashboard, - requestingUser: $this->userId - ); - } catch (Exception $e) { - return $this->mapServiceException(exception: $e); - } - - return new JSONResponse(data: $envelope, statusCode: Http::STATUS_OK); - }//end listVersions() - - /** - * Fetch a single snapshot body (REQ-VERS-004). - * - * @param string $uuid The dashboard UUID. - * @param integer $versionNumber The version number. - * - * @return JSONResponse The full snapshot body. - * - * @spec openspec/specs/dashboard-versioning/spec.md - */ - #[NoAdminRequired] - public function fetchVersion( - string $uuid, - int $versionNumber - ): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'dashboard-version.fetch-version'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - try { - $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - try { - $version = $this->versionService->fetchSnapshot( - dashboard: $dashboard, - versionNumber: $versionNumber, - requestingUser: $this->userId - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Version not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (Exception $e) { - return $this->mapServiceException(exception: $e); - } - - return new JSONResponse( - data: [ - 'version' => $version->jsonSerialize(), - 'snapshot' => $version->getSnapshotJson(), - ], - statusCode: Http::STATUS_OK - ); - }//end fetchVersion() - - /** - * Create an explicit snapshot (REQ-VERS-002). Bypasses the - * 60-second debounce window. The optional `note` field is read - * from the request body. - * - * @param string $uuid The dashboard UUID. - * @param string|null $note Optional snapshot note (request body). - * - * @return JSONResponse The persisted version row. - * - * @spec openspec/specs/dashboard-versioning/spec.md - */ - #[NoAdminRequired] - public function createVersion( - string $uuid, - ?string $note=null - ): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'dashboard-version.create-version'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - try { - $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - try { - $version = $this->versionService->createExplicitSnapshot( - dashboard: $dashboard, - requestingUser: $this->userId, - note: $note - ); - } catch (Exception $e) { - return $this->mapServiceException(exception: $e); - } - - return new JSONResponse( - data: ['version' => $version->jsonSerialize()], - statusCode: Http::STATUS_CREATED - ); - }//end createVersion() - - /** - * Restore a snapshot (REQ-VERS-005). Captures the pre-restore - * state as a new snapshot before applying the historical body. - * - * @param string $uuid The dashboard UUID. - * @param integer $versionNumber The version number to restore. - * - * @return JSONResponse The restored snapshot envelope. - * - * @spec openspec/specs/dashboard-versioning/spec.md - */ - #[NoAdminRequired] - public function restoreVersion( - string $uuid, - int $versionNumber - ): JSONResponse { - if ($this->userId === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'dashboard-version.restore-version'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - try { - $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Dashboard not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - try { - $result = $this->versionService->restoreVersion( - dashboard: $dashboard, - versionNumber: $versionNumber, - restoringUser: $this->userId - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Version not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (Exception $e) { - return $this->mapServiceException(exception: $e); - } - - return new JSONResponse( - data: [ - 'version' => $result['version']->jsonSerialize(), - 'snapshot' => $result['snapshot'], - ], - statusCode: Http::STATUS_OK - ); - }//end restoreVersion() - - /** - * Map a service-layer Exception to the appropriate JSON envelope. - * - * @param Exception $exception The exception. - * - * @return JSONResponse The mapped HTTP response. - */ - private function mapServiceException(Exception $exception): JSONResponse - { - $message = $exception->getMessage(); - - if ($message === DashboardVersionService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN) { - return new JSONResponse( - data: ['error' => 'forbidden'], - statusCode: Http::STATUS_FORBIDDEN - ); - } - - $this->logger->error( - message: 'launchpad: version operation failed', - context: ['exception' => $exception] - ); - - return new JSONResponse( - data: ['error' => 'Operation failed'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - }//end mapServiceException() +class DashboardVersionApiController extends Controller { + /** + * Constructor + * + * @param IRequest $request NC request. + * @param DashboardMapper $dashboardMapper Dashboard row lookup. + * @param DashboardVersionService $versionService Version service. + * @param ActionAuthService $actionAuth ADR-023 action authorization. + * @param IUserSession $userSession User session (IUser resolution). + * @param LoggerInterface $logger PSR logger. + * @param string|null $userId Current user ID. + */ + public function __construct( + IRequest $request, + private readonly DashboardMapper $dashboardMapper, + private readonly DashboardVersionService $versionService, + private readonly ActionAuthService $actionAuth, + private readonly IUserSession $userSession, + private readonly LoggerInterface $logger, + private readonly ?string $userId, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * List the versions for a dashboard, newest-first (REQ-VERS-003). + * + * @param string $uuid The dashboard UUID. + * + * @return JSONResponse The version list envelope. + * + * @spec openspec/specs/dashboard-versioning/spec.md + */ + #[NoAdminRequired] + public function listVersions(string $uuid): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'dashboard-version.list-versions'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + try { + $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + try { + $envelope = $this->versionService->listVersions( + dashboard: $dashboard, + requestingUser: $this->userId + ); + } catch (Exception $e) { + return $this->mapServiceException(exception: $e); + } + + return new JSONResponse(data: $envelope, statusCode: Http::STATUS_OK); + }//end listVersions() + + /** + * Fetch a single snapshot body (REQ-VERS-004). + * + * @param string $uuid The dashboard UUID. + * @param integer $versionNumber The version number. + * + * @return JSONResponse The full snapshot body. + * + * @spec openspec/specs/dashboard-versioning/spec.md + */ + #[NoAdminRequired] + public function fetchVersion( + string $uuid, + int $versionNumber, + ): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'dashboard-version.fetch-version'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + try { + $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + try { + $version = $this->versionService->fetchSnapshot( + dashboard: $dashboard, + versionNumber: $versionNumber, + requestingUser: $this->userId + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Version not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (Exception $e) { + return $this->mapServiceException(exception: $e); + } + + return new JSONResponse( + data: [ + 'version' => $version->jsonSerialize(), + 'snapshot' => $version->getSnapshotJson(), + ], + statusCode: Http::STATUS_OK + ); + }//end fetchVersion() + + /** + * Create an explicit snapshot (REQ-VERS-002). Bypasses the + * 60-second debounce window. The optional `note` field is read + * from the request body. + * + * @param string $uuid The dashboard UUID. + * @param string|null $note Optional snapshot note (request body). + * + * @return JSONResponse The persisted version row. + * + * @spec openspec/specs/dashboard-versioning/spec.md + */ + #[NoAdminRequired] + public function createVersion( + string $uuid, + ?string $note = null, + ): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'dashboard-version.create-version'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + try { + $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + try { + $version = $this->versionService->createExplicitSnapshot( + dashboard: $dashboard, + requestingUser: $this->userId, + note: $note + ); + } catch (Exception $e) { + return $this->mapServiceException(exception: $e); + } + + return new JSONResponse( + data: ['version' => $version->jsonSerialize()], + statusCode: Http::STATUS_CREATED + ); + }//end createVersion() + + /** + * Restore a snapshot (REQ-VERS-005). Captures the pre-restore + * state as a new snapshot before applying the historical body. + * + * @param string $uuid The dashboard UUID. + * @param integer $versionNumber The version number to restore. + * + * @return JSONResponse The restored snapshot envelope. + * + * @spec openspec/specs/dashboard-versioning/spec.md + */ + #[NoAdminRequired] + public function restoreVersion( + string $uuid, + int $versionNumber, + ): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'dashboard-version.restore-version'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + try { + $dashboard = $this->dashboardMapper->findByUuid(uuid: $uuid); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Dashboard not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + try { + $result = $this->versionService->restoreVersion( + dashboard: $dashboard, + versionNumber: $versionNumber, + restoringUser: $this->userId + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Version not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (Exception $e) { + return $this->mapServiceException(exception: $e); + } + + return new JSONResponse( + data: [ + 'version' => $result['version']->jsonSerialize(), + 'snapshot' => $result['snapshot'], + ], + statusCode: Http::STATUS_OK + ); + }//end restoreVersion() + + /** + * Map a service-layer Exception to the appropriate JSON envelope. + * + * @param Exception $exception The exception. + * + * @return JSONResponse The mapped HTTP response. + */ + private function mapServiceException(Exception $exception): JSONResponse { + $message = $exception->getMessage(); + + if ($message === DashboardVersionService::ERR_FORBIDDEN_NOT_OWNER_OR_ADMIN) { + return new JSONResponse( + data: ['error' => 'forbidden'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + $this->logger->error( + message: 'launchpad: version operation failed', + context: ['exception' => $exception] + ); + + return new JSONResponse( + data: ['error' => 'Operation failed'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end mapServiceException() }//end class diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php index 126b6c29..e7d94e8d 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -48,129 +48,126 @@ /** * Controller for the link-button-widget createFile flow. */ -class FileController extends Controller -{ - /** - * Constructor. - * - * @param IRequest $request The HTTP request. - * @param FileService $fileService File-creation pipeline. - * @param IUserSession $userSession Session accessor. - * @param LoggerInterface $logger PSR logger. - */ - public function __construct( - IRequest $request, - private readonly FileService $fileService, - private readonly IUserSession $userSession, - private readonly LoggerInterface $logger, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() +class FileController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The HTTP request. + * @param FileService $fileService File-creation pipeline. + * @param IUserSession $userSession Session accessor. + * @param LoggerInterface $logger PSR logger. + */ + public function __construct( + IRequest $request, + private readonly FileService $fileService, + private readonly IUserSession $userSession, + private readonly LoggerInterface $logger, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() - /** - * Handle `POST /api/files/create` (REQ-LBN-004). - * - * @param string|null $filename Leaf filename. - * @param string|null $dir Target subdirectory (default `/`). - * @param string|null $content Bytes to write (default empty). - * - * @return JSONResponse Either `{status, fileId, url}` on HTTP 200 - * or `{status, error, message}` on failure. - * - * @NoCSRFRequired - * - * @spec openspec/specs/resource-uploads/spec.md - */ - #[NoAdminRequired] - public function createFile( - ?string $filename=null, - ?string $dir='/', - ?string $content='' - ): JSONResponse { - try { - $userId = $this->resolveUserId(); + /** + * Handle `POST /api/files/create` (REQ-LBN-004). + * + * @param string|null $filename Leaf filename. + * @param string|null $dir Target subdirectory (default `/`). + * @param string|null $content Bytes to write (default empty). + * + * @return JSONResponse Either `{status, fileId, url}` on HTTP 200 + * or `{status, error, message}` on failure. + * + * @NoCSRFRequired + * + * @spec openspec/specs/resource-uploads/spec.md + */ + #[NoAdminRequired] + public function createFile( + ?string $filename = null, + ?string $dir = '/', + ?string $content = '', + ): JSONResponse { + try { + $userId = $this->resolveUserId(); - $result = $this->fileService->createFile( - userId: $userId, - filename: ($filename ?? ''), - dir: ($dir ?? '/'), - content: ($content ?? '') - ); + $result = $this->fileService->createFile( + userId: $userId, + filename: ($filename ?? ''), + dir: ($dir ?? '/'), + content: ($content ?? '') + ); - return new JSONResponse( - data: $result, - statusCode: Http::STATUS_OK - ); - } catch (ForbiddenException $e) { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'forbidden', - 'message' => 'Authentication required', - ], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } catch (ResourceException $e) { - if ($e instanceof StorageFailureException) { - $this->logger->error( - message: 'File create storage failure', - context: ['exception' => $e->getMessage()] - ); - } + return new JSONResponse( + data: $result, + statusCode: Http::STATUS_OK + ); + } catch (ForbiddenException $e) { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'forbidden', + 'message' => 'Authentication required', + ], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } catch (ResourceException $e) { + if ($e instanceof StorageFailureException) { + $this->logger->error( + message: 'File create storage failure', + context: ['exception' => $e->getMessage()] + ); + } - return $this->errorResponse(exception: $e); - } catch (Throwable $e) { - // Defence in depth — never leak raw messages on - // truly unexpected paths. - $this->logger->error( - message: 'Unexpected file create failure', - context: ['exception' => $e->getMessage()] - ); + return $this->errorResponse(exception: $e); + } catch (Throwable $e) { + // Defence in depth — never leak raw messages on + // truly unexpected paths. + $this->logger->error( + message: 'Unexpected file create failure', + context: ['exception' => $e->getMessage()] + ); - $fallback = new StorageFailureException( - message: 'Failed to create file' - ); + $fallback = new StorageFailureException( + message: 'Failed to create file' + ); - return $this->errorResponse(exception: $fallback); - }//end try - }//end createFile() + return $this->errorResponse(exception: $fallback); + }//end try + }//end createFile() - /** - * Resolve the logged-in user's ID. - * - * @return string The user's UID. - * - * @throws ForbiddenException When the request is not authenticated. - */ - private function resolveUserId(): string - { - $user = $this->userSession->getUser(); - if ($user === null) { - throw new ForbiddenException(); - } + /** + * Resolve the logged-in user's ID. + * + * @return string The user's UID. + * + * @throws ForbiddenException When the request is not authenticated. + */ + private function resolveUserId(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new ForbiddenException(); + } - return $user->getUID(); - }//end resolveUserId() + return $user->getUID(); + }//end resolveUserId() - /** - * Build the standardised error envelope from a typed exception. - * - * @param ResourceException $exception The typed exception. - * - * @return JSONResponse The error response. - */ - private function errorResponse(ResourceException $exception): JSONResponse - { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => $exception->getErrorCode(), - 'message' => $exception->getDisplayMessage(), - ], - statusCode: $exception->getHttpStatus() - ); - }//end errorResponse() + /** + * Build the standardised error envelope from a typed exception. + * + * @param ResourceException $exception The typed exception. + * + * @return JSONResponse The error response. + */ + private function errorResponse(ResourceException $exception): JSONResponse { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => $exception->getErrorCode(), + 'message' => $exception->getDisplayMessage(), + ], + statusCode: $exception->getHttpStatus() + ); + }//end errorResponse() }//end class diff --git a/lib/Controller/FilesWidgetController.php b/lib/Controller/FilesWidgetController.php index 2221c01c..2ef0a299 100644 --- a/lib/Controller/FilesWidgetController.php +++ b/lib/Controller/FilesWidgetController.php @@ -61,405 +61,397 @@ * underlying service. * @spec openspec/specs/files-widget/spec.md */ -class FilesWidgetController extends Controller -{ - /** - * Constructor. - * - * @param IRequest $request HTTP request. - * @param FilesWidgetService $service Files widget service. - * @param WidgetPlacementMapper $placementMapper Placement entity mapper. - * @param PermissionService $permissionService Dashboard permission gate. - * @param IUserSession $userSession Session accessor. - * @param LoggerInterface $logger PSR logger. - */ - public function __construct( - IRequest $request, - private readonly FilesWidgetService $service, - private readonly WidgetPlacementMapper $placementMapper, - private readonly PermissionService $permissionService, - private readonly IUserSession $userSession, - private readonly LoggerInterface $logger, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * `GET /api/widgets/files/{placementId}/contents` - * - * Returns the configured folder's contents as - * `{items: [...], nextCursor: ?string}`. Empty folder is HTTP 200 - * with `items: []`. Missing folder is HTTP 404. Read-denied folder - * is HTTP 403. - * - * @param integer $placementId The widget placement id. - * @param string $currentPath Sub-path inside the configured folder. - * @param integer $limit Page size (capped server-side). - * @param string $cursor Opaque pagination cursor. - * - * @return JSONResponse - * - * @spec openspec/specs/files-widget/spec.md - */ - #[NoAdminRequired] - #[NoCSRFRequired] - public function contents( - int $placementId, - string $currentPath='/', - int $limit=FilesWidgetService::DEFAULT_LIMIT, - string $cursor='' - ): JSONResponse { - $userId = $this->resolveUserId(); - if ($userId === null) { - return $this->unauthorised(); - } - - $config = $this->loadConfig(placementId: $placementId, userId: $userId); - if ($config === null) { - return new JSONResponse( - data: ['status' => 'error', 'error' => 'forbidden'], - statusCode: Http::STATUS_FORBIDDEN - ); - } - - try { - $page = $this->service->getContentsForPlacement( - userId: $userId, - config: $config, - currentSubPath: $currentPath, - limit: $limit, - cursor: $cursor - ); - - return new JSONResponse( - data: $page, - statusCode: Http::STATUS_OK - ); - } catch (FolderNotFoundException $e) { - return $this->errorResponse( - error: 'folder_not_found', - status: Http::STATUS_NOT_FOUND, - message: $e->getDisplayMessage() - ); - } catch (NoAccessException $e) { - return $this->errorResponse( - error: 'no_access', - status: Http::STATUS_FORBIDDEN, - message: $e->getDisplayMessage() - ); - } catch (Throwable $e) { - $this->logger->error( - message: 'Unexpected files widget contents failure', - context: ['exception' => $e->getMessage()] - ); - return $this->errorResponse( - error: 'unknown_error', - status: Http::STATUS_INTERNAL_SERVER_ERROR - ); - }//end try - }//end contents() - - /** - * `POST /api/widgets/files/{placementId}/upload` - * - * Accepts `multipart/form-data` with one or more `files[]` entries - * and writes them into the placement-configured folder (or a - * sub-path of it, if `currentPath` is supplied). - * - * @param integer $placementId The widget placement id. - * @param string $currentPath Sub-path inside the configured folder. - * - * @return JSONResponse - * - * @spec openspec/specs/files-widget/spec.md - */ - #[NoAdminRequired] - public function upload(int $placementId, string $currentPath='/'): JSONResponse - { - $userId = $this->resolveUserId(); - if ($userId === null) { - return $this->unauthorised(); - } - - $config = $this->loadConfig(placementId: $placementId, userId: $userId); - if ($config === null) { - return new JSONResponse( - data: ['status' => 'error', 'error' => 'forbidden'], - statusCode: Http::STATUS_FORBIDDEN - ); - } - - $files = $this->normaliseUploadedFiles(); - - try { - $result = $this->service->uploadFiles( - userId: $userId, - config: $config, - currentSubPath: $currentPath, - uploadedFiles: $files - ); - - return new JSONResponse( - data: $result, - statusCode: Http::STATUS_OK - ); - } catch (FolderNotFoundException $e) { - return $this->errorResponse( - error: 'folder_not_found', - status: Http::STATUS_NOT_FOUND, - message: $e->getDisplayMessage() - ); - } catch (NoAccessException $e) { - return $this->errorResponse( - error: 'no_access', - status: Http::STATUS_FORBIDDEN, - message: $e->getDisplayMessage() - ); - } catch (Throwable $e) { - $this->logger->error( - message: 'Unexpected files widget upload failure', - context: ['exception' => $e->getMessage()] - ); - return $this->errorResponse( - error: 'unknown_error', - status: Http::STATUS_INTERNAL_SERVER_ERROR - ); - }//end try - }//end upload() - - /** - * `DELETE /api/widgets/files/{placementId}/files/{fileId}` - * - * Moves the supplied file into the user's trash bin. - * - * @param integer $placementId The widget placement id. - * @param integer $fileId File id (must live inside the - * configured folder). - * - * @return JSONResponse - * - * @spec openspec/specs/files-widget/spec.md - */ - #[NoAdminRequired] - public function destroy(int $placementId, int $fileId): JSONResponse - { - $userId = $this->resolveUserId(); - if ($userId === null) { - return $this->unauthorised(); - } - - $config = $this->loadConfig(placementId: $placementId, userId: $userId); - if ($config === null) { - return new JSONResponse( - data: ['status' => 'error', 'error' => 'forbidden'], - statusCode: Http::STATUS_FORBIDDEN - ); - } - - try { - $result = $this->service->deleteFile( - userId: $userId, - config: $config, - fileId: $fileId - ); - - return new JSONResponse( - data: $result, - statusCode: Http::STATUS_OK - ); - } catch (FolderNotFoundException $e) { - return $this->errorResponse( - error: 'folder_not_found', - status: Http::STATUS_NOT_FOUND, - message: $e->getDisplayMessage() - ); - } catch (NoAccessException $e) { - return $this->errorResponse( - error: 'no_access', - status: Http::STATUS_FORBIDDEN, - message: $e->getDisplayMessage() - ); - } catch (Throwable $e) { - $this->logger->error( - message: 'Unexpected files widget delete failure', - context: ['exception' => $e->getMessage()] - ); - return $this->errorResponse( - error: 'unknown_error', - status: Http::STATUS_INTERNAL_SERVER_ERROR - ); - }//end try - }//end destroy() - - /** - * Resolve the active user's UID, or `null` for anonymous. - * - * @return string|null - */ - private function resolveUserId(): ?string - { - $user = $this->userSession->getUser(); - if ($user === null) { - return null; - } - - return $user->getUID(); - }//end resolveUserId() - - /** - * Load the placement, gate it through {@see PermissionService}, and - * return the parsed `widgetContent` config blob. - * - * Returns `null` when the placement is missing OR the user cannot - * view the underlying dashboard. The caller maps `null` to a - * forbidden response so missing-vs-no-access is indistinguishable - * to the client. - * - * @param integer $placementId Widget placement id. - * @param string $userId Viewing user's UID. - * - * @return array|null - */ - private function loadConfig(int $placementId, string $userId): ?array - { - try { - $placement = $this->placementMapper->find(id: $placementId); - } catch (Throwable $e) { - return null; - } - - // L2: upload is a write operation — require write-level permission - // (canAddWidget), not read-level (canViewDashboard). - if ($this->permissionService->canAddWidget( - userId: $userId, - dashboardId: $placement->getDashboardId() - ) === false - ) { - return null; - } - - // Registry-driven custom widgets persist their per-type config - // in the `content` column (added in Version001025). Older rows - // that pre-date the column may still carry the blob inside the - // legacy `style_config.content` slot, so we fall back to that - // shape when the dedicated column is empty. - $content = $placement->getContentArray(); - if ($content !== []) { - return $content; - } - - $legacy = $placement->getStyleConfigArray(); - if (isset($legacy['content']) === true && is_array($legacy['content']) === true) { - return $legacy['content']; - } - - return $legacy; - }//end loadConfig() - - /** - * Convert PHP's `$_FILES` super-global into a flat list of - * upload entries. Supports both single (`files=...`) and - * multi-part (`files[]=...`) submissions. - * - * @return list - * - * @SuppressWarnings(PHPMD.Superglobals) — required for multipart file uploads. - */ - private function normaliseUploadedFiles(): array - { - // @phpstan-ignore-next-line — superglobal access is mixed. - $raw = $_FILES['files'] ?? null; - if (is_array($raw) === false) { - return []; - } - - $names = ($raw['name'] ?? null); - $tmps = ($raw['tmp_name'] ?? null); - $sizes = ($raw['size'] ?? null); - $errors = ($raw['error'] ?? null); - - $entries = []; - if (is_array($names) === true) { - $count = count($names); - if (is_array($tmps) === false) { - $tmps = []; - } - - if (is_array($sizes) === false) { - $sizes = []; - } - - if (is_array($errors) === false) { - $errors = []; - } - - for ($i = 0; $i < $count; $i++) { - $entries[] = [ - 'name' => (string) ($names[$i] ?? ''), - 'tmp_name' => (string) ($tmps[$i] ?? ''), - 'size' => (int) ($sizes[$i] ?? 0), - 'error' => (int) ($errors[$i] ?? UPLOAD_ERR_NO_FILE), - ]; - } - } else if ($names !== null) { - $entries[] = [ - 'name' => (string) $names, - 'tmp_name' => (string) ($tmps ?? ''), - 'size' => (int) ($sizes ?? 0), - 'error' => (int) ($errors ?? UPLOAD_ERR_NO_FILE), - ]; - }//end if - - return $entries; - }//end normaliseUploadedFiles() - - /** - * Build a 401 envelope for the anonymous case. - * - * @return JSONResponse - */ - private function unauthorised(): JSONResponse - { - return new JSONResponse( - data: [ - 'status' => 'error', - 'error' => 'unauthorized', - 'message' => 'Authentication required', - ], - statusCode: Http::STATUS_UNAUTHORIZED - ); - }//end unauthorised() - - /** - * Build a typed error envelope. - * - * The status code is restricted to the union of HTTP status codes - * accepted by {@see JSONResponse::__construct()} so that PHPStan - * can verify the literal at every call-site. - * - * @param string $error Machine-readable error code. - * @param int<100,511> $status HTTP status code. - * @param string|null $message Optional human-readable message. - * - * @return JSONResponse - */ - private function errorResponse(string $error, int $status=Http::STATUS_BAD_REQUEST, ?string $message=null): JSONResponse - { - $payload = [ - 'status' => 'error', - 'error' => $error, - ]; - - if ($message !== null) { - $payload['message'] = $message; - } - - return new JSONResponse( - data: $payload, - statusCode: $status - ); - }//end errorResponse() +class FilesWidgetController extends Controller { + /** + * Constructor. + * + * @param IRequest $request HTTP request. + * @param FilesWidgetService $service Files widget service. + * @param WidgetPlacementMapper $placementMapper Placement entity mapper. + * @param PermissionService $permissionService Dashboard permission gate. + * @param IUserSession $userSession Session accessor. + * @param LoggerInterface $logger PSR logger. + */ + public function __construct( + IRequest $request, + private readonly FilesWidgetService $service, + private readonly WidgetPlacementMapper $placementMapper, + private readonly PermissionService $permissionService, + private readonly IUserSession $userSession, + private readonly LoggerInterface $logger, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * `GET /api/widgets/files/{placementId}/contents` + * + * Returns the configured folder's contents as + * `{items: [...], nextCursor: ?string}`. Empty folder is HTTP 200 + * with `items: []`. Missing folder is HTTP 404. Read-denied folder + * is HTTP 403. + * + * @param integer $placementId The widget placement id. + * @param string $currentPath Sub-path inside the configured folder. + * @param integer $limit Page size (capped server-side). + * @param string $cursor Opaque pagination cursor. + * + * @return JSONResponse + * + * @spec openspec/specs/files-widget/spec.md + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function contents( + int $placementId, + string $currentPath = '/', + int $limit = FilesWidgetService::DEFAULT_LIMIT, + string $cursor = '', + ): JSONResponse { + $userId = $this->resolveUserId(); + if ($userId === null) { + return $this->unauthorised(); + } + + $config = $this->loadConfig(placementId: $placementId, userId: $userId); + if ($config === null) { + return new JSONResponse( + data: ['status' => 'error', 'error' => 'forbidden'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + try { + $page = $this->service->getContentsForPlacement( + userId: $userId, + config: $config, + currentSubPath: $currentPath, + limit: $limit, + cursor: $cursor + ); + + return new JSONResponse( + data: $page, + statusCode: Http::STATUS_OK + ); + } catch (FolderNotFoundException $e) { + return $this->errorResponse( + error: 'folder_not_found', + status: Http::STATUS_NOT_FOUND, + message: $e->getDisplayMessage() + ); + } catch (NoAccessException $e) { + return $this->errorResponse( + error: 'no_access', + status: Http::STATUS_FORBIDDEN, + message: $e->getDisplayMessage() + ); + } catch (Throwable $e) { + $this->logger->error( + message: 'Unexpected files widget contents failure', + context: ['exception' => $e->getMessage()] + ); + return $this->errorResponse( + error: 'unknown_error', + status: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + }//end contents() + + /** + * `POST /api/widgets/files/{placementId}/upload` + * + * Accepts `multipart/form-data` with one or more `files[]` entries + * and writes them into the placement-configured folder (or a + * sub-path of it, if `currentPath` is supplied). + * + * @param integer $placementId The widget placement id. + * @param string $currentPath Sub-path inside the configured folder. + * + * @return JSONResponse + * + * @spec openspec/specs/files-widget/spec.md + */ + #[NoAdminRequired] + public function upload(int $placementId, string $currentPath = '/'): JSONResponse { + $userId = $this->resolveUserId(); + if ($userId === null) { + return $this->unauthorised(); + } + + $config = $this->loadConfig(placementId: $placementId, userId: $userId); + if ($config === null) { + return new JSONResponse( + data: ['status' => 'error', 'error' => 'forbidden'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + $files = $this->normaliseUploadedFiles(); + + try { + $result = $this->service->uploadFiles( + userId: $userId, + config: $config, + currentSubPath: $currentPath, + uploadedFiles: $files + ); + + return new JSONResponse( + data: $result, + statusCode: Http::STATUS_OK + ); + } catch (FolderNotFoundException $e) { + return $this->errorResponse( + error: 'folder_not_found', + status: Http::STATUS_NOT_FOUND, + message: $e->getDisplayMessage() + ); + } catch (NoAccessException $e) { + return $this->errorResponse( + error: 'no_access', + status: Http::STATUS_FORBIDDEN, + message: $e->getDisplayMessage() + ); + } catch (Throwable $e) { + $this->logger->error( + message: 'Unexpected files widget upload failure', + context: ['exception' => $e->getMessage()] + ); + return $this->errorResponse( + error: 'unknown_error', + status: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + }//end upload() + + /** + * `DELETE /api/widgets/files/{placementId}/files/{fileId}` + * + * Moves the supplied file into the user's trash bin. + * + * @param integer $placementId The widget placement id. + * @param integer $fileId File id (must live inside the + * configured folder). + * + * @return JSONResponse + * + * @spec openspec/specs/files-widget/spec.md + */ + #[NoAdminRequired] + public function destroy(int $placementId, int $fileId): JSONResponse { + $userId = $this->resolveUserId(); + if ($userId === null) { + return $this->unauthorised(); + } + + $config = $this->loadConfig(placementId: $placementId, userId: $userId); + if ($config === null) { + return new JSONResponse( + data: ['status' => 'error', 'error' => 'forbidden'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + try { + $result = $this->service->deleteFile( + userId: $userId, + config: $config, + fileId: $fileId + ); + + return new JSONResponse( + data: $result, + statusCode: Http::STATUS_OK + ); + } catch (FolderNotFoundException $e) { + return $this->errorResponse( + error: 'folder_not_found', + status: Http::STATUS_NOT_FOUND, + message: $e->getDisplayMessage() + ); + } catch (NoAccessException $e) { + return $this->errorResponse( + error: 'no_access', + status: Http::STATUS_FORBIDDEN, + message: $e->getDisplayMessage() + ); + } catch (Throwable $e) { + $this->logger->error( + message: 'Unexpected files widget delete failure', + context: ['exception' => $e->getMessage()] + ); + return $this->errorResponse( + error: 'unknown_error', + status: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + }//end destroy() + + /** + * Resolve the active user's UID, or `null` for anonymous. + * + * @return string|null + */ + private function resolveUserId(): ?string { + $user = $this->userSession->getUser(); + if ($user === null) { + return null; + } + + return $user->getUID(); + }//end resolveUserId() + + /** + * Load the placement, gate it through {@see PermissionService}, and + * return the parsed `widgetContent` config blob. + * + * Returns `null` when the placement is missing OR the user cannot + * view the underlying dashboard. The caller maps `null` to a + * forbidden response so missing-vs-no-access is indistinguishable + * to the client. + * + * @param integer $placementId Widget placement id. + * @param string $userId Viewing user's UID. + * + * @return array|null + */ + private function loadConfig(int $placementId, string $userId): ?array { + try { + $placement = $this->placementMapper->find(id: $placementId); + } catch (Throwable $e) { + return null; + } + + // L2: upload is a write operation — require write-level permission + // (canAddWidget), not read-level (canViewDashboard). + if ($this->permissionService->canAddWidget( + userId: $userId, + dashboardId: $placement->getDashboardId() + ) === false + ) { + return null; + } + + // Registry-driven custom widgets persist their per-type config + // in the `content` column (added in Version001025). Older rows + // that pre-date the column may still carry the blob inside the + // legacy `style_config.content` slot, so we fall back to that + // shape when the dedicated column is empty. + $content = $placement->getContentArray(); + if ($content !== []) { + return $content; + } + + $legacy = $placement->getStyleConfigArray(); + if (isset($legacy['content']) === true && is_array($legacy['content']) === true) { + return $legacy['content']; + } + + return $legacy; + }//end loadConfig() + + /** + * Convert PHP's `$_FILES` super-global into a flat list of + * upload entries. Supports both single (`files=...`) and + * multi-part (`files[]=...`) submissions. + * + * @return list + * + * @SuppressWarnings(PHPMD.Superglobals) — required for multipart file uploads. + */ + private function normaliseUploadedFiles(): array { + // @phpstan-ignore-next-line — superglobal access is mixed. + $raw = $_FILES['files'] ?? null; + if (is_array($raw) === false) { + return []; + } + + $names = ($raw['name'] ?? null); + $tmps = ($raw['tmp_name'] ?? null); + $sizes = ($raw['size'] ?? null); + $errors = ($raw['error'] ?? null); + + $entries = []; + if (is_array($names) === true) { + $count = count($names); + if (is_array($tmps) === false) { + $tmps = []; + } + + if (is_array($sizes) === false) { + $sizes = []; + } + + if (is_array($errors) === false) { + $errors = []; + } + + for ($i = 0; $i < $count; $i++) { + $entries[] = [ + 'name' => (string)($names[$i] ?? ''), + 'tmp_name' => (string)($tmps[$i] ?? ''), + 'size' => (int)($sizes[$i] ?? 0), + 'error' => (int)($errors[$i] ?? UPLOAD_ERR_NO_FILE), + ]; + } + } elseif ($names !== null) { + $entries[] = [ + 'name' => (string)$names, + 'tmp_name' => (string)($tmps ?? ''), + 'size' => (int)($sizes ?? 0), + 'error' => (int)($errors ?? UPLOAD_ERR_NO_FILE), + ]; + }//end if + + return $entries; + }//end normaliseUploadedFiles() + + /** + * Build a 401 envelope for the anonymous case. + * + * @return JSONResponse + */ + private function unauthorised(): JSONResponse { + return new JSONResponse( + data: [ + 'status' => 'error', + 'error' => 'unauthorized', + 'message' => 'Authentication required', + ], + statusCode: Http::STATUS_UNAUTHORIZED + ); + }//end unauthorised() + + /** + * Build a typed error envelope. + * + * The status code is restricted to the union of HTTP status codes + * accepted by {@see JSONResponse::__construct()} so that PHPStan + * can verify the literal at every call-site. + * + * @param string $error Machine-readable error code. + * @param int<100,511> $status HTTP status code. + * @param string|null $message Optional human-readable message. + * + * @return JSONResponse + */ + private function errorResponse(string $error, int $status = Http::STATUS_BAD_REQUEST, ?string $message = null): JSONResponse { + $payload = [ + 'status' => 'error', + 'error' => $error, + ]; + + if ($message !== null) { + $payload['message'] = $message; + } + + return new JSONResponse( + data: $payload, + statusCode: $status + ); + }//end errorResponse() }//end class diff --git a/lib/Controller/HealthController.php b/lib/Controller/HealthController.php index a6ecc1e8..2f721958 100644 --- a/lib/Controller/HealthController.php +++ b/lib/Controller/HealthController.php @@ -49,88 +49,86 @@ * * @spec openspec/changes/adopt-apphost/specs/prometheus-metrics/spec.md — Requirement: Health Check Endpoint (REQ-PROM-007) */ -class HealthController extends Controller -{ - /** - * Constructor. - * - * @param string $appName This leaf's app id (`launchpad`). - * @param IRequest $request The HTTP request. - * @param object|null $manifestLoader OpenRegister's ManifestLoader, or null when - * OpenRegister is unavailable. Untyped on - * purpose: a parameter TYPE is also a - * compile-time reference to a class that may - * not exist. - * @param object|null $executor OpenRegister's HealthCheckExecutor, or null. - */ - public function __construct( - string $appName, - IRequest $request, - private readonly ?object $manifestLoader=null, - private readonly ?object $executor=null, - ) { - parent::__construct(appName: $appName, request: $request); - }//end __construct() +class HealthController extends Controller { + /** + * Constructor. + * + * @param string $appName This leaf's app id (`launchpad`). + * @param IRequest $request The HTTP request. + * @param object|null $manifestLoader OpenRegister's ManifestLoader, or null when + * OpenRegister is unavailable. Untyped on + * purpose: a parameter TYPE is also a + * compile-time reference to a class that may + * not exist. + * @param object|null $executor OpenRegister's HealthCheckExecutor, or null. + */ + public function __construct( + string $appName, + IRequest $request, + private readonly ?object $manifestLoader = null, + private readonly ?object $executor = null, + ) { + parent::__construct(appName: $appName, request: $request); + }//end __construct() - /** - * GET /api/health — declarative health check (ADR-006), public. - * - * Reports `status: unavailable` with HTTP 503 when the engine is absent, - * which is a meaningful answer for a monitoring probe: the app is reachable, - * its declarative health engine is not. - * - * @return JSONResponse `{status, app, version, checks}`. - * - * @spec openspec/changes/adopt-apphost/specs/prometheus-metrics/spec.md — Requirement: Health Check Endpoint (REQ-PROM-007) - */ - #[PublicPage] - #[NoCSRFRequired] - public function index(): JSONResponse - { - $appId = $this->appName; + /** + * GET /api/health — declarative health check (ADR-006), public. + * + * Reports `status: unavailable` with HTTP 503 when the engine is absent, + * which is a meaningful answer for a monitoring probe: the app is reachable, + * its declarative health engine is not. + * + * @return JSONResponse `{status, app, version, checks}`. + * + * @spec openspec/changes/adopt-apphost/specs/prometheus-metrics/spec.md — Requirement: Health Check Endpoint (REQ-PROM-007) + */ + #[PublicPage] + #[NoCSRFRequired] + public function index(): JSONResponse { + $appId = $this->appName; - if ($this->manifestLoader === null || $this->executor === null) { - return new JSONResponse( - [ - 'status' => 'unavailable', - 'app' => $appId, - 'error' => 'OpenRegister AppHost observability engine unavailable', - 'checks' => [], - ], - Http::STATUS_SERVICE_UNAVAILABLE - ); - } + if ($this->manifestLoader === null || $this->executor === null) { + return new JSONResponse( + [ + 'status' => 'unavailable', + 'app' => $appId, + 'error' => 'OpenRegister AppHost observability engine unavailable', + 'checks' => [], + ], + Http::STATUS_SERVICE_UNAVAILABLE + ); + } - try { - $manifest = $this->manifestLoader->load(appId: $appId); - $result = $this->executor->execute(manifest: $manifest); + try { + $manifest = $this->manifestLoader->load(appId: $appId); + $result = $this->executor->execute(manifest: $manifest); - $response = new JSONResponse( - [ - 'status' => $result->status, - 'app' => $appId, - 'version' => $this->manifestLoader->appVersion(appId: $appId), - 'checks' => $result->checks, - ], - $result->httpStatusCode - ); + $response = new JSONResponse( + [ + 'status' => $result->status, + 'app' => $appId, + 'version' => $this->manifestLoader->appVersion(appId: $appId), + 'checks' => $result->checks, + ], + $result->httpStatusCode + ); - if ($manifest->cors === true) { - $response->addHeader('Access-Control-Allow-Origin', '*'); - $response->addHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); - } + if ($manifest->cors === true) { + $response->addHeader('Access-Control-Allow-Origin', '*'); + $response->addHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); + } - return $response; - } catch (Throwable $e) { - return new JSONResponse( - [ - 'status' => 'unavailable', - 'app' => $appId, - 'error' => $e->getMessage(), - 'checks' => [], - ], - Http::STATUS_SERVICE_UNAVAILABLE - ); - }//end try - }//end index() + return $response; + } catch (Throwable $e) { + return new JSONResponse( + [ + 'status' => 'unavailable', + 'app' => $appId, + 'error' => $e->getMessage(), + 'checks' => [], + ], + Http::STATUS_SERVICE_UNAVAILABLE + ); + }//end try + }//end index() }//end class diff --git a/lib/Controller/HealthPingController.php b/lib/Controller/HealthPingController.php index 033a0b7a..afdbc393 100644 --- a/lib/Controller/HealthPingController.php +++ b/lib/Controller/HealthPingController.php @@ -51,140 +51,136 @@ * * @spec openspec/specs/service-health-ping/spec.md */ -class HealthPingController extends Controller -{ - /** - * Constructor. - * - * @param IRequest $request HTTP request. - * @param HealthPingService $healthPingService Resolves + caches + validates health-ping badges. - * @param PermissionService $permissionService Dashboard/placement permission gate. - * @param IUserSession $userSession Session accessor. - * @param LoggerInterface $logger PSR logger. - */ - public function __construct( - IRequest $request, - private readonly HealthPingService $healthPingService, - private readonly PermissionService $permissionService, - private readonly IUserSession $userSession, - private readonly LoggerInterface $logger, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * `GET /api/health-ping/{placementId}` - * - * Returns the health badge for one placement. Returns 401 when - * anonymous, 403 when the caller may not view the underlying - * dashboard (REQ-HPING-003 "Caller authorization" — the ping is NEVER - * performed in that case), 404 when the placement does not exist or - * has no ping configured, else 200 with the badge (possibly - * `stale: true`). - * - * @param integer $placementId The widget placement id. - * - * @return JSONResponse - * - * @spec openspec/specs/service-health-ping/spec.md - */ - #[NoAdminRequired] - #[NoCSRFRequired] - public function show(int $placementId): JSONResponse - { - $userId = $this->resolveUserId(); - if ($userId === null) { - return new JSONResponse( - data: ['status' => 'error', 'error' => 'unauthorized'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } - - // REQ-HPING-003 "Caller authorization" — the auth guard runs - // BEFORE any resolution/ping is attempted. - if ($this->permissionService->canViewPlacement(userId: $userId, placementId: $placementId) === false) { - return new JSONResponse( - data: ['status' => 'error', 'error' => 'forbidden'], - statusCode: Http::STATUS_FORBIDDEN - ); - } - - try { - $badge = $this->healthPingService->resolveForPlacement(placementId: $placementId); - } catch (Throwable $exception) { - $this->logger->error( - message: 'Unexpected health-ping resolution failure', - context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()] - ); - return new JSONResponse( - data: ['status' => 'error', 'error' => 'unknown_error'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - } - - if (isset($badge['error']) === true) { - return new JSONResponse( - data: ['status' => 'error', 'error' => $badge['error']], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - return new JSONResponse( - data: $badge, - statusCode: Http::STATUS_OK - ); - }//end show() - - /** - * `POST /api/health-ping/validate` - * - * Validates a candidate health-ping config before the author saves - * the placement (REQ-HPING-001 "rejected at save time" — host - * allow-list, fail-closed). Performs NO ping — only the allow-list - * check that `resolveForPlacement()` would apply. - * - * @return JSONResponse `{valid: bool, errors: string[]}`. - * - * @spec openspec/specs/service-health-ping/spec.md - */ - #[NoAdminRequired] - public function validate(): JSONResponse - { - if ($this->resolveUserId() === null) { - return new JSONResponse( - data: ['status' => 'error', 'error' => 'unauthorized'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } - - $config = $this->request->getParam(key: 'config'); - if (is_array(value: $config) === false) { - $config = []; - } - - $errors = $this->healthPingService->validateConfig(config: $config); - - return new JSONResponse( - data: ['valid' => ($errors === []), 'errors' => $errors], - statusCode: Http::STATUS_OK - ); - }//end validate() - - /** - * Resolve the active user's UID, or `null` for anonymous. - * - * @return string|null - */ - private function resolveUserId(): ?string - { - $user = $this->userSession->getUser(); - if ($user === null) { - return null; - } - - return $user->getUID(); - }//end resolveUserId() +class HealthPingController extends Controller { + /** + * Constructor. + * + * @param IRequest $request HTTP request. + * @param HealthPingService $healthPingService Resolves + caches + validates health-ping badges. + * @param PermissionService $permissionService Dashboard/placement permission gate. + * @param IUserSession $userSession Session accessor. + * @param LoggerInterface $logger PSR logger. + */ + public function __construct( + IRequest $request, + private readonly HealthPingService $healthPingService, + private readonly PermissionService $permissionService, + private readonly IUserSession $userSession, + private readonly LoggerInterface $logger, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * `GET /api/health-ping/{placementId}` + * + * Returns the health badge for one placement. Returns 401 when + * anonymous, 403 when the caller may not view the underlying + * dashboard (REQ-HPING-003 "Caller authorization" — the ping is NEVER + * performed in that case), 404 when the placement does not exist or + * has no ping configured, else 200 with the badge (possibly + * `stale: true`). + * + * @param integer $placementId The widget placement id. + * + * @return JSONResponse + * + * @spec openspec/specs/service-health-ping/spec.md + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function show(int $placementId): JSONResponse { + $userId = $this->resolveUserId(); + if ($userId === null) { + return new JSONResponse( + data: ['status' => 'error', 'error' => 'unauthorized'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } + + // REQ-HPING-003 "Caller authorization" — the auth guard runs + // BEFORE any resolution/ping is attempted. + if ($this->permissionService->canViewPlacement(userId: $userId, placementId: $placementId) === false) { + return new JSONResponse( + data: ['status' => 'error', 'error' => 'forbidden'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + try { + $badge = $this->healthPingService->resolveForPlacement(placementId: $placementId); + } catch (Throwable $exception) { + $this->logger->error( + message: 'Unexpected health-ping resolution failure', + context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()] + ); + return new JSONResponse( + data: ['status' => 'error', 'error' => 'unknown_error'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + + if (isset($badge['error']) === true) { + return new JSONResponse( + data: ['status' => 'error', 'error' => $badge['error']], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + return new JSONResponse( + data: $badge, + statusCode: Http::STATUS_OK + ); + }//end show() + + /** + * `POST /api/health-ping/validate` + * + * Validates a candidate health-ping config before the author saves + * the placement (REQ-HPING-001 "rejected at save time" — host + * allow-list, fail-closed). Performs NO ping — only the allow-list + * check that `resolveForPlacement()` would apply. + * + * @return JSONResponse `{valid: bool, errors: string[]}`. + * + * @spec openspec/specs/service-health-ping/spec.md + */ + #[NoAdminRequired] + public function validate(): JSONResponse { + if ($this->resolveUserId() === null) { + return new JSONResponse( + data: ['status' => 'error', 'error' => 'unauthorized'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } + + $config = $this->request->getParam(key: 'config'); + if (is_array(value: $config) === false) { + $config = []; + } + + $errors = $this->healthPingService->validateConfig(config: $config); + + return new JSONResponse( + data: ['valid' => ($errors === []), 'errors' => $errors], + statusCode: Http::STATUS_OK + ); + }//end validate() + + /** + * Resolve the active user's UID, or `null` for anonymous. + * + * @return string|null + */ + private function resolveUserId(): ?string { + $user = $this->userSession->getUser(); + if ($user === null) { + return null; + } + + return $user->getUID(); + }//end resolveUserId() }//end class diff --git a/lib/Controller/IframeController.php b/lib/Controller/IframeController.php index 36d58e09..5b96a05b 100644 --- a/lib/Controller/IframeController.php +++ b/lib/Controller/IframeController.php @@ -46,112 +46,108 @@ * * @spec openspec/specs/iframe-embed-widget/spec.md */ -class IframeController extends Controller -{ - /** - * Constructor. - * - * @param IRequest $request HTTP request. - * @param IframeService $iframeService Allow-list validation + sandbox sanitisation. - * @param IUserSession $userSession Session accessor. - */ - public function __construct( - IRequest $request, - private readonly IframeService $iframeService, - private readonly IUserSession $userSession, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() +class IframeController extends Controller { + /** + * Constructor. + * + * @param IRequest $request HTTP request. + * @param IframeService $iframeService Allow-list validation + sandbox sanitisation. + * @param IUserSession $userSession Session accessor. + */ + public function __construct( + IRequest $request, + private readonly IframeService $iframeService, + private readonly IUserSession $userSession, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() - /** - * `POST /api/iframe/validate-url` - * - * Validates a candidate iframe config before the author saves the - * placement (REQ-IFRAME-002 "rejected at save time" — host allow-list, - * fail-closed). Requires only an authenticated caller — the allow-list - * itself is admin-controlled, not per-user. - * - * @return JSONResponse `{valid: bool, errors: string[]}`. - * - * @spec openspec/specs/iframe-embed-widget/spec.md - */ - #[NoAdminRequired] - public function validateUrl(): JSONResponse - { - if ($this->resolveUserId() === null) { - return new JSONResponse( - data: ['status' => 'error', 'error' => 'unauthorized'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } + /** + * `POST /api/iframe/validate-url` + * + * Validates a candidate iframe config before the author saves the + * placement (REQ-IFRAME-002 "rejected at save time" — host allow-list, + * fail-closed). Requires only an authenticated caller — the allow-list + * itself is admin-controlled, not per-user. + * + * @return JSONResponse `{valid: bool, errors: string[]}`. + * + * @spec openspec/specs/iframe-embed-widget/spec.md + */ + #[NoAdminRequired] + public function validateUrl(): JSONResponse { + if ($this->resolveUserId() === null) { + return new JSONResponse( + data: ['status' => 'error', 'error' => 'unauthorized'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } - $config = $this->request->getParam(key: 'config'); - if (is_array(value: $config) === false) { - $config = []; - } + $config = $this->request->getParam(key: 'config'); + if (is_array(value: $config) === false) { + $config = []; + } - $errors = $this->iframeService->validateConfig(config: $config); + $errors = $this->iframeService->validateConfig(config: $config); - return new JSONResponse( - data: ['valid' => ($errors === []), 'errors' => $errors], - statusCode: Http::STATUS_OK - ); - }//end validateUrl() + return new JSONResponse( + data: ['valid' => ($errors === []), 'errors' => $errors], + statusCode: Http::STATUS_OK + ); + }//end validateUrl() - /** - * `POST /api/iframe/framable` - * - * Server-side check of whether a URL may actually be framed - * (REQ-IFRAME-003 "graceful degradation"). The browser cannot tell an - * `X-Frame-Options: DENY` / `frame-ancestors 'none'` refusal apart from a - * normal cross-origin embed, so the widget calls this on mount and shows - * the fallback card up front when the target refuses framing, instead of - * a permanently blank frame. Allow-list fail-closed; never leaks the - * target's response body. - * - * @return JSONResponse `{framable: bool, reason: string}`. - * - * @spec openspec/specs/iframe-embed-widget/spec.md - */ - #[NoAdminRequired] - public function checkFramable(): JSONResponse - { - if ($this->resolveUserId() === null) { - return new JSONResponse( - data: ['status' => 'error', 'error' => 'unauthorized'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } + /** + * `POST /api/iframe/framable` + * + * Server-side check of whether a URL may actually be framed + * (REQ-IFRAME-003 "graceful degradation"). The browser cannot tell an + * `X-Frame-Options: DENY` / `frame-ancestors 'none'` refusal apart from a + * normal cross-origin embed, so the widget calls this on mount and shows + * the fallback card up front when the target refuses framing, instead of + * a permanently blank frame. Allow-list fail-closed; never leaks the + * target's response body. + * + * @return JSONResponse `{framable: bool, reason: string}`. + * + * @spec openspec/specs/iframe-embed-widget/spec.md + */ + #[NoAdminRequired] + public function checkFramable(): JSONResponse { + if ($this->resolveUserId() === null) { + return new JSONResponse( + data: ['status' => 'error', 'error' => 'unauthorized'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } - $url = trim(string: (string) $this->request->getParam(key: 'url', default: '')); - if ($url === '') { - return new JSONResponse( - data: ['framable' => false, 'reason' => 'url_required'], - statusCode: Http::STATUS_OK - ); - } + $url = trim(string: (string)$this->request->getParam(key: 'url', default: '')); + if ($url === '') { + return new JSONResponse( + data: ['framable' => false, 'reason' => 'url_required'], + statusCode: Http::STATUS_OK + ); + } - return new JSONResponse( - data: $this->iframeService->checkFramable(url: $url), - statusCode: Http::STATUS_OK - ); - }//end checkFramable() + return new JSONResponse( + data: $this->iframeService->checkFramable(url: $url), + statusCode: Http::STATUS_OK + ); + }//end checkFramable() - /** - * Resolve the active user's UID, or `null` for anonymous. - * - * @return string|null - */ - private function resolveUserId(): ?string - { - $user = $this->userSession->getUser(); - if ($user === null) { - return null; - } + /** + * Resolve the active user's UID, or `null` for anonymous. + * + * @return string|null + */ + private function resolveUserId(): ?string { + $user = $this->userSession->getUser(); + if ($user === null) { + return null; + } - return $user->getUID(); - }//end resolveUserId() + return $user->getUID(); + }//end resolveUserId() }//end class diff --git a/lib/Controller/KioskController.php b/lib/Controller/KioskController.php index 96c3166e..a2fb497b 100644 --- a/lib/Controller/KioskController.php +++ b/lib/Controller/KioskController.php @@ -50,267 +50,262 @@ * * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4 */ -class KioskController extends Controller -{ - /** - * Constructor. - * - * @param IRequest $request The incoming request. - * @param KioskService $kioskService The kiosk-playlist service. - * @param PublicShareContext $shareContext Request-scoped read-only bearer marker. - * @param LoggerInterface $logger PSR-3 logger. - * @param string|null $userId Authenticated user ID (null on public route). - */ - public function __construct( - IRequest $request, - private readonly KioskService $kioskService, - private readonly PublicShareContext $shareContext, - private readonly LoggerInterface $logger, - private readonly ?string $userId, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() +class KioskController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The incoming request. + * @param KioskService $kioskService The kiosk-playlist service. + * @param PublicShareContext $shareContext Request-scoped read-only bearer marker. + * @param LoggerInterface $logger PSR-3 logger. + * @param string|null $userId Authenticated user ID (null on public route). + */ + public function __construct( + IRequest $request, + private readonly KioskService $kioskService, + private readonly PublicShareContext $shareContext, + private readonly LoggerInterface $logger, + private readonly ?string $userId, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() - /** - * Create a kiosk playlist. - * - * Owner-or-admin per referenced dashboard (REQ-KIOSK-002). - * - * @param string|null $name Playlist name. - * @param array|null $entries Entries [{dashboardUuid, dwellSeconds}, ...]. - * @param int|null $refreshSeconds Requested refresh interval. - * - * @return DataResponse HTTP 201 with playlist payload, 403, or 401. - * - * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4 - */ - #[NoAdminRequired] - public function create( - ?string $name=null, - ?array $entries=null, - ?int $refreshSeconds=null - ): DataResponse { - if ($this->userId === null) { - return new DataResponse( - data: ['error' => 'Not logged in'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } + /** + * Create a kiosk playlist. + * + * Owner-or-admin per referenced dashboard (REQ-KIOSK-002). + * + * @param string|null $name Playlist name. + * @param array|null $entries Entries [{dashboardUuid, dwellSeconds}, ...]. + * @param int|null $refreshSeconds Requested refresh interval. + * + * @return DataResponse HTTP 201 with playlist payload, 403, or 401. + * + * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4 + */ + #[NoAdminRequired] + public function create( + ?string $name = null, + ?array $entries = null, + ?int $refreshSeconds = null, + ): DataResponse { + if ($this->userId === null) { + return new DataResponse( + data: ['error' => 'Not logged in'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } - try { - $playlist = $this->kioskService->createPlaylist( - name: (string) ($name ?? ''), - entries: ($entries ?? []), - refresh: (int) ($refreshSeconds ?? KioskService::REFRESH_DEFAULT), - callerId: $this->userId - ); - return new DataResponse( - data: $playlist->jsonSerialize(), - statusCode: Http::STATUS_CREATED - ); - } catch (OCSForbiddenException) { - return new DataResponse( - data: ['error' => 'Not authorized'], - statusCode: Http::STATUS_FORBIDDEN - ); - } catch (Exception $e) { - $this->logError(message: $e->getMessage()); - return new DataResponse( - data: ['error' => 'Internal error'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - }//end try - }//end create() + try { + $playlist = $this->kioskService->createPlaylist( + name: (string)($name ?? ''), + entries: ($entries ?? []), + refresh: (int)($refreshSeconds ?? KioskService::REFRESH_DEFAULT), + callerId: $this->userId + ); + return new DataResponse( + data: $playlist->jsonSerialize(), + statusCode: Http::STATUS_CREATED + ); + } catch (OCSForbiddenException) { + return new DataResponse( + data: ['error' => 'Not authorized'], + statusCode: Http::STATUS_FORBIDDEN + ); + } catch (Exception $e) { + $this->logError(message: $e->getMessage()); + return new DataResponse( + data: ['error' => 'Internal error'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + }//end create() - /** - * List playlists visible to the caller. - * - * Own playlists for users, all playlists for admins (REQ-KIOSK-002). - * - * @return DataResponse HTTP 200 array of playlists or 401. - * - * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4 - */ - #[NoAdminRequired] - public function index(): DataResponse - { - if ($this->userId === null) { - return new DataResponse( - data: ['error' => 'Not logged in'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } + /** + * List playlists visible to the caller. + * + * Own playlists for users, all playlists for admins (REQ-KIOSK-002). + * + * @return DataResponse HTTP 200 array of playlists or 401. + * + * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4 + */ + #[NoAdminRequired] + public function index(): DataResponse { + if ($this->userId === null) { + return new DataResponse( + data: ['error' => 'Not logged in'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } - $playlists = $this->kioskService->listPlaylists(callerId: $this->userId); - return new DataResponse( - data: array_map( - callback: static fn ($playlist) => $playlist->jsonSerialize(), - array: $playlists - ) - ); - }//end index() + $playlists = $this->kioskService->listPlaylists(callerId: $this->userId); + return new DataResponse( + data: array_map( + callback: static fn ($playlist) => $playlist->jsonSerialize(), + array: $playlists + ) + ); + }//end index() - /** - * Update a kiosk playlist. - * - * Owner-or-admin, re-validates every referenced dashboard (REQ-KIOSK-002). - * - * @param int $id Playlist primary key. - * @param string|null $name Playlist name. - * @param array|null $entries Entries [{dashboardUuid, dwellSeconds}, ...]. - * @param int|null $refreshSeconds Requested refresh interval. - * - * @return DataResponse HTTP 200 with playlist payload, 403, 404, or 401. - * - * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4 - */ - #[NoAdminRequired] - public function update( - int $id, - ?string $name=null, - ?array $entries=null, - ?int $refreshSeconds=null - ): DataResponse { - if ($this->userId === null) { - return new DataResponse( - data: ['error' => 'Not logged in'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } + /** + * Update a kiosk playlist. + * + * Owner-or-admin, re-validates every referenced dashboard (REQ-KIOSK-002). + * + * @param int $id Playlist primary key. + * @param string|null $name Playlist name. + * @param array|null $entries Entries [{dashboardUuid, dwellSeconds}, ...]. + * @param int|null $refreshSeconds Requested refresh interval. + * + * @return DataResponse HTTP 200 with playlist payload, 403, 404, or 401. + * + * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4 + */ + #[NoAdminRequired] + public function update( + int $id, + ?string $name = null, + ?array $entries = null, + ?int $refreshSeconds = null, + ): DataResponse { + if ($this->userId === null) { + return new DataResponse( + data: ['error' => 'Not logged in'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } - try { - $playlist = $this->kioskService->updatePlaylist( - id: $id, - name: (string) ($name ?? ''), - entries: ($entries ?? []), - refresh: (int) ($refreshSeconds ?? KioskService::REFRESH_DEFAULT), - callerId: $this->userId - ); - return new DataResponse(data: $playlist->jsonSerialize()); - } catch (PlaylistNotFoundException) { - return new DataResponse( - data: ['error' => 'Not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (OCSForbiddenException) { - return new DataResponse( - data: ['error' => 'Not authorized'], - statusCode: Http::STATUS_FORBIDDEN - ); - } catch (Exception $e) { - $this->logError(message: $e->getMessage()); - return new DataResponse( - data: ['error' => 'Internal error'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - }//end try - }//end update() + try { + $playlist = $this->kioskService->updatePlaylist( + id: $id, + name: (string)($name ?? ''), + entries: ($entries ?? []), + refresh: (int)($refreshSeconds ?? KioskService::REFRESH_DEFAULT), + callerId: $this->userId + ); + return new DataResponse(data: $playlist->jsonSerialize()); + } catch (PlaylistNotFoundException) { + return new DataResponse( + data: ['error' => 'Not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (OCSForbiddenException) { + return new DataResponse( + data: ['error' => 'Not authorized'], + statusCode: Http::STATUS_FORBIDDEN + ); + } catch (Exception $e) { + $this->logError(message: $e->getMessage()); + return new DataResponse( + data: ['error' => 'Internal error'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + }//end update() - /** - * Soft-revoke a kiosk playlist. - * - * Owner-or-admin only, idempotent (REQ-KIOSK-002). - * - * @param int $id Playlist primary key. - * - * @return DataResponse HTTP 204, 403, 404, or 401. - * - * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4 - */ - #[NoAdminRequired] - public function destroy(int $id): DataResponse - { - if ($this->userId === null) { - return new DataResponse( - data: ['error' => 'Not logged in'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } + /** + * Soft-revoke a kiosk playlist. + * + * Owner-or-admin only, idempotent (REQ-KIOSK-002). + * + * @param int $id Playlist primary key. + * + * @return DataResponse HTTP 204, 403, 404, or 401. + * + * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4 + */ + #[NoAdminRequired] + public function destroy(int $id): DataResponse { + if ($this->userId === null) { + return new DataResponse( + data: ['error' => 'Not logged in'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } - try { - $this->kioskService->revokePlaylist(id: $id, callerId: $this->userId); - return new DataResponse(data: [], statusCode: Http::STATUS_NO_CONTENT); - } catch (PlaylistNotFoundException) { - return new DataResponse( - data: ['error' => 'Not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (OCSForbiddenException) { - return new DataResponse( - data: ['error' => 'Not authorized'], - statusCode: Http::STATUS_FORBIDDEN - ); - } catch (Exception $e) { - $this->logError(message: $e->getMessage()); - return new DataResponse( - data: ['error' => 'Internal error'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - }//end try - }//end destroy() + try { + $this->kioskService->revokePlaylist(id: $id, callerId: $this->userId); + return new DataResponse(data: [], statusCode: Http::STATUS_NO_CONTENT); + } catch (PlaylistNotFoundException) { + return new DataResponse( + data: ['error' => 'Not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (OCSForbiddenException) { + return new DataResponse( + data: ['error' => 'Not authorized'], + statusCode: Http::STATUS_FORBIDDEN + ); + } catch (Exception $e) { + $this->logError(message: $e->getMessage()); + return new DataResponse( + data: ['error' => 'Internal error'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + }//end destroy() - /** - * Anonymously render a kiosk playlist via its token. - * - * Returns the playlist descriptor and the read-only render payload for - * every entry whose dashboard still exists. Unknown or revoked tokens - * return HTTP 404 with an identical shape (no existence leak). Shares the - * `launchpad_share_access` brute-force bucket with public-share renders. - * - * @param string $token The playlist token from the URL. - * - * @return DataResponse HTTP 200 render payload, 404 if invalid, 429 when throttled. - * - * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4 - */ - #[PublicPage] - #[NoCSRFRequired] - #[AnonRateLimit(limit: 60, period: 60)] - #[BruteForceProtection(action: PublicShareService::ACTION_SHARE_ACCESS)] - public function render(string $token): DataResponse - { - try { - $result = $this->kioskService->renderPlaylist(token: $token); + /** + * Anonymously render a kiosk playlist via its token. + * + * Returns the playlist descriptor and the read-only render payload for + * every entry whose dashboard still exists. Unknown or revoked tokens + * return HTTP 404 with an identical shape (no existence leak). Shares the + * `launchpad_share_access` brute-force bucket with public-share renders. + * + * @param string $token The playlist token from the URL. + * + * @return DataResponse HTTP 200 render payload, 404 if invalid, 429 when throttled. + * + * @spec openspec/changes/dashboard-kiosk-mode/tasks.md#task-4 + */ + #[PublicPage] + #[NoCSRFRequired] + #[AnonRateLimit(limit: 60, period: 60)] + #[BruteForceProtection(action: PublicShareService::ACTION_SHARE_ACCESS)] + public function render(string $token): DataResponse { + try { + $result = $this->kioskService->renderPlaylist(token: $token); - // Mark the request as a read-only bearer so any mutation service - // touched during render-payload hydration trips - // ShareReadOnlyException, mirroring public-share REQ-PSHR-006. - $this->shareContext->markBearer(token: $token); + // Mark the request as a read-only bearer so any mutation service + // touched during render-payload hydration trips + // ShareReadOnlyException, mirroring public-share REQ-PSHR-006. + $this->shareContext->markBearer(token: $token); - return new DataResponse(data: $result); - } catch (PlaylistNotFoundException) { - $response = new DataResponse( - data: ['error' => 'Not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - // Register a brute-force attempt on the shared bucket so token - // scanning the kiosk route counts toward the same throttle. - $response->throttle(['action' => PublicShareService::ACTION_SHARE_ACCESS]); - return $response; - } catch (Exception $e) { - $this->logError(message: $e->getMessage()); - return new DataResponse( - data: ['error' => 'Internal error'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - }//end try - }//end render() + return new DataResponse(data: $result); + } catch (PlaylistNotFoundException) { + $response = new DataResponse( + data: ['error' => 'Not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + // Register a brute-force attempt on the shared bucket so token + // scanning the kiosk route counts toward the same throttle. + $response->throttle(['action' => PublicShareService::ACTION_SHARE_ACCESS]); + return $response; + } catch (Exception $e) { + $this->logError(message: $e->getMessage()); + return new DataResponse( + data: ['error' => 'Internal error'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + }//end render() - /** - * Log a non-sensitive error message. - * - * @param string $message The error message. - * - * @return void - */ - private function logError(string $message): void - { - $this->logger->warning( - message: 'KioskController error: '.$message, - context: ['app' => Application::APP_ID] - ); - }//end logError() + /** + * Log a non-sensitive error message. + * + * @param string $message The error message. + * + * @return void + */ + private function logError(string $message): void { + $this->logger->warning( + message: 'KioskController error: ' . $message, + context: ['app' => Application::APP_ID] + ); + }//end logError() }//end class diff --git a/lib/Controller/LiveTileController.php b/lib/Controller/LiveTileController.php index 98bce948..b9fba652 100644 --- a/lib/Controller/LiveTileController.php +++ b/lib/Controller/LiveTileController.php @@ -55,168 +55,163 @@ * * @spec openspec/specs/live-data-tile-widget/spec.md */ -class LiveTileController extends Controller -{ - /** - * Constructor. - * - * @param IRequest $request HTTP request. - * @param LiveTileService $liveTileService Resolves + caches + validates live-tile values. - * @param PermissionService $permissionService Dashboard/placement permission gate. - * @param IUserSession $userSession Session accessor. - * @param LoggerInterface $logger PSR logger. - */ - public function __construct( - IRequest $request, - private readonly LiveTileService $liveTileService, - private readonly PermissionService $permissionService, - private readonly IUserSession $userSession, - private readonly LoggerInterface $logger, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * `GET /api/livetile/{placementId}` - * - * Returns the live-tile value for one placement. Returns 401 when - * anonymous, 403 when the caller may not view the underlying - * dashboard (REQ-LIVETILE-003 "Caller authorization" — the fetch is - * NEVER performed in that case), 404 when the placement does not - * exist, else 200 with the value (possibly `stale: true`). - * - * @param integer $placementId The widget placement id. - * - * @return JSONResponse - * - * @spec openspec/specs/live-data-tile-widget/spec.md - */ - #[NoAdminRequired] - #[NoCSRFRequired] - public function show(int $placementId): JSONResponse - { - $userId = $this->resolveUserId(); - if ($userId === null) { - return new JSONResponse( - data: ['status' => 'error', 'error' => 'unauthorized'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } - - // REQ-LIVETILE-003 "Caller authorization" — the auth guard runs - // BEFORE any resolution/fetch is attempted. - if ($this->permissionService->canViewPlacement(userId: $userId, placementId: $placementId) === false) { - return new JSONResponse( - data: ['status' => 'error', 'error' => 'forbidden'], - statusCode: Http::STATUS_FORBIDDEN - ); - } - - try { - $reading = $this->liveTileService->resolveForPlacement(placementId: $placementId); - } catch (Throwable $exception) { - $this->logger->error( - message: 'Unexpected live-tile resolution failure', - context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()] - ); - return new JSONResponse( - data: ['status' => 'error', 'error' => 'unknown_error'], - statusCode: Http::STATUS_INTERNAL_SERVER_ERROR - ); - } - - if (isset($reading['error']) === true) { - return new JSONResponse( - data: ['status' => 'error', 'error' => $reading['error']], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - return new JSONResponse( - data: $reading, - statusCode: Http::STATUS_OK - ); - }//end show() - - /** - * `GET /api/livetile/connector/status` - * - * Reports whether the OpenConnector `dashboard-http-datasource` - * capability is currently available, so the config form can hide or - * disable `connector` source mode (REQ-LIVETILE-005). Requires only - * an authenticated caller — carries no placement-specific data. - * - * @return JSONResponse `{available: bool}`. - * - * @spec openspec/specs/live-data-tile-widget/spec.md - */ - #[NoAdminRequired] - #[NoCSRFRequired] - public function connectorStatus(): JSONResponse - { - if ($this->resolveUserId() === null) { - return new JSONResponse( - data: ['status' => 'error', 'error' => 'unauthorized'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } - - return new JSONResponse( - data: ['available' => $this->liveTileService->isConnectorAvailable()], - statusCode: Http::STATUS_OK - ); - }//end connectorStatus() - - /** - * `POST /api/livetile/validate-source` - * - * Validates a candidate source config before the author saves the - * placement (REQ-LIVETILE-002 "rejected at save time" — host - * allow-list, fail-closed). Performs NO fetch — only the allow-list - * / capability-probe checks that `resolveForPlacement()` would apply. - * - * @return JSONResponse `{valid: bool, errors: string[]}`. - * - * @spec openspec/specs/live-data-tile-widget/spec.md - */ - #[NoAdminRequired] - public function validateSource(): JSONResponse - { - if ($this->resolveUserId() === null) { - return new JSONResponse( - data: ['status' => 'error', 'error' => 'unauthorized'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } - - $config = $this->request->getParam(key: 'config'); - if (is_array(value: $config) === false) { - $config = []; - } - - $errors = $this->liveTileService->validateSourceConfig(config: $config); - - return new JSONResponse( - data: ['valid' => ($errors === []), 'errors' => $errors], - statusCode: Http::STATUS_OK - ); - }//end validateSource() - - /** - * Resolve the active user's UID, or `null` for anonymous. - * - * @return string|null - */ - private function resolveUserId(): ?string - { - $user = $this->userSession->getUser(); - if ($user === null) { - return null; - } - - return $user->getUID(); - }//end resolveUserId() +class LiveTileController extends Controller { + /** + * Constructor. + * + * @param IRequest $request HTTP request. + * @param LiveTileService $liveTileService Resolves + caches + validates live-tile values. + * @param PermissionService $permissionService Dashboard/placement permission gate. + * @param IUserSession $userSession Session accessor. + * @param LoggerInterface $logger PSR logger. + */ + public function __construct( + IRequest $request, + private readonly LiveTileService $liveTileService, + private readonly PermissionService $permissionService, + private readonly IUserSession $userSession, + private readonly LoggerInterface $logger, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * `GET /api/livetile/{placementId}` + * + * Returns the live-tile value for one placement. Returns 401 when + * anonymous, 403 when the caller may not view the underlying + * dashboard (REQ-LIVETILE-003 "Caller authorization" — the fetch is + * NEVER performed in that case), 404 when the placement does not + * exist, else 200 with the value (possibly `stale: true`). + * + * @param integer $placementId The widget placement id. + * + * @return JSONResponse + * + * @spec openspec/specs/live-data-tile-widget/spec.md + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function show(int $placementId): JSONResponse { + $userId = $this->resolveUserId(); + if ($userId === null) { + return new JSONResponse( + data: ['status' => 'error', 'error' => 'unauthorized'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } + + // REQ-LIVETILE-003 "Caller authorization" — the auth guard runs + // BEFORE any resolution/fetch is attempted. + if ($this->permissionService->canViewPlacement(userId: $userId, placementId: $placementId) === false) { + return new JSONResponse( + data: ['status' => 'error', 'error' => 'forbidden'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + try { + $reading = $this->liveTileService->resolveForPlacement(placementId: $placementId); + } catch (Throwable $exception) { + $this->logger->error( + message: 'Unexpected live-tile resolution failure', + context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()] + ); + return new JSONResponse( + data: ['status' => 'error', 'error' => 'unknown_error'], + statusCode: Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + + if (isset($reading['error']) === true) { + return new JSONResponse( + data: ['status' => 'error', 'error' => $reading['error']], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + return new JSONResponse( + data: $reading, + statusCode: Http::STATUS_OK + ); + }//end show() + + /** + * `GET /api/livetile/connector/status` + * + * Reports whether the OpenConnector `dashboard-http-datasource` + * capability is currently available, so the config form can hide or + * disable `connector` source mode (REQ-LIVETILE-005). Requires only + * an authenticated caller — carries no placement-specific data. + * + * @return JSONResponse `{available: bool}`. + * + * @spec openspec/specs/live-data-tile-widget/spec.md + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function connectorStatus(): JSONResponse { + if ($this->resolveUserId() === null) { + return new JSONResponse( + data: ['status' => 'error', 'error' => 'unauthorized'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } + + return new JSONResponse( + data: ['available' => $this->liveTileService->isConnectorAvailable()], + statusCode: Http::STATUS_OK + ); + }//end connectorStatus() + + /** + * `POST /api/livetile/validate-source` + * + * Validates a candidate source config before the author saves the + * placement (REQ-LIVETILE-002 "rejected at save time" — host + * allow-list, fail-closed). Performs NO fetch — only the allow-list + * / capability-probe checks that `resolveForPlacement()` would apply. + * + * @return JSONResponse `{valid: bool, errors: string[]}`. + * + * @spec openspec/specs/live-data-tile-widget/spec.md + */ + #[NoAdminRequired] + public function validateSource(): JSONResponse { + if ($this->resolveUserId() === null) { + return new JSONResponse( + data: ['status' => 'error', 'error' => 'unauthorized'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } + + $config = $this->request->getParam(key: 'config'); + if (is_array(value: $config) === false) { + $config = []; + } + + $errors = $this->liveTileService->validateSourceConfig(config: $config); + + return new JSONResponse( + data: ['valid' => ($errors === []), 'errors' => $errors], + statusCode: Http::STATUS_OK + ); + }//end validateSource() + + /** + * Resolve the active user's UID, or `null` for anonymous. + * + * @return string|null + */ + private function resolveUserId(): ?string { + $user = $this->userSession->getUser(); + if ($user === null) { + return null; + } + + return $user->getUID(); + }//end resolveUserId() }//end class diff --git a/lib/Controller/ManifestController.php b/lib/Controller/ManifestController.php index abda58eb..1160278c 100644 --- a/lib/Controller/ManifestController.php +++ b/lib/Controller/ManifestController.php @@ -49,473 +49,463 @@ * rather than the individual manifest contributors precisely to keep this * count from growing as sections are added. */ -class ManifestController extends Controller -{ - /** - * OpenRegister register slug for launchpad dashboards. - * - * @var string - */ - private const REGISTER = 'launchpad'; - - /** - * OpenRegister schema slug for dashboard objects. - * - * @var string - */ - private const SCHEMA = 'dashboard'; - - /** - * V2 manifest schema URL. - * - * @var string - */ - private const SCHEMA_URL = 'https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json'; - - /** - * Constructor. - * - * @param IRequest $request The HTTP request. - * @param ContainerInterface $container The Nextcloud DI container; used to - * lazy-load ObjectService so that launchpad - * degrades gracefully when OpenRegister - * is not yet active. - * @param ActionAuthService $actionAuth ADR-023 action authorization. - * @param IUserSession $userSession User session (IUser resolution). - * @param LoggerInterface $logger PSR logger. - * @param string|null $userId The authenticated user ID, injected - * by the DI container. - */ - public function __construct( - IRequest $request, - private readonly ContainerInterface $container, - private readonly ActionAuthService $actionAuth, - private readonly IUserSession $userSession, - private readonly LoggerInterface $logger, - private readonly ?string $userId, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * Build and return the v2 app manifest for the authenticated user. - * - * Reads the user's dashboard objects from OpenRegister. Each object with - * a `slug` and `title` property becomes one page entry and one menu entry. - * Objects the user owns, plus objects explicitly granted to them via - * OpenRegister's per-object sharing primitive, are included. - * - * Route: GET /apps/launchpad/api/manifest - * - * @return JSONResponse A JSON document conforming to the v2 manifest - * schema. Returns HTTP 401 when no user is - * authenticated, HTTP 503 when OpenRegister is - * unavailable. - * - * @spec manifest-v2-runtime:REQ-MVR-001 - * @spec openspec/specs/runtime-shell/spec.md - */ - #[NoAdminRequired] - #[NoCSRFRequired] - public function index(): JSONResponse - { - if ($this->userId === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); - } - - try { - $this->actionAuth->requireAction($user, 'manifest.index'); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - // Retrieve ObjectService lazily — OpenRegister may not be enabled on - // every instance. Returning an empty manifest (not an error) lets the - // frontend render its "no dashboards yet" CTA without a red alert. - try { - /* - * @var \OCA\OpenRegister\Service\ObjectService $objectService - */ - - $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); - } catch (\Throwable $e) { - $this->logger->warning( - 'LaunchPad: OpenRegister ObjectService unavailable — returning empty manifest. '.$e->getMessage(), - ['app' => Application::APP_ID] - ); - - return new JSONResponse($this->buildManifest(dashboards: [], userId: $this->userId)); - }//end try - - // Fetch all dashboard objects owned by or shared with the current user. - $dashboards = $this->fetchUserDashboards(objectService: $objectService, userId: $this->userId); - - return new JSONResponse($this->buildManifest(dashboards: $dashboards, userId: $this->userId)); - - }//end index() - - /** - * Maximum number of granted dashboards folded into one manifest. - * - * A bound, not a policy: the grant lookup is cheap but the follow-up load is - * one IN(...) query whose parameter list should not grow without limit. If a - * user ever exceeds this, the manifest is truncated rather than slow — and - * the truncation is logged rather than silent, so it cannot be mistaken for - * "the user has no shared dashboards". - * - * @var int - */ - private const MAX_GRANTED = 200; - - /** - * Fetch dashboard objects from OpenRegister for the given user. - * - * C5 fix (REQ-MVR-001): replaces the non-existent `findObjects()` call - * (which caused a BadMethodCallException silently swallowed by Throwable) - * with the real `ObjectService::findAll()` API. The `owner` filter - * constrains results to the calling user's records — without it every - * user would receive the full dataset (latent IDOR on top of the API drift). - * - * Two sources, deduplicated: dashboards the user OWNS, and dashboards - * explicitly GRANTED to them through OpenRegister's per-object sharing - * primitive. The owner filter is deliberately kept on the first query rather - * than replaced by "let RBAC decide" — see fetchGrantedDashboards() for why - * the additive shape is the safe one. - * - * @param object $objectService The OpenRegister ObjectService instance. - * @param string $userId The authenticated Nextcloud user ID. - * - * @return array> Flat list of dashboard data arrays. - */ - private function fetchUserDashboards(object $objectService, string $userId): array - { - $dashboards = []; - $seen = []; - - try { - // C5 fix: use the real ObjectService::findAll() API. - // `findObjects()` does not exist; the old call threw - // BadMethodCallException that was silently swallowed, causing the - // manifest to always return empty pages/menu arrays. - // - // The owner filter MUST be NESTED under `@self`. OpenRegister splits - // filters into metadata filters (the magic table's `_`-prefixed - // columns, addressed as a nested `@self` array) and property filters - // (matched against the schema's own properties). A bare `owner` is - // therefore read as a property filter on an `owner` property, which - // the dashboard schema does not have — so it matched nothing and - // this endpoint returned an EMPTY MANIFEST to every user, including - // the owner of the dashboards. - // - // Measured on a live instance with admin owning two dashboards: no - // owner filter returned 2; a bare `owner => admin` returned 0; a - // DOTTED `@self.owner => admin` also returned 0; the nested - // `@self => [owner => admin]` returned 2. The control was a nested - // filter with a nonexistent user, which returned 0 — without it, - // "nested returns 2" could not be distinguished from "the filter was - // ignored, here is everything", which is the failure mode that - // produced the always-empty manifest in the first place. - $ownedResults = $objectService->findAll( - config: [ - 'filters' => [ - 'register' => self::REGISTER, - 'schema' => self::SCHEMA, - '@self' => ['owner' => $userId], - ], - 'limit' => 500, - ] - ); - - if (is_array($ownedResults) === true) { - $this->foldInto(rows: $ownedResults, dashboards: $dashboards, seen: $seen, extract: true); - } - } catch (DoesNotExistException $e) { - // The 'mydash' register or 'dashboard' schema has not been - // provisioned in OpenRegister on this instance yet. That simply - // means the user has no dashboards — degrade to an empty manifest - // so the frontend renders its "no dashboards yet" CTA instead of a - // 500 (OpenRegister surfaces this as DoesNotExistException, which - // extends \Exception and so is not a RuntimeException). - $this->logger->info( - 'MyDash: OpenRegister register/schema not provisioned — returning empty manifest. '.$e->getMessage(), - ['app' => Application::APP_ID, 'userId' => $userId] - ); - } catch (\RuntimeException | \InvalidArgumentException $e) { - // Narrow catch: only handle recoverable OR API errors. Let - // unexpected errors propagate so they are visible in the logs. - $this->logger->error( - 'LaunchPad: failed to fetch dashboards from OpenRegister: '.$e->getMessage(), - ['app' => Application::APP_ID, 'userId' => $userId] - ); - }//end try - - // Second source: dashboards explicitly granted to this user. Additive, - // and folded through the SAME $seen map so a dashboard the user both - // owns and was granted appears once. - $this->foldInto( - rows: $this->fetchGrantedDashboards(objectService: $objectService, userId: $userId), - dashboards: $dashboards, - seen: $seen, - extract: false - ); - - return $dashboards; - - }//end fetchUserDashboards() - - /** - * Fold one source's rows into the accumulator, skipping ones already seen. - * - * Shared by both sources on purpose: the owned query and the grant query must - * dedupe by the SAME identity rule, or a dashboard the user both owns and was - * granted would appear twice in the manifest. - * - * @param array $rows Rows from one source. - * @param array> $dashboards Accumulator, by reference. - * @param array $seen Identity map, by reference. - * @param bool $extract Whether the rows still - * need extractData() — - * the granted source has - * already normalised - * them. - * - * @return void - */ - private function foldInto(array $rows, array &$dashboards, array &$seen, bool $extract): void - { - foreach ($rows as $row) { - $data = $row; - if ($extract === true) { - $data = $this->extractData(item: $row); - } - - if (is_array($data) === false || empty($data) === true) { - continue; - } - - $id = ($data['id'] ?? $data['uuid'] ?? $data['slug'] ?? null); - if ($id !== null && isset($seen[$id]) === false) { - $seen[$id] = true; - $dashboards[] = $data; - } - } - - }//end foldInto() - - /** - * Dashboard objects explicitly granted to this user, via OpenRegister. - * - * WHY THIS IS ADDITIVE, rather than "drop the owner filter and let RBAC - * decide". Letting RBAC decide is the tidier design and it is what the - * OpenRegister `private` scope exists for — but it is only safe once the - * `dashboard` schema actually carries `scope: private`. A register-descriptor - * change lands through a repair step on upgrade, so there is necessarily a - * window (and, on any instance where that import did not apply, an - * indefinite one) in which the schema is still unscoped. An unfiltered - * findAll() against an unscoped schema returns EVERY user's dashboards. So - * the owner filter stays, and grants only ever ADD rows. The failure mode of - * this shape is a missing dashboard; the failure mode of the other is a - * cross-tenant leak in the manifest. - * - * `read` is the verb, because appearing in someone's manifest is exactly a - * read. The resolver answers only for the five core permission verbs and - * refuses anything else, so this cannot silently widen. - * - * Fails soft and empty: OpenRegister may be present without the sharing - * primitive (an older release), in which case the class is simply absent and - * the manifest degrades to owned-only — the behaviour before this change. - * - * @param object $objectService The OpenRegister ObjectService instance. - * @param string $userId The authenticated Nextcloud user ID. - * - * @return array> Granted dashboard data arrays. - */ - private function fetchGrantedDashboards(object $objectService, string $userId): array - { - try { - $grantResolver = $this->container->get('OCA\OpenRegister\Service\Rbac\ObjectGrantResolver'); - } catch (\Throwable $e) { - // OpenRegister without the per-object sharing primitive. Not an - // error: degrade to owned-only, which is the pre-existing behaviour. - $this->logger->debug( - 'LaunchPad: OpenRegister object-grant resolver unavailable — manifest is owned-only. '.$e->getMessage(), - ['app' => Application::APP_ID] - ); - - return []; - } - - try { - // Keys, not values: the resolver returns uuid => permission - // bitmask, so array_values() would yield the bitmasks. - $grantedUuids = array_keys($grantResolver->grantedObjectUuidsFor($userId, 'read')); - if (empty($grantedUuids) === true) { - return []; - } - - if (count($grantedUuids) > self::MAX_GRANTED) { - // Logged, never silent: a truncated manifest must not be - // indistinguishable from "nothing is shared with this user". - $this->logger->warning( - sprintf( - 'LaunchPad: %d granted dashboards exceeds the %d cap — manifest truncated.', - count($grantedUuids), - self::MAX_GRANTED - ), - ['app' => Application::APP_ID, 'userId' => $userId] - ); - $grantedUuids = array_slice($grantedUuids, 0, self::MAX_GRANTED); - } - - // `ids` is a first-class config key that matches `_uuid` OR `_slug`. - // A `filters['uuid']` entry would instead be read as a property - // filter on a `uuid` property — the same trap that made the owner - // query above return nothing. - $results = $objectService->findAll( - config: [ - 'filters' => [ - 'register' => self::REGISTER, - 'schema' => self::SCHEMA, - ], - 'ids' => $grantedUuids, - 'limit' => self::MAX_GRANTED, - ] - ); - - if (is_array($results) === false) { - return []; - } - - $granted = []; - foreach ($results as $item) { - $data = $this->extractData(item: $item); - if (empty($data) === false) { - $granted[] = $data; - } - } - - return $granted; - } catch (DoesNotExistException $e) { - // Register/schema not provisioned — same benign case the owned - // query already handles. - return []; - } catch (\RuntimeException | \InvalidArgumentException $e) { - $this->logger->error( - 'LaunchPad: failed to fetch granted dashboards from OpenRegister: '.$e->getMessage(), - ['app' => Application::APP_ID, 'userId' => $userId] - ); - - return []; - }//end try - - }//end fetchGrantedDashboards() - - /** - * Normalise a raw ObjectService result item to a plain data array. - * - * OpenRegister items may be returned as objects with a `getObject()` - * method or as plain associative arrays. - * - * @param mixed $item A single result from ObjectService::findAll(). - * - * @return array The plain data array, or [] on failure. - */ - private function extractData(mixed $item): array - { - if (is_array($item) === true) { - return $item; - } - - if (is_object($item) === true && method_exists($item, 'getObject') === true) { - $data = $item->getObject(); - if (is_array($data) === true) { - return $data; - } - - return []; - } - - if (is_object($item) === true && method_exists($item, 'jsonSerialize') === true) { - $data = $item->jsonSerialize(); - if (is_array($data) === true) { - return $data; - } - - return []; - } - - return []; - - }//end extractData() - - /** - * Build the v2 manifest array from a list of dashboard data arrays. - * - * @param array> $dashboards Flat list of dashboard data. - * @param string $userId The current user ID. - * - * @return array The v2 manifest document. - */ - private function buildManifest(array $dashboards, string $userId): array - { - $pages = []; - $menu = []; - $order = 0; - - foreach ($dashboards as $data) { - $slug = $data['slug'] ?? null; - $title = $data['title'] ?? null; - - if (empty($slug) === true || empty($title) === true) { - continue; - } - - $pageId = 'dashboard-'.$slug; - - $pages[] = [ - 'id' => $pageId, - 'route' => '/'.$slug, - 'type' => 'dashboard', - 'title' => $title, - 'widgets' => $data['widgets'] ?? [], - ]; - - $menu[] = [ - 'id' => 'menu-'.$slug, - 'label' => $title, - 'route' => $pageId, - 'order' => $order, - // ADR-077 Tier A concept `dashboard`. These entries are - // dashboards, so `icon-home` was both the wrong concept and a - // legacy `icon-*` CSS class — which renders as an invisible - // white glyph on NC34+ light themes. The name is registered in - // src/icons.js; CnIcon has no fallback for one that is not. - 'icon' => 'ViewDashboardOutline', - ]; - - $order++; - }//end foreach - - return [ - '$schema' => self::SCHEMA_URL, - 'version' => '1.0.0', - 'dependencies' => ['openregister'], - 'menu' => $menu, - 'pages' => $pages, - 'runtime' => [ - 'user' => [ - 'id' => $userId, - ], - ], - ]; - - }//end buildManifest() +class ManifestController extends Controller { + /** + * OpenRegister register slug for launchpad dashboards. + * + * @var string + */ + private const REGISTER = 'launchpad'; + + /** + * OpenRegister schema slug for dashboard objects. + * + * @var string + */ + private const SCHEMA = 'dashboard'; + + /** + * V2 manifest schema URL. + * + * @var string + */ + private const SCHEMA_URL = 'https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json'; + + /** + * Constructor. + * + * @param IRequest $request The HTTP request. + * @param ContainerInterface $container The Nextcloud DI container; used to + * lazy-load ObjectService so that launchpad + * degrades gracefully when OpenRegister + * is not yet active. + * @param ActionAuthService $actionAuth ADR-023 action authorization. + * @param IUserSession $userSession User session (IUser resolution). + * @param LoggerInterface $logger PSR logger. + * @param string|null $userId The authenticated user ID, injected + * by the DI container. + */ + public function __construct( + IRequest $request, + private readonly ContainerInterface $container, + private readonly ActionAuthService $actionAuth, + private readonly IUserSession $userSession, + private readonly LoggerInterface $logger, + private readonly ?string $userId, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * Build and return the v2 app manifest for the authenticated user. + * + * Reads the user's dashboard objects from OpenRegister. Each object with + * a `slug` and `title` property becomes one page entry and one menu entry. + * Objects the user owns, plus objects explicitly granted to them via + * OpenRegister's per-object sharing primitive, are included. + * + * Route: GET /apps/launchpad/api/manifest + * + * @return JSONResponse A JSON document conforming to the v2 manifest + * schema. Returns HTTP 401 when no user is + * authenticated, HTTP 503 when OpenRegister is + * unavailable. + * + * @spec manifest-v2-runtime:REQ-MVR-001 + * @spec openspec/specs/runtime-shell/spec.md + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function index(): JSONResponse { + if ($this->userId === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + try { + $this->actionAuth->requireAction($user, 'manifest.index'); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + // Retrieve ObjectService lazily — OpenRegister may not be enabled on + // every instance. Returning an empty manifest (not an error) lets the + // frontend render its "no dashboards yet" CTA without a red alert. + try { + /* + * @var \OCA\OpenRegister\Service\ObjectService $objectService + */ + + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + } catch (\Throwable $e) { + $this->logger->warning( + 'LaunchPad: OpenRegister ObjectService unavailable — returning empty manifest. ' . $e->getMessage(), + ['app' => Application::APP_ID] + ); + + return new JSONResponse($this->buildManifest(dashboards: [], userId: $this->userId)); + }//end try + + // Fetch all dashboard objects owned by or shared with the current user. + $dashboards = $this->fetchUserDashboards(objectService: $objectService, userId: $this->userId); + + return new JSONResponse($this->buildManifest(dashboards: $dashboards, userId: $this->userId)); + }//end index() + + /** + * Maximum number of granted dashboards folded into one manifest. + * + * A bound, not a policy: the grant lookup is cheap but the follow-up load is + * one IN(...) query whose parameter list should not grow without limit. If a + * user ever exceeds this, the manifest is truncated rather than slow — and + * the truncation is logged rather than silent, so it cannot be mistaken for + * "the user has no shared dashboards". + * + * @var int + */ + private const MAX_GRANTED = 200; + + /** + * Fetch dashboard objects from OpenRegister for the given user. + * + * C5 fix (REQ-MVR-001): replaces the non-existent `findObjects()` call + * (which caused a BadMethodCallException silently swallowed by Throwable) + * with the real `ObjectService::findAll()` API. The `owner` filter + * constrains results to the calling user's records — without it every + * user would receive the full dataset (latent IDOR on top of the API drift). + * + * Two sources, deduplicated: dashboards the user OWNS, and dashboards + * explicitly GRANTED to them through OpenRegister's per-object sharing + * primitive. The owner filter is deliberately kept on the first query rather + * than replaced by "let RBAC decide" — see fetchGrantedDashboards() for why + * the additive shape is the safe one. + * + * @param object $objectService The OpenRegister ObjectService instance. + * @param string $userId The authenticated Nextcloud user ID. + * + * @return array> Flat list of dashboard data arrays. + */ + private function fetchUserDashboards(object $objectService, string $userId): array { + $dashboards = []; + $seen = []; + + try { + // C5 fix: use the real ObjectService::findAll() API. + // `findObjects()` does not exist; the old call threw + // BadMethodCallException that was silently swallowed, causing the + // manifest to always return empty pages/menu arrays. + // + // The owner filter MUST be NESTED under `@self`. OpenRegister splits + // filters into metadata filters (the magic table's `_`-prefixed + // columns, addressed as a nested `@self` array) and property filters + // (matched against the schema's own properties). A bare `owner` is + // therefore read as a property filter on an `owner` property, which + // the dashboard schema does not have — so it matched nothing and + // this endpoint returned an EMPTY MANIFEST to every user, including + // the owner of the dashboards. + // + // Measured on a live instance with admin owning two dashboards: no + // owner filter returned 2; a bare `owner => admin` returned 0; a + // DOTTED `@self.owner => admin` also returned 0; the nested + // `@self => [owner => admin]` returned 2. The control was a nested + // filter with a nonexistent user, which returned 0 — without it, + // "nested returns 2" could not be distinguished from "the filter was + // ignored, here is everything", which is the failure mode that + // produced the always-empty manifest in the first place. + $ownedResults = $objectService->findAll( + config: [ + 'filters' => [ + 'register' => self::REGISTER, + 'schema' => self::SCHEMA, + '@self' => ['owner' => $userId], + ], + 'limit' => 500, + ] + ); + + if (is_array($ownedResults) === true) { + $this->foldInto(rows: $ownedResults, dashboards: $dashboards, seen: $seen, extract: true); + } + } catch (DoesNotExistException $e) { + // The 'mydash' register or 'dashboard' schema has not been + // provisioned in OpenRegister on this instance yet. That simply + // means the user has no dashboards — degrade to an empty manifest + // so the frontend renders its "no dashboards yet" CTA instead of a + // 500 (OpenRegister surfaces this as DoesNotExistException, which + // extends \Exception and so is not a RuntimeException). + $this->logger->info( + 'MyDash: OpenRegister register/schema not provisioned — returning empty manifest. ' . $e->getMessage(), + ['app' => Application::APP_ID, 'userId' => $userId] + ); + } catch (\RuntimeException|\InvalidArgumentException $e) { + // Narrow catch: only handle recoverable OR API errors. Let + // unexpected errors propagate so they are visible in the logs. + $this->logger->error( + 'LaunchPad: failed to fetch dashboards from OpenRegister: ' . $e->getMessage(), + ['app' => Application::APP_ID, 'userId' => $userId] + ); + }//end try + + // Second source: dashboards explicitly granted to this user. Additive, + // and folded through the SAME $seen map so a dashboard the user both + // owns and was granted appears once. + $this->foldInto( + rows: $this->fetchGrantedDashboards(objectService: $objectService, userId: $userId), + dashboards: $dashboards, + seen: $seen, + extract: false + ); + + return $dashboards; + }//end fetchUserDashboards() + + /** + * Fold one source's rows into the accumulator, skipping ones already seen. + * + * Shared by both sources on purpose: the owned query and the grant query must + * dedupe by the SAME identity rule, or a dashboard the user both owns and was + * granted would appear twice in the manifest. + * + * @param array $rows Rows from one source. + * @param array> $dashboards Accumulator, by reference. + * @param array $seen Identity map, by reference. + * @param bool $extract Whether the rows still + * need extractData() — + * the granted source has + * already normalised + * them. + * + * @return void + */ + private function foldInto(array $rows, array &$dashboards, array &$seen, bool $extract): void { + foreach ($rows as $row) { + $data = $row; + if ($extract === true) { + $data = $this->extractData(item: $row); + } + + if (is_array($data) === false || empty($data) === true) { + continue; + } + + $id = ($data['id'] ?? $data['uuid'] ?? $data['slug'] ?? null); + if ($id !== null && isset($seen[$id]) === false) { + $seen[$id] = true; + $dashboards[] = $data; + } + } + + }//end foldInto() + + /** + * Dashboard objects explicitly granted to this user, via OpenRegister. + * + * WHY THIS IS ADDITIVE, rather than "drop the owner filter and let RBAC + * decide". Letting RBAC decide is the tidier design and it is what the + * OpenRegister `private` scope exists for — but it is only safe once the + * `dashboard` schema actually carries `scope: private`. A register-descriptor + * change lands through a repair step on upgrade, so there is necessarily a + * window (and, on any instance where that import did not apply, an + * indefinite one) in which the schema is still unscoped. An unfiltered + * findAll() against an unscoped schema returns EVERY user's dashboards. So + * the owner filter stays, and grants only ever ADD rows. The failure mode of + * this shape is a missing dashboard; the failure mode of the other is a + * cross-tenant leak in the manifest. + * + * `read` is the verb, because appearing in someone's manifest is exactly a + * read. The resolver answers only for the five core permission verbs and + * refuses anything else, so this cannot silently widen. + * + * Fails soft and empty: OpenRegister may be present without the sharing + * primitive (an older release), in which case the class is simply absent and + * the manifest degrades to owned-only — the behaviour before this change. + * + * @param object $objectService The OpenRegister ObjectService instance. + * @param string $userId The authenticated Nextcloud user ID. + * + * @return array> Granted dashboard data arrays. + */ + private function fetchGrantedDashboards(object $objectService, string $userId): array { + try { + $grantResolver = $this->container->get('OCA\OpenRegister\Service\Rbac\ObjectGrantResolver'); + } catch (\Throwable $e) { + // OpenRegister without the per-object sharing primitive. Not an + // error: degrade to owned-only, which is the pre-existing behaviour. + $this->logger->debug( + 'LaunchPad: OpenRegister object-grant resolver unavailable — manifest is owned-only. ' . $e->getMessage(), + ['app' => Application::APP_ID] + ); + + return []; + } + + try { + // Keys, not values: the resolver returns uuid => permission + // bitmask, so array_values() would yield the bitmasks. + $grantedUuids = array_keys($grantResolver->grantedObjectUuidsFor($userId, 'read')); + if (empty($grantedUuids) === true) { + return []; + } + + if (count($grantedUuids) > self::MAX_GRANTED) { + // Logged, never silent: a truncated manifest must not be + // indistinguishable from "nothing is shared with this user". + $this->logger->warning( + sprintf( + 'LaunchPad: %d granted dashboards exceeds the %d cap — manifest truncated.', + count($grantedUuids), + self::MAX_GRANTED + ), + ['app' => Application::APP_ID, 'userId' => $userId] + ); + $grantedUuids = array_slice($grantedUuids, 0, self::MAX_GRANTED); + } + + // `ids` is a first-class config key that matches `_uuid` OR `_slug`. + // A `filters['uuid']` entry would instead be read as a property + // filter on a `uuid` property — the same trap that made the owner + // query above return nothing. + $results = $objectService->findAll( + config: [ + 'filters' => [ + 'register' => self::REGISTER, + 'schema' => self::SCHEMA, + ], + 'ids' => $grantedUuids, + 'limit' => self::MAX_GRANTED, + ] + ); + + if (is_array($results) === false) { + return []; + } + + $granted = []; + foreach ($results as $item) { + $data = $this->extractData(item: $item); + if (empty($data) === false) { + $granted[] = $data; + } + } + + return $granted; + } catch (DoesNotExistException $e) { + // Register/schema not provisioned — same benign case the owned + // query already handles. + return []; + } catch (\RuntimeException|\InvalidArgumentException $e) { + $this->logger->error( + 'LaunchPad: failed to fetch granted dashboards from OpenRegister: ' . $e->getMessage(), + ['app' => Application::APP_ID, 'userId' => $userId] + ); + + return []; + }//end try + + }//end fetchGrantedDashboards() + + /** + * Normalise a raw ObjectService result item to a plain data array. + * + * OpenRegister items may be returned as objects with a `getObject()` + * method or as plain associative arrays. + * + * @param mixed $item A single result from ObjectService::findAll(). + * + * @return array The plain data array, or [] on failure. + */ + private function extractData(mixed $item): array { + if (is_array($item) === true) { + return $item; + } + + if (is_object($item) === true && method_exists($item, 'getObject') === true) { + $data = $item->getObject(); + if (is_array($data) === true) { + return $data; + } + + return []; + } + + if (is_object($item) === true && method_exists($item, 'jsonSerialize') === true) { + $data = $item->jsonSerialize(); + if (is_array($data) === true) { + return $data; + } + + return []; + } + + return []; + }//end extractData() + + /** + * Build the v2 manifest array from a list of dashboard data arrays. + * + * @param array> $dashboards Flat list of dashboard data. + * @param string $userId The current user ID. + * + * @return array The v2 manifest document. + */ + private function buildManifest(array $dashboards, string $userId): array { + $pages = []; + $menu = []; + $order = 0; + + foreach ($dashboards as $data) { + $slug = $data['slug'] ?? null; + $title = $data['title'] ?? null; + + if (empty($slug) === true || empty($title) === true) { + continue; + } + + $pageId = 'dashboard-' . $slug; + + $pages[] = [ + 'id' => $pageId, + 'route' => '/' . $slug, + 'type' => 'dashboard', + 'title' => $title, + 'widgets' => $data['widgets'] ?? [], + ]; + + $menu[] = [ + 'id' => 'menu-' . $slug, + 'label' => $title, + 'route' => $pageId, + 'order' => $order, + // ADR-077 Tier A concept `dashboard`. These entries are + // dashboards, so `icon-home` was both the wrong concept and a + // legacy `icon-*` CSS class — which renders as an invisible + // white glyph on NC34+ light themes. The name is registered in + // src/icons.js; CnIcon has no fallback for one that is not. + 'icon' => 'ViewDashboardOutline', + ]; + + $order++; + }//end foreach + + return [ + '$schema' => self::SCHEMA_URL, + 'version' => '1.0.0', + 'dependencies' => ['openregister'], + 'menu' => $menu, + 'pages' => $pages, + 'runtime' => [ + 'user' => [ + 'id' => $userId, + ], + ], + ]; + + }//end buildManifest() }//end class diff --git a/lib/Controller/MetadataAdminController.php b/lib/Controller/MetadataAdminController.php index bba8e061..ebc90e34 100644 --- a/lib/Controller/MetadataAdminController.php +++ b/lib/Controller/MetadataAdminController.php @@ -51,370 +51,364 @@ * * @spec openspec/specs/dashboard-metadata-fields/spec.md */ -class MetadataAdminController extends Controller -{ - /** - * Constructor. - * - * @param IRequest $request The HTTP request. - * @param MetadataService $metadataService The metadata service facade. - * @param IGroupManager $groupManager Admin checker. - * @param IUserSession $userSession Current user session. - * @param ActionAuthService $actionAuth ADR-023 action authorization. - */ - public function __construct( - IRequest $request, - private readonly MetadataService $metadataService, - private readonly IGroupManager $groupManager, - private readonly IUserSession $userSession, - private readonly ActionAuthService $actionAuth, - ) { - parent::__construct( - appName: Application::APP_ID, - request: $request - ); - }//end __construct() - - /** - * Inline admin guard. - * - * @return JSONResponse|null Non-null = caller must be rejected. - */ - private function assertAdmin(): ?JSONResponse - { - $user = $this->userSession->getUser(); - if ($user === null) { - return new JSONResponse( - data: ['error' => 'Not authenticated'], - statusCode: Http::STATUS_UNAUTHORIZED - ); - } - - if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) { - return new JSONResponse( - data: ['error' => 'Admin required'], - statusCode: Http::STATUS_FORBIDDEN - ); - } - - return null; - }//end assertAdmin() - - /** - * `GET /api/admin/metadata-fields` — list all field definitions - * (REQ-MDFL-001). - * - * @return JSONResponse The fields array + count, or 403. - * - * @spec openspec/specs/dashboard-metadata-fields/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function listFields(): JSONResponse - { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - try { - $this->actionAuth->requireAction( - $this->userSession->getUser(), - 'metadata-admin.list-fields' - ); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - $fields = $this->metadataService->listFields(); - - return ResponseHelper::success( - data: [ - 'fields' => ResponseHelper::serializeList(entities: $fields), - 'count' => count($fields), - ] - ); - }//end listFields() - - /** - * `POST /api/admin/metadata-fields` — create a new field definition - * (REQ-MDFL-001). - * - * @param string $key The slug. - * @param string $label The display label. - * @param string $type The field type. - * @param array|null $options Option set (select types). - * @param int $required 0 / 1. - * @param int $sortOrder UI sort order. - * - * @return JSONResponse 201 + field, 400 on validation failure, - * 403 for non-admins. - * - * @spec openspec/specs/dashboard-metadata-fields/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function createField( - string $key='', - string $label='', - string $type='', - ?array $options=null, - int $required=0, - int $sortOrder=0 - ): JSONResponse { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - try { - $this->actionAuth->requireAction( - $this->userSession->getUser(), - 'metadata-admin.create-field' - ); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - try { - $field = $this->metadataService->createFieldDefinition( - key: $key, - label: $label, - type: $type, - options: $options, - required: $required, - sortOrder: $sortOrder - ); - } catch (InvalidMetadataFieldException $exception) { - return self::badRequest(message: $exception->getMessage()); - } - - return new JSONResponse( - data: $field->jsonSerialize(), - statusCode: Http::STATUS_CREATED - ); - }//end createField() - - /** - * `GET /api/admin/metadata-fields/{id}` — fetch a single field - * definition (REQ-MDFL-001). - * - * @param int $id The field id. - * - * @return JSONResponse 200 + field, 404 when missing, 403 for non-admins. - * - * @spec openspec/specs/dashboard-metadata-fields/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function getField(int $id): JSONResponse - { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - try { - $this->actionAuth->requireAction( - $this->userSession->getUser(), - 'metadata-admin.get-field' - ); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - try { - $field = $this->metadataService->getField(id: $id); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Field not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } - - return ResponseHelper::success(data: $field->jsonSerialize()); - }//end getField() - - /** - * `PUT /api/admin/metadata-fields/{id}` — update label / sortOrder - * / required / options. Forbids `key` rename (REQ-MDFL-002). - * - * @param int $id The field id. - * @param string|null $label The new label. - * @param int|null $sortOrder The new sort order. - * @param int|null $required The new required flag. - * @param array|null $options The new option set. - * @param string|null $key Forbidden — triggers 400. - * - * @return JSONResponse 200 + field, 400 on validation failure, - * 404 when missing, 403 for non-admins. - * - * @spec openspec/specs/dashboard-metadata-fields/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function updateField( - int $id, - ?string $label=null, - ?int $sortOrder=null, - ?int $required=null, - ?array $options=null, - ?string $key=null - ): JSONResponse { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - try { - $this->actionAuth->requireAction( - $this->userSession->getUser(), - 'metadata-admin.update-field' - ); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - $patch = self::buildPatch( - key: $key, - label: $label, - sortOrder: $sortOrder, - required: $required, - options: $options - ); - - try { - $field = $this->metadataService->updateFieldDefinition( - id: $id, - patch: $patch - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Field not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (InvalidMetadataFieldException $exception) { - return self::badRequest(message: $exception->getMessage()); - } - - return ResponseHelper::success(data: $field->jsonSerialize()); - }//end updateField() - - /** - * `DELETE /api/admin/metadata-fields/{id}?cascade=true` — - * REQ-MDFL-003. - * - * @param int $id The field id. - * @param bool $cascade Whether to cascade-delete dependent values. - * - * @return JSONResponse 200 on success, 409 when soft-deletion blocked, - * 404 when missing, 403 for non-admins. - * - * @spec openspec/specs/dashboard-metadata-fields/spec.md - */ - #[AuthorizedAdminSetting(LaunchPadAdmin::class)] - public function deleteField(int $id, bool $cascade=false): JSONResponse - { - $guard = $this->assertAdmin(); - if ($guard !== null) { - return $guard; - } - - try { - $this->actionAuth->requireAction( - $this->userSession->getUser(), - 'metadata-admin.delete-field' - ); - } catch (OCSForbiddenException) { - return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); - } - - try { - $this->metadataService->deleteFieldDefinition( - id: $id, - cascade: $cascade - ); - } catch (DoesNotExistException) { - return new JSONResponse( - data: ['error' => 'Field not found'], - statusCode: Http::STATUS_NOT_FOUND - ); - } catch (MetadataFieldHasValuesException $exception) { - return new JSONResponse( - data: [ - 'error' => MetadataFieldHasValuesException::ERROR_CODE, - 'message' => $exception->getMessage(), - 'valueCount' => $exception->getValueCount(), - ], - statusCode: Http::STATUS_CONFLICT - ); - } - - return ResponseHelper::success(data: ['status' => 'ok']); - }//end deleteField() - - /** - * Build the update patch from the individual nullable parameters. - * - * `null` means "not supplied" and the key is left out entirely, so - * the service can distinguish an omitted field from an explicit - * value. The router delivers `null` when the body omits the key; - * any explicit array (including an empty one) counts as "set - * options", so admins can clear an option set on non-select types. - * - * The forbidden `key` rename is deliberately forwarded rather than - * dropped — the service rejects it with the documented 400. - * - * @param string|null $key The (forbidden) new slug. - * @param string|null $label The new label. - * @param int|null $sortOrder The new sort order. - * @param int|null $required The new required flag. - * @param array|null $options The new option set. - * - * @return array The patch payload. - */ - private static function buildPatch( - ?string $key, - ?string $label, - ?int $sortOrder, - ?int $required, - ?array $options - ): array { - $patch = []; - if ($key !== null) { - $patch['key'] = $key; - } - - if ($label !== null) { - $patch['label'] = $label; - } - - if ($sortOrder !== null) { - $patch['sortOrder'] = $sortOrder; - } - - if ($required !== null) { - $patch['required'] = $required; - } - - if ($options !== null) { - $patch['options'] = $options; - } - - return $patch; - }//end buildPatch() - - /** - * Build a 400-with-error envelope. - * - * @param string $message The validation message. - * - * @return JSONResponse The 400 response. - */ - private static function badRequest(string $message): JSONResponse - { - return new JSONResponse( - data: [ - 'error' => InvalidMetadataFieldException::ERROR_CODE, - 'message' => $message, - ], - statusCode: Http::STATUS_BAD_REQUEST - ); - }//end badRequest() +class MetadataAdminController extends Controller { + /** + * Constructor. + * + * @param IRequest $request The HTTP request. + * @param MetadataService $metadataService The metadata service facade. + * @param IGroupManager $groupManager Admin checker. + * @param IUserSession $userSession Current user session. + * @param ActionAuthService $actionAuth ADR-023 action authorization. + */ + public function __construct( + IRequest $request, + private readonly MetadataService $metadataService, + private readonly IGroupManager $groupManager, + private readonly IUserSession $userSession, + private readonly ActionAuthService $actionAuth, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * Inline admin guard. + * + * @return JSONResponse|null Non-null = caller must be rejected. + */ + private function assertAdmin(): ?JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse( + data: ['error' => 'Not authenticated'], + statusCode: Http::STATUS_UNAUTHORIZED + ); + } + + if ($this->groupManager->isAdmin(userId: $user->getUID()) === false) { + return new JSONResponse( + data: ['error' => 'Admin required'], + statusCode: Http::STATUS_FORBIDDEN + ); + } + + return null; + }//end assertAdmin() + + /** + * `GET /api/admin/metadata-fields` — list all field definitions + * (REQ-MDFL-001). + * + * @return JSONResponse The fields array + count, or 403. + * + * @spec openspec/specs/dashboard-metadata-fields/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function listFields(): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + try { + $this->actionAuth->requireAction( + $this->userSession->getUser(), + 'metadata-admin.list-fields' + ); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + $fields = $this->metadataService->listFields(); + + return ResponseHelper::success( + data: [ + 'fields' => ResponseHelper::serializeList(entities: $fields), + 'count' => count($fields), + ] + ); + }//end listFields() + + /** + * `POST /api/admin/metadata-fields` — create a new field definition + * (REQ-MDFL-001). + * + * @param string $key The slug. + * @param string $label The display label. + * @param string $type The field type. + * @param array|null $options Option set (select types). + * @param int $required 0 / 1. + * @param int $sortOrder UI sort order. + * + * @return JSONResponse 201 + field, 400 on validation failure, + * 403 for non-admins. + * + * @spec openspec/specs/dashboard-metadata-fields/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function createField( + string $key = '', + string $label = '', + string $type = '', + ?array $options = null, + int $required = 0, + int $sortOrder = 0, + ): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + try { + $this->actionAuth->requireAction( + $this->userSession->getUser(), + 'metadata-admin.create-field' + ); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + try { + $field = $this->metadataService->createFieldDefinition( + key: $key, + label: $label, + type: $type, + options: $options, + required: $required, + sortOrder: $sortOrder + ); + } catch (InvalidMetadataFieldException $exception) { + return self::badRequest(message: $exception->getMessage()); + } + + return new JSONResponse( + data: $field->jsonSerialize(), + statusCode: Http::STATUS_CREATED + ); + }//end createField() + + /** + * `GET /api/admin/metadata-fields/{id}` — fetch a single field + * definition (REQ-MDFL-001). + * + * @param int $id The field id. + * + * @return JSONResponse 200 + field, 404 when missing, 403 for non-admins. + * + * @spec openspec/specs/dashboard-metadata-fields/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function getField(int $id): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + try { + $this->actionAuth->requireAction( + $this->userSession->getUser(), + 'metadata-admin.get-field' + ); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + try { + $field = $this->metadataService->getField(id: $id); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Field not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } + + return ResponseHelper::success(data: $field->jsonSerialize()); + }//end getField() + + /** + * `PUT /api/admin/metadata-fields/{id}` — update label / sortOrder + * / required / options. Forbids `key` rename (REQ-MDFL-002). + * + * @param int $id The field id. + * @param string|null $label The new label. + * @param int|null $sortOrder The new sort order. + * @param int|null $required The new required flag. + * @param array|null $options The new option set. + * @param string|null $key Forbidden — triggers 400. + * + * @return JSONResponse 200 + field, 400 on validation failure, + * 404 when missing, 403 for non-admins. + * + * @spec openspec/specs/dashboard-metadata-fields/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function updateField( + int $id, + ?string $label = null, + ?int $sortOrder = null, + ?int $required = null, + ?array $options = null, + ?string $key = null, + ): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + try { + $this->actionAuth->requireAction( + $this->userSession->getUser(), + 'metadata-admin.update-field' + ); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + $patch = self::buildPatch( + key: $key, + label: $label, + sortOrder: $sortOrder, + required: $required, + options: $options + ); + + try { + $field = $this->metadataService->updateFieldDefinition( + id: $id, + patch: $patch + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Field not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (InvalidMetadataFieldException $exception) { + return self::badRequest(message: $exception->getMessage()); + } + + return ResponseHelper::success(data: $field->jsonSerialize()); + }//end updateField() + + /** + * `DELETE /api/admin/metadata-fields/{id}?cascade=true` — + * REQ-MDFL-003. + * + * @param int $id The field id. + * @param bool $cascade Whether to cascade-delete dependent values. + * + * @return JSONResponse 200 on success, 409 when soft-deletion blocked, + * 404 when missing, 403 for non-admins. + * + * @spec openspec/specs/dashboard-metadata-fields/spec.md + */ + #[AuthorizedAdminSetting(LaunchPadAdmin::class)] + public function deleteField(int $id, bool $cascade = false): JSONResponse { + $guard = $this->assertAdmin(); + if ($guard !== null) { + return $guard; + } + + try { + $this->actionAuth->requireAction( + $this->userSession->getUser(), + 'metadata-admin.delete-field' + ); + } catch (OCSForbiddenException) { + return new JSONResponse(['error' => 'Forbidden'], Http::STATUS_FORBIDDEN); + } + + try { + $this->metadataService->deleteFieldDefinition( + id: $id, + cascade: $cascade + ); + } catch (DoesNotExistException) { + return new JSONResponse( + data: ['error' => 'Field not found'], + statusCode: Http::STATUS_NOT_FOUND + ); + } catch (MetadataFieldHasValuesException $exception) { + return new JSONResponse( + data: [ + 'error' => MetadataFieldHasValuesException::ERROR_CODE, + 'message' => $exception->getMessage(), + 'valueCount' => $exception->getValueCount(), + ], + statusCode: Http::STATUS_CONFLICT + ); + } + + return ResponseHelper::success(data: ['status' => 'ok']); + }//end deleteField() + + /** + * Build the update patch from the individual nullable parameters. + * + * `null` means "not supplied" and the key is left out entirely, so + * the service can distinguish an omitted field from an explicit + * value. The router delivers `null` when the body omits the key; + * any explicit array (including an empty one) counts as "set + * options", so admins can clear an option set on non-select types. + * + * The forbidden `key` rename is deliberately forwarded rather than + * dropped — the service rejects it with the documented 400. + * + * @param string|null $key The (forbidden) new slug. + * @param string|null $label The new label. + * @param int|null $sortOrder The new sort order. + * @param int|null $required The new required flag. + * @param array|null $options The new option set. + * + * @return array The patch payload. + */ + private static function buildPatch( + ?string $key, + ?string $label, + ?int $sortOrder, + ?int $required, + ?array $options, + ): array { + $patch = []; + if ($key !== null) { + $patch['key'] = $key; + } + + if ($label !== null) { + $patch['label'] = $label; + } + + if ($sortOrder !== null) { + $patch['sortOrder'] = $sortOrder; + } + + if ($required !== null) { + $patch['required'] = $required; + } + + if ($options !== null) { + $patch['options'] = $options; + } + + return $patch; + }//end buildPatch() + + /** + * Build a 400-with-error envelope. + * + * @param string $message The validation message. + * + * @return JSONResponse The 400 response. + */ + private static function badRequest(string $message): JSONResponse { + return new JSONResponse( + data: [ + 'error' => InvalidMetadataFieldException::ERROR_CODE, + 'message' => $message, + ], + statusCode: Http::STATUS_BAD_REQUEST + ); + }//end badRequest() }//end class diff --git a/lib/Controller/MetricsController.php b/lib/Controller/MetricsController.php index 61c2c1c0..b0ad0174 100644 --- a/lib/Controller/MetricsController.php +++ b/lib/Controller/MetricsController.php @@ -66,71 +66,69 @@ * * @spec openspec/changes/adopt-apphost/specs/prometheus-metrics/spec.md — Requirement: Metrics Endpoint (REQ-PROM-001) */ -class MetricsController extends Controller -{ - /** - * Prometheus text exposition content type. - * - * Inlined rather than read from `PrometheusRenderer::CONTENT_TYPE`, because - * referencing that constant is itself a compile-time dependency on a class - * that may not be installed — the same trap as the old `extends`. - * - * @var string - */ - public const CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8'; +class MetricsController extends Controller { + /** + * Prometheus text exposition content type. + * + * Inlined rather than read from `PrometheusRenderer::CONTENT_TYPE`, because + * referencing that constant is itself a compile-time dependency on a class + * that may not be installed — the same trap as the old `extends`. + * + * @var string + */ + public const CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8'; - /** - * Constructor. - * - * @param string $appName This leaf's app id (`launchpad`), which the - * engine uses to locate the manifest and to - * prefix the emitted metrics. - * @param IRequest $request The HTTP request. - * @param object|null $manifestLoader OpenRegister's ManifestLoader, or null when - * OpenRegister is unavailable. Untyped on - * purpose — see the class docblock. - * @param object|null $engine OpenRegister's MetricsEngine, or null. - */ - public function __construct( - string $appName, - IRequest $request, - private readonly ?object $manifestLoader=null, - private readonly ?object $engine=null, - ) { - parent::__construct(appName: $appName, request: $request); - }//end __construct() + /** + * Constructor. + * + * @param string $appName This leaf's app id (`launchpad`), which the + * engine uses to locate the manifest and to + * prefix the emitted metrics. + * @param IRequest $request The HTTP request. + * @param object|null $manifestLoader OpenRegister's ManifestLoader, or null when + * OpenRegister is unavailable. Untyped on + * purpose — see the class docblock. + * @param object|null $engine OpenRegister's MetricsEngine, or null. + */ + public function __construct( + string $appName, + IRequest $request, + private readonly ?object $manifestLoader = null, + private readonly ?object $engine = null, + ) { + parent::__construct(appName: $appName, request: $request); + }//end __construct() - /** - * GET /api/metrics — declarative Prometheus metrics (admin-only, ADR-006). - * - * @return TextPlainResponse Prometheus text exposition 0.0.4, or a plain 503 - * body when the engine is unavailable. - * - * @spec openspec/changes/adopt-apphost/specs/prometheus-metrics/spec.md — Requirement: Metrics Endpoint (REQ-PROM-001) - */ - #[NoCSRFRequired] - public function index(): TextPlainResponse - { - if ($this->manifestLoader === null || $this->engine === null) { - return new TextPlainResponse( - '# OpenRegister AppHost observability engine unavailable'."\n", - Http::STATUS_SERVICE_UNAVAILABLE - ); - } + /** + * GET /api/metrics — declarative Prometheus metrics (admin-only, ADR-006). + * + * @return TextPlainResponse Prometheus text exposition 0.0.4, or a plain 503 + * body when the engine is unavailable. + * + * @spec openspec/changes/adopt-apphost/specs/prometheus-metrics/spec.md — Requirement: Metrics Endpoint (REQ-PROM-001) + */ + #[NoCSRFRequired] + public function index(): TextPlainResponse { + if ($this->manifestLoader === null || $this->engine === null) { + return new TextPlainResponse( + '# OpenRegister AppHost observability engine unavailable' . "\n", + Http::STATUS_SERVICE_UNAVAILABLE + ); + } - try { - $manifest = $this->manifestLoader->load(appId: $this->appName); - $body = $this->engine->render(manifest: $manifest); - } catch (Throwable $e) { - return new TextPlainResponse( - '# metrics unavailable: '.$e->getMessage()."\n", - Http::STATUS_SERVICE_UNAVAILABLE - ); - } + try { + $manifest = $this->manifestLoader->load(appId: $this->appName); + $body = $this->engine->render(manifest: $manifest); + } catch (Throwable $e) { + return new TextPlainResponse( + '# metrics unavailable: ' . $e->getMessage() . "\n", + Http::STATUS_SERVICE_UNAVAILABLE + ); + } - $response = new TextPlainResponse($body); - $response->addHeader('Content-Type', self::CONTENT_TYPE); + $response = new TextPlainResponse($body); + $response->addHeader('Content-Type', self::CONTENT_TYPE); - return $response; - }//end index() + return $response; + }//end index() }//end class diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index bc78857d..151a69cc 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -57,527 +57,515 @@ * group services to fill * the contract. */ -class PageController extends Controller -{ - /** - * Constructor. - * - * @param IRequest $request The request. - * @param IManager $dashboardManager Nextcloud dashboard widget manager. - * @param IInitialState $initialState The Nextcloud initial-state service. - * @param IUserSession $userSession Active user session. - * @param WidgetService $widgetService Available-widgets descriptor formatter. - * @param DashboardService $dashboardService Dashboard listing + resolver - * (also exposes the - * `allow_user_dashboards` flag - * — REQ-ASET-003). - * @param AdminTemplateService $adminTemplateService Primary-group routing - * resolver (REQ-TMPL-012, - * REQ-TMPL-013). - * @param RoleFeaturePermissionService $roleFeaturePerm Per-user widget - * allow-list source - * (REQ-RFP-009..010). - * @param DashboardTreeService $treeService Slug-chain - * resolver used by the - * deep-link route. - * @param LoggerInterface $logger Used to record - * silent fallback - * when a deep-link - * path doesn't - * resolve to a - * visible dashboard. - * @param AdminSettingsService $adminSettingsService Source of the - * quick-search - * no-match - * fallback-target - * admin setting - * (tile-quick-search - * REQ-QSEARCH-004). - */ - public function __construct( - IRequest $request, - private readonly IManager $dashboardManager, - private readonly IInitialState $initialState, - private readonly IUserSession $userSession, - private readonly WidgetService $widgetService, - private readonly DashboardService $dashboardService, - private readonly AdminTemplateService $adminTemplateService, - private readonly RoleFeaturePermissionService $roleFeaturePerm, - private readonly DashboardTreeService $treeService, - private readonly LoggerInterface $logger, - private readonly AdminSettingsService $adminSettingsService, - ) { - parent::__construct(appName: Application::APP_ID, request: $request); - }//end __construct() - - /** - * Deep-link entry point — `/apps/launchpad/{deepLink}`. - * - * Symfony binds the captured slug-chain into `$deepLink`. Delegating - * to {@see self::index()} keeps the workspace render path single- - * sourced; the optional path argument merely overrides the active - * dashboard before initial-state assembly. - * - * @param string $deepLink Slug-chain captured from the URL (may - * contain `/` separators). - * - * @return TemplateResponse The workspace template response. - * - * @spec openspec/specs/runtime-shell/spec.md - */ - #[NoAdminRequired] - #[NoCSRFRequired] - public function deepLink(string $deepLink=''): TemplateResponse - { - return $this->index(deepLink: $deepLink); - }//end deepLink() - - /** - * Render the workspace page. - * - * Wires the full workspace initial-state contract into the template via - * {@see InitialStateBuilder}. Every key declared in REQ-INIT-002 is set - * before `apply()` runs; missing keys raise - * {@see \OCA\LaunchPad\Exception\MissingInitialStateException} so the page - * never renders with a partial payload. - * - * Deep-link path: when `$deepLink` resolves through the tree service - * to a dashboard the user can read, that dashboard is used as the - * active one (overriding the resolver's seven-step fallback). When - * the path doesn't resolve (renamed, deleted, never existed, or not - * visible to the caller), the controller logs a warning and falls - * back silently — bookmarks of stale slug chains still land on - * something instead of 404'ing. - * - * @param string $deepLink Optional slug-chain selecting the active - * dashboard. Empty string ⇒ default resolver. - * - * @return TemplateResponse The template response. - * - * @spec openspec/specs/runtime-shell/spec.md - */ - #[NoAdminRequired] - #[NoCSRFRequired] - public function index(string $deepLink=''): TemplateResponse - { - Util::addScript(application: Application::APP_ID, file: 'launchpad-main'); - Util::addStyle(application: Application::APP_ID, file: 'launchpad'); - - // Load all widget scripts so legacy widgets can register their callbacks. - $this->loadWidgetScripts(); - - $userId = $this->resolveUserId(); - - $routingGroup = $this->resolveRoutingGroup(userId: $userId); - $primaryGroupId = $routingGroup['id']; - $primaryGroupName = $routingGroup['name']; - - $isAdmin = false; - $visible = []; - $active = null; - if ($userId !== '') { - $isAdmin = $this->dashboardService->isAdmin(userId: $userId); - $visible = $this->dashboardService->getVisibleToUser(userId: $userId); - $active = $this->resolveActive( - userId: $userId, - deepLink: $deepLink, - primaryGroupId: $primaryGroupId - ); - } - - $descriptors = $this->splitDescriptors(visible: $visible); - $activeState = $this->describeActive(active: $active); - - $allowUserDashboards = $this->dashboardService->getAllowUserDashboards(); - - $builder = new InitialStateBuilder( - initialState: $this->initialState, - page: Page::WORKSPACE - ); - - $builder - ->setWidgets($this->widgetService->getAvailableWidgets()) - ->setLayout($activeState['layout']) - ->setPrimaryGroup($primaryGroupId) - ->setPrimaryGroupName($primaryGroupName) - ->setIsAdmin($isAdmin) - ->setActiveDashboardId($activeState['activeDashboardId']) - ->setDashboardSource($activeState['dashboardSource']) - ->setGroupDashboards($descriptors['group']) - ->setUserDashboards($descriptors['user']) - ->setAllowUserDashboards($allowUserDashboards); - - // PR #95 (role-based-content): per-user widget allow-list. - // `null` = no admin policy for this user (unlimited). - $allowedWidgets = null; - if ($userId !== '') { - $allowedWidgets = $this->roleFeaturePerm->getAllowedWidgetIds( - userId: $userId - ); - } - - // Tile-quick-search REQ-QSEARCH-004: read the admin-configured - // no-match fallback target. `getSettings()` already resolves the - // safe 'none' default when unset/invalid, so this never throws. - $quicksearchFallback = (string) ( - $this->adminSettingsService->getSettings()['quicksearchFallbackTarget'] ?? AdminSettingsService::DEFAULT_QUICKSEARCH_FALLBACK_TARGET - ); - - $builder - ->setAllowedWidgets($allowedWidgets) - ->setDeepLinkPath($activeState['deepLinkPath']) - ->setQuicksearchFallbackTarget($quicksearchFallback) - ->apply(); - - // REQ-SHELL-001: pass the chrome slot ids so Nextcloud treats - // `#app-workspace` as the main content slot and allocates no left - // navigation panel (the runtime shell renders its own slide-in - // sidebar via `dashboard-switcher-sidebar`). Renderer parameter - // names match the Nextcloud chrome conventions. - $response = new TemplateResponse( - appName: Application::APP_ID, - templateName: 'index', - params: [ - 'id-app-content' => '#app-workspace', - 'id-app-navigation' => null, - ] - ); - - $response->setContentSecurityPolicy(csp: $this->buildWorkspaceCsp()); - - return $response; - }//end index() - - /** - * Resolve the primary group this request routes through. - * - * Routing resolver — REQ-TMPL-012 / REQ-TMPL-013. The - * `AdminTemplateService` walks the admin-configured `group_order` - * priority list and returns the first group the user belongs to, OR - * the literal `'default'` sentinel when nothing matches. The display - * name comes from the same service so the lookup lives in exactly one - * place. - * - * @param string $userId The caller's uid, or '' when anonymous. - * - * @return array{id: string, name: string} The group id and its - * display name. - */ - private function resolveRoutingGroup(string $userId): array - { - $primaryGroupId = Dashboard::DEFAULT_GROUP_ID; - $primaryGroupName = $this->adminTemplateService->resolvePrimaryGroupDisplayName( - groupId: Dashboard::DEFAULT_GROUP_ID - ); - if ($userId !== '') { - $primaryGroupId = $this->adminTemplateService->resolvePrimaryGroup( - userId: $userId - ); - $primaryGroupName = $this->adminTemplateService->resolvePrimaryGroupDisplayName( - groupId: $primaryGroupId - ); - } - - return [ - 'id' => $primaryGroupId, - 'name' => $primaryGroupName, - ]; - }//end resolveRoutingGroup() - - /** - * Resolve the active session's user id. - * - * @return string The uid, or an empty string for an anonymous caller. - */ - private function resolveUserId(): string - { - $user = $this->userSession->getUser(); - if ($user === null) { - return ''; - } - - return $user->getUID(); - }//end resolveUserId() - - /** - * Split the visible dashboards into the group and user descriptor - * lists the initial-state contract expects (REQ-INIT-002). - * - * User-sourced entries drop the `source` key — the frontend infers it - * from the list the descriptor arrived in. - * - * @param array $visible The visible entries. - * - * @return array{group: list>, user: list>} - */ - private function splitDescriptors(array $visible): array - { - $groupDashboards = []; - $userDashboards = []; - foreach ($visible as $entry) { - $dashboard = $entry['dashboard']; - // Dashboard entity has no icon column today — surface an empty - // string so the frontend descriptor shape matches REQ-INIT-002. - $descriptor = [ - 'id' => (string) $dashboard->getUuid(), - 'name' => (string) $dashboard->getName(), - 'icon' => '', - 'source' => $entry['source'], - ]; - - if ($entry['source'] === Dashboard::SOURCE_USER) { - unset($descriptor['source']); - $userDashboards[] = $descriptor; - continue; - } - - $groupDashboards[] = $descriptor; - } - - return [ - 'group' => $groupDashboards, - 'user' => $userDashboards, - ]; - }//end splitDescriptors() - - /** - * Resolve the dashboard that should open for this request. - * - * Deep-link override: when the URL carries a slug-chain we try to land - * the user on that dashboard before consulting the seven-step - * resolver. Failures (path doesn't resolve, not visible, throws) are - * swallowed so a stale bookmark still opens *something* instead of - * breaking. - * - * @param string $userId The caller's uid (never empty here). - * @param string $deepLink The slug-chain from the URL, or ''. - * @param string $primaryGroupId The resolved primary group id. - * - * @return array{dashboard: Dashboard, source: string}|null The active - * entry, or - * null. - */ - private function resolveActive( - string $userId, - string $deepLink, - string $primaryGroupId - ): ?array { - $active = null; - if ($deepLink !== '') { - $active = $this->resolveDeepLink(userId: $userId, deepLink: $deepLink); - } - - if ($active !== null) { - return $active; - } - - return $this->dashboardService->resolveActiveDashboard( - userId: $userId, - primaryGroupId: $primaryGroupId - ); - }//end resolveActive() - - /** - * Resolve a slug-chain to a dashboard the caller may read. - * - * Every failure mode — unresolvable path, dashboard not visible, or a - * throwing resolver — returns null after logging, so the caller can - * fall back to the default resolver. - * - * @param string $userId The caller's uid. - * @param string $deepLink The slug-chain from the URL. - * - * @return array{dashboard: Dashboard, source: string}|null The matched - * entry, or - * null. - */ - private function resolveDeepLink(string $userId, string $deepLink): ?array - { - $active = null; - try { - $resolved = $this->treeService->resolvePath(path: $deepLink); - if ($resolved !== null) { - $active = $this->dashboardService->getDashboardForUser( - dashboardId: $resolved->getId(), - userId: $userId - ); - } - } catch (Throwable $t) { - $this->logger->warning( - message: 'launchpad: deep-link resolution failed for path "{path}": {message}', - context: [ - 'path' => $deepLink, - 'message' => $t->getMessage(), - ] - ); - } - - if ($active === null) { - $this->logger->info( - message: 'launchpad: deep-link path "{path}" not visible — falling back to default resolver', - context: ['path' => $deepLink] - ); - } - - return $active; - }//end resolveDeepLink() - - /** - * Derive the initial-state keys that describe the active dashboard. - * - * Returns the documented empty defaults when nothing is active, so - * the contract is fully populated either way. - * - * @param array{dashboard: Dashboard, source: string}|null $active The active entry. - * - * @return array{activeDashboardId: string, dashboardSource: string, layout: array, deepLinkPath: string} - */ - private function describeActive(?array $active): array - { - if ($active === null) { - return [ - 'activeDashboardId' => '', - 'dashboardSource' => Dashboard::SOURCE_GROUP, - 'layout' => [], - 'deepLinkPath' => '', - ]; - } - - $activeDashboard = $active['dashboard']; - $placements = $this->widgetService->getDashboardPlacements( - dashboardId: $activeDashboard->getId() - ); - - return [ - 'activeDashboardId' => (string) $activeDashboard->getUuid(), - 'dashboardSource' => (string) $active['source'], - 'layout' => array_map( - callback: function ($placement) { - return $placement->jsonSerialize(); - }, - array: $placements - ), - 'deepLinkPath' => $this->computeDeepLinkPath(dashboard: $activeDashboard), - ]; - }//end describeActive() - - /** - * Compute the canonical slug-chain for the active dashboard. - * - * The frontend reads this to keep the URL in sync (e.g. after a - * parent rename, a stale bookmarked path is normalised in-place via - * `history.replaceState`). A failure is logged and degrades to an - * empty path rather than breaking the render. - * - * @param Dashboard $dashboard The active dashboard. - * - * @return string The slug-chain, or '' when it could not be computed. - */ - private function computeDeepLinkPath(Dashboard $dashboard): string - { - try { - return $this->treeService->computePath( - uuid: (string) $dashboard->getUuid() - ); - } catch (Throwable $t) { - $this->logger->warning( - message: 'launchpad: failed to compute path for active dashboard {uuid}: {message}', - context: [ - 'uuid' => (string) $dashboard->getUuid(), - 'message' => $t->getMessage(), - ] - ); - } - - return ''; - }//end computeDeepLinkPath() - - /** - * Build the workspace content-security policy. - * - * REQ-VID: the video widget embeds YouTube/Vimeo players in an - * `'; - - $clean = $this->service->sanitiseSummaryHtml(html: $html); - - $this->assertStringNotContainsString('assertStringNotContainsString('service->sanitiseSummaryHtml(html: $html); - - $this->assertStringContainsString('rel="noopener noreferrer"', $clean); - }//end testSanitiseSummaryForcesRelOnLinks() - - public function testSanitiseSummaryNeutralisesJavascriptHref(): void - { - $html = 'danger'; - - $clean = $this->service->sanitiseSummaryHtml(html: $html); - - $this->assertStringNotContainsString('javascript:', $clean); - $this->assertStringContainsString('href="#"', $clean); - }//end testSanitiseSummaryNeutralisesJavascriptHref() - - public function testCheckAllowListAcceptsAllWhenEmpty(): void - { - $this->appConfig - ->method('getValueString') - ->willReturn(''); - - $this->assertTrue($this->service->checkAllowList(url: 'https://anything.example.com/feed')); - }//end testCheckAllowListAcceptsAllWhenEmpty() - - public function testCheckAllowListMatchesCaseInsensitively(): void - { - $this->appConfig - ->method('getValueString') - ->willReturn(json_encode(value: ['Example.Org'])); - - $this->assertTrue($this->service->checkAllowList(url: 'https://example.org/feed')); - }//end testCheckAllowListMatchesCaseInsensitively() - - public function testCheckAllowListRejectsHostnamesNotInList(): void - { - $this->appConfig - ->method('getValueString') - ->willReturn(json_encode(value: ['allowed.example.com'])); - - $this->assertFalse($this->service->checkAllowList(url: 'https://blocked.example.org/feed')); - }//end testCheckAllowListRejectsHostnamesNotInList() - - public function testCheckAllowListSubdomainNotImplied(): void - { - $this->appConfig - ->method('getValueString') - ->willReturn(json_encode(value: ['example.org'])); - - // Per spec REQ-NEWS-006: exact hostname required, no wildcard - // subdomain expansion. - $this->assertFalse($this->service->checkAllowList(url: 'https://news.example.org/feed')); - }//end testCheckAllowListSubdomainNotImplied() - - public function testCheckMetadataFilterRejectsWhenSpecNotImplemented(): void - { - // dashboard-metadata-fields capability not on this branch — the - // filter must conservatively return false (treat missing field - // as null which never matches a configured equality). - $this->assertFalse( - $this->service->checkMetadataFilter( - dashboardId: 1, - metadataFilter: ['fieldKey' => 'department', 'value' => 'marketing'] - ) - ); - }//end testCheckMetadataFilterRejectsWhenSpecNotImplemented() - - public function testFetchAndMergeFeedsWithEmptyListReturnsEmptyResponse(): void - { - $response = $this->service->fetchAndMergeFeeds(feedUrls: [], limit: 10); - - $this->assertSame([], $response['items']); - $this->assertSame(0, $response['feedsFailed']); - $this->assertSame([], $response['failedUrls']); - }//end testFetchAndMergeFeedsWithEmptyListReturnsEmptyResponse() - - /** - * C1 SSRF: http:// URLs MUST be rejected before any allow-list check. - * The UrlSafetyValidator rejects non-HTTPS, so fetchAndMergeFeeds must - * count the URL as failed without attempting a network request. - */ - public function testFetchAndMergeFeedsRejectsHttpUrls(): void - { - // No HTTP client interaction expected — SSRF guard fires first. - $this->clientService->expects($this->never())->method('newClient'); - - $response = $this->service->fetchAndMergeFeeds( - feedUrls: ['http://attacker.internal/feed.rss'], - limit: 10 - ); - - $this->assertSame([], $response['items']); - $this->assertSame(1, $response['feedsFailed']); - }//end testFetchAndMergeFeedsRejectsHttpUrls() - - /** - * C1 SSRF: extractFeedUrls MUST drop http:// entries (HTTPS-only). - */ - public function testExtractNewsConfigDropsHttpFeedUrls(): void - { - $placement = new WidgetPlacement(); - $placement->setStyleConfig(json_encode([ - 'feedUrls' => [ - 'https://valid.example.com/feed', - 'http://insecure.example.com/feed', - 'ftp://nope.example.com/feed', - ], - ])); - - $config = $this->service->extractNewsConfig(placement: $placement); - - $this->assertSame(['https://valid.example.com/feed'], $config['feedUrls']); - }//end testExtractNewsConfigDropsHttpFeedUrls() + $items = $this->service->parseRssFeed( + feedContent: $atom, + sourceUrl: 'https://example.com/atom', + sourceTitle: 'fallback' + ); + + $this->assertCount(1, $items); + $this->assertSame('urn:1', $items[0]['guid']); + $this->assertSame('Atom one', $items[0]['title']); + $this->assertSame('https://example.com/atom-one', $items[0]['link']); + $this->assertSame('Atom Source', $items[0]['sourceTitle']); + }//end testParseRssFeedAcceptsAtom() + + public function testParseRssFeedReturnsEmptyOnGarbage(): void { + $items = $this->service->parseRssFeed( + feedContent: '<>', + sourceUrl: 'https://x.example.com/bad', + sourceTitle: 'bad' + ); + + $this->assertSame([], $items); + }//end testParseRssFeedReturnsEmptyOnGarbage() + + public function testDeduplicateItemsKeepsFirstOccurrence(): void { + $items = [ + ['guid' => 'a', 'title' => 'first'], + ['guid' => 'b', 'title' => 'second'], + ['guid' => 'a', 'title' => 'duplicate'], + ]; + + $out = $this->service->deduplicateItems(items: $items); + + $this->assertCount(2, $out); + $this->assertSame('first', $out[0]['title']); + $this->assertSame('second', $out[1]['title']); + }//end testDeduplicateItemsKeepsFirstOccurrence() + + public function testSortItemsByDateDescending(): void { + $items = [ + ['guid' => '1', 'pubDate' => '2026-04-30T10:00:00Z'], + ['guid' => '2', 'pubDate' => '2026-05-01T16:00:00Z'], + ['guid' => '3', 'pubDate' => '2026-05-01T14:00:00Z'], + ]; + + $sorted = $this->service->sortItemsByDate(items: $items); + + $this->assertSame('2', $sorted[0]['guid']); + $this->assertSame('3', $sorted[1]['guid']); + $this->assertSame('1', $sorted[2]['guid']); + }//end testSortItemsByDateDescending() + + public function testSanitiseSummaryHtmlAllowsWhitelistedTags(): void { + $html = '

Read our latest post

'; + + $clean = $this->service->sanitiseSummaryHtml(html: $html); + + $this->assertStringContainsString('

', $clean); + $this->assertStringContainsString('', $clean); + }//end testSanitiseSummaryHtmlAllowsWhitelistedTags() + + public function testSanitiseSummaryStripsScriptTags(): void { + $html = '

Hi

'; + + $clean = $this->service->sanitiseSummaryHtml(html: $html); + + $this->assertStringNotContainsString('assertStringNotContainsString('service->sanitiseSummaryHtml(html: $html); + + $this->assertStringContainsString('rel="noopener noreferrer"', $clean); + }//end testSanitiseSummaryForcesRelOnLinks() + + public function testSanitiseSummaryNeutralisesJavascriptHref(): void { + $html = 'danger'; + + $clean = $this->service->sanitiseSummaryHtml(html: $html); + + $this->assertStringNotContainsString('javascript:', $clean); + $this->assertStringContainsString('href="#"', $clean); + }//end testSanitiseSummaryNeutralisesJavascriptHref() + + public function testCheckAllowListAcceptsAllWhenEmpty(): void { + $this->appConfig + ->method('getValueString') + ->willReturn(''); + + $this->assertTrue($this->service->checkAllowList(url: 'https://anything.example.com/feed')); + }//end testCheckAllowListAcceptsAllWhenEmpty() + + public function testCheckAllowListMatchesCaseInsensitively(): void { + $this->appConfig + ->method('getValueString') + ->willReturn(json_encode(value: ['Example.Org'])); + + $this->assertTrue($this->service->checkAllowList(url: 'https://example.org/feed')); + }//end testCheckAllowListMatchesCaseInsensitively() + + public function testCheckAllowListRejectsHostnamesNotInList(): void { + $this->appConfig + ->method('getValueString') + ->willReturn(json_encode(value: ['allowed.example.com'])); + + $this->assertFalse($this->service->checkAllowList(url: 'https://blocked.example.org/feed')); + }//end testCheckAllowListRejectsHostnamesNotInList() + + public function testCheckAllowListSubdomainNotImplied(): void { + $this->appConfig + ->method('getValueString') + ->willReturn(json_encode(value: ['example.org'])); + + // Per spec REQ-NEWS-006: exact hostname required, no wildcard + // subdomain expansion. + $this->assertFalse($this->service->checkAllowList(url: 'https://news.example.org/feed')); + }//end testCheckAllowListSubdomainNotImplied() + + public function testCheckMetadataFilterRejectsWhenSpecNotImplemented(): void { + // dashboard-metadata-fields capability not on this branch — the + // filter must conservatively return false (treat missing field + // as null which never matches a configured equality). + $this->assertFalse( + $this->service->checkMetadataFilter( + dashboardId: 1, + metadataFilter: ['fieldKey' => 'department', 'value' => 'marketing'] + ) + ); + }//end testCheckMetadataFilterRejectsWhenSpecNotImplemented() + + public function testFetchAndMergeFeedsWithEmptyListReturnsEmptyResponse(): void { + $response = $this->service->fetchAndMergeFeeds(feedUrls: [], limit: 10); + + $this->assertSame([], $response['items']); + $this->assertSame(0, $response['feedsFailed']); + $this->assertSame([], $response['failedUrls']); + }//end testFetchAndMergeFeedsWithEmptyListReturnsEmptyResponse() + + /** + * C1 SSRF: http:// URLs MUST be rejected before any allow-list check. + * The UrlSafetyValidator rejects non-HTTPS, so fetchAndMergeFeeds must + * count the URL as failed without attempting a network request. + */ + public function testFetchAndMergeFeedsRejectsHttpUrls(): void { + // No HTTP client interaction expected — SSRF guard fires first. + $this->clientService->expects($this->never())->method('newClient'); + + $response = $this->service->fetchAndMergeFeeds( + feedUrls: ['http://attacker.internal/feed.rss'], + limit: 10 + ); + + $this->assertSame([], $response['items']); + $this->assertSame(1, $response['feedsFailed']); + }//end testFetchAndMergeFeedsRejectsHttpUrls() + + /** + * C1 SSRF: extractFeedUrls MUST drop http:// entries (HTTPS-only). + */ + public function testExtractNewsConfigDropsHttpFeedUrls(): void { + $placement = new WidgetPlacement(); + $placement->setStyleConfig(json_encode([ + 'feedUrls' => [ + 'https://valid.example.com/feed', + 'http://insecure.example.com/feed', + 'ftp://nope.example.com/feed', + ], + ])); + + $config = $this->service->extractNewsConfig(placement: $placement); + + $this->assertSame(['https://valid.example.com/feed'], $config['feedUrls']); + }//end testExtractNewsConfigDropsHttpFeedUrls() }//end class diff --git a/tests/Unit/Service/OrgNavigationServiceTest.php b/tests/Unit/Service/OrgNavigationServiceTest.php index 381e1715..7b963437 100644 --- a/tests/Unit/Service/OrgNavigationServiceTest.php +++ b/tests/Unit/Service/OrgNavigationServiceTest.php @@ -41,444 +41,400 @@ /** * Tests for the org-wide navigation editor service. */ -class OrgNavigationServiceTest extends TestCase -{ - - /** @var IAppData&MockObject */ - private $appData; - - /** @var AdminTemplateService&MockObject */ - private $templateService; - - private OrgNavigationService $service; - - - protected function setUp(): void - { - $this->appData = $this->createMock(IAppData::class); - $this->templateService = $this->createMock(AdminTemplateService::class); - - $this->service = new OrgNavigationService( - appData: $this->appData, - templateService: $this->templateService, - ); - - }//end setUp() - - - /** - * Build a deterministic UUID v4 derived from the given seed so - * fixtures stay readable. - * - * @param string $seed The seed. - * - * @return string A canonical UUID string. - */ - private function uuid(string $seed): string - { - $hash = md5($seed); - return sprintf( - '%s-%s-4%s-8%s-%s', - substr($hash, 0, 8), - substr($hash, 8, 4), - substr($hash, 12, 3), - substr($hash, 15, 3), - substr($hash, 18, 12) - ); - - }//end uuid() - - - public function testValidateAcceptsWellFormedTree(): void - { - $tree = [ - [ - 'id' => $this->uuid('a'), - 'label' => 'Section A', - 'icon' => 'folder', - 'url' => null, - 'openInNewTab' => false, - 'groupVisibility' => null, - 'children' => [ - [ - 'id' => $this->uuid('a.1'), - 'label' => 'Child', - 'url' => '/apps/launchpad/dashboards', - 'children' => [], - ], - ], - ], - ]; - - $this->service->validateTree(tree: $tree); - $this->assertTrue(true); - - }//end testValidateAcceptsWellFormedTree() - - - public function testValidateRejectsTreeExceedingDepth(): void - { - $tree = [ - [ - 'id' => $this->uuid('l1'), - 'label' => 'L1', - 'children' => [ - [ - 'id' => $this->uuid('l2'), - 'label' => 'L2', - 'children' => [ - [ - 'id' => $this->uuid('l3'), - 'label' => 'L3', - 'children' => [ - [ - 'id' => $this->uuid('l4'), - 'label' => 'L4 too deep', - 'children' => [], - ], - ], - ], - ], - ], - ], - ], - ]; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Tree depth cannot exceed 3 levels'); - $this->service->validateTree(tree: $tree); - - }//end testValidateRejectsTreeExceedingDepth() - - - public function testValidateRejectsDuplicateIds(): void - { - $shared = $this->uuid('shared'); - $tree = [ - ['id' => $shared, 'label' => 'A'], - ['id' => $shared, 'label' => 'B'], - ]; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessageMatches('/duplicate.*id/i'); - $this->service->validateTree(tree: $tree); - - }//end testValidateRejectsDuplicateIds() - - - public function testValidateRejectsJavascriptUrl(): void - { - $tree = [ - ['id' => $this->uuid('x'), 'label' => 'X', 'url' => 'JavaScript:alert(1)'], - ]; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('URL scheme is not allowed'); - $this->service->validateTree(tree: $tree); - - }//end testValidateRejectsJavascriptUrl() - - - public function testValidateRejectsDataUrl(): void - { - $tree = [ - ['id' => $this->uuid('x'), 'label' => 'X', 'url' => 'data:text/html,'], - ]; - - $this->expectException(InvalidArgumentException::class); - $this->service->validateTree(tree: $tree); - - }//end testValidateRejectsDataUrl() - - - public function testValidateRejectsEmptyLabel(): void - { - $tree = [ - ['id' => $this->uuid('x'), 'label' => ' '], - ]; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('label is required'); - $this->service->validateTree(tree: $tree); - - }//end testValidateRejectsEmptyLabel() - - - public function testValidateRejectsNonUuidId(): void - { - $tree = [ - ['id' => 'not-a-uuid', 'label' => 'X'], - ]; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Node id must be a valid UUID'); - $this->service->validateTree(tree: $tree); - - }//end testValidateRejectsNonUuidId() - - - public function testFilterReturnsFullTreeWhenAllNodesAreUnrestricted(): void - { - $tree = [ - [ - 'id' => $this->uuid('a'), - 'label' => 'A', - 'groupVisibility' => null, - 'children' => [ - [ - 'id' => $this->uuid('a.1'), - 'label' => 'A.1', - 'groupVisibility' => null, - 'children' => [], - ], - ], - ], - ]; - - $this->templateService - ->method('getUserGroupIdsFor') - ->willReturn(['anyone']); - - $result = $this->service->filterTreeByUserGroups( - tree: $tree, - userId: 'alice' - ); - - $this->assertCount(1, $result); - $this->assertCount(1, $result[0]['children']); - - }//end testFilterReturnsFullTreeWhenAllNodesAreUnrestricted() - +class OrgNavigationServiceTest extends TestCase { + + /** @var IAppData&MockObject */ + private $appData; + + /** @var AdminTemplateService&MockObject */ + private $templateService; + + private OrgNavigationService $service; + + protected function setUp(): void { + $this->appData = $this->createMock(IAppData::class); + $this->templateService = $this->createMock(AdminTemplateService::class); + + $this->service = new OrgNavigationService( + appData: $this->appData, + templateService: $this->templateService, + ); + + }//end setUp() + + /** + * Build a deterministic UUID v4 derived from the given seed so + * fixtures stay readable. + * + * @param string $seed The seed. + * + * @return string A canonical UUID string. + */ + private function uuid(string $seed): string { + $hash = md5($seed); + return sprintf( + '%s-%s-4%s-8%s-%s', + substr($hash, 0, 8), + substr($hash, 8, 4), + substr($hash, 12, 3), + substr($hash, 15, 3), + substr($hash, 18, 12) + ); + + }//end uuid() + + public function testValidateAcceptsWellFormedTree(): void { + $tree = [ + [ + 'id' => $this->uuid('a'), + 'label' => 'Section A', + 'icon' => 'folder', + 'url' => null, + 'openInNewTab' => false, + 'groupVisibility' => null, + 'children' => [ + [ + 'id' => $this->uuid('a.1'), + 'label' => 'Child', + 'url' => '/apps/launchpad/dashboards', + 'children' => [], + ], + ], + ], + ]; + + $this->service->validateTree(tree: $tree); + $this->assertTrue(true); + + }//end testValidateAcceptsWellFormedTree() + + public function testValidateRejectsTreeExceedingDepth(): void { + $tree = [ + [ + 'id' => $this->uuid('l1'), + 'label' => 'L1', + 'children' => [ + [ + 'id' => $this->uuid('l2'), + 'label' => 'L2', + 'children' => [ + [ + 'id' => $this->uuid('l3'), + 'label' => 'L3', + 'children' => [ + [ + 'id' => $this->uuid('l4'), + 'label' => 'L4 too deep', + 'children' => [], + ], + ], + ], + ], + ], + ], + ], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Tree depth cannot exceed 3 levels'); + $this->service->validateTree(tree: $tree); + + }//end testValidateRejectsTreeExceedingDepth() + + public function testValidateRejectsDuplicateIds(): void { + $shared = $this->uuid('shared'); + $tree = [ + ['id' => $shared, 'label' => 'A'], + ['id' => $shared, 'label' => 'B'], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/duplicate.*id/i'); + $this->service->validateTree(tree: $tree); + + }//end testValidateRejectsDuplicateIds() + + public function testValidateRejectsJavascriptUrl(): void { + $tree = [ + ['id' => $this->uuid('x'), 'label' => 'X', 'url' => 'JavaScript:alert(1)'], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('URL scheme is not allowed'); + $this->service->validateTree(tree: $tree); + + }//end testValidateRejectsJavascriptUrl() + + public function testValidateRejectsDataUrl(): void { + $tree = [ + ['id' => $this->uuid('x'), 'label' => 'X', 'url' => 'data:text/html,'], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->service->validateTree(tree: $tree); + + }//end testValidateRejectsDataUrl() + + public function testValidateRejectsEmptyLabel(): void { + $tree = [ + ['id' => $this->uuid('x'), 'label' => ' '], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('label is required'); + $this->service->validateTree(tree: $tree); + + }//end testValidateRejectsEmptyLabel() + + public function testValidateRejectsNonUuidId(): void { + $tree = [ + ['id' => 'not-a-uuid', 'label' => 'X'], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Node id must be a valid UUID'); + $this->service->validateTree(tree: $tree); + + }//end testValidateRejectsNonUuidId() + + public function testFilterReturnsFullTreeWhenAllNodesAreUnrestricted(): void { + $tree = [ + [ + 'id' => $this->uuid('a'), + 'label' => 'A', + 'groupVisibility' => null, + 'children' => [ + [ + 'id' => $this->uuid('a.1'), + 'label' => 'A.1', + 'groupVisibility' => null, + 'children' => [], + ], + ], + ], + ]; + + $this->templateService + ->method('getUserGroupIdsFor') + ->willReturn(['anyone']); + + $result = $this->service->filterTreeByUserGroups( + tree: $tree, + userId: 'alice' + ); + + $this->assertCount(1, $result); + $this->assertCount(1, $result[0]['children']); + + }//end testFilterReturnsFullTreeWhenAllNodesAreUnrestricted() + + public function testFilterHidesNodeWhenUserNotInGroup(): void { + $tree = [ + [ + 'id' => $this->uuid('admin'), + 'label' => 'Admin only', + 'groupVisibility' => ['admin'], + 'children' => [], + ], + [ + 'id' => $this->uuid('public'), + 'label' => 'Public', + 'groupVisibility' => null, + 'children' => [], + ], + ]; + + $this->templateService + ->method('getUserGroupIdsFor') + ->willReturn(['users']); + + $result = $this->service->filterTreeByUserGroups( + tree: $tree, + userId: 'bob' + ); + + $this->assertCount(1, $result); + $this->assertSame('Public', $result[0]['label']); + + }//end testFilterHidesNodeWhenUserNotInGroup() + + public function testFilterShowsNodeWhenUserMatchesAnyListedGroup(): void { + $tree = [ + [ + 'id' => $this->uuid('mkt'), + 'label' => 'Sales/Marketing', + 'groupVisibility' => ['marketing', 'sales'], + 'children' => [], + ], + ]; + + $this->templateService + ->method('getUserGroupIdsFor') + ->willReturn(['sales']); + + $result = $this->service->filterTreeByUserGroups( + tree: $tree, + userId: 'sam' + ); + + $this->assertCount(1, $result); + + }//end testFilterShowsNodeWhenUserMatchesAnyListedGroup() + + public function testFilterCascadesHiddenParentToChildren(): void { + $tree = [ + [ + 'id' => $this->uuid('p'), + 'label' => 'Parent', + 'groupVisibility' => ['secret'], + 'children' => [ + [ + 'id' => $this->uuid('c'), + 'label' => 'Child', + 'groupVisibility' => null, + 'children' => [], + ], + ], + ], + ]; + + $this->templateService + ->method('getUserGroupIdsFor') + ->willReturn(['users']); + + $result = $this->service->filterTreeByUserGroups( + tree: $tree, + userId: 'eve' + ); + + $this->assertSame([], $result); + + }//end testFilterCascadesHiddenParentToChildren() + + public function testGetTreeReturnsEmptyWhenFolderMissing(): void { + $this->appData + ->method('getFolder') + ->willThrowException(new NotFoundException()); + + $this->assertSame([], $this->service->getTree()); + + }//end testGetTreeReturnsEmptyWhenFolderMissing() + + public function testGetTreeReturnsEmptyWhenFileMissing(): void { + $folder = $this->createMock(ISimpleFolder::class); + $folder->method('getFile') + ->willThrowException(new NotFoundException()); + + $this->appData + ->method('getFolder') + ->willReturn($folder); + + $this->assertSame([], $this->service->getTree()); + + }//end testGetTreeReturnsEmptyWhenFileMissing() + + public function testGetTreeDecodesPersistedJson(): void { + $payload = json_encode([ + ['id' => $this->uuid('only'), 'label' => 'Only', 'children' => []], + ]); + + $file = $this->createMock(ISimpleFile::class); + $file->method('getSize')->willReturn(strlen((string)$payload)); + $file->method('getContent')->willReturn($payload); + + $folder = $this->createMock(ISimpleFolder::class); + $folder->method('getFile')->willReturn($file); + + $this->appData + ->method('getFolder') + ->willReturn($folder); + + $tree = $this->service->getTree(language: 'nl'); + + $this->assertCount(1, $tree); + $this->assertSame('Only', $tree[0]['label']); + + }//end testGetTreeDecodesPersistedJson() + + public function testSetTreeWritesNewFileWhenAbsent(): void { + $folder = $this->createMock(ISimpleFolder::class); + $folder->method('getFile') + ->willThrowException(new NotFoundException()); + + $folder->expects($this->once()) + ->method('newFile') + ->with( + $this->equalTo('nl.json'), + $this->callback(static function (string $content): bool { + $decoded = json_decode($content, true); + return is_array($decoded) === true + && count($decoded) === 1 + && $decoded[0]['label'] === 'Item'; + }) + ); + + $this->appData + ->method('getFolder') + ->willReturn($folder); + + $this->service->setTree( + tree: [ + ['id' => $this->uuid('one'), 'label' => 'Item', 'children' => []], + ], + language: 'nl' + ); + + }//end testSetTreeWritesNewFileWhenAbsent() + + public function testSetTreeOverwritesExistingFile(): void { + $file = $this->createMock(ISimpleFile::class); + $file->expects($this->once())->method('putContent'); + + $folder = $this->createMock(ISimpleFolder::class); + $folder->method('getFile')->willReturn($file); + + $this->appData + ->method('getFolder') + ->willReturn($folder); - public function testFilterHidesNodeWhenUserNotInGroup(): void - { - $tree = [ - [ - 'id' => $this->uuid('admin'), - 'label' => 'Admin only', - 'groupVisibility' => ['admin'], - 'children' => [], - ], - [ - 'id' => $this->uuid('public'), - 'label' => 'Public', - 'groupVisibility' => null, - 'children' => [], - ], - ]; - - $this->templateService - ->method('getUserGroupIdsFor') - ->willReturn(['users']); + $this->service->setTree( + tree: [ + ['id' => $this->uuid('over'), 'label' => 'Over', 'children' => []], + ], + language: 'en' + ); - $result = $this->service->filterTreeByUserGroups( - tree: $tree, - userId: 'bob' - ); + }//end testSetTreeOverwritesExistingFile() - $this->assertCount(1, $result); - $this->assertSame('Public', $result[0]['label']); - - }//end testFilterHidesNodeWhenUserNotInGroup() - - - public function testFilterShowsNodeWhenUserMatchesAnyListedGroup(): void - { - $tree = [ - [ - 'id' => $this->uuid('mkt'), - 'label' => 'Sales/Marketing', - 'groupVisibility' => ['marketing', 'sales'], - 'children' => [], - ], - ]; - - $this->templateService - ->method('getUserGroupIdsFor') - ->willReturn(['sales']); - - $result = $this->service->filterTreeByUserGroups( - tree: $tree, - userId: 'sam' - ); - - $this->assertCount(1, $result); - - }//end testFilterShowsNodeWhenUserMatchesAnyListedGroup() - - - public function testFilterCascadesHiddenParentToChildren(): void - { - $tree = [ - [ - 'id' => $this->uuid('p'), - 'label' => 'Parent', - 'groupVisibility' => ['secret'], - 'children' => [ - [ - 'id' => $this->uuid('c'), - 'label' => 'Child', - 'groupVisibility' => null, - 'children' => [], - ], - ], - ], - ]; - - $this->templateService - ->method('getUserGroupIdsFor') - ->willReturn(['users']); - - $result = $this->service->filterTreeByUserGroups( - tree: $tree, - userId: 'eve' - ); - - $this->assertSame([], $result); - - }//end testFilterCascadesHiddenParentToChildren() - - - public function testGetTreeReturnsEmptyWhenFolderMissing(): void - { - $this->appData - ->method('getFolder') - ->willThrowException(new NotFoundException()); - - $this->assertSame([], $this->service->getTree()); - - }//end testGetTreeReturnsEmptyWhenFolderMissing() - - - public function testGetTreeReturnsEmptyWhenFileMissing(): void - { - $folder = $this->createMock(ISimpleFolder::class); - $folder->method('getFile') - ->willThrowException(new NotFoundException()); - - $this->appData - ->method('getFolder') - ->willReturn($folder); - - $this->assertSame([], $this->service->getTree()); - - }//end testGetTreeReturnsEmptyWhenFileMissing() - - - public function testGetTreeDecodesPersistedJson(): void - { - $payload = json_encode([ - ['id' => $this->uuid('only'), 'label' => 'Only', 'children' => []], - ]); - - $file = $this->createMock(ISimpleFile::class); - $file->method('getSize')->willReturn(strlen((string) $payload)); - $file->method('getContent')->willReturn($payload); - - $folder = $this->createMock(ISimpleFolder::class); - $folder->method('getFile')->willReturn($file); - - $this->appData - ->method('getFolder') - ->willReturn($folder); - - $tree = $this->service->getTree(language: 'nl'); - - $this->assertCount(1, $tree); - $this->assertSame('Only', $tree[0]['label']); - - }//end testGetTreeDecodesPersistedJson() - - - public function testSetTreeWritesNewFileWhenAbsent(): void - { - $folder = $this->createMock(ISimpleFolder::class); - $folder->method('getFile') - ->willThrowException(new NotFoundException()); - - $folder->expects($this->once()) - ->method('newFile') - ->with( - $this->equalTo('nl.json'), - $this->callback(static function (string $content): bool { - $decoded = json_decode($content, true); - return is_array($decoded) === true - && count($decoded) === 1 - && $decoded[0]['label'] === 'Item'; - }) - ); - - $this->appData - ->method('getFolder') - ->willReturn($folder); - - $this->service->setTree( - tree: [ - ['id' => $this->uuid('one'), 'label' => 'Item', 'children' => []], - ], - language: 'nl' - ); - - }//end testSetTreeWritesNewFileWhenAbsent() - - - public function testSetTreeOverwritesExistingFile(): void - { - $file = $this->createMock(ISimpleFile::class); - $file->expects($this->once())->method('putContent'); - - $folder = $this->createMock(ISimpleFolder::class); - $folder->method('getFile')->willReturn($file); - - $this->appData - ->method('getFolder') - ->willReturn($folder); - - $this->service->setTree( - tree: [ - ['id' => $this->uuid('over'), 'label' => 'Over', 'children' => []], - ], - language: 'en' - ); - - }//end testSetTreeOverwritesExistingFile() - - - public function testSetTreeRejectsInvalidPayload(): void - { - $this->appData->expects($this->never())->method('getFolder'); - - $this->expectException(InvalidArgumentException::class); - $this->service->setTree( - tree: [ - ['id' => 'not-uuid', 'label' => 'X'], - ], - language: 'nl' - ); - - }//end testSetTreeRejectsInvalidPayload() - - - public function testSanitiseUrlAcceptsHttpsAndRelativePaths(): void - { - $this->assertSame( - 'https://example.com/x', - $this->service->sanitiseUrl(url: 'https://example.com/x') - ); - $this->assertSame( - '/apps/launchpad/dashboards', - $this->service->sanitiseUrl(url: '/apps/launchpad/dashboards') - ); - - }//end testSanitiseUrlAcceptsHttpsAndRelativePaths() - - - public function testSanitiseUrlRejectsVbscript(): void - { - $this->expectException(InvalidArgumentException::class); - $this->service->sanitiseUrl(url: 'VBScript:msgbox'); - - }//end testSanitiseUrlRejectsVbscript() + public function testSetTreeRejectsInvalidPayload(): void { + $this->appData->expects($this->never())->method('getFolder'); + $this->expectException(InvalidArgumentException::class); + $this->service->setTree( + tree: [ + ['id' => 'not-uuid', 'label' => 'X'], + ], + language: 'nl' + ); + + }//end testSetTreeRejectsInvalidPayload() + + public function testSanitiseUrlAcceptsHttpsAndRelativePaths(): void { + $this->assertSame( + 'https://example.com/x', + $this->service->sanitiseUrl(url: 'https://example.com/x') + ); + $this->assertSame( + '/apps/launchpad/dashboards', + $this->service->sanitiseUrl(url: '/apps/launchpad/dashboards') + ); + + }//end testSanitiseUrlAcceptsHttpsAndRelativePaths() + + public function testSanitiseUrlRejectsVbscript(): void { + $this->expectException(InvalidArgumentException::class); + $this->service->sanitiseUrl(url: 'VBScript:msgbox'); + + }//end testSanitiseUrlRejectsVbscript() }//end class diff --git a/tests/Unit/Service/OrphanedDataCleanupServiceTest.php b/tests/Unit/Service/OrphanedDataCleanupServiceTest.php index c20b49d7..b7271f85 100644 --- a/tests/Unit/Service/OrphanedDataCleanupServiceTest.php +++ b/tests/Unit/Service/OrphanedDataCleanupServiceTest.php @@ -37,285 +37,276 @@ /** * Unit tests for OrphanedDataCleanupService. */ -class OrphanedDataCleanupServiceTest extends TestCase -{ - /** - * Registry mock. - * - * @var CategoryRegistryService&MockObject - */ - private $registry; - - /** - * Cache factory mock. - * - * @var ICacheFactory&MockObject - */ - private $cacheFactory; - - /** - * Cache mock returned by the factory. - * - * @var ICache&MockObject - */ - private $cache; - - /** - * DB connection mock (transaction tracking). - * - * @var IDBConnection&MockObject - */ - private $db; - - /** - * Activity manager mock. - * - * @var IActivityManager&MockObject - */ - private $activity; - - /** - * Logger mock. - * - * @var LoggerInterface&MockObject - */ - private $logger; - - /** - * Service under test. - * - * @var OrphanedDataCleanupService - */ - private OrphanedDataCleanupService $service; - - /** - * Build all mocks. - * - * @return void - */ - protected function setUp(): void - { - $this->registry = $this->createMock(originalClassName: CategoryRegistryService::class); - $this->cacheFactory = $this->createMock(originalClassName: ICacheFactory::class); - $this->cache = $this->createMock(originalClassName: ICache::class); - $this->db = $this->createMock(originalClassName: IDBConnection::class); - $this->activity = $this->createMock(originalClassName: IActivityManager::class); - $this->logger = $this->createMock(originalClassName: LoggerInterface::class); - - $this->cacheFactory->method('createDistributed')->willReturn($this->cache); - - $this->service = new OrphanedDataCleanupService( - registry: $this->registry, - cacheFactory: $this->cacheFactory, - db: $this->db, - activityManager: $this->activity, - logger: $this->logger, - ); - } - - /** - * Build a category mock returning the supplied count from `scan` - * and `purge`. `isAvailable()` is `true` by default. - * - * @param string $name Category identifier. - * @param int $count Count to return from scan/purge. - * @param bool $available Whether the category is available. - * - * @return CleanupCategoryInterface&MockObject The category. - */ - private function makeCategory( - string $name, - int $count, - bool $available=true - ): CleanupCategoryInterface { - $category = $this->createMock(originalClassName: CleanupCategoryInterface::class); - $category->method('getName')->willReturn($name); - $category->method('isAvailable')->willReturn($available); - $category->method('scan')->willReturn($count); - $category->method('purge')->willReturn($count); - - return $category; - } - - /** - * Scan with no filter MUST traverse every registered category in - * registration order and aggregate counts. - * - * @return void - */ - public function testScanAggregatesCountsAcrossRegistry(): void - { - $a = $this->makeCategory(name: 'a', count: 3); - $b = $this->makeCategory(name: 'b', count: 0); - - $this->registry->method('getCategoryNames')->willReturn(['a', 'b']); - $this->registry->method('getCategoryByName')->willReturnMap( - [ - ['a', $a], - ['b', $b], - ] - ); - - // Cache empty so the orchestrator runs a fresh scan. - $this->cache->method('get')->willReturn(null); - - $result = $this->service->scan(); - - $this->assertSame(expected: 3, actual: $result->getTotalRows()); - $this->assertSame( - expected: ['a' => 3, 'b' => 0], - actual: $result->getByCategory() - ); - } - - /** - * Categories whose `isAvailable()` is `false` MUST end up under - * `skipped` and contribute no count. - * - * @return void - */ - public function testScanSkipsUnavailableCategories(): void - { - $a = $this->makeCategory(name: 'a', count: 3); - $b = $this->makeCategory(name: 'b', count: 99, available: false); - - $this->registry->method('getCategoryNames')->willReturn(['a', 'b']); - $this->registry->method('getCategoryByName')->willReturnMap( - [ - ['a', $a], - ['b', $b], - ] - ); - $this->cache->method('get')->willReturn(null); - - $result = $this->service->scan(); - - $this->assertSame(expected: ['a' => 3], actual: $result->getByCategory()); - $this->assertSame(expected: ['b'], actual: $result->getSkipped()); - $this->assertSame(expected: 3, actual: $result->getTotalRows()); - } - - /** - * The cache hit path MUST short-circuit the registry traversal - * and return a hydrated DTO. - * - * @return void - */ - public function testScanReturnsCachedResultWhenAvailable(): void - { - // Registry MUST NOT be touched on a cache hit. - $this->registry->expects($this->never())->method('getCategoryNames'); - - $this->cache->method('get')->willReturn( - [ - 'byCategory' => ['x' => 7], - 'totalRows' => 7, - 'durationMs' => 1, - 'dryRun' => false, - 'scannedAt' => '2026-05-03T10:00:00Z', - 'skipped' => [], - ] - ); - - $result = $this->service->scan(); - - $this->assertSame(expected: 7, actual: $result->getTotalRows()); - $this->assertSame( - expected: '2026-05-03T10:00:00Z', - actual: $result->getScannedAt() - ); - } - - /** - * A successful real purge MUST invalidate the cache. - * - * @return void - */ - public function testRealPurgeInvalidatesCache(): void - { - $a = $this->makeCategory(name: 'a', count: 2); - - $this->registry->method('getCategoryNames')->willReturn(['a']); - $this->registry->method('getCategoryByName')->willReturn($a); - - $this->cache->expects($this->once()) - ->method('remove') - ->with(self::equalTo('launchpad.cleanup.scan')); - - $this->service->purge(); - } - - /** - * Dry-run purge MUST wrap the work in a transaction rollback and - * MUST NOT emit an Activity event or invalidate the cache. - * - * @return void - */ - public function testDryRunRollsBackAndDoesNotEmitEvent(): void - { - $a = $this->makeCategory(name: 'a', count: 5); - - $this->registry->method('getCategoryNames')->willReturn(['a']); - $this->registry->method('getCategoryByName')->willReturn($a); - - $this->db->expects($this->once())->method('beginTransaction'); - $this->db->expects($this->once())->method('rollBack'); - $this->cache->expects($this->never())->method('remove'); - $this->activity->expects($this->never())->method('publish'); - - $result = $this->service->purge(categoryNames: [], dryRun: true); - - $this->assertTrue(condition: $result->isDryRun()); - $this->assertSame(expected: 5, actual: $result->getTotalRows()); - } - - /** - * Real purge with a non-zero total MUST publish exactly one - * activity event tagged with the source. - * - * @return void - */ - public function testRealPurgeEmitsOneActivityEvent(): void - { - $a = $this->makeCategory(name: 'a', count: 4); - - $this->registry->method('getCategoryNames')->willReturn(['a']); - $this->registry->method('getCategoryByName')->willReturn($a); - - $event = $this->createMock(originalClassName: IEvent::class); - $event->method('setApp')->willReturnSelf(); - $event->method('setType')->willReturnSelf(); - $event->method('setAffectedUser')->willReturnSelf(); - $event->method('setAuthor')->willReturnSelf(); - $event->method('setSubject')->willReturnSelf(); - $event->method('setObject')->willReturnSelf(); - - $this->activity->method('generateEvent')->willReturn($event); - $this->activity->expects($this->once())->method('publish'); - - $this->service->purge( - categoryNames: [], - dryRun: false, - userId: 'admin', - source: 'cli' - ); - } - - /** - * A real purge that finds zero rows MUST NOT emit an activity - * event (avoids audit-log spam from idle daily runs). - * - * @return void - */ - public function testRealPurgeWithZeroRowsDoesNotEmitEvent(): void - { - $a = $this->makeCategory(name: 'a', count: 0); - - $this->registry->method('getCategoryNames')->willReturn(['a']); - $this->registry->method('getCategoryByName')->willReturn($a); - - $this->activity->expects($this->never())->method('publish'); - - $this->service->purge(); - } +class OrphanedDataCleanupServiceTest extends TestCase { + /** + * Registry mock. + * + * @var CategoryRegistryService&MockObject + */ + private $registry; + + /** + * Cache factory mock. + * + * @var ICacheFactory&MockObject + */ + private $cacheFactory; + + /** + * Cache mock returned by the factory. + * + * @var ICache&MockObject + */ + private $cache; + + /** + * DB connection mock (transaction tracking). + * + * @var IDBConnection&MockObject + */ + private $db; + + /** + * Activity manager mock. + * + * @var IActivityManager&MockObject + */ + private $activity; + + /** + * Logger mock. + * + * @var LoggerInterface&MockObject + */ + private $logger; + + /** + * Service under test. + * + * @var OrphanedDataCleanupService + */ + private OrphanedDataCleanupService $service; + + /** + * Build all mocks. + * + * @return void + */ + protected function setUp(): void { + $this->registry = $this->createMock(originalClassName: CategoryRegistryService::class); + $this->cacheFactory = $this->createMock(originalClassName: ICacheFactory::class); + $this->cache = $this->createMock(originalClassName: ICache::class); + $this->db = $this->createMock(originalClassName: IDBConnection::class); + $this->activity = $this->createMock(originalClassName: IActivityManager::class); + $this->logger = $this->createMock(originalClassName: LoggerInterface::class); + + $this->cacheFactory->method('createDistributed')->willReturn($this->cache); + + $this->service = new OrphanedDataCleanupService( + registry: $this->registry, + cacheFactory: $this->cacheFactory, + db: $this->db, + activityManager: $this->activity, + logger: $this->logger, + ); + } + + /** + * Build a category mock returning the supplied count from `scan` + * and `purge`. `isAvailable()` is `true` by default. + * + * @param string $name Category identifier. + * @param int $count Count to return from scan/purge. + * @param bool $available Whether the category is available. + * + * @return CleanupCategoryInterface&MockObject The category. + */ + private function makeCategory( + string $name, + int $count, + bool $available = true, + ): CleanupCategoryInterface { + $category = $this->createMock(originalClassName: CleanupCategoryInterface::class); + $category->method('getName')->willReturn($name); + $category->method('isAvailable')->willReturn($available); + $category->method('scan')->willReturn($count); + $category->method('purge')->willReturn($count); + + return $category; + } + + /** + * Scan with no filter MUST traverse every registered category in + * registration order and aggregate counts. + * + * @return void + */ + public function testScanAggregatesCountsAcrossRegistry(): void { + $a = $this->makeCategory(name: 'a', count: 3); + $b = $this->makeCategory(name: 'b', count: 0); + + $this->registry->method('getCategoryNames')->willReturn(['a', 'b']); + $this->registry->method('getCategoryByName')->willReturnMap( + [ + ['a', $a], + ['b', $b], + ] + ); + + // Cache empty so the orchestrator runs a fresh scan. + $this->cache->method('get')->willReturn(null); + + $result = $this->service->scan(); + + $this->assertSame(expected: 3, actual: $result->getTotalRows()); + $this->assertSame( + expected: ['a' => 3, 'b' => 0], + actual: $result->getByCategory() + ); + } + + /** + * Categories whose `isAvailable()` is `false` MUST end up under + * `skipped` and contribute no count. + * + * @return void + */ + public function testScanSkipsUnavailableCategories(): void { + $a = $this->makeCategory(name: 'a', count: 3); + $b = $this->makeCategory(name: 'b', count: 99, available: false); + + $this->registry->method('getCategoryNames')->willReturn(['a', 'b']); + $this->registry->method('getCategoryByName')->willReturnMap( + [ + ['a', $a], + ['b', $b], + ] + ); + $this->cache->method('get')->willReturn(null); + + $result = $this->service->scan(); + + $this->assertSame(expected: ['a' => 3], actual: $result->getByCategory()); + $this->assertSame(expected: ['b'], actual: $result->getSkipped()); + $this->assertSame(expected: 3, actual: $result->getTotalRows()); + } + + /** + * The cache hit path MUST short-circuit the registry traversal + * and return a hydrated DTO. + * + * @return void + */ + public function testScanReturnsCachedResultWhenAvailable(): void { + // Registry MUST NOT be touched on a cache hit. + $this->registry->expects($this->never())->method('getCategoryNames'); + + $this->cache->method('get')->willReturn( + [ + 'byCategory' => ['x' => 7], + 'totalRows' => 7, + 'durationMs' => 1, + 'dryRun' => false, + 'scannedAt' => '2026-05-03T10:00:00Z', + 'skipped' => [], + ] + ); + + $result = $this->service->scan(); + + $this->assertSame(expected: 7, actual: $result->getTotalRows()); + $this->assertSame( + expected: '2026-05-03T10:00:00Z', + actual: $result->getScannedAt() + ); + } + + /** + * A successful real purge MUST invalidate the cache. + * + * @return void + */ + public function testRealPurgeInvalidatesCache(): void { + $a = $this->makeCategory(name: 'a', count: 2); + + $this->registry->method('getCategoryNames')->willReturn(['a']); + $this->registry->method('getCategoryByName')->willReturn($a); + + $this->cache->expects($this->once()) + ->method('remove') + ->with(self::equalTo('launchpad.cleanup.scan')); + + $this->service->purge(); + } + + /** + * Dry-run purge MUST wrap the work in a transaction rollback and + * MUST NOT emit an Activity event or invalidate the cache. + * + * @return void + */ + public function testDryRunRollsBackAndDoesNotEmitEvent(): void { + $a = $this->makeCategory(name: 'a', count: 5); + + $this->registry->method('getCategoryNames')->willReturn(['a']); + $this->registry->method('getCategoryByName')->willReturn($a); + + $this->db->expects($this->once())->method('beginTransaction'); + $this->db->expects($this->once())->method('rollBack'); + $this->cache->expects($this->never())->method('remove'); + $this->activity->expects($this->never())->method('publish'); + + $result = $this->service->purge(categoryNames: [], dryRun: true); + + $this->assertTrue(condition: $result->isDryRun()); + $this->assertSame(expected: 5, actual: $result->getTotalRows()); + } + + /** + * Real purge with a non-zero total MUST publish exactly one + * activity event tagged with the source. + * + * @return void + */ + public function testRealPurgeEmitsOneActivityEvent(): void { + $a = $this->makeCategory(name: 'a', count: 4); + + $this->registry->method('getCategoryNames')->willReturn(['a']); + $this->registry->method('getCategoryByName')->willReturn($a); + + $event = $this->createMock(originalClassName: IEvent::class); + $event->method('setApp')->willReturnSelf(); + $event->method('setType')->willReturnSelf(); + $event->method('setAffectedUser')->willReturnSelf(); + $event->method('setAuthor')->willReturnSelf(); + $event->method('setSubject')->willReturnSelf(); + $event->method('setObject')->willReturnSelf(); + + $this->activity->method('generateEvent')->willReturn($event); + $this->activity->expects($this->once())->method('publish'); + + $this->service->purge( + categoryNames: [], + dryRun: false, + userId: 'admin', + source: 'cli' + ); + } + + /** + * A real purge that finds zero rows MUST NOT emit an activity + * event (avoids audit-log spam from idle daily runs). + * + * @return void + */ + public function testRealPurgeWithZeroRowsDoesNotEmitEvent(): void { + $a = $this->makeCategory(name: 'a', count: 0); + + $this->registry->method('getCategoryNames')->willReturn(['a']); + $this->registry->method('getCategoryByName')->willReturn($a); + + $this->activity->expects($this->never())->method('publish'); + + $this->service->purge(); + } } diff --git a/tests/Unit/Service/PeopleWidgetServiceTest.php b/tests/Unit/Service/PeopleWidgetServiceTest.php index f71cb889..18b559b8 100644 --- a/tests/Unit/Service/PeopleWidgetServiceTest.php +++ b/tests/Unit/Service/PeopleWidgetServiceTest.php @@ -44,531 +44,509 @@ * * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Mirrors constructor. */ -class PeopleWidgetServiceTest extends TestCase -{ - - /** - * @var IUserManager&MockObject - */ - private $userManager; - - /** - * @var IGroupManager&MockObject - */ - private $groupManager; - - /** - * @var IAccountManager&MockObject - */ - private $accountManager; - - /** - * @var IURLGenerator&MockObject - */ - private $urlGenerator; - - /** - * @var AdminTemplateService&MockObject - */ - private $adminTemplateService; - - private PeopleWidgetService $service; - - /** - * @return void - */ - protected function setUp(): void - { - parent::setUp(); - - $this->userManager = $this->createMock(originalClassName: IUserManager::class); - $this->groupManager = $this->createMock(originalClassName: IGroupManager::class); - $this->accountManager = $this->createMock(originalClassName: IAccountManager::class); - $this->urlGenerator = $this->createMock(originalClassName: IURLGenerator::class); - $this->adminTemplateService = $this->createMock(originalClassName: AdminTemplateService::class); - - $this->urlGenerator->method('linkToRouteAbsolute') - ->willReturnCallback( - callback: static fn(string $route, array $args=[]): string => 'https://example.test/'.$route.'?'.http_build_query(data: $args) - ); - - // Default: any user has no groups. Tests can override per-call. - $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn([]); - - $this->service = new PeopleWidgetService( - userManager: $this->userManager, - groupManager: $this->groupManager, - accountManager: $this->accountManager, - urlGenerator: $this->urlGenerator, - adminTemplateService: $this->adminTemplateService, - ); - }//end setUp() - - // --------------------------------------------------------------- - // computeDaysToBirthday — pure helper (REQ-PPL-005) - // --------------------------------------------------------------- - - /** - * @return void - */ - public function testComputeDaysToBirthdayReturnsNullForBlankInput(): void - { - $this->assertNull(actual: PeopleWidgetService::computeDaysToBirthday(birthdate: null)); - $this->assertNull(actual: PeopleWidgetService::computeDaysToBirthday(birthdate: '')); - $this->assertNull(actual: PeopleWidgetService::computeDaysToBirthday(birthdate: 'not-a-date')); - }//end testComputeDaysToBirthdayReturnsNullForBlankInput() - - /** - * @return void - */ - public function testComputeDaysToBirthdayHandlesIsoInput(): void - { - $today = new \DateTimeImmutable(datetime: 'today'); - $iso = $today->modify(modifier: '+5 days')->format(format: '1990-m-d'); - - $this->assertSame( - expected: 5, - actual: PeopleWidgetService::computeDaysToBirthday(birthdate: $iso) - ); - }//end testComputeDaysToBirthdayHandlesIsoInput() - - /** - * @return void - */ - public function testComputeDaysToBirthdayHandlesLocaleFormat(): void - { - $today = new \DateTimeImmutable(datetime: 'today'); - $locale = $today->modify(modifier: '+10 days')->format(format: 'd-m-1990'); - - $this->assertSame( - expected: 10, - actual: PeopleWidgetService::computeDaysToBirthday(birthdate: $locale) - ); - }//end testComputeDaysToBirthdayHandlesLocaleFormat() - - /** - * @return void - */ - public function testComputeDaysToBirthdayWrapsToNextYearWhenPast(): void - { - $today = new \DateTimeImmutable(datetime: 'today'); - $past = $today->modify(modifier: '-30 days')->format(format: '1990-m-d'); - - $days = PeopleWidgetService::computeDaysToBirthday(birthdate: $past); - $this->assertNotNull(actual: $days); - $this->assertGreaterThan(300, $days); - }//end testComputeDaysToBirthdayWrapsToNextYearWhenPast() - - /** - * Feb-29 birthday must NOT throw on non-leap years; the service falls - * back to Feb-28. We verify by parsing 2027 (not a leap year) as the - * candidate window. - * - * @return void - */ - public function testComputeDaysToBirthdayHandlesFeb29OnNonLeapYear(): void - { - $days = PeopleWidgetService::computeDaysToBirthday(birthdate: '2000-02-29'); - $this->assertNotNull( - actual: $days, - message: 'Feb-29 input must not throw or return null on any year' - ); - }//end testComputeDaysToBirthdayHandlesFeb29OnNonLeapYear() - - // --------------------------------------------------------------- - // listUsers — argument validation (REQ-PPL-003) - // --------------------------------------------------------------- - - /** - * @return void - */ - public function testListUsersRejectsLimitOverMax(): void - { - $this->expectException(exception: InvalidArgumentException::class); - $this->service->listUsers(limit: PeopleWidgetService::MAX_LIMIT + 1); - }//end testListUsersRejectsLimitOverMax() - - /** - * @return void - */ - public function testListUsersRejectsZeroLimit(): void - { - $this->expectException(exception: InvalidArgumentException::class); - $this->service->listUsers(limit: 0); - }//end testListUsersRejectsZeroLimit() - - /** - * @return void - */ - public function testListUsersRejectsNegativeOffset(): void - { - $this->expectException(exception: InvalidArgumentException::class); - $this->service->listUsers(offset: -1); - }//end testListUsersRejectsNegativeOffset() - - /** - * @return void - */ - public function testListUsersRejectsRecentActivitySort(): void - { - $this->expectException(exception: InvalidArgumentException::class); - $this->service->listUsers(sortBy: 'recent-activity'); - }//end testListUsersRejectsRecentActivitySort() - - // --------------------------------------------------------------- - // listUsers — pagination + projection (REQ-PPL-003, REQ-PPL-004) - // --------------------------------------------------------------- - - /** - * @return void - */ - public function testListUsersReturnsPaginationShape(): void - { - $users = []; - $users[] = $this->makeUser(uid: 'alice', display: 'Alice', email: 'alice@example.test'); - $users[] = $this->makeUser(uid: 'bob', display: 'Bob', email: ''); - $users[] = $this->makeUser(uid: 'carol', display: 'Carol', email: 'carol@example.test'); - - $this->wireDirectory(orderedUsers: $users); - $this->groupManager->method('getUserGroupIds')->willReturn([]); - - // Empty account so the optional fields are omitted. - $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); - - $result = $this->service->listUsers(limit: 2, offset: 0); - - $this->assertSame(expected: 3, actual: $result['total']); - $this->assertTrue(condition: $result['hasMore']); - $this->assertCount(expectedCount: 2, haystack: $result['users']); - - // Default sort = displayName ASC. - $this->assertSame(expected: 'alice', actual: $result['users'][0]['uid']); - $this->assertSame(expected: 'bob', actual: $result['users'][1]['uid']); - - // Empty email is OMITTED, not nulled (REQ-PPL-004). - $this->assertArrayNotHasKey(key: 'email', array: $result['users'][1]); - $this->assertArrayHasKey(key: 'email', array: $result['users'][0]); - $this->assertSame( - expected: 'alice@example.test', - actual: $result['users'][0]['email'] - ); - - // Avatar URL points to the configured route. - $this->assertStringContainsString( - needle: 'core.avatar.getAvatar', - haystack: $result['users'][0]['avatarUrl'] - ); - }//end testListUsersReturnsPaginationShape() - - /** - * @return void - */ - public function testListUsersLastPageHasMoreFalse(): void - { - $users = []; - $users[] = $this->makeUser(uid: 'alice', display: 'Alice'); - $users[] = $this->makeUser(uid: 'bob', display: 'Bob'); - - $this->wireDirectory(orderedUsers: $users); - $this->groupManager->method('getUserGroupIds')->willReturn([]); - $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); - - $result = $this->service->listUsers(limit: 50, offset: 0); - - $this->assertFalse(condition: $result['hasMore']); - $this->assertSame(expected: 2, actual: $result['total']); - $this->assertCount(expectedCount: 2, haystack: $result['users']); - }//end testListUsersLastPageHasMoreFalse() - - /** - * @return void - */ - public function testListUsersExcludesDisabledByDefault(): void - { - $alice = $this->makeUser(uid: 'alice', display: 'Alice', enabled: true); - $eve = $this->makeUser(uid: 'eve', display: 'Eve', enabled: false); - - // The backend returns both (display-name order); the bounded page - // path skips the disabled user inside the window, and the exact - // total comes from countUsersTotal() minus countDisabledUsers(). - $this->wireDirectory(orderedUsers: [$alice, $eve], disabledCount: 1); - $this->groupManager->method('getUserGroupIds')->willReturn([]); - $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); - - $result = $this->service->listUsers(); - - $this->assertSame(expected: 1, actual: $result['total']); - $this->assertSame(expected: 'alice', actual: $result['users'][0]['uid']); - }//end testListUsersExcludesDisabledByDefault() - - // --------------------------------------------------------------- - // listUsers — group filter (REQ-PPL-006) - // --------------------------------------------------------------- - - /** - * @return void - */ - public function testGroupFilterUnionDeduplicates(): void - { - $alice = $this->makeUser(uid: 'alice', display: 'Alice'); - $bob = $this->makeUser(uid: 'bob', display: 'Bob'); - $carol = $this->makeUser(uid: 'carol', display: 'Carol'); - - $mgmt = $this->createMock(originalClassName: IGroup::class); - $mgmt->method('getUsers')->willReturn([$alice, $bob]); - - $prod = $this->createMock(originalClassName: IGroup::class); - $prod->method('getUsers')->willReturn([$bob, $carol]); - - $this->groupManager->method('get') - ->willReturnCallback( - callback: static function (string $gid) use ($mgmt, $prod) { - if ($gid === 'management') { - return $mgmt; - } - - if ($gid === 'product') { - return $prod; - } - - return null; - } - ); - - // Group sort path consults getUserGroupIds; default sort doesn't. - $this->groupManager->method('getUserGroupIds')->willReturn([]); - $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); - - $result = $this->service->listUsers( - filters: [ - [ - 'fieldName' => 'group', - 'operator' => 'in', - 'values' => ['management', 'product'], - ], - ], - ); - - // Bob appears once across both groups (dedup). - $uids = array_map( - callback: static fn(array $u): string => $u['uid'], - array: $result['users'] - ); - $this->assertSame(expected: ['alice', 'bob', 'carol'], actual: $uids); - $this->assertSame(expected: 3, actual: $result['total']); - }//end testGroupFilterUnionDeduplicates() - - /** - * @return void - */ - public function testUnknownGroupYieldsZeroUsersWithoutError(): void - { - $this->groupManager->method('get')->willReturn(null); - $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); - - $result = $this->service->listUsers( - filters: [ - [ - 'fieldName' => 'group', - 'operator' => 'in', - 'values' => ['nonexistent'], - ], - ], - ); - - $this->assertSame(expected: 0, actual: $result['total']); - $this->assertSame(expected: [], actual: $result['users']); - $this->assertFalse(condition: $result['hasMore']); - }//end testUnknownGroupYieldsZeroUsersWithoutError() - - // --------------------------------------------------------------- - // listUsers — account-field projection (REQ-PPL-005) - // --------------------------------------------------------------- - - /** - * @return void - */ - public function testBirthdateIsNormalisedToIso(): void - { - $alice = $this->makeUser(uid: 'alice', display: 'Alice'); - $this->wireDirectory(orderedUsers: [$alice]); - $this->groupManager->method('getUserGroupIds')->willReturn([]); - - $account = $this->makeAccount( - properties: [ - IAccountManager::PROPERTY_BIRTHDATE => '10-06-1990', - IAccountManager::PROPERTY_ROLE => 'PM', - ] - ); - $this->accountManager->method('getAccount')->willReturn($account); - - $result = $this->service->listUsers(); - - $this->assertSame( - expected: '1990-06-10', - actual: $result['users'][0]['birthdate'] - ); - $this->assertSame(expected: 'PM', actual: $result['users'][0]['role']); - }//end testBirthdateIsNormalisedToIso() - - /** - * @return void - */ - public function testShowBirthdaysFalseStripsBirthdate(): void - { - $alice = $this->makeUser(uid: 'alice', display: 'Alice'); - $this->wireDirectory(orderedUsers: [$alice]); - $this->groupManager->method('getUserGroupIds')->willReturn([]); - - $account = $this->makeAccount( - properties: [IAccountManager::PROPERTY_BIRTHDATE => '1990-06-10'] - ); - $this->accountManager->method('getAccount')->willReturn($account); - - $result = $this->service->listUsers(showBirthdays: false); - - $this->assertArrayNotHasKey( - key: 'birthdate', - array: $result['users'][0] - ); - }//end testShowBirthdaysFalseStripsBirthdate() - - // --------------------------------------------------------------- - // listUsers — bounded directory scan (fix-people-widget-unbounded-user-scan) - // --------------------------------------------------------------- - - /** - * With no `group` filter and the default `displayName` sort, the - * service MUST page directly from the backend via a bounded - * `searchDisplayName($pattern, $limit, $offset)` call and MUST NOT - * fall back to the unbounded `search('')` full-directory scan. - * - * @return void - */ - public function testDisplayNameSortUsesBoundedSearchNotFullScan(): void - { - $alice = $this->makeUser(uid: 'alice', display: 'Alice'); - $bob = $this->makeUser(uid: 'bob', display: 'Bob'); - - // The unbounded scan MUST NOT be used for this path. - $this->userManager->expects($this->never())->method('search'); - - $captured = []; - $this->userManager->expects($this->atLeastOnce()) - ->method('searchDisplayName') - ->willReturnCallback( - function (string $pattern, ?int $limit=null, ?int $offset=null) use (&$captured, $alice, $bob): array { - $captured[] = ['pattern' => $pattern, 'limit' => $limit, 'offset' => $offset]; - return array_slice([$alice, $bob], (int) $offset, ($limit ?? 2)); - } - ); - $this->userManager->method('countUsersTotal')->willReturn(2); - $this->userManager->method('countDisabledUsers')->willReturn(0); - $this->groupManager->method('getUserGroupIds')->willReturn([]); - $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); - - $result = $this->service->listUsers(limit: 10, offset: 0); - - // The backend was asked for a bounded, non-null limit. - $this->assertNotEmpty($captured); - $this->assertNotNull($captured[0]['limit']); - $this->assertGreaterThanOrEqual(10, $captured[0]['limit']); - $this->assertSame(0, $captured[0]['offset']); - $this->assertSame('', $captured[0]['pattern']); - - // Envelope semantics unchanged from the caller's point of view. - $this->assertSame(2, $result['total']); - $this->assertFalse($result['hasMore']); - $this->assertSame(['alice', 'bob'], array_column($result['users'], 'uid')); - }//end testDisplayNameSortUsesBoundedSearchNotFullScan() - - // --------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------- - - /** - * Wire the user-directory backend for a no-group-filter, - * display-name-sorted listing: a bounded `searchDisplayName` that - * honours the streamed `limit`/`offset` window plus the - * `countUsersTotal`/`countDisabledUsers` counters the bounded path - * uses to size `total` without a full scan. - * - * @param IUser[] $orderedUsers Users in display-name order (enabled - * and disabled). `countUsersTotal` - * reports the full length. - * @param int $disabledCount Number of disabled users in the set. - * - * @return void - */ - private function wireDirectory(array $orderedUsers, int $disabledCount=0): void - { - $this->userManager->method('searchDisplayName') - ->willReturnCallback( - static function (string $pattern, ?int $limit=null, ?int $offset=null) use ($orderedUsers): array { - return array_slice( - $orderedUsers, - (int) $offset, - ($limit ?? count($orderedUsers)) - ); - } - ); - $this->userManager->method('countUsersTotal')->willReturn(count($orderedUsers)); - $this->userManager->method('countDisabledUsers')->willReturn($disabledCount); - }//end wireDirectory() - - /** - * @param string $uid The user id. - * @param string $display Display name. - * @param string $email Email or empty string. - * @param bool $enabled Whether the user is enabled. - * - * @return IUser&MockObject - */ - private function makeUser( - string $uid, - string $display, - string $email='', - bool $enabled=true - ): IUser { - $user = $this->createMock(originalClassName: IUser::class); - $user->method('getUID')->willReturn($uid); - $user->method('getDisplayName')->willReturn($display); - $user->method('getEMailAddress')->willReturn($email === '' ? null : $email); - $user->method('isEnabled')->willReturn($enabled); - return $user; - }//end makeUser() - - /** - * Build an account whose every property returns the empty string — - * matches the "no profile fields set" baseline. - * - * @return IAccount&MockObject - */ - private function emptyAccount(): IAccount - { - return $this->makeAccount(properties: []); - }//end emptyAccount() - - /** - * @param array $properties Map of property name → value. - * Properties absent from the map - * resolve to the empty string. - * - * @return IAccount&MockObject - */ - private function makeAccount(array $properties): IAccount - { - $account = $this->createMock(originalClassName: IAccount::class); - $account->method('getProperty') - ->willReturnCallback( - callback: function (string $name) use ($properties): IAccountProperty { - $prop = $this->createMock(originalClassName: IAccountProperty::class); - $prop->method('getValue')->willReturn($properties[$name] ?? ''); - $prop->method('getName')->willReturn($name); - return $prop; - } - ); - - return $account; - }//end makeAccount() +class PeopleWidgetServiceTest extends TestCase { + + /** + * @var IUserManager&MockObject + */ + private $userManager; + + /** + * @var IGroupManager&MockObject + */ + private $groupManager; + + /** + * @var IAccountManager&MockObject + */ + private $accountManager; + + /** + * @var IURLGenerator&MockObject + */ + private $urlGenerator; + + /** + * @var AdminTemplateService&MockObject + */ + private $adminTemplateService; + + private PeopleWidgetService $service; + + /** + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->userManager = $this->createMock(originalClassName: IUserManager::class); + $this->groupManager = $this->createMock(originalClassName: IGroupManager::class); + $this->accountManager = $this->createMock(originalClassName: IAccountManager::class); + $this->urlGenerator = $this->createMock(originalClassName: IURLGenerator::class); + $this->adminTemplateService = $this->createMock(originalClassName: AdminTemplateService::class); + + $this->urlGenerator->method('linkToRouteAbsolute') + ->willReturnCallback( + callback: static fn (string $route, array $args = []): string => 'https://example.test/' . $route . '?' . http_build_query(data: $args) + ); + + // Default: any user has no groups. Tests can override per-call. + $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn([]); + + $this->service = new PeopleWidgetService( + userManager: $this->userManager, + groupManager: $this->groupManager, + accountManager: $this->accountManager, + urlGenerator: $this->urlGenerator, + adminTemplateService: $this->adminTemplateService, + ); + }//end setUp() + + // --------------------------------------------------------------- + // computeDaysToBirthday — pure helper (REQ-PPL-005) + // --------------------------------------------------------------- + + /** + * @return void + */ + public function testComputeDaysToBirthdayReturnsNullForBlankInput(): void { + $this->assertNull(actual: PeopleWidgetService::computeDaysToBirthday(birthdate: null)); + $this->assertNull(actual: PeopleWidgetService::computeDaysToBirthday(birthdate: '')); + $this->assertNull(actual: PeopleWidgetService::computeDaysToBirthday(birthdate: 'not-a-date')); + }//end testComputeDaysToBirthdayReturnsNullForBlankInput() + + /** + * @return void + */ + public function testComputeDaysToBirthdayHandlesIsoInput(): void { + $today = new \DateTimeImmutable(datetime: 'today'); + $iso = $today->modify(modifier: '+5 days')->format(format: '1990-m-d'); + + $this->assertSame( + expected: 5, + actual: PeopleWidgetService::computeDaysToBirthday(birthdate: $iso) + ); + }//end testComputeDaysToBirthdayHandlesIsoInput() + + /** + * @return void + */ + public function testComputeDaysToBirthdayHandlesLocaleFormat(): void { + $today = new \DateTimeImmutable(datetime: 'today'); + $locale = $today->modify(modifier: '+10 days')->format(format: 'd-m-1990'); + + $this->assertSame( + expected: 10, + actual: PeopleWidgetService::computeDaysToBirthday(birthdate: $locale) + ); + }//end testComputeDaysToBirthdayHandlesLocaleFormat() + + /** + * @return void + */ + public function testComputeDaysToBirthdayWrapsToNextYearWhenPast(): void { + $today = new \DateTimeImmutable(datetime: 'today'); + $past = $today->modify(modifier: '-30 days')->format(format: '1990-m-d'); + + $days = PeopleWidgetService::computeDaysToBirthday(birthdate: $past); + $this->assertNotNull(actual: $days); + $this->assertGreaterThan(300, $days); + }//end testComputeDaysToBirthdayWrapsToNextYearWhenPast() + + /** + * Feb-29 birthday must NOT throw on non-leap years; the service falls + * back to Feb-28. We verify by parsing 2027 (not a leap year) as the + * candidate window. + * + * @return void + */ + public function testComputeDaysToBirthdayHandlesFeb29OnNonLeapYear(): void { + $days = PeopleWidgetService::computeDaysToBirthday(birthdate: '2000-02-29'); + $this->assertNotNull( + actual: $days, + message: 'Feb-29 input must not throw or return null on any year' + ); + }//end testComputeDaysToBirthdayHandlesFeb29OnNonLeapYear() + + // --------------------------------------------------------------- + // listUsers — argument validation (REQ-PPL-003) + // --------------------------------------------------------------- + + /** + * @return void + */ + public function testListUsersRejectsLimitOverMax(): void { + $this->expectException(exception: InvalidArgumentException::class); + $this->service->listUsers(limit: PeopleWidgetService::MAX_LIMIT + 1); + }//end testListUsersRejectsLimitOverMax() + + /** + * @return void + */ + public function testListUsersRejectsZeroLimit(): void { + $this->expectException(exception: InvalidArgumentException::class); + $this->service->listUsers(limit: 0); + }//end testListUsersRejectsZeroLimit() + + /** + * @return void + */ + public function testListUsersRejectsNegativeOffset(): void { + $this->expectException(exception: InvalidArgumentException::class); + $this->service->listUsers(offset: -1); + }//end testListUsersRejectsNegativeOffset() + + /** + * @return void + */ + public function testListUsersRejectsRecentActivitySort(): void { + $this->expectException(exception: InvalidArgumentException::class); + $this->service->listUsers(sortBy: 'recent-activity'); + }//end testListUsersRejectsRecentActivitySort() + + // --------------------------------------------------------------- + // listUsers — pagination + projection (REQ-PPL-003, REQ-PPL-004) + // --------------------------------------------------------------- + + /** + * @return void + */ + public function testListUsersReturnsPaginationShape(): void { + $users = []; + $users[] = $this->makeUser(uid: 'alice', display: 'Alice', email: 'alice@example.test'); + $users[] = $this->makeUser(uid: 'bob', display: 'Bob', email: ''); + $users[] = $this->makeUser(uid: 'carol', display: 'Carol', email: 'carol@example.test'); + + $this->wireDirectory(orderedUsers: $users); + $this->groupManager->method('getUserGroupIds')->willReturn([]); + + // Empty account so the optional fields are omitted. + $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); + + $result = $this->service->listUsers(limit: 2, offset: 0); + + $this->assertSame(expected: 3, actual: $result['total']); + $this->assertTrue(condition: $result['hasMore']); + $this->assertCount(expectedCount: 2, haystack: $result['users']); + + // Default sort = displayName ASC. + $this->assertSame(expected: 'alice', actual: $result['users'][0]['uid']); + $this->assertSame(expected: 'bob', actual: $result['users'][1]['uid']); + + // Empty email is OMITTED, not nulled (REQ-PPL-004). + $this->assertArrayNotHasKey(key: 'email', array: $result['users'][1]); + $this->assertArrayHasKey(key: 'email', array: $result['users'][0]); + $this->assertSame( + expected: 'alice@example.test', + actual: $result['users'][0]['email'] + ); + + // Avatar URL points to the configured route. + $this->assertStringContainsString( + needle: 'core.avatar.getAvatar', + haystack: $result['users'][0]['avatarUrl'] + ); + }//end testListUsersReturnsPaginationShape() + + /** + * @return void + */ + public function testListUsersLastPageHasMoreFalse(): void { + $users = []; + $users[] = $this->makeUser(uid: 'alice', display: 'Alice'); + $users[] = $this->makeUser(uid: 'bob', display: 'Bob'); + + $this->wireDirectory(orderedUsers: $users); + $this->groupManager->method('getUserGroupIds')->willReturn([]); + $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); + + $result = $this->service->listUsers(limit: 50, offset: 0); + + $this->assertFalse(condition: $result['hasMore']); + $this->assertSame(expected: 2, actual: $result['total']); + $this->assertCount(expectedCount: 2, haystack: $result['users']); + }//end testListUsersLastPageHasMoreFalse() + + /** + * @return void + */ + public function testListUsersExcludesDisabledByDefault(): void { + $alice = $this->makeUser(uid: 'alice', display: 'Alice', enabled: true); + $eve = $this->makeUser(uid: 'eve', display: 'Eve', enabled: false); + + // The backend returns both (display-name order); the bounded page + // path skips the disabled user inside the window, and the exact + // total comes from countUsersTotal() minus countDisabledUsers(). + $this->wireDirectory(orderedUsers: [$alice, $eve], disabledCount: 1); + $this->groupManager->method('getUserGroupIds')->willReturn([]); + $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); + + $result = $this->service->listUsers(); + + $this->assertSame(expected: 1, actual: $result['total']); + $this->assertSame(expected: 'alice', actual: $result['users'][0]['uid']); + }//end testListUsersExcludesDisabledByDefault() + + // --------------------------------------------------------------- + // listUsers — group filter (REQ-PPL-006) + // --------------------------------------------------------------- + + /** + * @return void + */ + public function testGroupFilterUnionDeduplicates(): void { + $alice = $this->makeUser(uid: 'alice', display: 'Alice'); + $bob = $this->makeUser(uid: 'bob', display: 'Bob'); + $carol = $this->makeUser(uid: 'carol', display: 'Carol'); + + $mgmt = $this->createMock(originalClassName: IGroup::class); + $mgmt->method('getUsers')->willReturn([$alice, $bob]); + + $prod = $this->createMock(originalClassName: IGroup::class); + $prod->method('getUsers')->willReturn([$bob, $carol]); + + $this->groupManager->method('get') + ->willReturnCallback( + callback: static function (string $gid) use ($mgmt, $prod) { + if ($gid === 'management') { + return $mgmt; + } + + if ($gid === 'product') { + return $prod; + } + + return null; + } + ); + + // Group sort path consults getUserGroupIds; default sort doesn't. + $this->groupManager->method('getUserGroupIds')->willReturn([]); + $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); + + $result = $this->service->listUsers( + filters: [ + [ + 'fieldName' => 'group', + 'operator' => 'in', + 'values' => ['management', 'product'], + ], + ], + ); + + // Bob appears once across both groups (dedup). + $uids = array_map( + callback: static fn (array $u): string => $u['uid'], + array: $result['users'] + ); + $this->assertSame(expected: ['alice', 'bob', 'carol'], actual: $uids); + $this->assertSame(expected: 3, actual: $result['total']); + }//end testGroupFilterUnionDeduplicates() + + /** + * @return void + */ + public function testUnknownGroupYieldsZeroUsersWithoutError(): void { + $this->groupManager->method('get')->willReturn(null); + $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); + + $result = $this->service->listUsers( + filters: [ + [ + 'fieldName' => 'group', + 'operator' => 'in', + 'values' => ['nonexistent'], + ], + ], + ); + + $this->assertSame(expected: 0, actual: $result['total']); + $this->assertSame(expected: [], actual: $result['users']); + $this->assertFalse(condition: $result['hasMore']); + }//end testUnknownGroupYieldsZeroUsersWithoutError() + + // --------------------------------------------------------------- + // listUsers — account-field projection (REQ-PPL-005) + // --------------------------------------------------------------- + + /** + * @return void + */ + public function testBirthdateIsNormalisedToIso(): void { + $alice = $this->makeUser(uid: 'alice', display: 'Alice'); + $this->wireDirectory(orderedUsers: [$alice]); + $this->groupManager->method('getUserGroupIds')->willReturn([]); + + $account = $this->makeAccount( + properties: [ + IAccountManager::PROPERTY_BIRTHDATE => '10-06-1990', + IAccountManager::PROPERTY_ROLE => 'PM', + ] + ); + $this->accountManager->method('getAccount')->willReturn($account); + + $result = $this->service->listUsers(); + + $this->assertSame( + expected: '1990-06-10', + actual: $result['users'][0]['birthdate'] + ); + $this->assertSame(expected: 'PM', actual: $result['users'][0]['role']); + }//end testBirthdateIsNormalisedToIso() + + /** + * @return void + */ + public function testShowBirthdaysFalseStripsBirthdate(): void { + $alice = $this->makeUser(uid: 'alice', display: 'Alice'); + $this->wireDirectory(orderedUsers: [$alice]); + $this->groupManager->method('getUserGroupIds')->willReturn([]); + + $account = $this->makeAccount( + properties: [IAccountManager::PROPERTY_BIRTHDATE => '1990-06-10'] + ); + $this->accountManager->method('getAccount')->willReturn($account); + + $result = $this->service->listUsers(showBirthdays: false); + + $this->assertArrayNotHasKey( + key: 'birthdate', + array: $result['users'][0] + ); + }//end testShowBirthdaysFalseStripsBirthdate() + + // --------------------------------------------------------------- + // listUsers — bounded directory scan (fix-people-widget-unbounded-user-scan) + // --------------------------------------------------------------- + + /** + * With no `group` filter and the default `displayName` sort, the + * service MUST page directly from the backend via a bounded + * `searchDisplayName($pattern, $limit, $offset)` call and MUST NOT + * fall back to the unbounded `search('')` full-directory scan. + * + * @return void + */ + public function testDisplayNameSortUsesBoundedSearchNotFullScan(): void { + $alice = $this->makeUser(uid: 'alice', display: 'Alice'); + $bob = $this->makeUser(uid: 'bob', display: 'Bob'); + + // The unbounded scan MUST NOT be used for this path. + $this->userManager->expects($this->never())->method('search'); + + $captured = []; + $this->userManager->expects($this->atLeastOnce()) + ->method('searchDisplayName') + ->willReturnCallback( + function (string $pattern, ?int $limit = null, ?int $offset = null) use (&$captured, $alice, $bob): array { + $captured[] = ['pattern' => $pattern, 'limit' => $limit, 'offset' => $offset]; + return array_slice([$alice, $bob], (int)$offset, ($limit ?? 2)); + } + ); + $this->userManager->method('countUsersTotal')->willReturn(2); + $this->userManager->method('countDisabledUsers')->willReturn(0); + $this->groupManager->method('getUserGroupIds')->willReturn([]); + $this->accountManager->method('getAccount')->willReturn($this->emptyAccount()); + + $result = $this->service->listUsers(limit: 10, offset: 0); + + // The backend was asked for a bounded, non-null limit. + $this->assertNotEmpty($captured); + $this->assertNotNull($captured[0]['limit']); + $this->assertGreaterThanOrEqual(10, $captured[0]['limit']); + $this->assertSame(0, $captured[0]['offset']); + $this->assertSame('', $captured[0]['pattern']); + + // Envelope semantics unchanged from the caller's point of view. + $this->assertSame(2, $result['total']); + $this->assertFalse($result['hasMore']); + $this->assertSame(['alice', 'bob'], array_column($result['users'], 'uid')); + }//end testDisplayNameSortUsesBoundedSearchNotFullScan() + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** + * Wire the user-directory backend for a no-group-filter, + * display-name-sorted listing: a bounded `searchDisplayName` that + * honours the streamed `limit`/`offset` window plus the + * `countUsersTotal`/`countDisabledUsers` counters the bounded path + * uses to size `total` without a full scan. + * + * @param IUser[] $orderedUsers Users in display-name order (enabled + * and disabled). `countUsersTotal` + * reports the full length. + * @param int $disabledCount Number of disabled users in the set. + * + * @return void + */ + private function wireDirectory(array $orderedUsers, int $disabledCount = 0): void { + $this->userManager->method('searchDisplayName') + ->willReturnCallback( + static function (string $pattern, ?int $limit = null, ?int $offset = null) use ($orderedUsers): array { + return array_slice( + $orderedUsers, + (int)$offset, + ($limit ?? count($orderedUsers)) + ); + } + ); + $this->userManager->method('countUsersTotal')->willReturn(count($orderedUsers)); + $this->userManager->method('countDisabledUsers')->willReturn($disabledCount); + }//end wireDirectory() + + /** + * @param string $uid The user id. + * @param string $display Display name. + * @param string $email Email or empty string. + * @param bool $enabled Whether the user is enabled. + * + * @return IUser&MockObject + */ + private function makeUser( + string $uid, + string $display, + string $email = '', + bool $enabled = true, + ): IUser { + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getUID')->willReturn($uid); + $user->method('getDisplayName')->willReturn($display); + $user->method('getEMailAddress')->willReturn($email === '' ? null : $email); + $user->method('isEnabled')->willReturn($enabled); + return $user; + }//end makeUser() + + /** + * Build an account whose every property returns the empty string — + * matches the "no profile fields set" baseline. + * + * @return IAccount&MockObject + */ + private function emptyAccount(): IAccount { + return $this->makeAccount(properties: []); + }//end emptyAccount() + + /** + * @param array $properties Map of property name → value. + * Properties absent from the map + * resolve to the empty string. + * + * @return IAccount&MockObject + */ + private function makeAccount(array $properties): IAccount { + $account = $this->createMock(originalClassName: IAccount::class); + $account->method('getProperty') + ->willReturnCallback( + callback: function (string $name) use ($properties): IAccountProperty { + $prop = $this->createMock(originalClassName: IAccountProperty::class); + $prop->method('getValue')->willReturn($properties[$name] ?? ''); + $prop->method('getName')->willReturn($name); + return $prop; + } + ); + + return $account; + }//end makeAccount() }//end class diff --git a/tests/Unit/Service/PlacementServiceQuotaWiringTest.php b/tests/Unit/Service/PlacementServiceQuotaWiringTest.php index a8d6bcb0..51ae0aaf 100644 --- a/tests/Unit/Service/PlacementServiceQuotaWiringTest.php +++ b/tests/Unit/Service/PlacementServiceQuotaWiringTest.php @@ -34,89 +34,83 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -class PlacementServiceQuotaWiringTest extends TestCase -{ - - /** @var WidgetPlacementMapper&MockObject */ - private $placementMapper; - - /** @var AdminSettingMapper&MockObject */ - private $settingMapper; - - private PlacementService $service; - - protected function setUp(): void - { - parent::setUp(); - - $this->placementMapper = $this->createMock(WidgetPlacementMapper::class); - $this->settingMapper = $this->createMock(AdminSettingMapper::class); - /** @var DashboardMapper&MockObject $dashboardMapper */ - $dashboardMapper = $this->createMock(DashboardMapper::class); - - $quotaService = new QuotaService( - settingMapper: $this->settingMapper, - dashboardMapper: $dashboardMapper, - placementMapper: $this->placementMapper, - ); - - $this->service = new PlacementService( - placementMapper: $this->placementMapper, - tileUpdater: $this->createMock(TileUpdater::class), - placementUpdater: $this->createMock(PlacementUpdater::class), - publicShareContext: null, - quotaService: $quotaService, - ); - }//end setUp() - - /** - * Wire the widget quota to `$limit`. - * - * @param int $limit The per-dashboard widget quota. - * - * @return void - */ - private function withWidgetLimit(int $limit): void - { - $this->settingMapper->method('getValue')->willReturnCallback( - function (string $k, $default=null) use ($limit) { - if ($k === AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD) { - return $limit; - } - - return $default; - } - ); - }//end withWidgetLimit() - - public function testAddWidgetThrowsAtQuota(): void - { - $this->withWidgetLimit(40); - $this->placementMapper->method('countByDashboardId')->willReturn(40); - $this->placementMapper->expects($this->never())->method('insert'); - - $this->expectException(QuotaExceededException::class); - $this->service->addWidget(dashboardId: 7, widgetId: 'clock'); - }//end testAddWidgetThrowsAtQuota() - - public function testAddTileThrowsAtQuota(): void - { - $this->withWidgetLimit(40); - $this->placementMapper->method('countByDashboardId')->willReturn(40); - $this->placementMapper->expects($this->never())->method('insert'); - - $this->expectException(QuotaExceededException::class); - $this->service->addTileFromArray(dashboardId: 7, tileData: ['title' => 'X']); - }//end testAddTileThrowsAtQuota() - - public function testAddWidgetAllowedBelowQuota(): void - { - $this->withWidgetLimit(40); - $this->placementMapper->method('countByDashboardId')->willReturn(39); - $this->placementMapper->expects($this->once()) - ->method('insert') - ->willReturnArgument(0); - - $this->service->addWidget(dashboardId: 7, widgetId: 'clock'); - }//end testAddWidgetAllowedBelowQuota() +class PlacementServiceQuotaWiringTest extends TestCase { + + /** @var WidgetPlacementMapper&MockObject */ + private $placementMapper; + + /** @var AdminSettingMapper&MockObject */ + private $settingMapper; + + private PlacementService $service; + + protected function setUp(): void { + parent::setUp(); + + $this->placementMapper = $this->createMock(WidgetPlacementMapper::class); + $this->settingMapper = $this->createMock(AdminSettingMapper::class); + /** @var DashboardMapper&MockObject $dashboardMapper */ + $dashboardMapper = $this->createMock(DashboardMapper::class); + + $quotaService = new QuotaService( + settingMapper: $this->settingMapper, + dashboardMapper: $dashboardMapper, + placementMapper: $this->placementMapper, + ); + + $this->service = new PlacementService( + placementMapper: $this->placementMapper, + tileUpdater: $this->createMock(TileUpdater::class), + placementUpdater: $this->createMock(PlacementUpdater::class), + publicShareContext: null, + quotaService: $quotaService, + ); + }//end setUp() + + /** + * Wire the widget quota to `$limit`. + * + * @param int $limit The per-dashboard widget quota. + * + * @return void + */ + private function withWidgetLimit(int $limit): void { + $this->settingMapper->method('getValue')->willReturnCallback( + function (string $k, $default = null) use ($limit) { + if ($k === AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD) { + return $limit; + } + + return $default; + } + ); + }//end withWidgetLimit() + + public function testAddWidgetThrowsAtQuota(): void { + $this->withWidgetLimit(40); + $this->placementMapper->method('countByDashboardId')->willReturn(40); + $this->placementMapper->expects($this->never())->method('insert'); + + $this->expectException(QuotaExceededException::class); + $this->service->addWidget(dashboardId: 7, widgetId: 'clock'); + }//end testAddWidgetThrowsAtQuota() + + public function testAddTileThrowsAtQuota(): void { + $this->withWidgetLimit(40); + $this->placementMapper->method('countByDashboardId')->willReturn(40); + $this->placementMapper->expects($this->never())->method('insert'); + + $this->expectException(QuotaExceededException::class); + $this->service->addTileFromArray(dashboardId: 7, tileData: ['title' => 'X']); + }//end testAddTileThrowsAtQuota() + + public function testAddWidgetAllowedBelowQuota(): void { + $this->withWidgetLimit(40); + $this->placementMapper->method('countByDashboardId')->willReturn(39); + $this->placementMapper->expects($this->once()) + ->method('insert') + ->willReturnArgument(0); + + $this->service->addWidget(dashboardId: 7, widgetId: 'clock'); + }//end testAddWidgetAllowedBelowQuota() }//end class diff --git a/tests/Unit/Service/PublicShareContextTest.php b/tests/Unit/Service/PublicShareContextTest.php index c34e61a4..59bbfeb0 100644 --- a/tests/Unit/Service/PublicShareContextTest.php +++ b/tests/Unit/Service/PublicShareContextTest.php @@ -27,41 +27,32 @@ use OCA\LaunchPad\Service\PublicShareContext; use PHPUnit\Framework\TestCase; -class PublicShareContextTest extends TestCase -{ - - - public function testDefaultsToNonBearer(): void - { - $ctx = new PublicShareContext(); - $this->assertFalse($ctx->isBearer()); - $this->assertNull($ctx->getToken()); - }//end testDefaultsToNonBearer() - - - public function testRequireMutablePassesByDefault(): void - { - $ctx = new PublicShareContext(); - $ctx->requireMutable(); - // No exception — control reaches here. - $this->assertTrue(true); - }//end testRequireMutablePassesByDefault() - - - public function testMarkBearerFlipsFlagAndStoresToken(): void - { - $ctx = new PublicShareContext(); - $ctx->markBearer(token: 'tok_abc123'); - $this->assertTrue($ctx->isBearer()); - $this->assertSame('tok_abc123', $ctx->getToken()); - }//end testMarkBearerFlipsFlagAndStoresToken() - - - public function testRequireMutableThrowsAfterMarkBearer(): void - { - $ctx = new PublicShareContext(); - $ctx->markBearer(token: 'tok_xyz'); - $this->expectException(ShareReadOnlyException::class); - $ctx->requireMutable(); - }//end testRequireMutableThrowsAfterMarkBearer() +class PublicShareContextTest extends TestCase { + + public function testDefaultsToNonBearer(): void { + $ctx = new PublicShareContext(); + $this->assertFalse($ctx->isBearer()); + $this->assertNull($ctx->getToken()); + }//end testDefaultsToNonBearer() + + public function testRequireMutablePassesByDefault(): void { + $ctx = new PublicShareContext(); + $ctx->requireMutable(); + // No exception — control reaches here. + $this->assertTrue(true); + }//end testRequireMutablePassesByDefault() + + public function testMarkBearerFlipsFlagAndStoresToken(): void { + $ctx = new PublicShareContext(); + $ctx->markBearer(token: 'tok_abc123'); + $this->assertTrue($ctx->isBearer()); + $this->assertSame('tok_abc123', $ctx->getToken()); + }//end testMarkBearerFlipsFlagAndStoresToken() + + public function testRequireMutableThrowsAfterMarkBearer(): void { + $ctx = new PublicShareContext(); + $ctx->markBearer(token: 'tok_xyz'); + $this->expectException(ShareReadOnlyException::class); + $ctx->requireMutable(); + }//end testRequireMutableThrowsAfterMarkBearer() }//end class diff --git a/tests/Unit/Service/PublicShareServiceTest.php b/tests/Unit/Service/PublicShareServiceTest.php index ea8f2180..7a535bda 100644 --- a/tests/Unit/Service/PublicShareServiceTest.php +++ b/tests/Unit/Service/PublicShareServiceTest.php @@ -40,275 +40,262 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -class PublicShareServiceTest extends TestCase -{ - - /** @var PublicShareMapper&MockObject */ - private $shareMapper; - - /** @var DashboardMapper&MockObject */ - private $dashMapper; - - /** @var IGroupManager&MockObject */ - private $groupManager; - - /** @var IHasher&MockObject */ - private $hasher; - - /** @var ISecureRandom&MockObject */ - private $secureRandom; - - /** @var IThrottler&MockObject */ - private $throttler; - - /** @var LoggerInterface&MockObject */ - private $logger; - - /** @var WidgetPlacementMapper&MockObject */ - private $placementMapper; - - private PublicShareService $service; - - protected function setUp(): void - { - $this->shareMapper = $this->createMock(PublicShareMapper::class); - $this->dashMapper = $this->createMock(DashboardMapper::class); - $this->groupManager = $this->createMock(IGroupManager::class); - $this->hasher = $this->createMock(IHasher::class); - $this->secureRandom = $this->createMock(ISecureRandom::class); - $this->throttler = $this->createMock(IThrottler::class); - $this->logger = $this->createMock(LoggerInterface::class); - $this->placementMapper = $this->createMock(WidgetPlacementMapper::class); - - $this->service = new PublicShareService( - shareMapper: $this->shareMapper, - dashMapper: $this->dashMapper, - groupManager: $this->groupManager, - hasher: $this->hasher, - secureRandom: $this->secureRandom, - throttler: $this->throttler, - logger: $this->logger, - placementMapper: $this->placementMapper, - ); - } - - // ------------------------------------------------------------------------- - // createPublicShare - // ------------------------------------------------------------------------- - - public function testCreateShareOwnerCanCreate(): void - { - $dashboard = new Dashboard(); - $dashboard->setUserId('alice'); - - $this->dashMapper->method('findByUuid')->willReturn($dashboard); - $this->groupManager->method('isAdmin')->willReturn(false); - $this->secureRandom->method('generate')->willReturn(str_repeat('a', 64)); - - $saved = new PublicShare(); - $saved->setToken(str_repeat('a', 64)); - $this->shareMapper->method('insert')->willReturn($saved); - - $result = $this->service->createPublicShare( - dashboardUuid: 'some-uuid', - callerId: 'alice' - ); - - $this->assertInstanceOf(PublicShare::class, $result); - } - - public function testCreateShareNonOwnerThrowsForbidden(): void - { - $dashboard = new Dashboard(); - $dashboard->setUserId('alice'); - - $this->dashMapper->method('findByUuid')->willReturn($dashboard); - $this->groupManager->method('isAdmin')->willReturn(false); - - $this->expectException(OCSForbiddenException::class); - - $this->service->createPublicShare( - dashboardUuid: 'some-uuid', - callerId: 'bob' - ); - } - - public function testCreateShareAdminCanCreate(): void - { - $dashboard = new Dashboard(); - $dashboard->setUserId('alice'); - - $this->dashMapper->method('findByUuid')->willReturn($dashboard); - $this->groupManager->method('isAdmin')->willReturn(true); - $this->secureRandom->method('generate')->willReturn(str_repeat('x', 64)); - - $saved = new PublicShare(); - $saved->setToken(str_repeat('x', 64)); - $this->shareMapper->method('insert')->willReturn($saved); - - $result = $this->service->createPublicShare( - dashboardUuid: 'some-uuid', - callerId: 'admin' - ); - - $this->assertInstanceOf(PublicShare::class, $result); - } - - public function testCreateShareHashesPassword(): void - { - $dashboard = new Dashboard(); - $dashboard->setUserId('alice'); - - $this->dashMapper->method('findByUuid')->willReturn($dashboard); - $this->groupManager->method('isAdmin')->willReturn(false); - $this->secureRandom->method('generate')->willReturn(str_repeat('t', 64)); - - $this->hasher - ->expects($this->once()) - ->method('hash') - ->with('SecurePass123!') - ->willReturn('$2y$hashed'); - - $saved = new PublicShare(); - $saved->setPasswordHash('$2y$hashed'); - $this->shareMapper->method('insert')->willReturn($saved); - - $this->service->createPublicShare( - dashboardUuid: 'some-uuid', - callerId: 'alice', - password: 'SecurePass123!' - ); - } - - // ------------------------------------------------------------------------- - // renderShareContent - // ------------------------------------------------------------------------- - - public function testRenderInvalidTokenThrowsNotFound(): void - { - $this->shareMapper - ->method('findByToken') - ->willThrowException(new DoesNotExistException('not found')); - - $this->expectException(ShareNotFoundException::class); - - $this->service->renderShareContent(token: 'invalid', ipAddress: '127.0.0.1'); - } - - public function testRenderRevokedShareThrowsNotFound(): void - { - $share = new PublicShare(); - $share->setToken('tok'); - $share->setRevokedAt('2026-01-01 00:00:00'); - - $this->shareMapper->method('findByToken')->willReturn($share); - - $this->expectException(ShareNotFoundException::class); - - $this->service->renderShareContent(token: 'tok', ipAddress: '127.0.0.1'); - } - - public function testRenderExpiredShareThrowsNotFound(): void - { - $share = new PublicShare(); - $share->setToken('tok'); - $share->setRevokedAt(null); - // Past date. - $share->setExpiresAt('2020-01-01 00:00:00'); - - $this->shareMapper->method('findByToken')->willReturn($share); - - $this->expectException(ShareNotFoundException::class); - - $this->service->renderShareContent(token: 'tok', ipAddress: '127.0.0.1'); - } - - public function testRenderPasswordProtectedWithoutPasswordThrowsRequired(): void - { - $share = new PublicShare(); - $share->setToken('tok'); - $share->setRevokedAt(null); - $share->setExpiresAt(null); - $share->setPasswordHash('$2y$hash'); - $share->setDashboardUuid('some-uuid'); - - $this->shareMapper->method('findByToken')->willReturn($share); - - $this->expectException(SharePasswordRequiredException::class); - - $this->service->renderShareContent(token: 'tok', ipAddress: '127.0.0.1'); - } - - public function testRenderValidTokenWithoutPasswordSucceeds(): void - { - $share = new PublicShare(); - $share->setToken('tok'); - $share->setRevokedAt(null); - $share->setExpiresAt(null); - $share->setPasswordHash(null); - $share->setDashboardUuid('some-uuid'); - - $this->shareMapper->method('findByToken')->willReturn($share); - $this->shareMapper->method('incrementViewCount'); - - $dashboard = new Dashboard(); - $dashboard->setUserId('alice'); - $this->dashMapper->method('findByUuid')->willReturn($dashboard); - $this->placementMapper->method('findByDashboardId')->willReturn([]); - - $result = $this->service->renderShareContent(token: 'tok', ipAddress: '127.0.0.1'); - - $this->assertArrayHasKey('share', $result); - $this->assertArrayHasKey('dashboard', $result); - $this->assertArrayHasKey('placements', $result); - } - - // ------------------------------------------------------------------------- - // unlockShare - // ------------------------------------------------------------------------- - - public function testUnlockCorrectPasswordReturnsTrue(): void - { - $share = new PublicShare(); - $share->setToken('tok'); - $share->setRevokedAt(null); - $share->setExpiresAt(null); - $share->setPasswordHash('$2y$hash'); - - $this->shareMapper->method('findByToken')->willReturn($share); - $this->hasher->method('verify')->willReturn(true); - - $result = $this->service->unlockShare( - token: 'tok', - password: 'SecurePass123!', - ipAddress: '127.0.0.1' - ); - - $this->assertTrue($result); - } - - public function testUnlockWrongPasswordReturnsFalseAndRegistersAttempt(): void - { - $share = new PublicShare(); - $share->setToken('tok'); - $share->setRevokedAt(null); - $share->setExpiresAt(null); - $share->setPasswordHash('$2y$hash'); - - $this->shareMapper->method('findByToken')->willReturn($share); - $this->hasher->method('verify')->willReturn(false); - - $this->throttler - ->expects($this->once()) - ->method('registerAttempt') - ->with(PublicShareService::ACTION_SHARE_PASSWORD, '127.0.0.1'); - - $result = $this->service->unlockShare( - token: 'tok', - password: 'WrongPassword', - ipAddress: '127.0.0.1' - ); - - $this->assertFalse($result); - } +class PublicShareServiceTest extends TestCase { + + /** @var PublicShareMapper&MockObject */ + private $shareMapper; + + /** @var DashboardMapper&MockObject */ + private $dashMapper; + + /** @var IGroupManager&MockObject */ + private $groupManager; + + /** @var IHasher&MockObject */ + private $hasher; + + /** @var ISecureRandom&MockObject */ + private $secureRandom; + + /** @var IThrottler&MockObject */ + private $throttler; + + /** @var LoggerInterface&MockObject */ + private $logger; + + /** @var WidgetPlacementMapper&MockObject */ + private $placementMapper; + + private PublicShareService $service; + + protected function setUp(): void { + $this->shareMapper = $this->createMock(PublicShareMapper::class); + $this->dashMapper = $this->createMock(DashboardMapper::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->hasher = $this->createMock(IHasher::class); + $this->secureRandom = $this->createMock(ISecureRandom::class); + $this->throttler = $this->createMock(IThrottler::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->placementMapper = $this->createMock(WidgetPlacementMapper::class); + + $this->service = new PublicShareService( + shareMapper: $this->shareMapper, + dashMapper: $this->dashMapper, + groupManager: $this->groupManager, + hasher: $this->hasher, + secureRandom: $this->secureRandom, + throttler: $this->throttler, + logger: $this->logger, + placementMapper: $this->placementMapper, + ); + } + + // ------------------------------------------------------------------------- + // createPublicShare + // ------------------------------------------------------------------------- + + public function testCreateShareOwnerCanCreate(): void { + $dashboard = new Dashboard(); + $dashboard->setUserId('alice'); + + $this->dashMapper->method('findByUuid')->willReturn($dashboard); + $this->groupManager->method('isAdmin')->willReturn(false); + $this->secureRandom->method('generate')->willReturn(str_repeat('a', 64)); + + $saved = new PublicShare(); + $saved->setToken(str_repeat('a', 64)); + $this->shareMapper->method('insert')->willReturn($saved); + + $result = $this->service->createPublicShare( + dashboardUuid: 'some-uuid', + callerId: 'alice' + ); + + $this->assertInstanceOf(PublicShare::class, $result); + } + + public function testCreateShareNonOwnerThrowsForbidden(): void { + $dashboard = new Dashboard(); + $dashboard->setUserId('alice'); + + $this->dashMapper->method('findByUuid')->willReturn($dashboard); + $this->groupManager->method('isAdmin')->willReturn(false); + + $this->expectException(OCSForbiddenException::class); + + $this->service->createPublicShare( + dashboardUuid: 'some-uuid', + callerId: 'bob' + ); + } + + public function testCreateShareAdminCanCreate(): void { + $dashboard = new Dashboard(); + $dashboard->setUserId('alice'); + + $this->dashMapper->method('findByUuid')->willReturn($dashboard); + $this->groupManager->method('isAdmin')->willReturn(true); + $this->secureRandom->method('generate')->willReturn(str_repeat('x', 64)); + + $saved = new PublicShare(); + $saved->setToken(str_repeat('x', 64)); + $this->shareMapper->method('insert')->willReturn($saved); + + $result = $this->service->createPublicShare( + dashboardUuid: 'some-uuid', + callerId: 'admin' + ); + + $this->assertInstanceOf(PublicShare::class, $result); + } + + public function testCreateShareHashesPassword(): void { + $dashboard = new Dashboard(); + $dashboard->setUserId('alice'); + + $this->dashMapper->method('findByUuid')->willReturn($dashboard); + $this->groupManager->method('isAdmin')->willReturn(false); + $this->secureRandom->method('generate')->willReturn(str_repeat('t', 64)); + + $this->hasher + ->expects($this->once()) + ->method('hash') + ->with('SecurePass123!') + ->willReturn('$2y$hashed'); + + $saved = new PublicShare(); + $saved->setPasswordHash('$2y$hashed'); + $this->shareMapper->method('insert')->willReturn($saved); + + $this->service->createPublicShare( + dashboardUuid: 'some-uuid', + callerId: 'alice', + password: 'SecurePass123!' + ); + } + + // ------------------------------------------------------------------------- + // renderShareContent + // ------------------------------------------------------------------------- + + public function testRenderInvalidTokenThrowsNotFound(): void { + $this->shareMapper + ->method('findByToken') + ->willThrowException(new DoesNotExistException('not found')); + + $this->expectException(ShareNotFoundException::class); + + $this->service->renderShareContent(token: 'invalid', ipAddress: '127.0.0.1'); + } + + public function testRenderRevokedShareThrowsNotFound(): void { + $share = new PublicShare(); + $share->setToken('tok'); + $share->setRevokedAt('2026-01-01 00:00:00'); + + $this->shareMapper->method('findByToken')->willReturn($share); + + $this->expectException(ShareNotFoundException::class); + + $this->service->renderShareContent(token: 'tok', ipAddress: '127.0.0.1'); + } + + public function testRenderExpiredShareThrowsNotFound(): void { + $share = new PublicShare(); + $share->setToken('tok'); + $share->setRevokedAt(null); + // Past date. + $share->setExpiresAt('2020-01-01 00:00:00'); + + $this->shareMapper->method('findByToken')->willReturn($share); + + $this->expectException(ShareNotFoundException::class); + + $this->service->renderShareContent(token: 'tok', ipAddress: '127.0.0.1'); + } + + public function testRenderPasswordProtectedWithoutPasswordThrowsRequired(): void { + $share = new PublicShare(); + $share->setToken('tok'); + $share->setRevokedAt(null); + $share->setExpiresAt(null); + $share->setPasswordHash('$2y$hash'); + $share->setDashboardUuid('some-uuid'); + + $this->shareMapper->method('findByToken')->willReturn($share); + + $this->expectException(SharePasswordRequiredException::class); + + $this->service->renderShareContent(token: 'tok', ipAddress: '127.0.0.1'); + } + + public function testRenderValidTokenWithoutPasswordSucceeds(): void { + $share = new PublicShare(); + $share->setToken('tok'); + $share->setRevokedAt(null); + $share->setExpiresAt(null); + $share->setPasswordHash(null); + $share->setDashboardUuid('some-uuid'); + + $this->shareMapper->method('findByToken')->willReturn($share); + $this->shareMapper->method('incrementViewCount'); + + $dashboard = new Dashboard(); + $dashboard->setUserId('alice'); + $this->dashMapper->method('findByUuid')->willReturn($dashboard); + $this->placementMapper->method('findByDashboardId')->willReturn([]); + + $result = $this->service->renderShareContent(token: 'tok', ipAddress: '127.0.0.1'); + + $this->assertArrayHasKey('share', $result); + $this->assertArrayHasKey('dashboard', $result); + $this->assertArrayHasKey('placements', $result); + } + + // ------------------------------------------------------------------------- + // unlockShare + // ------------------------------------------------------------------------- + + public function testUnlockCorrectPasswordReturnsTrue(): void { + $share = new PublicShare(); + $share->setToken('tok'); + $share->setRevokedAt(null); + $share->setExpiresAt(null); + $share->setPasswordHash('$2y$hash'); + + $this->shareMapper->method('findByToken')->willReturn($share); + $this->hasher->method('verify')->willReturn(true); + + $result = $this->service->unlockShare( + token: 'tok', + password: 'SecurePass123!', + ipAddress: '127.0.0.1' + ); + + $this->assertTrue($result); + } + + public function testUnlockWrongPasswordReturnsFalseAndRegistersAttempt(): void { + $share = new PublicShare(); + $share->setToken('tok'); + $share->setRevokedAt(null); + $share->setExpiresAt(null); + $share->setPasswordHash('$2y$hash'); + + $this->shareMapper->method('findByToken')->willReturn($share); + $this->hasher->method('verify')->willReturn(false); + + $this->throttler + ->expects($this->once()) + ->method('registerAttempt') + ->with(PublicShareService::ACTION_SHARE_PASSWORD, '127.0.0.1'); + + $result = $this->service->unlockShare( + token: 'tok', + password: 'WrongPassword', + ipAddress: '127.0.0.1' + ); + + $this->assertFalse($result); + } }//end class diff --git a/tests/Unit/Service/QuotaServiceTest.php b/tests/Unit/Service/QuotaServiceTest.php index 8fbef0f1..12a4f93e 100644 --- a/tests/Unit/Service/QuotaServiceTest.php +++ b/tests/Unit/Service/QuotaServiceTest.php @@ -27,314 +27,295 @@ use OCA\LaunchPad\Service\QuotaService; use PHPUnit\Framework\TestCase; -class QuotaServiceTest extends TestCase -{ - - private QuotaService $service; - - private AdminSettingMapper $settingMapper; - - private DashboardMapper $dashboardMapper; - - private WidgetPlacementMapper $placementMapper; - - protected function setUp(): void - { - $this->settingMapper = $this->createMock(AdminSettingMapper::class); - $this->dashboardMapper = $this->createMock(DashboardMapper::class); - $this->placementMapper = $this->createMock(WidgetPlacementMapper::class); - $this->service = new QuotaService( - settingMapper: $this->settingMapper, - dashboardMapper: $this->dashboardMapper, - placementMapper: $this->placementMapper, - ); - }//end setUp() - - /** - * Wire the setting mapper's getValue() for a given numeric quota + - * allow-multiple flag. - * - * @param string $key The numeric quota key. - * @param int $value The numeric quota value. - * @param bool $allowMultiple The allow_multiple_dashboards flag. - * - * @return void - */ - private function withSettings(string $key, int $value, bool $allowMultiple=true): void - { - $this->settingMapper->method('getValue')->willReturnCallback( - function (string $k, $default=null) use ($key, $value, $allowMultiple) { - if ($k === $key) { - return $value; - } - - if ($k === AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS) { - return $allowMultiple; - } - - return $default; - } - ); - }//end withSettings() - - // ----- REQ-QUOTA-002: dashboard count enforcement ----- - - public function testDashboardCreateAllowedBelowLimit(): void - { - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(4); - - // No exception => allowed. - $this->service->assertCanCreateDashboard(userId: 'alice'); - $this->addToAssertionCount(1); - }//end testDashboardCreateAllowedBelowLimit() - - public function testDashboardCreateBlockedAtLimit(): void - { - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(5); - - try { - $this->service->assertCanCreateDashboard(userId: 'alice'); - $this->fail('Expected QuotaExceededException'); - } catch (QuotaExceededException $e) { - $this->assertSame(QuotaExceededException::QUOTA_DASHBOARDS, $e->getQuota()); - $this->assertSame(5, $e->getLimit()); - $this->assertSame(5, $e->getCurrent()); - $this->assertSame(409, $e->getHttpStatus()); - $this->assertSame( - [ - 'error' => 'quota_exceeded', - 'quota' => 'dashboards', - 'limit' => 5, - 'current' => 5, - ], - $e->toResponseBody() - ); - }//end try - }//end testDashboardCreateBlockedAtLimit() - - public function testDashboardCreateUnlimitedWhenZero(): void - { - // REQ-QUOTA-001 — 0 means unlimited; count never queried. - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 0); - $this->dashboardMapper->expects($this->never()) - ->method('countPersonalByUserId'); - - $this->service->assertCanCreateDashboard(userId: 'alice'); - $this->addToAssertionCount(1); - }//end testDashboardCreateUnlimitedWhenZero() - - public function testDashboardCreateLiveRecountAfterDelete(): void - { - // REQ-QUOTA-002 — count is computed live, so dropping below the - // limit immediately permits a new create. - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(4); - - $this->service->assertCanCreateDashboard(userId: 'alice'); - $this->addToAssertionCount(1); - }//end testDashboardCreateLiveRecountAfterDelete() - - public function testGrandfatheringBlocksWhenOverLoweredLimit(): void - { - // REQ-QUOTA-005 — usage (8) exceeds a lowered limit (5): new - // creation blocked, exception carries the real over-quota count. - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(8); - - try { - $this->service->assertCanCreateDashboard(userId: 'alice'); - $this->fail('Expected QuotaExceededException'); - } catch (QuotaExceededException $e) { - $this->assertSame(5, $e->getLimit()); - $this->assertSame(8, $e->getCurrent()); - } - }//end testGrandfatheringBlocksWhenOverLoweredLimit() - - // ----- REQ-QUOTA-002 / D6: most-restrictive-wins ----- - - public function testAllowMultipleFalseGivesEffectiveLimitOne(): void - { - // REQ-QUOTA-002 — allow_multiple_dashboards = false ⇒ effective - // limit 1 regardless of the numeric setting (5). - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5, allowMultiple: false); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(1); - - try { - $this->service->assertCanCreateDashboard(userId: 'alice'); - $this->fail('Expected QuotaExceededException'); - } catch (QuotaExceededException $e) { - $this->assertSame(1, $e->getLimit()); - $this->assertSame(1, $e->getCurrent()); - } - }//end testAllowMultipleFalseGivesEffectiveLimitOne() - - public function testNumericQuotaDoesNotLoosenBooleanRestriction(): void - { - // REQ-QUOTA-002 — even with a generous numeric quota, the boolean - // off-switch keeps the effective limit at 1, so a user with 1 - // dashboard is blocked from a second. - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 100, allowMultiple: false); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(1); - - $this->expectException(QuotaExceededException::class); - $this->service->assertCanCreateDashboard(userId: 'alice'); - }//end testNumericQuotaDoesNotLoosenBooleanRestriction() - - // ----- REQ-QUOTA-003: widget count enforcement ----- - - public function testWidgetAddAllowedBelowLimit(): void - { - $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 40); - $this->placementMapper->method('countByDashboardId')->willReturn(39); - - $this->service->assertCanAddPlacement(dashboardId: 7); - $this->addToAssertionCount(1); - }//end testWidgetAddAllowedBelowLimit() - - public function testWidgetAddBlockedAtLimit(): void - { - $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 40); - $this->placementMapper->method('countByDashboardId')->willReturn(40); - - try { - $this->service->assertCanAddPlacement(dashboardId: 7); - $this->fail('Expected QuotaExceededException'); - } catch (QuotaExceededException $e) { - $this->assertSame(QuotaExceededException::QUOTA_WIDGETS, $e->getQuota()); - $this->assertSame(40, $e->getLimit()); - $this->assertSame(40, $e->getCurrent()); - $this->assertSame('widgets', $e->toResponseBody()['quota']); - }//end try - }//end testWidgetAddBlockedAtLimit() - - public function testWidgetAddUnlimitedWhenZero(): void - { - $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 0); - $this->placementMapper->expects($this->never()) - ->method('countByDashboardId'); - - $this->service->assertCanAddPlacement(dashboardId: 7); - $this->addToAssertionCount(1); - }//end testWidgetAddUnlimitedWhenZero() - - // ----- REQ-QUOTA-004: provisioning bypass ----- - - public function testProvisioningBypassesDashboardQuota(): void - { - // REQ-QUOTA-004 — inside runProvisioning(), an over-quota user's - // creation is NOT blocked (template rollout). Count never queried. - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); - $this->dashboardMapper->expects($this->never()) - ->method('countPersonalByUserId'); - - $ran = $this->service->runProvisioning( - function () { - $this->service->assertCanCreateDashboard(userId: 'alice'); - return 'rolled-out'; - } - ); - - $this->assertSame('rolled-out', $ran); - }//end testProvisioningBypassesDashboardQuota() - - public function testProvisioningBypassesWidgetQuota(): void - { - // REQ-QUOTA-004 — compulsory-widget push bypasses the widget quota. - $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 40); - $this->placementMapper->expects($this->never()) - ->method('countByDashboardId'); - - $this->service->runProvisioning( - function () { - $this->service->assertCanAddPlacement(dashboardId: 7); - } - ); - $this->addToAssertionCount(1); - }//end testProvisioningBypassesWidgetQuota() - - public function testProvisioningFlagResetsAfterCallEvenOnThrow(): void - { - // REQ-QUOTA-004 — a throwing provisioning call must NOT leave the - // service permanently bypassed: the next user-initiated assert is - // enforced again. - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(5); - - try { - $this->service->runProvisioning( - function () { - throw new \RuntimeException('boom'); - } - ); - } catch (\RuntimeException) { - // expected - } - - $this->assertFalse($this->service->isProvisioning()); - $this->expectException(QuotaExceededException::class); - $this->service->assertCanCreateDashboard(userId: 'alice'); - }//end testProvisioningFlagResetsAfterCallEvenOnThrow() - - public function testAdminBoundByQuotaOutsideProvisioning(): void - { - // REQ-QUOTA-004 — an admin creating their own personal dashboard - // through the normal flow (no provisioning wrapper) is still bound. - $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(5); - - $this->expectException(QuotaExceededException::class); - $this->service->assertCanCreateDashboard(userId: 'carol'); - }//end testAdminBoundByQuotaOutsideProvisioning() - - // ----- REQ-QUOTA-006: quota status envelope ----- - - public function testGetQuotaStatusEnvelopeShape(): void - { - $this->settingMapper->method('getValue')->willReturnCallback( - function (string $k, $default=null) { - return match ($k) { - AdminSetting::KEY_MAX_DASHBOARDS_PER_USER => 5, - AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD => 40, - AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS => true, - default => $default, - }; - } - ); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(3); - - $status = $this->service->getQuotaStatus(userId: 'alice'); - - $this->assertSame( - [ - 'maxDashboards' => 5, - 'dashboardsUsed' => 3, - 'maxWidgetsPerDashboard' => 40, - ], - $status - ); - }//end testGetQuotaStatusEnvelopeShape() - - public function testGetQuotaStatusReflectsEffectiveLimit(): void - { - // REQ-QUOTA-006 / D6 — the envelope surfaces the EFFECTIVE limit, so - // allow_multiple_dashboards = false shows maxDashboards = 1. - $this->settingMapper->method('getValue')->willReturnCallback( - function (string $k, $default=null) { - return match ($k) { - AdminSetting::KEY_MAX_DASHBOARDS_PER_USER => 5, - AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD => 0, - AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS => false, - default => $default, - }; - } - ); - $this->dashboardMapper->method('countPersonalByUserId')->willReturn(1); - - $status = $this->service->getQuotaStatus(userId: 'alice'); - - $this->assertSame(1, $status['maxDashboards']); - $this->assertSame(0, $status['maxWidgetsPerDashboard']); - }//end testGetQuotaStatusReflectsEffectiveLimit() +class QuotaServiceTest extends TestCase { + + private QuotaService $service; + + private AdminSettingMapper $settingMapper; + + private DashboardMapper $dashboardMapper; + + private WidgetPlacementMapper $placementMapper; + + protected function setUp(): void { + $this->settingMapper = $this->createMock(AdminSettingMapper::class); + $this->dashboardMapper = $this->createMock(DashboardMapper::class); + $this->placementMapper = $this->createMock(WidgetPlacementMapper::class); + $this->service = new QuotaService( + settingMapper: $this->settingMapper, + dashboardMapper: $this->dashboardMapper, + placementMapper: $this->placementMapper, + ); + }//end setUp() + + /** + * Wire the setting mapper's getValue() for a given numeric quota + + * allow-multiple flag. + * + * @param string $key The numeric quota key. + * @param int $value The numeric quota value. + * @param bool $allowMultiple The allow_multiple_dashboards flag. + * + * @return void + */ + private function withSettings(string $key, int $value, bool $allowMultiple = true): void { + $this->settingMapper->method('getValue')->willReturnCallback( + function (string $k, $default = null) use ($key, $value, $allowMultiple) { + if ($k === $key) { + return $value; + } + + if ($k === AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS) { + return $allowMultiple; + } + + return $default; + } + ); + }//end withSettings() + + // ----- REQ-QUOTA-002: dashboard count enforcement ----- + + public function testDashboardCreateAllowedBelowLimit(): void { + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(4); + + // No exception => allowed. + $this->service->assertCanCreateDashboard(userId: 'alice'); + $this->addToAssertionCount(1); + }//end testDashboardCreateAllowedBelowLimit() + + public function testDashboardCreateBlockedAtLimit(): void { + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(5); + + try { + $this->service->assertCanCreateDashboard(userId: 'alice'); + $this->fail('Expected QuotaExceededException'); + } catch (QuotaExceededException $e) { + $this->assertSame(QuotaExceededException::QUOTA_DASHBOARDS, $e->getQuota()); + $this->assertSame(5, $e->getLimit()); + $this->assertSame(5, $e->getCurrent()); + $this->assertSame(409, $e->getHttpStatus()); + $this->assertSame( + [ + 'error' => 'quota_exceeded', + 'quota' => 'dashboards', + 'limit' => 5, + 'current' => 5, + ], + $e->toResponseBody() + ); + }//end try + }//end testDashboardCreateBlockedAtLimit() + + public function testDashboardCreateUnlimitedWhenZero(): void { + // REQ-QUOTA-001 — 0 means unlimited; count never queried. + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 0); + $this->dashboardMapper->expects($this->never()) + ->method('countPersonalByUserId'); + + $this->service->assertCanCreateDashboard(userId: 'alice'); + $this->addToAssertionCount(1); + }//end testDashboardCreateUnlimitedWhenZero() + + public function testDashboardCreateLiveRecountAfterDelete(): void { + // REQ-QUOTA-002 — count is computed live, so dropping below the + // limit immediately permits a new create. + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(4); + + $this->service->assertCanCreateDashboard(userId: 'alice'); + $this->addToAssertionCount(1); + }//end testDashboardCreateLiveRecountAfterDelete() + + public function testGrandfatheringBlocksWhenOverLoweredLimit(): void { + // REQ-QUOTA-005 — usage (8) exceeds a lowered limit (5): new + // creation blocked, exception carries the real over-quota count. + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(8); + + try { + $this->service->assertCanCreateDashboard(userId: 'alice'); + $this->fail('Expected QuotaExceededException'); + } catch (QuotaExceededException $e) { + $this->assertSame(5, $e->getLimit()); + $this->assertSame(8, $e->getCurrent()); + } + }//end testGrandfatheringBlocksWhenOverLoweredLimit() + + // ----- REQ-QUOTA-002 / D6: most-restrictive-wins ----- + + public function testAllowMultipleFalseGivesEffectiveLimitOne(): void { + // REQ-QUOTA-002 — allow_multiple_dashboards = false ⇒ effective + // limit 1 regardless of the numeric setting (5). + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5, allowMultiple: false); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(1); + + try { + $this->service->assertCanCreateDashboard(userId: 'alice'); + $this->fail('Expected QuotaExceededException'); + } catch (QuotaExceededException $e) { + $this->assertSame(1, $e->getLimit()); + $this->assertSame(1, $e->getCurrent()); + } + }//end testAllowMultipleFalseGivesEffectiveLimitOne() + + public function testNumericQuotaDoesNotLoosenBooleanRestriction(): void { + // REQ-QUOTA-002 — even with a generous numeric quota, the boolean + // off-switch keeps the effective limit at 1, so a user with 1 + // dashboard is blocked from a second. + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 100, allowMultiple: false); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(1); + + $this->expectException(QuotaExceededException::class); + $this->service->assertCanCreateDashboard(userId: 'alice'); + }//end testNumericQuotaDoesNotLoosenBooleanRestriction() + + // ----- REQ-QUOTA-003: widget count enforcement ----- + + public function testWidgetAddAllowedBelowLimit(): void { + $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 40); + $this->placementMapper->method('countByDashboardId')->willReturn(39); + + $this->service->assertCanAddPlacement(dashboardId: 7); + $this->addToAssertionCount(1); + }//end testWidgetAddAllowedBelowLimit() + + public function testWidgetAddBlockedAtLimit(): void { + $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 40); + $this->placementMapper->method('countByDashboardId')->willReturn(40); + + try { + $this->service->assertCanAddPlacement(dashboardId: 7); + $this->fail('Expected QuotaExceededException'); + } catch (QuotaExceededException $e) { + $this->assertSame(QuotaExceededException::QUOTA_WIDGETS, $e->getQuota()); + $this->assertSame(40, $e->getLimit()); + $this->assertSame(40, $e->getCurrent()); + $this->assertSame('widgets', $e->toResponseBody()['quota']); + }//end try + }//end testWidgetAddBlockedAtLimit() + + public function testWidgetAddUnlimitedWhenZero(): void { + $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 0); + $this->placementMapper->expects($this->never()) + ->method('countByDashboardId'); + + $this->service->assertCanAddPlacement(dashboardId: 7); + $this->addToAssertionCount(1); + }//end testWidgetAddUnlimitedWhenZero() + + // ----- REQ-QUOTA-004: provisioning bypass ----- + + public function testProvisioningBypassesDashboardQuota(): void { + // REQ-QUOTA-004 — inside runProvisioning(), an over-quota user's + // creation is NOT blocked (template rollout). Count never queried. + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); + $this->dashboardMapper->expects($this->never()) + ->method('countPersonalByUserId'); + + $ran = $this->service->runProvisioning( + function () { + $this->service->assertCanCreateDashboard(userId: 'alice'); + return 'rolled-out'; + } + ); + + $this->assertSame('rolled-out', $ran); + }//end testProvisioningBypassesDashboardQuota() + + public function testProvisioningBypassesWidgetQuota(): void { + // REQ-QUOTA-004 — compulsory-widget push bypasses the widget quota. + $this->withSettings(AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD, 40); + $this->placementMapper->expects($this->never()) + ->method('countByDashboardId'); + + $this->service->runProvisioning( + function () { + $this->service->assertCanAddPlacement(dashboardId: 7); + } + ); + $this->addToAssertionCount(1); + }//end testProvisioningBypassesWidgetQuota() + + public function testProvisioningFlagResetsAfterCallEvenOnThrow(): void { + // REQ-QUOTA-004 — a throwing provisioning call must NOT leave the + // service permanently bypassed: the next user-initiated assert is + // enforced again. + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(5); + + try { + $this->service->runProvisioning( + function () { + throw new \RuntimeException('boom'); + } + ); + } catch (\RuntimeException) { + // expected + } + + $this->assertFalse($this->service->isProvisioning()); + $this->expectException(QuotaExceededException::class); + $this->service->assertCanCreateDashboard(userId: 'alice'); + }//end testProvisioningFlagResetsAfterCallEvenOnThrow() + + public function testAdminBoundByQuotaOutsideProvisioning(): void { + // REQ-QUOTA-004 — an admin creating their own personal dashboard + // through the normal flow (no provisioning wrapper) is still bound. + $this->withSettings(AdminSetting::KEY_MAX_DASHBOARDS_PER_USER, 5); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(5); + + $this->expectException(QuotaExceededException::class); + $this->service->assertCanCreateDashboard(userId: 'carol'); + }//end testAdminBoundByQuotaOutsideProvisioning() + + // ----- REQ-QUOTA-006: quota status envelope ----- + + public function testGetQuotaStatusEnvelopeShape(): void { + $this->settingMapper->method('getValue')->willReturnCallback( + function (string $k, $default = null) { + return match ($k) { + AdminSetting::KEY_MAX_DASHBOARDS_PER_USER => 5, + AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD => 40, + AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS => true, + default => $default, + }; + } + ); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(3); + + $status = $this->service->getQuotaStatus(userId: 'alice'); + + $this->assertSame( + [ + 'maxDashboards' => 5, + 'dashboardsUsed' => 3, + 'maxWidgetsPerDashboard' => 40, + ], + $status + ); + }//end testGetQuotaStatusEnvelopeShape() + + public function testGetQuotaStatusReflectsEffectiveLimit(): void { + // REQ-QUOTA-006 / D6 — the envelope surfaces the EFFECTIVE limit, so + // allow_multiple_dashboards = false shows maxDashboards = 1. + $this->settingMapper->method('getValue')->willReturnCallback( + function (string $k, $default = null) { + return match ($k) { + AdminSetting::KEY_MAX_DASHBOARDS_PER_USER => 5, + AdminSetting::KEY_MAX_WIDGETS_PER_DASHBOARD => 0, + AdminSetting::KEY_ALLOW_MULTIPLE_DASHBOARDS => false, + default => $default, + }; + } + ); + $this->dashboardMapper->method('countPersonalByUserId')->willReturn(1); + + $status = $this->service->getQuotaStatus(userId: 'alice'); + + $this->assertSame(1, $status['maxDashboards']); + $this->assertSame(0, $status['maxWidgetsPerDashboard']); + }//end testGetQuotaStatusReflectsEffectiveLimit() }//end class diff --git a/tests/Unit/Service/ReactionServiceTest.php b/tests/Unit/Service/ReactionServiceTest.php index 8cae8e1c..53b95bbf 100644 --- a/tests/Unit/Service/ReactionServiceTest.php +++ b/tests/Unit/Service/ReactionServiceTest.php @@ -28,9 +28,8 @@ use OCA\LaunchPad\Db\DashboardReactionMapper; use OCA\LaunchPad\Service\PermissionDeniedException; use OCA\LaunchPad\Service\PermissionService; -use OCA\LaunchPad\Service\ReactionService; use OCA\LaunchPad\Service\ReactionsDisabledException; -use OCP\AppFramework\Db\DoesNotExistException; +use OCA\LaunchPad\Service\ReactionService; use OCP\DB\Exception as DbException; use OCP\IAppConfig; use OCP\IUser; @@ -41,348 +40,329 @@ /** * Tests for ReactionService. */ -class ReactionServiceTest extends TestCase -{ - private DashboardReactionMapper&MockObject $reactionMapper; - private DashboardMapper&MockObject $dashboardMapper; - private PermissionService&MockObject $permissionService; - private IAppConfig&MockObject $appConfig; - private IUserManager&MockObject $userManager; - private ReactionService $service; - - protected function setUp(): void - { - $this->reactionMapper = $this->createMock(originalClassName: DashboardReactionMapper::class); - $this->dashboardMapper = $this->createMock(originalClassName: DashboardMapper::class); - $this->permissionService = $this->createMock(originalClassName: PermissionService::class); - $this->appConfig = $this->createMock(originalClassName: IAppConfig::class); - $this->userManager = $this->createMock(originalClassName: IUserManager::class); - - $this->service = new ReactionService( - reactionMapper: $this->reactionMapper, - dashboardMapper: $this->dashboardMapper, - permissionService: $this->permissionService, - appConfig: $this->appConfig, - userManager: $this->userManager, - ); - } - - private function makeDashboard(?int $perDashFlag, int $id=1, string $uuid='dash-123'): Dashboard - { - $dashboard = new Dashboard(); - // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters - // Entity __call uses $args[0] — named args break the magic forwarding. - $dashboard->setId($id); - $dashboard->setUuid($uuid); - $dashboard->setReactionsEnabled($perDashFlag); - // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters - return $dashboard; - } - - /** - * REQ-RXN-006 — null/1/0 tri-state resolution. - */ - public function testIsReactionsEnabledTriState(): void - { - $this->appConfig->method('getValueBool')->willReturn(true); - - $this->assertTrue($this->service->isReactionsEnabled(dashboard: $this->makeDashboard(perDashFlag: 1))); - $this->assertFalse($this->service->isReactionsEnabled(dashboard: $this->makeDashboard(perDashFlag: 0))); - $this->assertTrue($this->service->isReactionsEnabled(dashboard: $this->makeDashboard(perDashFlag: null))); - } - - /** - * REQ-RXN-007 scenario "Admin updates the allowed emoji list". - */ - public function testValidateEmojiRejectsNonWhitelisted(): void - { - $this->appConfig->method('getValueString')->willReturn('["👍","❤️"]'); - - $this->expectException(InvalidArgumentException::class); - $this->service->validateEmoji(emoji: '🚀'); - } - - public function testValidateEmojiAcceptsWhitelisted(): void - { - $this->appConfig->method('getValueString')->willReturn('["👍","❤️"]'); - - $this->service->validateEmoji(emoji: '❤️'); - $this->expectNotToPerformAssertions(); - } - - public function testValidateEmojiRejectsEmpty(): void - { - $this->appConfig->method('getValueString')->willReturn('["👍"]'); - $this->expectException(InvalidArgumentException::class); - $this->service->validateEmoji(emoji: ''); - } - - /** - * REQ-RXN-007 scenario "Default allowed emoji list". - */ - public function testGetAllowedEmojisDefaults(): void - { - $this->appConfig->method('getValueString')->willReturn(''); - $this->assertSame( - ReactionService::DEFAULT_ALLOWED_EMOJIS, - $this->service->getAllowedEmojis() - ); - } - - public function testGetAllowedEmojisFallsBackOnCorruptJson(): void - { - $this->appConfig->method('getValueString')->willReturn('not-json'); - $this->assertSame( - ReactionService::DEFAULT_ALLOWED_EMOJIS, - $this->service->getAllowedEmojis() - ); - } - - /** - * REQ-RXN-007 scenario "Empty emoji in whitelist" — admin-set - * empty list returned as-is so validateEmoji rejects everything. - */ - public function testGetAllowedEmojisEmptyAdminListSurfacesAsEmpty(): void - { - $this->appConfig->method('getValueString')->willReturn('[]'); - $this->assertSame([], $this->service->getAllowedEmojis()); - } - - /** - * REQ-RXN-008 — non-VIEW user rejected with PermissionDeniedException. - */ - public function testAddReactionPermissionDenied(): void - { - $dash = $this->makeDashboard(perDashFlag: 1); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(false); - - $this->expectException(PermissionDeniedException::class); - $this->service->addReaction( - dashboardUuid: 'dash-123', - userId: 'bob', - emoji: '👍' - ); - } - - /** - * REQ-RXN-005 — global off + per-dashboard null returns - * ReactionsDisabledException on POST. - */ - public function testAddReactionDisabledThrows(): void - { - $dash = $this->makeDashboard(perDashFlag: null); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(true); - $this->appConfig->method('getValueBool')->willReturn(false); - - $this->expectException(ReactionsDisabledException::class); - $this->service->addReaction( - dashboardUuid: 'dash-123', - userId: 'alice', - emoji: '👍' - ); - } - - /** - * REQ-RXN-001 scenario "User re-posts the same emoji" — duplicate - * insert (unique constraint) is swallowed; summary returned as if - * the row already existed. - */ - public function testAddReactionIdempotentOnUniqueConstraint(): void - { - $dash = $this->makeDashboard(perDashFlag: 1); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(true); - $this->appConfig->method('getValueString')->willReturn('["👍"]'); - $this->appConfig->method('getValueBool')->willReturn(true); - - $duplicate = $this->createMock(originalClassName: DbException::class); - $duplicate->method('getReason')->willReturn(DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION); - $this->reactionMapper->method('addReaction')->willThrowException($duplicate); - - $this->reactionMapper->method('countByEmoji')->willReturn(['👍' => 1]); - $existing = new DashboardReaction(); - $existing->setEmoji('👍'); - $this->reactionMapper->method('findByUser')->willReturn([$existing]); - - $summary = $this->service->addReaction( - dashboardUuid: 'dash-123', - userId: 'alice', - emoji: '👍' - ); - - $this->assertTrue($summary['enabled']); - $this->assertSame(['👍'], $summary['mine']); - $this->assertSame(['👍' => 1], (array) $summary['counts']); - } - - /** - * REQ-RXN-003 scenario "Reactions disabled on dashboard" — GET - * returns the empty-shape summary regardless of stored rows. - */ - public function testGetReactionsSummaryDisabledShape(): void - { - $dash = $this->makeDashboard(perDashFlag: 0); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(true); - $this->appConfig->method('getValueBool')->willReturn(true); - - $this->reactionMapper->expects($this->never())->method('countByEmoji'); - - $summary = $this->service->getReactionsSummary( - dashboardUuid: 'dash-123', - userId: 'alice' - ); - - $this->assertFalse($summary['enabled']); - $this->assertSame([], (array) $summary['counts']); - $this->assertSame([], $summary['mine']); - } - - /** - * REQ-RXN-003 scenario "User retrieves reactions on a dashboard - * they can view" — counts + mine populated from mapper. - */ - public function testGetReactionsSummaryEnabledShape(): void - { - $dash = $this->makeDashboard(perDashFlag: 1); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(true); - $this->appConfig->method('getValueBool')->willReturn(true); - - $this->reactionMapper->method('countByEmoji')->willReturn([ - '👍' => 3, - '❤️' => 1, - '🎉' => 2, - ]); - - $a = new DashboardReaction(); - $a->setEmoji('👍'); - $b = new DashboardReaction(); - $b->setEmoji('🎉'); - $this->reactionMapper->method('findByUser')->willReturn([$a, $b]); - - $summary = $this->service->getReactionsSummary( - dashboardUuid: 'dash-123', - userId: 'alice' - ); - - $this->assertTrue($summary['enabled']); - $this->assertSame( - ['👍' => 3, '❤️' => 1, '🎉' => 2], - (array) $summary['counts'] - ); - $this->assertSame(['👍', '🎉'], $summary['mine']); - } - - /** - * REQ-RXN-002 — DELETE delegates to mapper; idempotent return - * value bubbles up. - */ - public function testRemoveReactionDelegatesToMapper(): void - { - $dash = $this->makeDashboard(perDashFlag: 1); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(true); - - $this->reactionMapper->expects($this->once()) - ->method('removeReaction') - ->with( - $this->equalTo('dash-123'), - $this->equalTo('alice'), - $this->equalTo('👍'), - ) - ->willReturn(true); - - $result = $this->service->removeReaction( - dashboardUuid: 'dash-123', - userId: 'alice', - emoji: '👍' - ); - - $this->assertTrue($result); - } - - /** - * REQ-RXN-009 — cascade delete delegates to mapper. - */ - public function testDeleteReactionsByDashboardDelegates(): void - { - $this->reactionMapper->expects($this->once()) - ->method('deleteByDashboardUuid') - ->with($this->equalTo('dash-123')) - ->willReturn(7); - - $this->assertSame( - 7, - $this->service->deleteReactionsByDashboard(dashboardUuid: 'dash-123') - ); - } - - /** - * REQ-RXN-004 — pagination cap + cursor advance. - */ - public function testGetReactorsByEmojiPagination(): void - { - $dash = $this->makeDashboard(perDashFlag: 1); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(true); - - $rows = []; - for ($i = 0; $i < ReactionService::REACTORS_PAGE_SIZE; $i++) { - $r = new DashboardReaction(); - $r->setUserId(sprintf('user%d', $i)); - $r->setEmoji('🎉'); - $rows[] = $r; - } - - $this->reactionMapper->method('findByEmoji')->willReturn($rows); - $this->reactionMapper->method('countReactorsByEmoji')->willReturn(150); - - $user = $this->createMock(originalClassName: IUser::class); - $user->method('getDisplayName')->willReturn('User'); - $this->userManager->method('get')->willReturn($user); - - $page = $this->service->getReactorsByEmoji( - dashboardUuid: 'dash-123', - emoji: '🎉', - userId: 'alice', - cursor: null - ); - - $this->assertCount(ReactionService::REACTORS_PAGE_SIZE, $page['items']); - $this->assertSame('100', $page['nextCursor']); - $this->assertSame(150, $page['total']); - } - - /** - * Last page exposes nextCursor === null. - */ - public function testGetReactorsByEmojiLastPageNoCursor(): void - { - $dash = $this->makeDashboard(perDashFlag: 1); - $this->dashboardMapper->method('findByUuid')->willReturn($dash); - $this->permissionService->method('canViewDashboard')->willReturn(true); - - $r = new DashboardReaction(); - $r->setUserId('alice'); - $r->setEmoji('🎉'); - $this->reactionMapper->method('findByEmoji')->willReturn([$r]); - $this->reactionMapper->method('countReactorsByEmoji')->willReturn(1); - - $user = $this->createMock(originalClassName: IUser::class); - $user->method('getDisplayName')->willReturn('Alice'); - $this->userManager->method('get')->willReturn($user); - - $page = $this->service->getReactorsByEmoji( - dashboardUuid: 'dash-123', - emoji: '🎉', - userId: 'alice', - cursor: null - ); - - $this->assertNull($page['nextCursor']); - $this->assertSame(1, $page['total']); - } +class ReactionServiceTest extends TestCase { + private DashboardReactionMapper&MockObject $reactionMapper; + private DashboardMapper&MockObject $dashboardMapper; + private PermissionService&MockObject $permissionService; + private IAppConfig&MockObject $appConfig; + private IUserManager&MockObject $userManager; + private ReactionService $service; + + protected function setUp(): void { + $this->reactionMapper = $this->createMock(originalClassName: DashboardReactionMapper::class); + $this->dashboardMapper = $this->createMock(originalClassName: DashboardMapper::class); + $this->permissionService = $this->createMock(originalClassName: PermissionService::class); + $this->appConfig = $this->createMock(originalClassName: IAppConfig::class); + $this->userManager = $this->createMock(originalClassName: IUserManager::class); + + $this->service = new ReactionService( + reactionMapper: $this->reactionMapper, + dashboardMapper: $this->dashboardMapper, + permissionService: $this->permissionService, + appConfig: $this->appConfig, + userManager: $this->userManager, + ); + } + + private function makeDashboard(?int $perDashFlag, int $id = 1, string $uuid = 'dash-123'): Dashboard { + $dashboard = new Dashboard(); + // phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + // Entity __call uses $args[0] — named args break the magic forwarding. + $dashboard->setId($id); + $dashboard->setUuid($uuid); + $dashboard->setReactionsEnabled($perDashFlag); + // phpcs:enable CustomSniffs.Functions.NamedParameters.RequireNamedParameters + return $dashboard; + } + + /** + * REQ-RXN-006 — null/1/0 tri-state resolution. + */ + public function testIsReactionsEnabledTriState(): void { + $this->appConfig->method('getValueBool')->willReturn(true); + + $this->assertTrue($this->service->isReactionsEnabled(dashboard: $this->makeDashboard(perDashFlag: 1))); + $this->assertFalse($this->service->isReactionsEnabled(dashboard: $this->makeDashboard(perDashFlag: 0))); + $this->assertTrue($this->service->isReactionsEnabled(dashboard: $this->makeDashboard(perDashFlag: null))); + } + + /** + * REQ-RXN-007 scenario "Admin updates the allowed emoji list". + */ + public function testValidateEmojiRejectsNonWhitelisted(): void { + $this->appConfig->method('getValueString')->willReturn('["👍","❤️"]'); + + $this->expectException(InvalidArgumentException::class); + $this->service->validateEmoji(emoji: '🚀'); + } + + public function testValidateEmojiAcceptsWhitelisted(): void { + $this->appConfig->method('getValueString')->willReturn('["👍","❤️"]'); + + $this->service->validateEmoji(emoji: '❤️'); + $this->expectNotToPerformAssertions(); + } + + public function testValidateEmojiRejectsEmpty(): void { + $this->appConfig->method('getValueString')->willReturn('["👍"]'); + $this->expectException(InvalidArgumentException::class); + $this->service->validateEmoji(emoji: ''); + } + + /** + * REQ-RXN-007 scenario "Default allowed emoji list". + */ + public function testGetAllowedEmojisDefaults(): void { + $this->appConfig->method('getValueString')->willReturn(''); + $this->assertSame( + ReactionService::DEFAULT_ALLOWED_EMOJIS, + $this->service->getAllowedEmojis() + ); + } + + public function testGetAllowedEmojisFallsBackOnCorruptJson(): void { + $this->appConfig->method('getValueString')->willReturn('not-json'); + $this->assertSame( + ReactionService::DEFAULT_ALLOWED_EMOJIS, + $this->service->getAllowedEmojis() + ); + } + + /** + * REQ-RXN-007 scenario "Empty emoji in whitelist" — admin-set + * empty list returned as-is so validateEmoji rejects everything. + */ + public function testGetAllowedEmojisEmptyAdminListSurfacesAsEmpty(): void { + $this->appConfig->method('getValueString')->willReturn('[]'); + $this->assertSame([], $this->service->getAllowedEmojis()); + } + + /** + * REQ-RXN-008 — non-VIEW user rejected with PermissionDeniedException. + */ + public function testAddReactionPermissionDenied(): void { + $dash = $this->makeDashboard(perDashFlag: 1); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(false); + + $this->expectException(PermissionDeniedException::class); + $this->service->addReaction( + dashboardUuid: 'dash-123', + userId: 'bob', + emoji: '👍' + ); + } + + /** + * REQ-RXN-005 — global off + per-dashboard null returns + * ReactionsDisabledException on POST. + */ + public function testAddReactionDisabledThrows(): void { + $dash = $this->makeDashboard(perDashFlag: null); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(true); + $this->appConfig->method('getValueBool')->willReturn(false); + + $this->expectException(ReactionsDisabledException::class); + $this->service->addReaction( + dashboardUuid: 'dash-123', + userId: 'alice', + emoji: '👍' + ); + } + + /** + * REQ-RXN-001 scenario "User re-posts the same emoji" — duplicate + * insert (unique constraint) is swallowed; summary returned as if + * the row already existed. + */ + public function testAddReactionIdempotentOnUniqueConstraint(): void { + $dash = $this->makeDashboard(perDashFlag: 1); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(true); + $this->appConfig->method('getValueString')->willReturn('["👍"]'); + $this->appConfig->method('getValueBool')->willReturn(true); + + $duplicate = $this->createMock(originalClassName: DbException::class); + $duplicate->method('getReason')->willReturn(DbException::REASON_UNIQUE_CONSTRAINT_VIOLATION); + $this->reactionMapper->method('addReaction')->willThrowException($duplicate); + + $this->reactionMapper->method('countByEmoji')->willReturn(['👍' => 1]); + $existing = new DashboardReaction(); + $existing->setEmoji('👍'); + $this->reactionMapper->method('findByUser')->willReturn([$existing]); + + $summary = $this->service->addReaction( + dashboardUuid: 'dash-123', + userId: 'alice', + emoji: '👍' + ); + + $this->assertTrue($summary['enabled']); + $this->assertSame(['👍'], $summary['mine']); + $this->assertSame(['👍' => 1], (array)$summary['counts']); + } + + /** + * REQ-RXN-003 scenario "Reactions disabled on dashboard" — GET + * returns the empty-shape summary regardless of stored rows. + */ + public function testGetReactionsSummaryDisabledShape(): void { + $dash = $this->makeDashboard(perDashFlag: 0); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(true); + $this->appConfig->method('getValueBool')->willReturn(true); + + $this->reactionMapper->expects($this->never())->method('countByEmoji'); + + $summary = $this->service->getReactionsSummary( + dashboardUuid: 'dash-123', + userId: 'alice' + ); + + $this->assertFalse($summary['enabled']); + $this->assertSame([], (array)$summary['counts']); + $this->assertSame([], $summary['mine']); + } + + /** + * REQ-RXN-003 scenario "User retrieves reactions on a dashboard + * they can view" — counts + mine populated from mapper. + */ + public function testGetReactionsSummaryEnabledShape(): void { + $dash = $this->makeDashboard(perDashFlag: 1); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(true); + $this->appConfig->method('getValueBool')->willReturn(true); + + $this->reactionMapper->method('countByEmoji')->willReturn([ + '👍' => 3, + '❤️' => 1, + '🎉' => 2, + ]); + + $a = new DashboardReaction(); + $a->setEmoji('👍'); + $b = new DashboardReaction(); + $b->setEmoji('🎉'); + $this->reactionMapper->method('findByUser')->willReturn([$a, $b]); + + $summary = $this->service->getReactionsSummary( + dashboardUuid: 'dash-123', + userId: 'alice' + ); + + $this->assertTrue($summary['enabled']); + $this->assertSame( + ['👍' => 3, '❤️' => 1, '🎉' => 2], + (array)$summary['counts'] + ); + $this->assertSame(['👍', '🎉'], $summary['mine']); + } + + /** + * REQ-RXN-002 — DELETE delegates to mapper; idempotent return + * value bubbles up. + */ + public function testRemoveReactionDelegatesToMapper(): void { + $dash = $this->makeDashboard(perDashFlag: 1); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(true); + + $this->reactionMapper->expects($this->once()) + ->method('removeReaction') + ->with( + $this->equalTo('dash-123'), + $this->equalTo('alice'), + $this->equalTo('👍'), + ) + ->willReturn(true); + + $result = $this->service->removeReaction( + dashboardUuid: 'dash-123', + userId: 'alice', + emoji: '👍' + ); + + $this->assertTrue($result); + } + + /** + * REQ-RXN-009 — cascade delete delegates to mapper. + */ + public function testDeleteReactionsByDashboardDelegates(): void { + $this->reactionMapper->expects($this->once()) + ->method('deleteByDashboardUuid') + ->with($this->equalTo('dash-123')) + ->willReturn(7); + + $this->assertSame( + 7, + $this->service->deleteReactionsByDashboard(dashboardUuid: 'dash-123') + ); + } + + /** + * REQ-RXN-004 — pagination cap + cursor advance. + */ + public function testGetReactorsByEmojiPagination(): void { + $dash = $this->makeDashboard(perDashFlag: 1); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(true); + + $rows = []; + for ($i = 0; $i < ReactionService::REACTORS_PAGE_SIZE; $i++) { + $r = new DashboardReaction(); + $r->setUserId(sprintf('user%d', $i)); + $r->setEmoji('🎉'); + $rows[] = $r; + } + + $this->reactionMapper->method('findByEmoji')->willReturn($rows); + $this->reactionMapper->method('countReactorsByEmoji')->willReturn(150); + + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getDisplayName')->willReturn('User'); + $this->userManager->method('get')->willReturn($user); + + $page = $this->service->getReactorsByEmoji( + dashboardUuid: 'dash-123', + emoji: '🎉', + userId: 'alice', + cursor: null + ); + + $this->assertCount(ReactionService::REACTORS_PAGE_SIZE, $page['items']); + $this->assertSame('100', $page['nextCursor']); + $this->assertSame(150, $page['total']); + } + + /** + * Last page exposes nextCursor === null. + */ + public function testGetReactorsByEmojiLastPageNoCursor(): void { + $dash = $this->makeDashboard(perDashFlag: 1); + $this->dashboardMapper->method('findByUuid')->willReturn($dash); + $this->permissionService->method('canViewDashboard')->willReturn(true); + + $r = new DashboardReaction(); + $r->setUserId('alice'); + $r->setEmoji('🎉'); + $this->reactionMapper->method('findByEmoji')->willReturn([$r]); + $this->reactionMapper->method('countReactorsByEmoji')->willReturn(1); + + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getDisplayName')->willReturn('Alice'); + $this->userManager->method('get')->willReturn($user); + + $page = $this->service->getReactorsByEmoji( + dashboardUuid: 'dash-123', + emoji: '🎉', + userId: 'alice', + cursor: null + ); + + $this->assertNull($page['nextCursor']); + $this->assertSame(1, $page['total']); + } } diff --git a/tests/Unit/Service/ResourceServeServiceTest.php b/tests/Unit/Service/ResourceServeServiceTest.php index 99e0d26b..791859a2 100644 --- a/tests/Unit/Service/ResourceServeServiceTest.php +++ b/tests/Unit/Service/ResourceServeServiceTest.php @@ -30,143 +30,129 @@ use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; -class ResourceServeServiceTest extends TestCase -{ - private ResourceServeService $service; - - /** @var IAppData&MockObject */ - private $appData; - - /** @var LoggerInterface&MockObject */ - private $logger; - - /** @var ISimpleFolder&MockObject */ - private $folder; - - protected function setUp(): void - { - $this->appData = $this->createMock(IAppData::class); - $this->logger = $this->createMock(LoggerInterface::class); - $this->folder = $this->createMock(ISimpleFolder::class); - - $this->service = new ResourceServeService( - appData: $this->appData, - logger: $this->logger, - ); - } - - public function testFindFileReturnsFileWhenPresent(): void - { - $file = $this->createMock(ISimpleFile::class); - $this->appData->method('getFolder') - ->with(ResourceService::FOLDER)->willReturn($this->folder); - $this->folder->method('getFile')->with('resource_abc.png')->willReturn($file); - - $this->assertSame($file, $this->service->findFile(filename: 'resource_abc.png')); - } - - public function testFindFileReturnsNullWhenFolderMissing(): void - { - $this->appData->method('getFolder') - ->willThrowException(new NotFoundException()); - - $this->assertNull($this->service->findFile(filename: 'whatever.png')); - } - - public function testFindFileReturnsNullWhenFileMissing(): void - { - $this->appData->method('getFolder')->willReturn($this->folder); - $this->folder->method('getFile') - ->willThrowException(new NotFoundException()); - - $this->assertNull($this->service->findFile(filename: 'gone.png')); - } - - public function testFindFileReturnsNullOnUnexpectedException(): void - { - $this->appData->method('getFolder') - ->willThrowException(new \RuntimeException('boom')); - $this->logger->expects($this->once())->method('warning'); - - $this->assertNull($this->service->findFile(filename: 'whatever.png')); - } - - public function testListFilesReturnsAllSimpleFiles(): void - { - $a = $this->createMock(ISimpleFile::class); - $b = $this->createMock(ISimpleFile::class); - $this->appData->method('getFolder')->willReturn($this->folder); - $this->folder->method('getDirectoryListing')->willReturn([$a, $b]); - - $this->assertSame([$a, $b], $this->service->listFiles()); - } - - public function testListFilesReturnsEmptyArrayWhenFolderMissing(): void - { - $this->appData->method('getFolder') - ->willThrowException(new NotFoundException()); - - $this->assertSame([], $this->service->listFiles()); - } - - public function testListFilesReturnsEmptyArrayOnUnexpectedException(): void - { - $this->appData->method('getFolder') - ->willThrowException(new \RuntimeException('disk failure')); - $this->logger->expects($this->once())->method('warning'); - - $this->assertSame([], $this->service->listFiles()); - } - - public function testListFilesSkipsNonFileEntries(): void - { - $a = $this->createMock(ISimpleFile::class); - $bogus = new \stdClass(); - $this->appData->method('getFolder')->willReturn($this->folder); - $this->folder->method('getDirectoryListing')->willReturn([$a, $bogus]); - - $this->assertSame([$a], $this->service->listFiles()); - } - - /** - * @dataProvider contentTypeProvider - */ - public function testContentTypeForFilename(string $filename, string $expected): void - { - $this->assertSame($expected, $this->service->contentTypeForFilename(filename: $filename)); - } - - /** - * @return array> - */ - public static function contentTypeProvider(): array - { - return [ - 'png lowercase' => ['resource_a.png', 'image/png'], - 'jpg lowercase' => ['resource_a.jpg', 'image/jpeg'], - 'jpeg lowercase' => ['resource_a.jpeg', 'image/jpeg'], - 'gif lowercase' => ['resource_a.gif', 'image/gif'], - 'svg lowercase' => ['resource_a.svg', 'image/svg+xml'], - 'webp lowercase' => ['resource_a.webp', 'image/webp'], - 'png uppercase' => ['resource_a.PNG', 'image/png'], - 'svg uppercase' => ['resource_a.SVG', 'image/svg+xml'], - 'unknown ext' => ['resource_a.bin', 'application/octet-stream'], - 'no extension' => ['noext', 'application/octet-stream'], - 'empty extension' => ['weird.', 'application/octet-stream'], - 'dotfile' => ['.hidden', 'application/octet-stream'], - ]; - } - - public function testFormatTimestampReturnsIso8601Utc(): void - { - // 2023-11-14T22:13:20+00:00 - $iso = $this->service->formatTimestamp(epoch: 1700000000); - $this->assertSame('2023-11-14T22:13:20+00:00', $iso); - } - - public function testFormatTimestampUsesUtcRegardlessOfPhpDefault(): void - { - $iso = $this->service->formatTimestamp(epoch: 0); - $this->assertSame('1970-01-01T00:00:00+00:00', $iso); - } +class ResourceServeServiceTest extends TestCase { + private ResourceServeService $service; + + /** @var IAppData&MockObject */ + private $appData; + + /** @var LoggerInterface&MockObject */ + private $logger; + + /** @var ISimpleFolder&MockObject */ + private $folder; + + protected function setUp(): void { + $this->appData = $this->createMock(IAppData::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->folder = $this->createMock(ISimpleFolder::class); + + $this->service = new ResourceServeService( + appData: $this->appData, + logger: $this->logger, + ); + } + + public function testFindFileReturnsFileWhenPresent(): void { + $file = $this->createMock(ISimpleFile::class); + $this->appData->method('getFolder') + ->with(ResourceService::FOLDER)->willReturn($this->folder); + $this->folder->method('getFile')->with('resource_abc.png')->willReturn($file); + + $this->assertSame($file, $this->service->findFile(filename: 'resource_abc.png')); + } + + public function testFindFileReturnsNullWhenFolderMissing(): void { + $this->appData->method('getFolder') + ->willThrowException(new NotFoundException()); + + $this->assertNull($this->service->findFile(filename: 'whatever.png')); + } + + public function testFindFileReturnsNullWhenFileMissing(): void { + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->method('getFile') + ->willThrowException(new NotFoundException()); + + $this->assertNull($this->service->findFile(filename: 'gone.png')); + } + + public function testFindFileReturnsNullOnUnexpectedException(): void { + $this->appData->method('getFolder') + ->willThrowException(new \RuntimeException('boom')); + $this->logger->expects($this->once())->method('warning'); + + $this->assertNull($this->service->findFile(filename: 'whatever.png')); + } + + public function testListFilesReturnsAllSimpleFiles(): void { + $a = $this->createMock(ISimpleFile::class); + $b = $this->createMock(ISimpleFile::class); + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->method('getDirectoryListing')->willReturn([$a, $b]); + + $this->assertSame([$a, $b], $this->service->listFiles()); + } + + public function testListFilesReturnsEmptyArrayWhenFolderMissing(): void { + $this->appData->method('getFolder') + ->willThrowException(new NotFoundException()); + + $this->assertSame([], $this->service->listFiles()); + } + + public function testListFilesReturnsEmptyArrayOnUnexpectedException(): void { + $this->appData->method('getFolder') + ->willThrowException(new \RuntimeException('disk failure')); + $this->logger->expects($this->once())->method('warning'); + + $this->assertSame([], $this->service->listFiles()); + } + + public function testListFilesSkipsNonFileEntries(): void { + $a = $this->createMock(ISimpleFile::class); + $bogus = new \stdClass(); + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->method('getDirectoryListing')->willReturn([$a, $bogus]); + + $this->assertSame([$a], $this->service->listFiles()); + } + + /** + * @dataProvider contentTypeProvider + */ + public function testContentTypeForFilename(string $filename, string $expected): void { + $this->assertSame($expected, $this->service->contentTypeForFilename(filename: $filename)); + } + + /** + * @return array> + */ + public static function contentTypeProvider(): array { + return [ + 'png lowercase' => ['resource_a.png', 'image/png'], + 'jpg lowercase' => ['resource_a.jpg', 'image/jpeg'], + 'jpeg lowercase' => ['resource_a.jpeg', 'image/jpeg'], + 'gif lowercase' => ['resource_a.gif', 'image/gif'], + 'svg lowercase' => ['resource_a.svg', 'image/svg+xml'], + 'webp lowercase' => ['resource_a.webp', 'image/webp'], + 'png uppercase' => ['resource_a.PNG', 'image/png'], + 'svg uppercase' => ['resource_a.SVG', 'image/svg+xml'], + 'unknown ext' => ['resource_a.bin', 'application/octet-stream'], + 'no extension' => ['noext', 'application/octet-stream'], + 'empty extension' => ['weird.', 'application/octet-stream'], + 'dotfile' => ['.hidden', 'application/octet-stream'], + ]; + } + + public function testFormatTimestampReturnsIso8601Utc(): void { + // 2023-11-14T22:13:20+00:00 + $iso = $this->service->formatTimestamp(epoch: 1700000000); + $this->assertSame('2023-11-14T22:13:20+00:00', $iso); + } + + public function testFormatTimestampUsesUtcRegardlessOfPhpDefault(): void { + $iso = $this->service->formatTimestamp(epoch: 0); + $this->assertSame('1970-01-01T00:00:00+00:00', $iso); + } } diff --git a/tests/Unit/Service/ResourceServiceSvgIntegrationTest.php b/tests/Unit/Service/ResourceServiceSvgIntegrationTest.php index ab67f732..1a74dec6 100644 --- a/tests/Unit/Service/ResourceServiceSvgIntegrationTest.php +++ b/tests/Unit/Service/ResourceServiceSvgIntegrationTest.php @@ -38,148 +38,139 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -class ResourceServiceSvgIntegrationTest extends TestCase -{ - private ResourceService $service; - - /** @var IAppData&MockObject */ - private $appData; - - /** @var ISimpleFolder&MockObject */ - private $folder; - - /** Captures whatever bytes the service writes to disk. */ - private string $persistedBytes = ''; - - protected function setUp(): void - { - $this->appData = $this->createMock(IAppData::class); - $this->folder = $this->createMock(ISimpleFolder::class); - $this->appData->method('getFolder')->willReturn($this->folder); - - $this->folder->method('newFile')->willReturnCallback( - function (string $name, $content): ISimpleFile { - $this->persistedBytes = (string) $content; - return $this->createMock(ISimpleFile::class); - } - ); - - $this->service = new ResourceService( - appData: $this->appData, - mimeValidator: new ImageMimeValidator(), - svgSanitiser: new SvgSanitiser(), - ); - } - - private function dataUrl(string $bytes): string - { - return 'data:image/svg+xml;base64,' . base64_encode($bytes); - } - - public function testMaliciousSvgUploadStripsScriptAndPersists(): void - { - $svg = '' - . '' - . '' - . ''; - - $result = $this->service->upload( - base64DataUrl: $this->dataUrl($svg) - ); - - $this->assertStringStartsWith('/apps/launchpad/resource/resource_', $result['url']); - $this->assertStringNotContainsString('persistedBytes); - $this->assertStringNotContainsString('alert', $this->persistedBytes); - $this->assertStringContainsString('persistedBytes); - } - - public function testGarbagePayloadThrowsInvalidSvg(): void - { - $this->folder->expects($this->never())->method('newFile'); - - $this->expectException(InvalidSvgException::class); - $this->service->upload( - base64DataUrl: $this->dataUrl('service->upload( - base64DataUrl: $this->dataUrl('fail('Expected InvalidSvgException'); - } catch (InvalidSvgException $exception) { - $this->assertSame('invalid_svg', $exception->getErrorCode()); - $this->assertSame(400, $exception->getHttpStatus()); - } - } - - public function testOversizeSvgBelowCapAfterSanitisationIsAccepted(): void - { - // Build an SVG whose original bytes exceed 5 MB but whose - // sanitised form (script stripped) drops below the cap. Pad - // a stripped-out ' - . '' - . ''; - - $this->assertGreaterThan( - (5 * 1024 * 1024), - strlen($svg), - 'Original payload must exceed the 5 MB cap' - ); - - $result = $this->service->upload( - base64DataUrl: $this->dataUrl($svg) - ); - - // Sanitised result MUST be under the cap. - $this->assertLessThan( - ResourceService::MAX_BYTES, - $result['size'], - 'Sanitised payload size must be under 5 MB' - ); - $this->assertStringNotContainsString($payload, $this->persistedBytes); - $this->assertStringContainsString('persistedBytes); - } - - public function testNearCapCleanSvgIsAccepted(): void - { - // A clean 4.9 MB-ish SVG whose sanitised output is roughly the - // same size — must succeed, well under the 5 MB cap. - $padding = str_repeat(' ', (int) (4.8 * 1024 * 1024)); - $svg = '' - . '' . $padding . '' - . '' - . ''; - - $result = $this->service->upload( - base64DataUrl: $this->dataUrl($svg) - ); - - $this->assertLessThan(ResourceService::MAX_BYTES, $result['size']); - $this->assertStringContainsString('persistedBytes); - } - - public function testPersistedBytesAreSanitisedNotOriginal(): void - { - $svg = '' - . '' - . '' - . ''; - - $this->service->upload( - base64DataUrl: $this->dataUrl($svg) - ); - - // Persisted bytes MUST NOT be byte-equal to the original. - $this->assertNotSame($svg, $this->persistedBytes); - $this->assertStringNotContainsString('EVIL_MARKER_42', $this->persistedBytes); - $this->assertStringContainsString('persistedBytes); - } +class ResourceServiceSvgIntegrationTest extends TestCase { + private ResourceService $service; + + /** @var IAppData&MockObject */ + private $appData; + + /** @var ISimpleFolder&MockObject */ + private $folder; + + /** Captures whatever bytes the service writes to disk. */ + private string $persistedBytes = ''; + + protected function setUp(): void { + $this->appData = $this->createMock(IAppData::class); + $this->folder = $this->createMock(ISimpleFolder::class); + $this->appData->method('getFolder')->willReturn($this->folder); + + $this->folder->method('newFile')->willReturnCallback( + function (string $name, $content): ISimpleFile { + $this->persistedBytes = (string)$content; + return $this->createMock(ISimpleFile::class); + } + ); + + $this->service = new ResourceService( + appData: $this->appData, + mimeValidator: new ImageMimeValidator(), + svgSanitiser: new SvgSanitiser(), + ); + } + + private function dataUrl(string $bytes): string { + return 'data:image/svg+xml;base64,' . base64_encode($bytes); + } + + public function testMaliciousSvgUploadStripsScriptAndPersists(): void { + $svg = '' + . '' + . '' + . ''; + + $result = $this->service->upload( + base64DataUrl: $this->dataUrl($svg) + ); + + $this->assertStringStartsWith('/apps/launchpad/resource/resource_', $result['url']); + $this->assertStringNotContainsString('persistedBytes); + $this->assertStringNotContainsString('alert', $this->persistedBytes); + $this->assertStringContainsString('persistedBytes); + } + + public function testGarbagePayloadThrowsInvalidSvg(): void { + $this->folder->expects($this->never())->method('newFile'); + + $this->expectException(InvalidSvgException::class); + $this->service->upload( + base64DataUrl: $this->dataUrl('service->upload( + base64DataUrl: $this->dataUrl('fail('Expected InvalidSvgException'); + } catch (InvalidSvgException $exception) { + $this->assertSame('invalid_svg', $exception->getErrorCode()); + $this->assertSame(400, $exception->getHttpStatus()); + } + } + + public function testOversizeSvgBelowCapAfterSanitisationIsAccepted(): void { + // Build an SVG whose original bytes exceed 5 MB but whose + // sanitised form (script stripped) drops below the cap. Pad + // a stripped-out ' + . '' + . ''; + + $this->assertGreaterThan( + (5 * 1024 * 1024), + strlen($svg), + 'Original payload must exceed the 5 MB cap' + ); + + $result = $this->service->upload( + base64DataUrl: $this->dataUrl($svg) + ); + + // Sanitised result MUST be under the cap. + $this->assertLessThan( + ResourceService::MAX_BYTES, + $result['size'], + 'Sanitised payload size must be under 5 MB' + ); + $this->assertStringNotContainsString($payload, $this->persistedBytes); + $this->assertStringContainsString('persistedBytes); + } + + public function testNearCapCleanSvgIsAccepted(): void { + // A clean 4.9 MB-ish SVG whose sanitised output is roughly the + // same size — must succeed, well under the 5 MB cap. + $padding = str_repeat(' ', (int)(4.8 * 1024 * 1024)); + $svg = '' + . '' . $padding . '' + . '' + . ''; + + $result = $this->service->upload( + base64DataUrl: $this->dataUrl($svg) + ); + + $this->assertLessThan(ResourceService::MAX_BYTES, $result['size']); + $this->assertStringContainsString('persistedBytes); + } + + public function testPersistedBytesAreSanitisedNotOriginal(): void { + $svg = '' + . '' + . '' + . ''; + + $this->service->upload( + base64DataUrl: $this->dataUrl($svg) + ); + + // Persisted bytes MUST NOT be byte-equal to the original. + $this->assertNotSame($svg, $this->persistedBytes); + $this->assertStringNotContainsString('EVIL_MARKER_42', $this->persistedBytes); + $this->assertStringContainsString('persistedBytes); + } } diff --git a/tests/Unit/Service/ResourceServiceTest.php b/tests/Unit/Service/ResourceServiceTest.php index 7ea0dbb0..2b24e431 100644 --- a/tests/Unit/Service/ResourceServiceTest.php +++ b/tests/Unit/Service/ResourceServiceTest.php @@ -33,311 +33,289 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -class ResourceServiceTest extends TestCase -{ - private ResourceService $service; - - /** - * @var IAppData&MockObject - */ - private $appData; - - /** - * @var ImageMimeValidator&MockObject - */ - private $mimeValidator; - - /** - * @var ISimpleFolder&MockObject - */ - private $folder; - - protected function setUp(): void - { - $this->appData = $this->createMock(IAppData::class); - $this->mimeValidator = $this->createMock(ImageMimeValidator::class); - $this->folder = $this->createMock(ISimpleFolder::class); - - $this->service = new ResourceService( - appData: $this->appData, - mimeValidator: $this->mimeValidator, - svgSanitiser: new SvgSanitiser(), - ); - } - - /** - * Tiny 1x1 PNG bytes (red pixel). - */ - private function tinyPng(): string - { - return base64_decode( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', - true - ); - } - - /** - * Build a base64 data URL from raw bytes and a declared type. - */ - private function dataUrl(string $type, string $bytes): string - { - return 'data:image/' . $type . ';base64,' . base64_encode($bytes); - } - - public function testMissingDataUrlPrefixIsRejected(): void - { - $this->expectException(InvalidDataUrlException::class); - $this->service->upload(base64DataUrl: 'iVBORw0KGgo'); - } - - public function testEmptyInputIsRejected(): void - { - $this->expectException(InvalidDataUrlException::class); - $this->service->upload(base64DataUrl: ''); - } - - public function testDisallowedTypeIsRejected(): void - { - $this->expectException(InvalidImageFormatException::class); - $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'bmp', bytes: 'whatever') - ); - } - - public function testMixedCaseDeclaredTypeIsAcceptedAndLowercased(): void - { - $this->mimeValidator->expects($this->once())->method('validate') - ->with('png', $this->tinyPng()); - - $this->folder->expects($this->once())->method('newFile') - ->willReturnCallback(function (string $name, $content): ISimpleFile { - $this->assertStringEndsWith('.png', $name); - $this->assertStringStartsWith('resource_', $name); - - return $this->createMock(ISimpleFile::class); - }); - - $this->appData->expects($this->once())->method('getFolder') - ->with(ResourceService::FOLDER)->willReturn($this->folder); - - $result = $this->service->upload( - base64DataUrl: 'data:image/PNG;base64,' . base64_encode($this->tinyPng()) - ); - - $this->assertSame('success', 'success'); // sanity - $this->assertStringStartsWith('/apps/launchpad/resource/resource_', $result['url']); - $this->assertStringEndsWith('.png', $result['url']); - $this->assertSame(strlen($this->tinyPng()), $result['size']); - } - - public function testSvgPlusXmlNormalisesToSvg(): void - { - $svg = ''; - $this->mimeValidator->expects($this->once())->method('validate') - ->with('svg', $svg); - - $this->folder->method('newFile') - ->willReturnCallback(function (string $name): ISimpleFile { - $this->assertStringEndsWith('.svg', $name); - return $this->createMock(ISimpleFile::class); - }); - - $this->appData->method('getFolder')->willReturn($this->folder); - - $result = $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'svg+xml', bytes: $svg) - ); - - $this->assertStringEndsWith('.svg', $result['name']); - } - - public function testOversizePayloadIsRejectedBeforeValidator(): void - { - // 6 MB blob. - $oversize = str_repeat('A', (6 * 1024 * 1024)); - $this->mimeValidator->expects($this->never())->method('validate'); - $this->appData->expects($this->never())->method('getFolder'); - - $this->expectException(FileTooLargeException::class); - $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'png', bytes: $oversize) - ); - } - - public function testSizeAtCapIsAccepted(): void - { - // Exactly 5 MB → allowed (cap is "exceeds", not "equals"). - $atCap = str_repeat('A', (5 * 1024 * 1024)); - $this->mimeValidator->expects($this->once())->method('validate'); - $this->folder->method('newFile') - ->willReturn($this->createMock(ISimpleFile::class)); - $this->appData->method('getFolder')->willReturn($this->folder); - - $result = $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'png', bytes: $atCap) - ); - - $this->assertSame((5 * 1024 * 1024), $result['size']); - } - - public function testMimeMismatchBubblesUp(): void - { - $this->mimeValidator->method('validate') - ->willThrowException(new MimeMismatchException()); - - $this->expectException(MimeMismatchException::class); - $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'png', bytes: 'whatever') - ); - } - - public function testFolderAutoCreatedWhenMissing(): void - { - $this->mimeValidator->method('validate'); - $this->appData->expects($this->once())->method('getFolder') - ->with(ResourceService::FOLDER) - ->willThrowException(new NotFoundException()); - $this->appData->expects($this->once())->method('newFolder') - ->with(ResourceService::FOLDER)->willReturn($this->folder); - $this->folder->expects($this->once())->method('newFile') - ->willReturn($this->createMock(ISimpleFile::class)); - - $result = $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'png', bytes: $this->tinyPng()) - ); - - $this->assertStringStartsWith('resource_', $result['name']); - } - - public function testStorageFailureIsWrapped(): void - { - $this->mimeValidator->method('validate'); - $this->appData->method('getFolder')->willReturn($this->folder); - $this->folder->method('newFile') - ->willThrowException(new NotPermittedException('disk full')); - - $this->expectException(StorageFailureException::class); - $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'png', bytes: $this->tinyPng()) - ); - } - - public function testFilenameMatchesSpecPattern(): void - { - $this->mimeValidator->method('validate'); - $this->appData->method('getFolder')->willReturn($this->folder); - $this->folder->method('newFile') - ->willReturn($this->createMock(ISimpleFile::class)); - - $result = $this->service->upload( - base64DataUrl: $this->dataUrl(type: 'png', bytes: $this->tinyPng()) - ); - - // resource_.png — uniqid with - // more_entropy=true returns hex + dot + decimal. - $this->assertMatchesRegularExpression( - '#^resource_[a-f0-9.]+\.(jpeg|jpg|png|gif|svg|webp)$#', - $result['name'] - ); - } - - // --------------------------------------------------------------- - // uploadRaw() — raw multipart path (REQ-RES-014). - // --------------------------------------------------------------- - - public function testUploadRawStoresBytesAndReturnsEnvelope(): void - { - $this->mimeValidator->expects($this->once())->method('validate') - ->with('png', $this->tinyPng()); - $this->appData->method('getFolder')->willReturn($this->folder); - $this->folder->expects($this->once())->method('newFile') - ->willReturnCallback(function (string $name): ISimpleFile { - $this->assertStringStartsWith('resource_', $name); - $this->assertStringEndsWith('.png', $name); - return $this->createMock(ISimpleFile::class); - }); - - $result = $this->service->uploadRaw(bytes: $this->tinyPng(), declaredType: 'png'); - - $this->assertStringStartsWith('/apps/launchpad/resource/resource_', $result['url']); - $this->assertStringEndsWith('.png', $result['url']); - $this->assertSame(strlen($this->tinyPng()), $result['size']); - } - - public function testUploadRawNormalisesUppercaseAndSvgXmlType(): void - { - $svg = ''; - $this->mimeValidator->method('validate'); - $this->appData->method('getFolder')->willReturn($this->folder); - $this->folder->method('newFile') - ->willReturnCallback(function (string $name): ISimpleFile { - $this->assertStringEndsWith('.svg', $name); - return $this->createMock(ISimpleFile::class); - }); - - // 'SVG+XML' (uppercase, +xml suffix) must normalise to 'svg'. - $result = $this->service->uploadRaw(bytes: $svg, declaredType: 'SVG+XML'); - - $this->assertStringEndsWith('.svg', $result['name']); - } - - public function testUploadRawEmptyBytesRejected(): void - { - $this->expectException(InvalidDataUrlException::class); - $this->appData->expects($this->never())->method('getFolder'); - $this->service->uploadRaw(bytes: '', declaredType: 'png'); - } - - public function testUploadRawDisallowedTypeRejected(): void - { - $this->expectException(InvalidImageFormatException::class); - $this->appData->expects($this->never())->method('getFolder'); - $this->service->uploadRaw(bytes: 'whatever', declaredType: 'bmp'); - } - - public function testUploadRawOversizeRejectedBeforeValidator(): void - { - $oversize = str_repeat('A', (6 * 1024 * 1024)); - $this->mimeValidator->expects($this->never())->method('validate'); - $this->appData->expects($this->never())->method('getFolder'); - - $this->expectException(FileTooLargeException::class); - $this->service->uploadRaw(bytes: $oversize, declaredType: 'png'); - } - - public function testUploadRawSanitisesSvgScriptBeforePersisting(): void - { - // Uses the real SvgSanitiser (wired in setUp): a '; - $this->mimeValidator->method('validate'); - $this->appData->method('getFolder')->willReturn($this->folder); - - $persisted = null; - $this->folder->method('newFile') - ->willReturnCallback(function (string $name, $content) use (&$persisted): ISimpleFile { - $persisted = $content; - return $this->createMock(ISimpleFile::class); - }); - - $this->service->uploadRaw(bytes: $svg, declaredType: 'svg'); - - $this->assertIsString($persisted); - $this->assertStringNotContainsStringIgnoringCase('assertStringContainsString('mimeValidator->method('validate') - ->willThrowException(new MimeMismatchException()); - $this->appData->expects($this->never())->method('getFolder'); - - $this->expectException(MimeMismatchException::class); - $this->service->uploadRaw(bytes: $this->tinyPng(), declaredType: 'webp'); - } +class ResourceServiceTest extends TestCase { + private ResourceService $service; + + /** + * @var IAppData&MockObject + */ + private $appData; + + /** + * @var ImageMimeValidator&MockObject + */ + private $mimeValidator; + + /** + * @var ISimpleFolder&MockObject + */ + private $folder; + + protected function setUp(): void { + $this->appData = $this->createMock(IAppData::class); + $this->mimeValidator = $this->createMock(ImageMimeValidator::class); + $this->folder = $this->createMock(ISimpleFolder::class); + + $this->service = new ResourceService( + appData: $this->appData, + mimeValidator: $this->mimeValidator, + svgSanitiser: new SvgSanitiser(), + ); + } + + /** + * Tiny 1x1 PNG bytes (red pixel). + */ + private function tinyPng(): string { + return base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + true + ); + } + + /** + * Build a base64 data URL from raw bytes and a declared type. + */ + private function dataUrl(string $type, string $bytes): string { + return 'data:image/' . $type . ';base64,' . base64_encode($bytes); + } + + public function testMissingDataUrlPrefixIsRejected(): void { + $this->expectException(InvalidDataUrlException::class); + $this->service->upload(base64DataUrl: 'iVBORw0KGgo'); + } + + public function testEmptyInputIsRejected(): void { + $this->expectException(InvalidDataUrlException::class); + $this->service->upload(base64DataUrl: ''); + } + + public function testDisallowedTypeIsRejected(): void { + $this->expectException(InvalidImageFormatException::class); + $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'bmp', bytes: 'whatever') + ); + } + + public function testMixedCaseDeclaredTypeIsAcceptedAndLowercased(): void { + $this->mimeValidator->expects($this->once())->method('validate') + ->with('png', $this->tinyPng()); + + $this->folder->expects($this->once())->method('newFile') + ->willReturnCallback(function (string $name, $content): ISimpleFile { + $this->assertStringEndsWith('.png', $name); + $this->assertStringStartsWith('resource_', $name); + + return $this->createMock(ISimpleFile::class); + }); + + $this->appData->expects($this->once())->method('getFolder') + ->with(ResourceService::FOLDER)->willReturn($this->folder); + + $result = $this->service->upload( + base64DataUrl: 'data:image/PNG;base64,' . base64_encode($this->tinyPng()) + ); + + $this->assertSame('success', 'success'); // sanity + $this->assertStringStartsWith('/apps/launchpad/resource/resource_', $result['url']); + $this->assertStringEndsWith('.png', $result['url']); + $this->assertSame(strlen($this->tinyPng()), $result['size']); + } + + public function testSvgPlusXmlNormalisesToSvg(): void { + $svg = ''; + $this->mimeValidator->expects($this->once())->method('validate') + ->with('svg', $svg); + + $this->folder->method('newFile') + ->willReturnCallback(function (string $name): ISimpleFile { + $this->assertStringEndsWith('.svg', $name); + return $this->createMock(ISimpleFile::class); + }); + + $this->appData->method('getFolder')->willReturn($this->folder); + + $result = $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'svg+xml', bytes: $svg) + ); + + $this->assertStringEndsWith('.svg', $result['name']); + } + + public function testOversizePayloadIsRejectedBeforeValidator(): void { + // 6 MB blob. + $oversize = str_repeat('A', (6 * 1024 * 1024)); + $this->mimeValidator->expects($this->never())->method('validate'); + $this->appData->expects($this->never())->method('getFolder'); + + $this->expectException(FileTooLargeException::class); + $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'png', bytes: $oversize) + ); + } + + public function testSizeAtCapIsAccepted(): void { + // Exactly 5 MB → allowed (cap is "exceeds", not "equals"). + $atCap = str_repeat('A', (5 * 1024 * 1024)); + $this->mimeValidator->expects($this->once())->method('validate'); + $this->folder->method('newFile') + ->willReturn($this->createMock(ISimpleFile::class)); + $this->appData->method('getFolder')->willReturn($this->folder); + + $result = $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'png', bytes: $atCap) + ); + + $this->assertSame((5 * 1024 * 1024), $result['size']); + } + + public function testMimeMismatchBubblesUp(): void { + $this->mimeValidator->method('validate') + ->willThrowException(new MimeMismatchException()); + + $this->expectException(MimeMismatchException::class); + $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'png', bytes: 'whatever') + ); + } + + public function testFolderAutoCreatedWhenMissing(): void { + $this->mimeValidator->method('validate'); + $this->appData->expects($this->once())->method('getFolder') + ->with(ResourceService::FOLDER) + ->willThrowException(new NotFoundException()); + $this->appData->expects($this->once())->method('newFolder') + ->with(ResourceService::FOLDER)->willReturn($this->folder); + $this->folder->expects($this->once())->method('newFile') + ->willReturn($this->createMock(ISimpleFile::class)); + + $result = $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'png', bytes: $this->tinyPng()) + ); + + $this->assertStringStartsWith('resource_', $result['name']); + } + + public function testStorageFailureIsWrapped(): void { + $this->mimeValidator->method('validate'); + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->method('newFile') + ->willThrowException(new NotPermittedException('disk full')); + + $this->expectException(StorageFailureException::class); + $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'png', bytes: $this->tinyPng()) + ); + } + + public function testFilenameMatchesSpecPattern(): void { + $this->mimeValidator->method('validate'); + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->method('newFile') + ->willReturn($this->createMock(ISimpleFile::class)); + + $result = $this->service->upload( + base64DataUrl: $this->dataUrl(type: 'png', bytes: $this->tinyPng()) + ); + + // resource_.png — uniqid with + // more_entropy=true returns hex + dot + decimal. + $this->assertMatchesRegularExpression( + '#^resource_[a-f0-9.]+\.(jpeg|jpg|png|gif|svg|webp)$#', + $result['name'] + ); + } + + // --------------------------------------------------------------- + // uploadRaw() — raw multipart path (REQ-RES-014). + // --------------------------------------------------------------- + + public function testUploadRawStoresBytesAndReturnsEnvelope(): void { + $this->mimeValidator->expects($this->once())->method('validate') + ->with('png', $this->tinyPng()); + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->expects($this->once())->method('newFile') + ->willReturnCallback(function (string $name): ISimpleFile { + $this->assertStringStartsWith('resource_', $name); + $this->assertStringEndsWith('.png', $name); + return $this->createMock(ISimpleFile::class); + }); + + $result = $this->service->uploadRaw(bytes: $this->tinyPng(), declaredType: 'png'); + + $this->assertStringStartsWith('/apps/launchpad/resource/resource_', $result['url']); + $this->assertStringEndsWith('.png', $result['url']); + $this->assertSame(strlen($this->tinyPng()), $result['size']); + } + + public function testUploadRawNormalisesUppercaseAndSvgXmlType(): void { + $svg = ''; + $this->mimeValidator->method('validate'); + $this->appData->method('getFolder')->willReturn($this->folder); + $this->folder->method('newFile') + ->willReturnCallback(function (string $name): ISimpleFile { + $this->assertStringEndsWith('.svg', $name); + return $this->createMock(ISimpleFile::class); + }); + + // 'SVG+XML' (uppercase, +xml suffix) must normalise to 'svg'. + $result = $this->service->uploadRaw(bytes: $svg, declaredType: 'SVG+XML'); + + $this->assertStringEndsWith('.svg', $result['name']); + } + + public function testUploadRawEmptyBytesRejected(): void { + $this->expectException(InvalidDataUrlException::class); + $this->appData->expects($this->never())->method('getFolder'); + $this->service->uploadRaw(bytes: '', declaredType: 'png'); + } + + public function testUploadRawDisallowedTypeRejected(): void { + $this->expectException(InvalidImageFormatException::class); + $this->appData->expects($this->never())->method('getFolder'); + $this->service->uploadRaw(bytes: 'whatever', declaredType: 'bmp'); + } + + public function testUploadRawOversizeRejectedBeforeValidator(): void { + $oversize = str_repeat('A', (6 * 1024 * 1024)); + $this->mimeValidator->expects($this->never())->method('validate'); + $this->appData->expects($this->never())->method('getFolder'); + + $this->expectException(FileTooLargeException::class); + $this->service->uploadRaw(bytes: $oversize, declaredType: 'png'); + } + + public function testUploadRawSanitisesSvgScriptBeforePersisting(): void { + // Uses the real SvgSanitiser (wired in setUp): a '; + $this->mimeValidator->method('validate'); + $this->appData->method('getFolder')->willReturn($this->folder); + + $persisted = null; + $this->folder->method('newFile') + ->willReturnCallback(function (string $name, $content) use (&$persisted): ISimpleFile { + $persisted = $content; + return $this->createMock(ISimpleFile::class); + }); + + $this->service->uploadRaw(bytes: $svg, declaredType: 'svg'); + + $this->assertIsString($persisted); + $this->assertStringNotContainsStringIgnoringCase('assertStringContainsString('mimeValidator->method('validate') + ->willThrowException(new MimeMismatchException()); + $this->appData->expects($this->never())->method('getFolder'); + + $this->expectException(MimeMismatchException::class); + $this->service->uploadRaw(bytes: $this->tinyPng(), declaredType: 'webp'); + } } diff --git a/tests/Unit/Service/RoleFeaturePermissionServiceTest.php b/tests/Unit/Service/RoleFeaturePermissionServiceTest.php index 6a54506d..abf20fae 100644 --- a/tests/Unit/Service/RoleFeaturePermissionServiceTest.php +++ b/tests/Unit/Service/RoleFeaturePermissionServiceTest.php @@ -37,263 +37,251 @@ use OCP\IUserManager; use PHPUnit\Framework\TestCase; -class RoleFeaturePermissionServiceTest extends TestCase -{ - private RoleFeaturePermissionService $service; - - private RoleFeaturePermissionMapper $permMapper; - - private RoleLayoutDefaultMapper $defaultMapper; - - private WidgetPlacementMapper $placementMapper; - - private AdminSettingsService $adminSettings; - - private AdminTemplateService $adminTemplateService; - - private IUserManager $userManager; - - private IGroupManager $groupManager; - - protected function setUp(): void - { - $this->permMapper = $this->createMock(originalClassName: RoleFeaturePermissionMapper::class); - $this->defaultMapper = $this->createMock(originalClassName: RoleLayoutDefaultMapper::class); - $this->placementMapper = $this->createMock(originalClassName: WidgetPlacementMapper::class); - $this->adminSettings = $this->createMock(originalClassName: AdminSettingsService::class); - $this->adminTemplateService = $this->createMock(originalClassName: AdminTemplateService::class); - $this->userManager = $this->createMock(originalClassName: IUserManager::class); - $this->groupManager = $this->createMock(originalClassName: IGroupManager::class); - - // Default: the user under test is NOT an admin so the existing - // role-resolution assertions exercise the group-matching path. - $this->groupManager->method('isAdmin')->willReturn(false); - - $this->service = new RoleFeaturePermissionService( - permissionMapper: $this->permMapper, - defaultMapper: $this->defaultMapper, - placementMapper: $this->placementMapper, - adminSettings: $this->adminSettings, - adminTemplateService: $this->adminTemplateService, - userManager: $this->userManager, - groupManager: $this->groupManager, - ); - }//end setUp() - - /** - * Build a RoleFeaturePermission entity from arrays. - */ - private function makePerm( - string $groupId, - array $allowed, - array $denied = [] - ): RoleFeaturePermission { - $entity = new RoleFeaturePermission(); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setName('perm-' . $groupId); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setGroupId($groupId); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setAllowedWidgets(json_encode(value: $allowed)); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $entity->setDeniedWidgets(json_encode(value: $denied)); - return $entity; - }//end makePerm() - - /** - * Mock IUserManager + AdminTemplateService so the user is in a list - * of groups. The service calls - * {@see AdminTemplateService::getUserGroupIdsFor()} to honour the - * REQ-TMPL-013 routing-resolver invariant — group lookups never - * touch IGroupManager directly. - */ - private function withUserGroups(string $userId, array $groupIds): void - { - $user = $this->createMock(originalClassName: IUser::class); - $this->userManager->method('get') - ->willReturn(value: $user); - $this->adminTemplateService->method('getUserGroupIdsFor') - ->willReturn(value: $groupIds); - }//end withUserGroups() - - public function testNoRestrictionConfiguredReturnsNull(): void - { - $this->withUserGroups(userId: 'alice', groupIds: ['employees']); - $this->adminSettings->method('getGroupOrder') - ->willReturn(value: ['employees']); - $this->permMapper->method('findByGroupIds') - ->willReturn(value: []); - $this->permMapper->method('findByGroupId') - ->will($this->throwException(exception: new DoesNotExistException(msg: 'no row'))); - - $result = $this->service->getAllowedWidgetIds(userId: 'alice'); - $this->assertNull(actual: $result); - }//end testNoRestrictionConfiguredReturnsNull() - - public function testSingleGroupReturnsAllowedSet(): void - { - $this->withUserGroups(userId: 'alice', groupIds: ['employees']); - $this->adminSettings->method('getGroupOrder') - ->willReturn(value: ['employees', 'managers']); - $this->permMapper->method('findByGroupIds') - ->willReturn(value: [ - $this->makePerm(groupId: 'employees', allowed: ['activity', 'recommendations']), - ]); - - $result = $this->service->getAllowedWidgetIds(userId: 'alice'); - $this->assertSame(expected: ['activity', 'recommendations'], actual: $result); - }//end testSingleGroupReturnsAllowedSet() - - public function testMultiGroupUnionWidens(): void - { - $this->withUserGroups(userId: 'alice', groupIds: ['employees', 'managers']); - $this->adminSettings->method('getGroupOrder') - ->willReturn(value: ['employees', 'managers']); - $this->permMapper->method('findByGroupIds') - ->willReturn(value: [ - $this->makePerm(groupId: 'employees', allowed: ['activity']), - $this->makePerm(groupId: 'managers', allowed: ['analytics']), - ]); - - $result = $this->service->getAllowedWidgetIds(userId: 'alice'); - $this->assertSame(expected: ['activity', 'analytics'], actual: $result); - }//end testMultiGroupUnionWidens() - - public function testDenyWinsOverAllow(): void - { - $this->withUserGroups(userId: 'alice', groupIds: ['employees', 'security']); - $this->adminSettings->method('getGroupOrder') - ->willReturn(value: ['employees', 'security']); - $this->permMapper->method('findByGroupIds') - ->willReturn(value: [ - $this->makePerm(groupId: 'employees', allowed: ['activity', 'analytics']), - $this->makePerm(groupId: 'security', allowed: [], denied: ['analytics']), - ]); - - $result = $this->service->getAllowedWidgetIds(userId: 'alice'); - $this->assertSame(expected: ['activity'], actual: $result); - }//end testDenyWinsOverAllow() - - public function testFallbackToDefaultGroupWhenNoGroupOrderMatch(): void - { - $this->withUserGroups(userId: 'alice', groupIds: ['unmapped']); - $this->adminSettings->method('getGroupOrder') - ->willReturn(value: []); - $this->permMapper->method('findByGroupIds') - ->willReturn(value: []); - $this->permMapper->method('findByGroupId') - ->with($this->equalTo(value: RoleFeaturePermission::GROUP_DEFAULT)) - ->willReturn(value: $this->makePerm(groupId: 'default', allowed: ['recommendations'])); - - $result = $this->service->getAllowedWidgetIds(userId: 'alice'); - $this->assertSame(expected: ['recommendations'], actual: $result); - }//end testFallbackToDefaultGroupWhenNoGroupOrderMatch() - - public function testIsWidgetAllowedTrueWhenUnconfigured(): void - { - $this->withUserGroups(userId: 'alice', groupIds: []); - $this->permMapper->method('findByGroupId') - ->will($this->throwException(exception: new DoesNotExistException(msg: 'no row'))); - - $this->assertTrue(condition: $this->service->isWidgetAllowed( - userId: 'alice', - widgetId: 'whatever' - )); - }//end testIsWidgetAllowedTrueWhenUnconfigured() - - /** - * Admin break-glass: a Nextcloud admin is never restricted by the - * role-feature-permission allow-list, even when a restrictive `default` - * row exists (the bug — admins were falling back to the demo-seeded - * `default` row and getting 403 on their own dashboard). - */ - public function testAdminBypassesDefaultRestriction(): void - { - $admin = $this->createMock(originalClassName: IGroupManager::class); - $admin->method('isAdmin')->willReturn(true); - - $service = new RoleFeaturePermissionService( - permissionMapper: $this->permMapper, - defaultMapper: $this->defaultMapper, - placementMapper: $this->placementMapper, - adminSettings: $this->adminSettings, - adminTemplateService: $this->adminTemplateService, - userManager: $this->userManager, - groupManager: $admin, - ); - - // Even if a restrictive `default` row would be returned, the admin - // short-circuit must run first: getAllowedWidgetIds → null (no - // restriction) and isWidgetAllowed → true for any widget. - $this->permMapper->method('findByGroupId') - ->willReturn(value: $this->makePerm(groupId: 'default', allowed: ['activity'])); - - $this->assertNull(actual: $service->getAllowedWidgetIds(userId: 'admin')); - $this->assertTrue(condition: $service->isWidgetAllowed( - userId: 'admin', - widgetId: 'links' - )); - }//end testAdminBypassesDefaultRestriction() - - public function testSeedLayoutNoOpWhenDashboardHasPlacements(): void - { - $dashboard = new Dashboard(); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $dashboard->setId(42); - $this->placementMapper->method('findByDashboardId') - ->willReturn(value: ['existing-placement']); - - // No mapper / group manager calls expected because the guard fires first. - $this->defaultMapper->expects($this->never()) - ->method('findByGroupId'); - - $created = $this->service->seedLayoutFromRoleDefaults( - userId: 'alice', - dashboard: $dashboard - ); - $this->assertSame(expected: 0, actual: $created); - }//end testSeedLayoutNoOpWhenDashboardHasPlacements() - - public function testSeedLayoutCreatesPlacementsWhenEmpty(): void - { - $dashboard = new Dashboard(); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $dashboard->setId(99); - - $this->placementMapper->method('findByDashboardId') - ->willReturn(value: []); - $this->withUserGroups(userId: 'alice', groupIds: ['managers']); - $this->adminSettings->method('getGroupOrder') - ->willReturn(value: ['managers']); - - $rld = new RoleLayoutDefault(); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setName('manager-activity'); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setGroupId('managers'); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setWidgetId('activity'); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setGridX(0); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setGridY(0); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setGridWidth(6); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setGridHeight(5); - // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters - $rld->setSortOrder(0); - - $this->defaultMapper->method('findByGroupId') - ->willReturn(value: [$rld]); - - $this->placementMapper->expects($this->once()) - ->method('insert'); - - $created = $this->service->seedLayoutFromRoleDefaults( - userId: 'alice', - dashboard: $dashboard - ); - $this->assertSame(expected: 1, actual: $created); - }//end testSeedLayoutCreatesPlacementsWhenEmpty() +class RoleFeaturePermissionServiceTest extends TestCase { + private RoleFeaturePermissionService $service; + + private RoleFeaturePermissionMapper $permMapper; + + private RoleLayoutDefaultMapper $defaultMapper; + + private WidgetPlacementMapper $placementMapper; + + private AdminSettingsService $adminSettings; + + private AdminTemplateService $adminTemplateService; + + private IUserManager $userManager; + + private IGroupManager $groupManager; + + protected function setUp(): void { + $this->permMapper = $this->createMock(originalClassName: RoleFeaturePermissionMapper::class); + $this->defaultMapper = $this->createMock(originalClassName: RoleLayoutDefaultMapper::class); + $this->placementMapper = $this->createMock(originalClassName: WidgetPlacementMapper::class); + $this->adminSettings = $this->createMock(originalClassName: AdminSettingsService::class); + $this->adminTemplateService = $this->createMock(originalClassName: AdminTemplateService::class); + $this->userManager = $this->createMock(originalClassName: IUserManager::class); + $this->groupManager = $this->createMock(originalClassName: IGroupManager::class); + + // Default: the user under test is NOT an admin so the existing + // role-resolution assertions exercise the group-matching path. + $this->groupManager->method('isAdmin')->willReturn(false); + + $this->service = new RoleFeaturePermissionService( + permissionMapper: $this->permMapper, + defaultMapper: $this->defaultMapper, + placementMapper: $this->placementMapper, + adminSettings: $this->adminSettings, + adminTemplateService: $this->adminTemplateService, + userManager: $this->userManager, + groupManager: $this->groupManager, + ); + }//end setUp() + + /** + * Build a RoleFeaturePermission entity from arrays. + */ + private function makePerm( + string $groupId, + array $allowed, + array $denied = [], + ): RoleFeaturePermission { + $entity = new RoleFeaturePermission(); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setName('perm-' . $groupId); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setGroupId($groupId); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setAllowedWidgets(json_encode(value: $allowed)); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $entity->setDeniedWidgets(json_encode(value: $denied)); + return $entity; + }//end makePerm() + + /** + * Mock IUserManager + AdminTemplateService so the user is in a list + * of groups. The service calls + * {@see AdminTemplateService::getUserGroupIdsFor()} to honour the + * REQ-TMPL-013 routing-resolver invariant — group lookups never + * touch IGroupManager directly. + */ + private function withUserGroups(string $userId, array $groupIds): void { + $user = $this->createMock(originalClassName: IUser::class); + $this->userManager->method('get') + ->willReturn(value: $user); + $this->adminTemplateService->method('getUserGroupIdsFor') + ->willReturn(value: $groupIds); + }//end withUserGroups() + + public function testNoRestrictionConfiguredReturnsNull(): void { + $this->withUserGroups(userId: 'alice', groupIds: ['employees']); + $this->adminSettings->method('getGroupOrder') + ->willReturn(value: ['employees']); + $this->permMapper->method('findByGroupIds') + ->willReturn(value: []); + $this->permMapper->method('findByGroupId') + ->will($this->throwException(exception: new DoesNotExistException(msg: 'no row'))); + + $result = $this->service->getAllowedWidgetIds(userId: 'alice'); + $this->assertNull(actual: $result); + }//end testNoRestrictionConfiguredReturnsNull() + + public function testSingleGroupReturnsAllowedSet(): void { + $this->withUserGroups(userId: 'alice', groupIds: ['employees']); + $this->adminSettings->method('getGroupOrder') + ->willReturn(value: ['employees', 'managers']); + $this->permMapper->method('findByGroupIds') + ->willReturn(value: [ + $this->makePerm(groupId: 'employees', allowed: ['activity', 'recommendations']), + ]); + + $result = $this->service->getAllowedWidgetIds(userId: 'alice'); + $this->assertSame(expected: ['activity', 'recommendations'], actual: $result); + }//end testSingleGroupReturnsAllowedSet() + + public function testMultiGroupUnionWidens(): void { + $this->withUserGroups(userId: 'alice', groupIds: ['employees', 'managers']); + $this->adminSettings->method('getGroupOrder') + ->willReturn(value: ['employees', 'managers']); + $this->permMapper->method('findByGroupIds') + ->willReturn(value: [ + $this->makePerm(groupId: 'employees', allowed: ['activity']), + $this->makePerm(groupId: 'managers', allowed: ['analytics']), + ]); + + $result = $this->service->getAllowedWidgetIds(userId: 'alice'); + $this->assertSame(expected: ['activity', 'analytics'], actual: $result); + }//end testMultiGroupUnionWidens() + + public function testDenyWinsOverAllow(): void { + $this->withUserGroups(userId: 'alice', groupIds: ['employees', 'security']); + $this->adminSettings->method('getGroupOrder') + ->willReturn(value: ['employees', 'security']); + $this->permMapper->method('findByGroupIds') + ->willReturn(value: [ + $this->makePerm(groupId: 'employees', allowed: ['activity', 'analytics']), + $this->makePerm(groupId: 'security', allowed: [], denied: ['analytics']), + ]); + + $result = $this->service->getAllowedWidgetIds(userId: 'alice'); + $this->assertSame(expected: ['activity'], actual: $result); + }//end testDenyWinsOverAllow() + + public function testFallbackToDefaultGroupWhenNoGroupOrderMatch(): void { + $this->withUserGroups(userId: 'alice', groupIds: ['unmapped']); + $this->adminSettings->method('getGroupOrder') + ->willReturn(value: []); + $this->permMapper->method('findByGroupIds') + ->willReturn(value: []); + $this->permMapper->method('findByGroupId') + ->with($this->equalTo(value: RoleFeaturePermission::GROUP_DEFAULT)) + ->willReturn(value: $this->makePerm(groupId: 'default', allowed: ['recommendations'])); + + $result = $this->service->getAllowedWidgetIds(userId: 'alice'); + $this->assertSame(expected: ['recommendations'], actual: $result); + }//end testFallbackToDefaultGroupWhenNoGroupOrderMatch() + + public function testIsWidgetAllowedTrueWhenUnconfigured(): void { + $this->withUserGroups(userId: 'alice', groupIds: []); + $this->permMapper->method('findByGroupId') + ->will($this->throwException(exception: new DoesNotExistException(msg: 'no row'))); + + $this->assertTrue(condition: $this->service->isWidgetAllowed( + userId: 'alice', + widgetId: 'whatever' + )); + }//end testIsWidgetAllowedTrueWhenUnconfigured() + + /** + * Admin break-glass: a Nextcloud admin is never restricted by the + * role-feature-permission allow-list, even when a restrictive `default` + * row exists (the bug — admins were falling back to the demo-seeded + * `default` row and getting 403 on their own dashboard). + */ + public function testAdminBypassesDefaultRestriction(): void { + $admin = $this->createMock(originalClassName: IGroupManager::class); + $admin->method('isAdmin')->willReturn(true); + + $service = new RoleFeaturePermissionService( + permissionMapper: $this->permMapper, + defaultMapper: $this->defaultMapper, + placementMapper: $this->placementMapper, + adminSettings: $this->adminSettings, + adminTemplateService: $this->adminTemplateService, + userManager: $this->userManager, + groupManager: $admin, + ); + + // Even if a restrictive `default` row would be returned, the admin + // short-circuit must run first: getAllowedWidgetIds → null (no + // restriction) and isWidgetAllowed → true for any widget. + $this->permMapper->method('findByGroupId') + ->willReturn(value: $this->makePerm(groupId: 'default', allowed: ['activity'])); + + $this->assertNull(actual: $service->getAllowedWidgetIds(userId: 'admin')); + $this->assertTrue(condition: $service->isWidgetAllowed( + userId: 'admin', + widgetId: 'links' + )); + }//end testAdminBypassesDefaultRestriction() + + public function testSeedLayoutNoOpWhenDashboardHasPlacements(): void { + $dashboard = new Dashboard(); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $dashboard->setId(42); + $this->placementMapper->method('findByDashboardId') + ->willReturn(value: ['existing-placement']); + + // No mapper / group manager calls expected because the guard fires first. + $this->defaultMapper->expects($this->never()) + ->method('findByGroupId'); + + $created = $this->service->seedLayoutFromRoleDefaults( + userId: 'alice', + dashboard: $dashboard + ); + $this->assertSame(expected: 0, actual: $created); + }//end testSeedLayoutNoOpWhenDashboardHasPlacements() + + public function testSeedLayoutCreatesPlacementsWhenEmpty(): void { + $dashboard = new Dashboard(); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $dashboard->setId(99); + + $this->placementMapper->method('findByDashboardId') + ->willReturn(value: []); + $this->withUserGroups(userId: 'alice', groupIds: ['managers']); + $this->adminSettings->method('getGroupOrder') + ->willReturn(value: ['managers']); + + $rld = new RoleLayoutDefault(); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setName('manager-activity'); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setGroupId('managers'); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setWidgetId('activity'); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setGridX(0); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setGridY(0); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setGridWidth(6); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setGridHeight(5); + // phpcs:ignore CustomSniffs.Functions.NamedParameters.RequireNamedParameters + $rld->setSortOrder(0); + + $this->defaultMapper->method('findByGroupId') + ->willReturn(value: [$rld]); + + $this->placementMapper->expects($this->once()) + ->method('insert'); + + $created = $this->service->seedLayoutFromRoleDefaults( + userId: 'alice', + dashboard: $dashboard + ); + $this->assertSame(expected: 1, actual: $created); + }//end testSeedLayoutCreatesPlacementsWhenEmpty() }//end class diff --git a/tests/Unit/Service/RoleServiceTest.php b/tests/Unit/Service/RoleServiceTest.php index cad9cb3a..7a366267 100644 --- a/tests/Unit/Service/RoleServiceTest.php +++ b/tests/Unit/Service/RoleServiceTest.php @@ -39,324 +39,300 @@ * @SuppressWarnings(PHPMD.TooManyMethods) * @SuppressWarnings(PHPMD.TooManyPublicMethods) */ -class RoleServiceTest extends TestCase -{ - /** @var RoleAssignmentMapper&MockObject */ - private $mapper; - /** @var IUserManager&MockObject */ - private $userManager; - /** @var IGroupManager&MockObject */ - private $groupManager; - /** @var AdminTemplateService&MockObject */ - private $adminTemplateService; - - private RoleService $service; - - protected function setUp(): void - { - parent::setUp(); - - $this->mapper = $this->createMock(RoleAssignmentMapper::class); - $this->userManager = $this->createMock(IUserManager::class); - $this->groupManager = $this->createMock(IGroupManager::class); - $this->adminTemplateService = $this->createMock(AdminTemplateService::class); - - $this->service = new RoleService( - mapper: $this->mapper, - userManager: $this->userManager, - groupManager: $this->groupManager, - adminTemplateService: $this->adminTemplateService, - ); - } - - private function makeAssignment( - ?string $userId, - ?string $groupId, - string $role - ): RoleAssignment { - $assignment = new RoleAssignment(); - $assignment->setUserId($userId); - $assignment->setGroupId($groupId); - $assignment->setRole($role); - $assignment->setAssignedBy('admin-user'); - $assignment->setAssignedAt('2026-05-02T12:00:00+00:00'); - return $assignment; - } - - // ================================================================== - // Effective-role resolution (REQ-ROLE-005) - // ================================================================== - - public function testNcAdminAlwaysGetsAdminRole(): void - { - $this->groupManager->method('isAdmin')->with('alice')->willReturn(true); - - // No mapper lookup is required for NC admins. - $this->mapper->expects($this->never())->method('findByUser'); - $this->mapper->expects($this->never())->method('findByGroupIds'); - - $this->assertSame( - RoleAssignment::ROLE_ADMIN, - $this->service->getEffectiveRole(userId: 'alice') - ); - $this->assertSame( - RoleAssignment::SOURCE_NC_ADMIN, - $this->service->getRoleSource(userId: 'alice') - ); - } - - public function testDirectUserAssignmentUsedAsIs(): void - { - $this->groupManager->method('isAdmin')->willReturn(false); - $this->mapper->method('findByUser')->with('bob')->willReturn( - [$this->makeAssignment('bob', null, RoleAssignment::ROLE_VIEWER)] - ); - - // Group lookups MUST be skipped per REQ-ROLE-005 step 2. - $this->mapper->expects($this->never())->method('findByGroupIds'); - $this->adminTemplateService->expects($this->never())->method('getUserGroupIdsFor'); - - $this->assertSame( - RoleAssignment::ROLE_VIEWER, - $this->service->getEffectiveRole(userId: 'bob') - ); - } - - public function testDirectAssignmentBeatsHigherGroupRole(): void - { - // REQ-ROLE-009 scenario 1: direct viewer beats group admin. - $this->groupManager->method('isAdmin')->willReturn(false); - $this->mapper->method('findByUser')->with('bob')->willReturn( - [$this->makeAssignment('bob', null, RoleAssignment::ROLE_VIEWER)] - ); - - $this->mapper->expects($this->never())->method('findByGroupIds'); - - $this->assertSame( - RoleAssignment::ROLE_VIEWER, - $this->service->getEffectiveRole(userId: 'bob') - ); - $this->assertSame( - RoleAssignment::SOURCE_USER_ASSIGNED, - $this->service->getRoleSource(userId: 'bob') - ); - } - - public function testHighestGroupRoleWins(): void - { - $this->groupManager->method('isAdmin')->willReturn(false); - $this->mapper->method('findByUser')->willReturn([]); - - $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn( - ['engineering', 'sales'] - ); - - $this->mapper->method('findByGroupIds')->willReturn([ - $this->makeAssignment(null, 'sales', RoleAssignment::ROLE_VIEWER), - $this->makeAssignment(null, 'engineering', RoleAssignment::ROLE_EDITOR), - ]); - - $this->assertSame( - RoleAssignment::ROLE_EDITOR, - $this->service->getEffectiveRole(userId: 'charlie') - ); - $this->assertSame( - RoleAssignment::SOURCE_GROUP_ASSIGNED_PREFIX.'engineering', - $this->service->getRoleSource(userId: 'charlie') - ); - } - - public function testNoAssignmentReturnsNullRoleAndSource(): void - { - $this->groupManager->method('isAdmin')->willReturn(false); - $this->mapper->method('findByUser')->willReturn([]); - $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn([]); - $this->mapper->method('findByGroupIds')->willReturn([]); - - $this->assertNull($this->service->getEffectiveRole(userId: 'eve')); - $this->assertNull($this->service->getRoleSource(userId: 'eve')); - } - - // ================================================================== - // Validation (REQ-ROLE-004) - // ================================================================== - - public function testValidateRoleAcceptsKnownRoles(): void - { - $this->service->validateRole(role: 'admin'); - $this->service->validateRole(role: 'editor'); - $this->service->validateRole(role: 'viewer'); - $this->expectNotToPerformAssertions(); - } - - public function testValidateRoleRejectsUnknown(): void - { - $this->expectException(InvalidRoleAssignmentException::class); - $this->service->validateRole(role: 'superuser'); - } - - public function testValidateTargetRequiresOneOfUserOrGroup(): void - { - $this->expectException(InvalidRoleAssignmentException::class); - $this->service->validateTarget(userId: null, groupId: null); - } - - public function testValidateTargetRejectsBoth(): void - { - $this->expectException(InvalidRoleAssignmentException::class); - $this->service->validateTarget(userId: 'bob', groupId: 'engineering'); - } - - public function testValidateTargetRejectsUnknownUser(): void - { - $this->userManager->method('userExists')->with('ghost')->willReturn(false); - - $this->expectException(InvalidRoleAssignmentException::class); - $this->service->validateTarget(userId: 'ghost', groupId: null); - } - - public function testValidateTargetRejectsUnknownGroup(): void - { - $this->groupManager->method('groupExists')->with('phantom')->willReturn(false); - - $this->expectException(InvalidRoleAssignmentException::class); - $this->service->validateTarget(userId: null, groupId: 'phantom'); - } - - // ================================================================== - // Assignment CRUD (REQ-ROLE-004) - // ================================================================== - - public function testAssignRolePersistsAndReturnsEntity(): void - { - $this->userManager->method('userExists')->with('bob')->willReturn(true); - $this->mapper->method('findUserRole')->with('bob', 'editor')->willReturn(null); - - $this->mapper->expects($this->once()) - ->method('insert') - ->willReturnCallback(static fn(RoleAssignment $a) => $a); - - $assignment = $this->service->assignRole( - userId: 'bob', - groupId: null, - role: 'editor', - assignedBy: 'admin-user' - ); - - $this->assertSame('bob', $assignment->getUserId()); - $this->assertNull($assignment->getGroupId()); - $this->assertSame('editor', $assignment->getRole()); - $this->assertSame('admin-user', $assignment->getAssignedBy()); - $this->assertNotNull($assignment->getAssignedAt()); - } - - public function testAssignRoleRejectsDuplicateUserRole(): void - { - $this->userManager->method('userExists')->with('bob')->willReturn(true); - $existing = $this->makeAssignment('bob', null, 'editor'); - $this->mapper->method('findUserRole')->with('bob', 'editor')->willReturn($existing); - - $this->expectException(DuplicateRoleAssignmentException::class); - $this->service->assignRole( - userId: 'bob', - groupId: null, - role: 'editor', - assignedBy: 'admin-user' - ); - } - - public function testAssignRoleRejectsDuplicateGroupRole(): void - { - $this->groupManager->method('groupExists')->with('engineering')->willReturn(true); - $existing = $this->makeAssignment(null, 'engineering', 'editor'); - $this->mapper->method('findGroupRole')->with('engineering', 'editor')->willReturn($existing); - - $this->expectException(DuplicateRoleAssignmentException::class); - $this->service->assignRole( - userId: null, - groupId: 'engineering', - role: 'editor', - assignedBy: 'admin-user' - ); - } - - public function testRemoveRoleThrowsWhenNoRowAffected(): void - { - $this->mapper->method('deleteById')->with(99)->willReturn(0); - - $this->expectException(DoesNotExistException::class); - $this->service->removeRole(id: 99); - } - - public function testRemoveRoleSucceedsWhenRowDeleted(): void - { - $this->mapper->method('deleteById')->with(7)->willReturn(1); - - $this->service->removeRole(id: 7); - $this->expectNotToPerformAssertions(); - } - - // ================================================================== - // Cascade entry points (REQ-ROLE-010, REQ-ROLE-011) - // ================================================================== - - public function testDeleteByUserIdDelegatesToMapper(): void - { - $this->mapper->expects($this->once()) - ->method('deleteByUserId') - ->with('bob') - ->willReturn(2); - - $this->assertSame(2, $this->service->deleteByUserId(userId: 'bob')); - } - - public function testDeleteByGroupIdDelegatesToMapper(): void - { - $this->mapper->expects($this->once()) - ->method('deleteByGroupId') - ->with('engineering') - ->willReturn(3); - - $this->assertSame( - 3, - $this->service->deleteByGroupId(groupId: 'engineering') - ); - } - - // ================================================================== - // Authorization helpers (REQ-ROLE-001..003, REQ-ROLE-008) - // ================================================================== - - public function testIsAdminForNcAdmin(): void - { - $this->groupManager->method('isAdmin')->with('alice')->willReturn(true); - $this->assertTrue($this->service->isAdmin(userId: 'alice')); - } - - public function testIsViewerWhenViewerAssigned(): void - { - $this->groupManager->method('isAdmin')->willReturn(false); - $this->mapper->method('findByUser')->willReturn( - [$this->makeAssignment('charlie', null, RoleAssignment::ROLE_VIEWER)] - ); - - $this->assertTrue($this->service->isViewer(userId: 'charlie')); - $this->assertFalse($this->service->canMutate(userId: 'charlie')); - } - - public function testCanMutateForUnassignedUser(): void - { - $this->groupManager->method('isAdmin')->willReturn(false); - $this->mapper->method('findByUser')->willReturn([]); - $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn([]); - $this->mapper->method('findByGroupIds')->willReturn([]); - - $this->assertTrue($this->service->canMutate(userId: 'eve')); - } - - public function testIsEditorOrHigherTrueForAdminAndEditor(): void - { - $this->groupManager->method('isAdmin')->with('alice')->willReturn(true); - $this->assertTrue($this->service->isEditorOrHigher(userId: 'alice')); - } +class RoleServiceTest extends TestCase { + /** @var RoleAssignmentMapper&MockObject */ + private $mapper; + /** @var IUserManager&MockObject */ + private $userManager; + /** @var IGroupManager&MockObject */ + private $groupManager; + /** @var AdminTemplateService&MockObject */ + private $adminTemplateService; + + private RoleService $service; + + protected function setUp(): void { + parent::setUp(); + + $this->mapper = $this->createMock(RoleAssignmentMapper::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->adminTemplateService = $this->createMock(AdminTemplateService::class); + + $this->service = new RoleService( + mapper: $this->mapper, + userManager: $this->userManager, + groupManager: $this->groupManager, + adminTemplateService: $this->adminTemplateService, + ); + } + + private function makeAssignment( + ?string $userId, + ?string $groupId, + string $role, + ): RoleAssignment { + $assignment = new RoleAssignment(); + $assignment->setUserId($userId); + $assignment->setGroupId($groupId); + $assignment->setRole($role); + $assignment->setAssignedBy('admin-user'); + $assignment->setAssignedAt('2026-05-02T12:00:00+00:00'); + return $assignment; + } + + // ================================================================== + // Effective-role resolution (REQ-ROLE-005) + // ================================================================== + + public function testNcAdminAlwaysGetsAdminRole(): void { + $this->groupManager->method('isAdmin')->with('alice')->willReturn(true); + + // No mapper lookup is required for NC admins. + $this->mapper->expects($this->never())->method('findByUser'); + $this->mapper->expects($this->never())->method('findByGroupIds'); + + $this->assertSame( + RoleAssignment::ROLE_ADMIN, + $this->service->getEffectiveRole(userId: 'alice') + ); + $this->assertSame( + RoleAssignment::SOURCE_NC_ADMIN, + $this->service->getRoleSource(userId: 'alice') + ); + } + + public function testDirectUserAssignmentUsedAsIs(): void { + $this->groupManager->method('isAdmin')->willReturn(false); + $this->mapper->method('findByUser')->with('bob')->willReturn( + [$this->makeAssignment('bob', null, RoleAssignment::ROLE_VIEWER)] + ); + + // Group lookups MUST be skipped per REQ-ROLE-005 step 2. + $this->mapper->expects($this->never())->method('findByGroupIds'); + $this->adminTemplateService->expects($this->never())->method('getUserGroupIdsFor'); + + $this->assertSame( + RoleAssignment::ROLE_VIEWER, + $this->service->getEffectiveRole(userId: 'bob') + ); + } + + public function testDirectAssignmentBeatsHigherGroupRole(): void { + // REQ-ROLE-009 scenario 1: direct viewer beats group admin. + $this->groupManager->method('isAdmin')->willReturn(false); + $this->mapper->method('findByUser')->with('bob')->willReturn( + [$this->makeAssignment('bob', null, RoleAssignment::ROLE_VIEWER)] + ); + + $this->mapper->expects($this->never())->method('findByGroupIds'); + + $this->assertSame( + RoleAssignment::ROLE_VIEWER, + $this->service->getEffectiveRole(userId: 'bob') + ); + $this->assertSame( + RoleAssignment::SOURCE_USER_ASSIGNED, + $this->service->getRoleSource(userId: 'bob') + ); + } + + public function testHighestGroupRoleWins(): void { + $this->groupManager->method('isAdmin')->willReturn(false); + $this->mapper->method('findByUser')->willReturn([]); + + $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn( + ['engineering', 'sales'] + ); + + $this->mapper->method('findByGroupIds')->willReturn([ + $this->makeAssignment(null, 'sales', RoleAssignment::ROLE_VIEWER), + $this->makeAssignment(null, 'engineering', RoleAssignment::ROLE_EDITOR), + ]); + + $this->assertSame( + RoleAssignment::ROLE_EDITOR, + $this->service->getEffectiveRole(userId: 'charlie') + ); + $this->assertSame( + RoleAssignment::SOURCE_GROUP_ASSIGNED_PREFIX . 'engineering', + $this->service->getRoleSource(userId: 'charlie') + ); + } + + public function testNoAssignmentReturnsNullRoleAndSource(): void { + $this->groupManager->method('isAdmin')->willReturn(false); + $this->mapper->method('findByUser')->willReturn([]); + $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn([]); + $this->mapper->method('findByGroupIds')->willReturn([]); + + $this->assertNull($this->service->getEffectiveRole(userId: 'eve')); + $this->assertNull($this->service->getRoleSource(userId: 'eve')); + } + + // ================================================================== + // Validation (REQ-ROLE-004) + // ================================================================== + + public function testValidateRoleAcceptsKnownRoles(): void { + $this->service->validateRole(role: 'admin'); + $this->service->validateRole(role: 'editor'); + $this->service->validateRole(role: 'viewer'); + $this->expectNotToPerformAssertions(); + } + + public function testValidateRoleRejectsUnknown(): void { + $this->expectException(InvalidRoleAssignmentException::class); + $this->service->validateRole(role: 'superuser'); + } + + public function testValidateTargetRequiresOneOfUserOrGroup(): void { + $this->expectException(InvalidRoleAssignmentException::class); + $this->service->validateTarget(userId: null, groupId: null); + } + + public function testValidateTargetRejectsBoth(): void { + $this->expectException(InvalidRoleAssignmentException::class); + $this->service->validateTarget(userId: 'bob', groupId: 'engineering'); + } + + public function testValidateTargetRejectsUnknownUser(): void { + $this->userManager->method('userExists')->with('ghost')->willReturn(false); + + $this->expectException(InvalidRoleAssignmentException::class); + $this->service->validateTarget(userId: 'ghost', groupId: null); + } + + public function testValidateTargetRejectsUnknownGroup(): void { + $this->groupManager->method('groupExists')->with('phantom')->willReturn(false); + + $this->expectException(InvalidRoleAssignmentException::class); + $this->service->validateTarget(userId: null, groupId: 'phantom'); + } + + // ================================================================== + // Assignment CRUD (REQ-ROLE-004) + // ================================================================== + + public function testAssignRolePersistsAndReturnsEntity(): void { + $this->userManager->method('userExists')->with('bob')->willReturn(true); + $this->mapper->method('findUserRole')->with('bob', 'editor')->willReturn(null); + + $this->mapper->expects($this->once()) + ->method('insert') + ->willReturnCallback(static fn (RoleAssignment $a) => $a); + + $assignment = $this->service->assignRole( + userId: 'bob', + groupId: null, + role: 'editor', + assignedBy: 'admin-user' + ); + + $this->assertSame('bob', $assignment->getUserId()); + $this->assertNull($assignment->getGroupId()); + $this->assertSame('editor', $assignment->getRole()); + $this->assertSame('admin-user', $assignment->getAssignedBy()); + $this->assertNotNull($assignment->getAssignedAt()); + } + + public function testAssignRoleRejectsDuplicateUserRole(): void { + $this->userManager->method('userExists')->with('bob')->willReturn(true); + $existing = $this->makeAssignment('bob', null, 'editor'); + $this->mapper->method('findUserRole')->with('bob', 'editor')->willReturn($existing); + + $this->expectException(DuplicateRoleAssignmentException::class); + $this->service->assignRole( + userId: 'bob', + groupId: null, + role: 'editor', + assignedBy: 'admin-user' + ); + } + + public function testAssignRoleRejectsDuplicateGroupRole(): void { + $this->groupManager->method('groupExists')->with('engineering')->willReturn(true); + $existing = $this->makeAssignment(null, 'engineering', 'editor'); + $this->mapper->method('findGroupRole')->with('engineering', 'editor')->willReturn($existing); + + $this->expectException(DuplicateRoleAssignmentException::class); + $this->service->assignRole( + userId: null, + groupId: 'engineering', + role: 'editor', + assignedBy: 'admin-user' + ); + } + + public function testRemoveRoleThrowsWhenNoRowAffected(): void { + $this->mapper->method('deleteById')->with(99)->willReturn(0); + + $this->expectException(DoesNotExistException::class); + $this->service->removeRole(id: 99); + } + + public function testRemoveRoleSucceedsWhenRowDeleted(): void { + $this->mapper->method('deleteById')->with(7)->willReturn(1); + + $this->service->removeRole(id: 7); + $this->expectNotToPerformAssertions(); + } + + // ================================================================== + // Cascade entry points (REQ-ROLE-010, REQ-ROLE-011) + // ================================================================== + + public function testDeleteByUserIdDelegatesToMapper(): void { + $this->mapper->expects($this->once()) + ->method('deleteByUserId') + ->with('bob') + ->willReturn(2); + + $this->assertSame(2, $this->service->deleteByUserId(userId: 'bob')); + } + + public function testDeleteByGroupIdDelegatesToMapper(): void { + $this->mapper->expects($this->once()) + ->method('deleteByGroupId') + ->with('engineering') + ->willReturn(3); + + $this->assertSame( + 3, + $this->service->deleteByGroupId(groupId: 'engineering') + ); + } + + // ================================================================== + // Authorization helpers (REQ-ROLE-001..003, REQ-ROLE-008) + // ================================================================== + + public function testIsAdminForNcAdmin(): void { + $this->groupManager->method('isAdmin')->with('alice')->willReturn(true); + $this->assertTrue($this->service->isAdmin(userId: 'alice')); + } + + public function testIsViewerWhenViewerAssigned(): void { + $this->groupManager->method('isAdmin')->willReturn(false); + $this->mapper->method('findByUser')->willReturn( + [$this->makeAssignment('charlie', null, RoleAssignment::ROLE_VIEWER)] + ); + + $this->assertTrue($this->service->isViewer(userId: 'charlie')); + $this->assertFalse($this->service->canMutate(userId: 'charlie')); + } + + public function testCanMutateForUnassignedUser(): void { + $this->groupManager->method('isAdmin')->willReturn(false); + $this->mapper->method('findByUser')->willReturn([]); + $this->adminTemplateService->method('getUserGroupIdsFor')->willReturn([]); + $this->mapper->method('findByGroupIds')->willReturn([]); + + $this->assertTrue($this->service->canMutate(userId: 'eve')); + } + + public function testIsEditorOrHigherTrueForAdminAndEditor(): void { + $this->groupManager->method('isAdmin')->with('alice')->willReturn(true); + $this->assertTrue($this->service->isEditorOrHigher(userId: 'alice')); + } }//end class diff --git a/tests/Unit/Service/SetupWizardServiceTest.php b/tests/Unit/Service/SetupWizardServiceTest.php index 1804636e..ce7840d9 100644 --- a/tests/Unit/Service/SetupWizardServiceTest.php +++ b/tests/Unit/Service/SetupWizardServiceTest.php @@ -29,161 +29,149 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -class SetupWizardServiceTest extends TestCase -{ - private SetupWizardService $service; - - /** @var AdminSettingMapper&MockObject */ - private $settingMapper; - - /** @var IAppManager&MockObject */ - private $appManager; - - protected function setUp(): void - { - $this->settingMapper = $this->createMock(AdminSettingMapper::class); - $this->appManager = $this->createMock(IAppManager::class); - $this->service = new SetupWizardService( - settingMapper: $this->settingMapper, - appManager: $this->appManager - ); - } - - public function testGetWizardStateOnFreshInstance(): void - { - $this->settingMapper->method('getValue') - ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) - ->willReturn(false); - $this->settingMapper->method('getAllAsArray')->willReturn([]); - - $state = $this->service->getWizardState(); - - $this->assertFalse($state['complete']); - $this->assertSame(2, $state['currentRecommendedStep']); - $this->assertSame('done', $state['stepStatuses']['1']); - $this->assertSame('pending', $state['stepStatuses']['2']); - $this->assertSame('pending', $state['stepStatuses']['3']); - $this->assertSame('skipped', $state['stepStatuses']['4']); - $this->assertSame('skipped', $state['stepStatuses']['5']); - $this->assertSame('skipped', $state['stepStatuses']['6']); - $this->assertSame('pending', $state['stepStatuses']['7']); - } - - public function testGetWizardStateAfterStorageWritten(): void - { - $this->settingMapper->method('getValue') - ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) - ->willReturn(false); - $this->settingMapper->method('getAllAsArray')->willReturn([ - AdminSetting::KEY_CONTENT_STORAGE => 'database', - ]); - - $state = $this->service->getWizardState(); - - $this->assertSame('done', $state['stepStatuses']['2']); - // Step 3 still pending so the recommended step jumps to 3. - $this->assertSame(3, $state['currentRecommendedStep']); - } - - public function testGetWizardStateAfterCompletion(): void - { - $this->settingMapper->method('getValue') - ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) - ->willReturn(true); - $this->settingMapper->method('getAllAsArray')->willReturn([ - AdminSetting::KEY_CONTENT_STORAGE => 'database', - AdminSetting::KEY_GROUP_ORDER => ['engineering'], - AdminSetting::KEY_FOOTER_CONFIG => ['layout' => 'structured'], - ]); - - $state = $this->service->getWizardState(); - - $this->assertTrue($state['complete']); - $this->assertSame('done', $state['stepStatuses']['7']); - $this->assertSame('done', $state['stepStatuses']['6']); - // Steps 4/5 are 'skipped' (sibling capabilities pending), so the - // first non-'done' status is Step 4. The wizard "complete" flag - // is the source of truth for hiding the banner; the recommended - // step is purely a UX hint per REQ-WIZ-008. - $this->assertSame(4, $state['currentRecommendedStep']); - } - - public function testMarkWizardCompleteSetsFlagAndReturnsState(): void - { - $this->settingMapper - ->expects($this->once()) - ->method('setSetting') - ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, true); - $this->settingMapper->method('getValue') - ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) - ->willReturn(true); - $this->settingMapper->method('getAllAsArray')->willReturn([]); - - $state = $this->service->markWizardComplete(); - - $this->assertTrue($state['complete']); - } - - public function testMarkWizardCompleteIsIdempotent(): void - { - // Even when already true the service still writes (idempotent - // semantics — the controller doesn't need a defensive guard). - $this->settingMapper - ->expects($this->exactly(2)) - ->method('setSetting'); - $this->settingMapper->method('getValue')->willReturn(true); - $this->settingMapper->method('getAllAsArray')->willReturn([]); - - $first = $this->service->markWizardComplete(); - $second = $this->service->markWizardComplete(); - - $this->assertSame($first, $second); - } - - public function testGetGroupfolderAvailabilityDelegates(): void - { - $this->appManager - ->expects($this->once()) - ->method('isInstalled') - ->with('groupfolders') - ->willReturn(true); - - $this->assertTrue($this->service->hasGroupfolderApp()); - } - - public function testSetContentStorageRejectsUnsupportedValue(): void - { - $this->settingMapper->expects($this->never())->method('setSetting'); - $this->expectException(InvalidArgumentException::class); - - $this->service->setContentStorage(value: 'cassette-tape'); - } - - public function testSetContentStoragePersistsKnownValues(): void - { - $this->settingMapper - ->expects($this->once()) - ->method('setSetting') - ->with(AdminSetting::KEY_CONTENT_STORAGE, 'groupfolder'); - - $this->service->setContentStorage(value: SetupWizardService::STORAGE_GROUPFOLDER); - } - - public function testGetContentStorageDefaultsToDatabase(): void - { - $this->settingMapper->method('getValue') - ->with(AdminSetting::KEY_CONTENT_STORAGE, null) - ->willReturn(null); - - $this->assertSame('database', $this->service->getContentStorage()); - } - - public function testGetContentStorageReturnsPersisted(): void - { - $this->settingMapper->method('getValue') - ->with(AdminSetting::KEY_CONTENT_STORAGE, null) - ->willReturn('groupfolder'); - - $this->assertSame('groupfolder', $this->service->getContentStorage()); - } +class SetupWizardServiceTest extends TestCase { + private SetupWizardService $service; + + /** @var AdminSettingMapper&MockObject */ + private $settingMapper; + + /** @var IAppManager&MockObject */ + private $appManager; + + protected function setUp(): void { + $this->settingMapper = $this->createMock(AdminSettingMapper::class); + $this->appManager = $this->createMock(IAppManager::class); + $this->service = new SetupWizardService( + settingMapper: $this->settingMapper, + appManager: $this->appManager + ); + } + + public function testGetWizardStateOnFreshInstance(): void { + $this->settingMapper->method('getValue') + ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) + ->willReturn(false); + $this->settingMapper->method('getAllAsArray')->willReturn([]); + + $state = $this->service->getWizardState(); + + $this->assertFalse($state['complete']); + $this->assertSame(2, $state['currentRecommendedStep']); + $this->assertSame('done', $state['stepStatuses']['1']); + $this->assertSame('pending', $state['stepStatuses']['2']); + $this->assertSame('pending', $state['stepStatuses']['3']); + $this->assertSame('skipped', $state['stepStatuses']['4']); + $this->assertSame('skipped', $state['stepStatuses']['5']); + $this->assertSame('skipped', $state['stepStatuses']['6']); + $this->assertSame('pending', $state['stepStatuses']['7']); + } + + public function testGetWizardStateAfterStorageWritten(): void { + $this->settingMapper->method('getValue') + ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) + ->willReturn(false); + $this->settingMapper->method('getAllAsArray')->willReturn([ + AdminSetting::KEY_CONTENT_STORAGE => 'database', + ]); + + $state = $this->service->getWizardState(); + + $this->assertSame('done', $state['stepStatuses']['2']); + // Step 3 still pending so the recommended step jumps to 3. + $this->assertSame(3, $state['currentRecommendedStep']); + } + + public function testGetWizardStateAfterCompletion(): void { + $this->settingMapper->method('getValue') + ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) + ->willReturn(true); + $this->settingMapper->method('getAllAsArray')->willReturn([ + AdminSetting::KEY_CONTENT_STORAGE => 'database', + AdminSetting::KEY_GROUP_ORDER => ['engineering'], + AdminSetting::KEY_FOOTER_CONFIG => ['layout' => 'structured'], + ]); + + $state = $this->service->getWizardState(); + + $this->assertTrue($state['complete']); + $this->assertSame('done', $state['stepStatuses']['7']); + $this->assertSame('done', $state['stepStatuses']['6']); + // Steps 4/5 are 'skipped' (sibling capabilities pending), so the + // first non-'done' status is Step 4. The wizard "complete" flag + // is the source of truth for hiding the banner; the recommended + // step is purely a UX hint per REQ-WIZ-008. + $this->assertSame(4, $state['currentRecommendedStep']); + } + + public function testMarkWizardCompleteSetsFlagAndReturnsState(): void { + $this->settingMapper + ->expects($this->once()) + ->method('setSetting') + ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, true); + $this->settingMapper->method('getValue') + ->with(AdminSetting::KEY_SETUP_WIZARD_COMPLETE, false) + ->willReturn(true); + $this->settingMapper->method('getAllAsArray')->willReturn([]); + + $state = $this->service->markWizardComplete(); + + $this->assertTrue($state['complete']); + } + + public function testMarkWizardCompleteIsIdempotent(): void { + // Even when already true the service still writes (idempotent + // semantics — the controller doesn't need a defensive guard). + $this->settingMapper + ->expects($this->exactly(2)) + ->method('setSetting'); + $this->settingMapper->method('getValue')->willReturn(true); + $this->settingMapper->method('getAllAsArray')->willReturn([]); + + $first = $this->service->markWizardComplete(); + $second = $this->service->markWizardComplete(); + + $this->assertSame($first, $second); + } + + public function testGetGroupfolderAvailabilityDelegates(): void { + $this->appManager + ->expects($this->once()) + ->method('isInstalled') + ->with('groupfolders') + ->willReturn(true); + + $this->assertTrue($this->service->hasGroupfolderApp()); + } + + public function testSetContentStorageRejectsUnsupportedValue(): void { + $this->settingMapper->expects($this->never())->method('setSetting'); + $this->expectException(InvalidArgumentException::class); + + $this->service->setContentStorage(value: 'cassette-tape'); + } + + public function testSetContentStoragePersistsKnownValues(): void { + $this->settingMapper + ->expects($this->once()) + ->method('setSetting') + ->with(AdminSetting::KEY_CONTENT_STORAGE, 'groupfolder'); + + $this->service->setContentStorage(value: SetupWizardService::STORAGE_GROUPFOLDER); + } + + public function testGetContentStorageDefaultsToDatabase(): void { + $this->settingMapper->method('getValue') + ->with(AdminSetting::KEY_CONTENT_STORAGE, null) + ->willReturn(null); + + $this->assertSame('database', $this->service->getContentStorage()); + } + + public function testGetContentStorageReturnsPersisted(): void { + $this->settingMapper->method('getValue') + ->with(AdminSetting::KEY_CONTENT_STORAGE, null) + ->willReturn('groupfolder'); + + $this->assertSame('groupfolder', $this->service->getContentStorage()); + } } diff --git a/tests/Unit/Service/SlugGeneratorTest.php b/tests/Unit/Service/SlugGeneratorTest.php index 9bd24ca2..03356506 100644 --- a/tests/Unit/Service/SlugGeneratorTest.php +++ b/tests/Unit/Service/SlugGeneratorTest.php @@ -25,114 +25,104 @@ /** * Unit tests for {@see SlugGenerator}. */ -class SlugGeneratorTest extends TestCase -{ - /** - * Standard alphanumeric name → simple lowercase slug. - * - * @return void - */ - public function testSlugifyLowercases(): void - { - $this->assertSame('marketing', SlugGenerator::slugify(name: 'Marketing')); - }//end testSlugifyLowercases() +class SlugGeneratorTest extends TestCase { + /** + * Standard alphanumeric name → simple lowercase slug. + * + * @return void + */ + public function testSlugifyLowercases(): void { + $this->assertSame('marketing', SlugGenerator::slugify(name: 'Marketing')); + }//end testSlugifyLowercases() - /** - * Multi-word names → dash-joined. - * - * @return void - */ - public function testSlugifySpacesBecomeDashes(): void - { - $this->assertSame( - 'q1-campaigns', - SlugGenerator::slugify(name: 'Q1 Campaigns') - ); - }//end testSlugifySpacesBecomeDashes() + /** + * Multi-word names → dash-joined. + * + * @return void + */ + public function testSlugifySpacesBecomeDashes(): void { + $this->assertSame( + 'q1-campaigns', + SlugGenerator::slugify(name: 'Q1 Campaigns') + ); + }//end testSlugifySpacesBecomeDashes() - /** - * Punctuation outside the grammar is stripped. - * - * @return void - */ - public function testSlugifyStripsPunctuation(): void - { - $this->assertSame( - 'hello-world', - SlugGenerator::slugify(name: 'Hello, World!') - ); - }//end testSlugifyStripsPunctuation() + /** + * Punctuation outside the grammar is stripped. + * + * @return void + */ + public function testSlugifyStripsPunctuation(): void { + $this->assertSame( + 'hello-world', + SlugGenerator::slugify(name: 'Hello, World!') + ); + }//end testSlugifyStripsPunctuation() - /** - * Repeated separators collapse to one dash. - * - * @return void - */ - public function testSlugifyCollapsesRepeatedDashes(): void - { - $this->assertSame( - 'foo-bar', - SlugGenerator::slugify(name: 'foo --- bar') - ); - }//end testSlugifyCollapsesRepeatedDashes() + /** + * Repeated separators collapse to one dash. + * + * @return void + */ + public function testSlugifyCollapsesRepeatedDashes(): void { + $this->assertSame( + 'foo-bar', + SlugGenerator::slugify(name: 'foo --- bar') + ); + }//end testSlugifyCollapsesRepeatedDashes() - /** - * Names that yield no legal characters → empty slug. - * - * @return void - */ - public function testSlugifyReturnsEmptyOnNoLegalChars(): void - { - $this->assertSame('', SlugGenerator::slugify(name: '!!!')); - }//end testSlugifyReturnsEmptyOnNoLegalChars() + /** + * Names that yield no legal characters → empty slug. + * + * @return void + */ + public function testSlugifyReturnsEmptyOnNoLegalChars(): void { + $this->assertSame('', SlugGenerator::slugify(name: '!!!')); + }//end testSlugifyReturnsEmptyOnNoLegalChars() - /** - * Slug exceeding 128 characters → truncated. - * - * @return void - */ - public function testSlugifyTruncatesLongInput(): void - { - $longName = str_repeat('a', 200); - $slug = SlugGenerator::slugify(name: $longName); + /** + * Slug exceeding 128 characters → truncated. + * + * @return void + */ + public function testSlugifyTruncatesLongInput(): void { + $longName = str_repeat('a', 200); + $slug = SlugGenerator::slugify(name: $longName); - $this->assertLessThanOrEqual(SlugGenerator::MAX_LENGTH, strlen($slug)); - }//end testSlugifyTruncatesLongInput() + $this->assertLessThanOrEqual(SlugGenerator::MAX_LENGTH, strlen($slug)); + }//end testSlugifyTruncatesLongInput() - /** - * isValid: legal grammar accepted. - * - * @return void - */ - public function testIsValidAcceptsLegalSlugs(): void - { - $this->assertTrue(SlugGenerator::isValid(slug: 'q1-campaigns')); - $this->assertTrue(SlugGenerator::isValid(slug: 'snake_case')); - $this->assertTrue(SlugGenerator::isValid(slug: 'abc123')); - }//end testIsValidAcceptsLegalSlugs() + /** + * isValid: legal grammar accepted. + * + * @return void + */ + public function testIsValidAcceptsLegalSlugs(): void { + $this->assertTrue(SlugGenerator::isValid(slug: 'q1-campaigns')); + $this->assertTrue(SlugGenerator::isValid(slug: 'snake_case')); + $this->assertTrue(SlugGenerator::isValid(slug: 'abc123')); + }//end testIsValidAcceptsLegalSlugs() - /** - * isValid: empty / uppercase / punctuation rejected. - * - * @return void - */ - public function testIsValidRejectsIllegalSlugs(): void - { - $this->assertFalse(SlugGenerator::isValid(slug: '')); - $this->assertFalse(SlugGenerator::isValid(slug: 'Q1')); - $this->assertFalse(SlugGenerator::isValid(slug: 'q1 campaigns')); - $this->assertFalse(SlugGenerator::isValid(slug: 'q1!')); - }//end testIsValidRejectsIllegalSlugs() + /** + * isValid: empty / uppercase / punctuation rejected. + * + * @return void + */ + public function testIsValidRejectsIllegalSlugs(): void { + $this->assertFalse(SlugGenerator::isValid(slug: '')); + $this->assertFalse(SlugGenerator::isValid(slug: 'Q1')); + $this->assertFalse(SlugGenerator::isValid(slug: 'q1 campaigns')); + $this->assertFalse(SlugGenerator::isValid(slug: 'q1!')); + }//end testIsValidRejectsIllegalSlugs() - /** - * isValid: 128-character cap enforced. - * - * @return void - */ - public function testIsValidRejectsOverLengthSlugs(): void - { - $this->assertFalse( - SlugGenerator::isValid(slug: str_repeat('a', 129)) - ); - }//end testIsValidRejectsOverLengthSlugs() + /** + * isValid: 128-character cap enforced. + * + * @return void + */ + public function testIsValidRejectsOverLengthSlugs(): void { + $this->assertFalse( + SlugGenerator::isValid(slug: str_repeat('a', 129)) + ); + }//end testIsValidRejectsOverLengthSlugs() }//end class diff --git a/tests/Unit/Service/SvgSanitiserTest.php b/tests/Unit/Service/SvgSanitiserTest.php index bd99462a..cbe9d4b7 100644 --- a/tests/Unit/Service/SvgSanitiserTest.php +++ b/tests/Unit/Service/SvgSanitiserTest.php @@ -25,243 +25,224 @@ namespace Unit\Service; use OCA\LaunchPad\Service\SvgSanitiser; -use PHPUnit\Framework\Attributes\Small; use PHPUnit\Framework\TestCase; -class SvgSanitiserTest extends TestCase -{ - private SvgSanitiser $sanitiser; - - protected function setUp(): void - { - $this->sanitiser = new SvgSanitiser(); - } - - public function testCleanSvgRoundTrips(): void - { - $svg = '' - . '' - . ''; - - $result = $this->sanitiser->sanitize($svg); - - $this->assertNotNull($result); - $this->assertStringContainsString('assertStringContainsString('fill="red"', $result); - } - - public function testScriptElementRemoved(): void - { - $svg = '' - . '' - . '' - . ''; - - $result = $this->sanitiser->sanitize($svg); - - $this->assertNotNull($result); - $this->assertStringNotContainsString('assertStringNotContainsString('alert', $result); - $this->assertStringContainsString('