diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..783ff3915 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,66 @@ +name: Bug report +description: Report a problem with Acquia CLI. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! Please fill out the fields below to help us reproduce and fix the issue. + + If you have a question or need help from Acquia, visit the [Acquia Support Portal](https://acquia.my.site.com/s/) or the [discussions section](https://github.com/acquia/cli/discussions) instead. + - type: input + id: acli-version + attributes: + label: Acquia CLI version + description: Output of `acli --version`. + placeholder: Acquia CLI 2.x.x + validations: + required: true + - type: input + id: php-version + attributes: + label: PHP version + description: Output of `php --version`. + placeholder: PHP 8.3.x + validations: + required: true + - type: input + id: os + attributes: + label: Operating system + description: Your OS and version, and whether you're running in a special environment (Acquia Cloud IDE, WSL, Docker, etc.). + placeholder: macOS 15.5, Ubuntu 24.04, Windows 11 (WSL2), Acquia Cloud IDE + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: The exact commands you ran and any relevant configuration. + placeholder: | + 1. Run `acli ...` + 2. ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What you expected to happen. + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What actually happened, including any error messages. + validations: + required: true + - type: textarea + id: verbose-output + attributes: + label: Verbose output + description: Re-run the failing command with maximum verbosity (`acli -vvv`) and paste the output. Remove any sensitive information such as API keys or tokens. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..19bf0fe27 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability report + url: https://www.acquia.com/why-acquia/acquia-security + about: Please do not report security vulnerabilities in public issues. Report them privately via Acquia's security program. + - name: Acquia Support Portal + url: https://acquia.my.site.com/s/ + about: Get support from Acquia for your subscription and products. + - name: GitHub Discussions + url: https://github.com/acquia/cli/discussions + about: Ask questions and discuss ideas with other Acquia CLI users. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..88458584f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,29 @@ +name: Feature request +description: Suggest a new feature or improvement for Acquia CLI. +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting an improvement! If you'd like to discuss an idea with other Acquia CLI users first, consider the [discussions section](https://github.com/acquia/cli/discussions). + - type: textarea + id: problem + attributes: + label: Problem statement + description: What problem are you trying to solve? What is currently difficult or impossible to do? + validations: + required: true + - type: textarea + id: proposed-solution + attributes: + label: Proposed solution + description: How would you like Acquia CLI to solve this? Include example commands or output if helpful. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any workarounds or alternative solutions you've considered. + validations: + required: false diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..9b1d140de --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# Acquia CLI + +PHP 8.2+ Symfony Console application distributed as a PHAR (built with Box). +Commands talk to the Acquia Cloud Platform API and Acquia Cloud Site Factory +(ACSF) API. + +## Commands + +```shell +composer install # install dependencies +composer test # lint + cs + stan + unit +composer unit # full PHPUnit suite (serial group, then paratest) +composer cs # phpcs (acquia/coding-standards) +composer cbf # phpcbf autofix +composer stan # PHPStan (pass --memory-limit=2G if it OOMs) +composer mutation # Infection mutation testing +composer box-install && composer box-compile # build var/acli.phar +``` + +Run a single test class: + +```shell +vendor/bin/phpunit --filter SomeCommandTest tests/phpunit/src/Commands/... +``` + +GrumPHP runs phpcs as a pre-commit hook; commits fail on style violations. + +## Architecture + +- `bin/acli` boots `src/Kernel.php` (Symfony DI container, services wired in + `config/`). Commands are services in `src/Command/`. +- `src/Command/CommandBase.php` is the base for all commands: telemetry, + authentication, alias→UUID conversion, update checks. +- `api:*` and `acsf:*` commands are NOT hand-written: they are generated at + runtime by `src/Command/Api/ApiCommandHelper.php` from the OpenAPI specs in + `assets/acquia-spec.json` and `assets/acsf-spec.yaml`. To change an API + command, change the helper or regenerate the spec (`composer + update-cloud-api-spec`), not individual command classes. +- `src/DataStore/` persists config: `CloudDataStore` (`~/.acquia/cloud_api.conf`, + contains API credentials — must stay chmod 0600) and `AcquiaCliDatastore`. +- Telemetry goes to Amplitude and errors to Bugsnag via + `src/Helpers/TelemetryHelper.php`. Anything derived from command input must + pass through `TelemetryHelper::redactSensitiveData()` before being sent. + +## Conventions + +- TDD: write or update the failing PHPUnit test before changing `src/`. +- Tests use prophecy mocks; base classes `tests/phpunit/src/TestBase.php` and + `CommandTestBase.php` provide mock helpers for the Cloud API client + (`mockRequest()` reads from the OpenAPI spec fixtures). +- PHPUnit metadata uses PHP attributes (`#[Group]`, `#[DataProvider]`), not + doc-comment annotations. +- Tests that mutate global state belong in the `serial` group + (`#[Group('serial')]`); everything else runs under paratest. +- `declare(strict_types=1);` in every file; strict comparisons; phpcs enforces + ordered use-statement imports. +- Process execution: always pass argument arrays to + `LocalMachineHelper::execute()`; never interpolate user input into + `executeFromCmd()` shell strings. +- SSH/rsync/git invocations use `-o StrictHostKeyChecking=accept-new`; do not + weaken to `=no`. + +## Gotchas + +- Symfony is pinned to 6.4: `typhonius/acquia-logstream` blocks Symfony 7. +- PHP_CodeSniffer is pinned to 3.x: `acquia/coding-standards` and + `drupal/coder` block phpcs 4. +- `minimum-stability: dev` is required by the `consolidation/self-update` + fork pin; `prefer-stable: true` keeps everything else on stable releases. +- The update check in `CommandBase::checkForNewVersion()` is cached for 24h + (`self:clear-caches` clears it). diff --git a/README.md b/README.md index 9cfa27baf..17c416aec 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,16 @@ Acquia CLI is not a local development environment. If you are looking for an int Install instructions and official documentation are available at https://docs.acquia.com/acquia-cli/install/ +### Shell completion + +Acquia CLI supports tab completion for bash, zsh, and fish. To enable it, run +`acli completion --help` and follow the installation instructions for your +shell. For example, for bash: + +```shell +acli completion bash > /etc/bash_completion.d/acli +``` + ## Contribution See [CONTRIBUTING.md](CONTRIBUTING.md) for instructions on building, testing, and contributing to Acquia CLI. diff --git a/bin/acli b/bin/acli index 15d54e06d..4fb063b81 100755 --- a/bin/acli +++ b/bin/acli @@ -24,6 +24,7 @@ use Acquia\Cli\Helpers\LocalMachineHelper; use Dotenv\Dotenv; use SelfUpdate\SelfUpdateCommand; use SelfUpdate\SelfUpdateManager; +use Symfony\Component\Console\CommandLoader\FactoryCommandLoader; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Filesystem\Filesystem; @@ -97,8 +98,12 @@ $application = $container->get(Application::class); $output = $container->get(OutputInterface::class); /** @var ApiCommandHelper $helper */ $helper = $container->get(ApiCommandHelper::class); -$application->addCommands($helper->getApiCommands( __DIR__ . '/../assets/acquia-spec.json', 'api', $container->get(ApiCommandFactory::class))); -$application->addCommands($helper->getApiCommands( __DIR__ . '/../assets/acsf-spec.json', 'acsf', $container->get(AcsfCommandFactory::class))); +// Register the spec-derived commands lazily so a normal invocation only builds +// the single command being run, rather than all ~500 API/ACSF commands. +$application->setCommandLoader(new FactoryCommandLoader(array_merge( + $helper->getApiCommandFactories(__DIR__ . '/../assets/acquia-spec.json', 'api', $container->get(ApiCommandFactory::class)), + $helper->getApiCommandFactories(__DIR__ . '/../assets/acsf-spec.json', 'acsf', $container->get(AcsfCommandFactory::class)) +))); try { /** @var SelfUpdateManager $selfUpdateManager*/ $selfUpdateManager = $container->get(SelfUpdateManager::class); diff --git a/composer.json b/composer.json index 25931e71e..9eb7b99ba 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,6 @@ "grasmash/expander": "^3.0.1", "guzzlehttp/guzzle": "^7.4", "http-interop/http-factory-guzzle": "^1.0", - "laminas/laminas-validator": "^2.20.0", "league/csv": "^9.8", "loophp/phposinfo": "^1.7.2", "ltd-beget/dns-zone-configurator": "^1.4.0", @@ -51,7 +50,7 @@ "symfony/process": "^6.4", "symfony/validator": "^6.4", "symfony/yaml": "^6.4", - "thecodingmachine/safe": "3.0.2", + "thecodingmachine/safe": "^3.4", "typhonius/acquia-logstream": "^0.0.15", "typhonius/acquia-php-sdk-v2": "^3.8.0", "vlucas/phpdotenv": "^5.5", @@ -61,17 +60,17 @@ "acquia/coding-standards": "^3.0.2", "brianium/paratest": "^7", "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", - "dominikb/composer-license-checker": "^2.4", - "infection/infection": "^0.31.2", - "jangregor/phpstan-prophecy": "^1.0", + "dominikb/composer-license-checker": "^3.0", + "infection/infection": "^0.32", + "jangregor/phpstan-prophecy": "^2.0", "mikey179/vfsstream": "^1.6", "overtrue/phplint": "^9.6.2", "phpro/grumphp": "^2.9.0", "phpspec/prophecy": "^1.17", "phpspec/prophecy-phpunit": "^2.0", "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", "phpunit/phpunit": "^11", "slevomat/coding-standard": "^8.10", "squizlabs/php_codesniffer": "^3.5", diff --git a/composer.lock b/composer.lock index bdbb12780..b4215450f 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": "1a77bea93c21f1af1d49773a69642456", + "content-hash": "ef40c9a910dc4704d29c344e291ad744", "packages": [ { "name": "acquia/drupal-environment-detector", @@ -250,16 +250,16 @@ }, { "name": "composer/ca-bundle", - "version": "1.5.11", + "version": "1.5.12", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "68ff39175e8e94a4bb1d259407ce51a6a60f09e6" + "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/68ff39175e8e94a4bb1d259407ce51a6a60f09e6", - "reference": "68ff39175e8e94a4bb1d259407ce51a6a60f09e6", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", + "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", "shasum": "" }, "require": { @@ -306,7 +306,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.11" + "source": "https://github.com/composer/ca-bundle/tree/1.5.12" }, "funding": [ { @@ -318,7 +318,7 @@ "type": "github" } ], - "time": "2026-03-30T09:16:10+00:00" + "time": "2026-05-19T11:26:22+00:00" }, { "name": "composer/semver", @@ -5564,16 +5564,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { @@ -5620,7 +5620,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -5640,7 +5640,7 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:10:57+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { "name": "symfony/process", @@ -6314,16 +6314,16 @@ }, { "name": "thecodingmachine/safe", - "version": "v3.0.2", + "version": "v3.4.0", "source": { "type": "git", "url": "https://github.com/thecodingmachine/safe.git", - "reference": "22ffad3248982a784f9870a37aeb2e522bd19645" + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/22ffad3248982a784f9870a37aeb2e522bd19645", - "reference": "22ffad3248982a784f9870a37aeb2e522bd19645", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", "shasum": "" }, "require": { @@ -6433,7 +6433,7 @@ "description": "PHP core functions that throw exceptions instead of returning FALSE on error", "support": { "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/v3.0.2" + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" }, "funding": [ { @@ -6444,12 +6444,16 @@ "url": "https://github.com/shish", "type": "github" }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, { "url": "https://github.com/staabm", "type": "github" } ], - "time": "2025-02-19T19:23:00+00:00" + "time": "2026-02-04T18:08:13+00:00" }, { "name": "typhonius/acquia-logstream", @@ -7845,16 +7849,16 @@ }, { "name": "composer/composer", - "version": "2.9.8", + "version": "2.10.1", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "39ee8baff8e97a1b657bbfcd6a236ff93a5efbb2" + "reference": "4120703b9bda8795075047b40361d7ec4d2abe49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/39ee8baff8e97a1b657bbfcd6a236ff93a5efbb2", - "reference": "39ee8baff8e97a1b657bbfcd6a236ff93a5efbb2", + "url": "https://api.github.com/repos/composer/composer/zipball/4120703b9bda8795075047b40361d7ec4d2abe49", + "reference": "4120703b9bda8795075047b40361d7ec4d2abe49", "shasum": "" }, "require": { @@ -7907,7 +7911,7 @@ ] }, "branch-alias": { - "dev-main": "2.9-dev" + "dev-main": "2.10-dev" } }, "autoload": { @@ -7942,7 +7946,7 @@ "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", "security": "https://github.com/composer/composer/security/policy", - "source": "https://github.com/composer/composer/tree/2.9.8" + "source": "https://github.com/composer/composer/tree/2.10.1" }, "funding": [ { @@ -7954,7 +7958,7 @@ "type": "github" } ], - "time": "2026-05-13T07:28:38+00:00" + "time": "2026-06-04T08:25:59+00:00" }, { "name": "composer/metadata-minifier", @@ -8592,33 +8596,33 @@ }, { "name": "dominikb/composer-license-checker", - "version": "2.7.0", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/dominikb/composer-license-checker.git", - "reference": "c94bda40a6cf37a98c8bf3494f8f655656724ce8" + "reference": "ae7cb2f0a7c5f581c9cf8188bcb2047b4dbc542a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dominikb/composer-license-checker/zipball/c94bda40a6cf37a98c8bf3494f8f655656724ce8", - "reference": "c94bda40a6cf37a98c8bf3494f8f655656724ce8", + "url": "https://api.github.com/repos/dominikb/composer-license-checker/zipball/ae7cb2f0a7c5f581c9cf8188bcb2047b4dbc542a", + "reference": "ae7cb2f0a7c5f581c9cf8188bcb2047b4dbc542a", "shasum": "" }, "require": { - "composer/composer": "~2.2.23 || ^2.7.0", + "composer/composer": "^2.7.7", "ext-json": "*", - "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", - "php": "^7.3 || ^8.0", + "guzzlehttp/guzzle": "^7.4.5", + "php": "^8.2", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", - "symfony/cache": "~4.2.12 || ^4.3.8 || ^5.2 || ^6.0 || ^7.0", - "symfony/console": "^5.3 || ^6.0 || ^7.0", - "symfony/css-selector": "^4.2 || ^5.2 || ^6.0 || ^7.0", - "symfony/dom-crawler": "^5.2 || ^6.0 || ^7.0" + "symfony/cache": "^6.0 || ^7.0 || ^8.0", + "symfony/console": "^6.0 || ^7.0 || ^8.0", + "symfony/css-selector": "^6.0 || ^7.0 || ^8.0", + "symfony/dom-crawler": "^6.0 || ^7.0 || ^8.0" }, "require-dev": { - "mockery/mockery": "^1.3.3", - "phpunit/phpunit": "^9.3", - "symfony/var-dumper": "^4.2 || ^5.2 || ^6.0 || ^7.0" + "mockery/mockery": "^1.3.6", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "symfony/var-dumper": "^6.0 || ^7.0 || ^8.0" }, "bin": [ "composer-license-checker" @@ -8651,9 +8655,9 @@ ], "support": { "issues": "https://github.com/dominikb/composer-license-checker/issues", - "source": "https://github.com/dominikb/composer-license-checker/tree/2.7.0" + "source": "https://github.com/dominikb/composer-license-checker/tree/3.0.0" }, - "time": "2025-01-26T20:46:05+00:00" + "time": "2026-04-08T20:36:51+00:00" }, { "name": "drupal/coder", @@ -9017,16 +9021,16 @@ }, { "name": "infection/infection", - "version": "0.31.9", + "version": "0.32.6", "source": { "type": "git", "url": "https://github.com/infection/infection.git", - "reference": "f9628fcd7f76eadf24726e57a81937c42458232b" + "reference": "4ed769947eaf2ecf42203027301bad2bedf037e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/infection/infection/zipball/f9628fcd7f76eadf24726e57a81937c42458232b", - "reference": "f9628fcd7f76eadf24726e57a81937c42458232b", + "url": "https://api.github.com/repos/infection/infection/zipball/4ed769947eaf2ecf42203027301bad2bedf037e5", + "reference": "4ed769947eaf2ecf42203027301bad2bedf037e5", "shasum": "" }, "require": { @@ -9043,20 +9047,22 @@ "infection/include-interceptor": "^0.2.5", "infection/mutator": "^0.4", "justinrainbow/json-schema": "^6.0", - "nikic/php-parser": "^5.3", + "nikic/php-parser": "^5.6.2", "ondram/ci-detector": "^4.1.0", "php": "^8.2", - "sanmai/di-container": "^0.1.4", + "psr/log": "^2.0 || ^3.0", + "sanmai/di-container": "^0.1.12", "sanmai/duoclock": "^0.1.0", "sanmai/later": "^0.1.7", - "sanmai/pipeline": "^7.0", - "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0", - "symfony/console": "^6.4 || ^7.0", - "symfony/filesystem": "^6.4 || ^7.0", - "symfony/finder": "^6.4 || ^7.0", - "symfony/process": "^6.4 || ^7.0", + "sanmai/pipeline": "^7.2", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", + "symfony/finder": "^6.4 || ^7.0 || ^8.0", + "symfony/polyfill-php85": "^1.33", + "symfony/process": "^6.4 || ^7.0 || ^8.0", "thecodingmachine/safe": "^v3.0", - "webmozart/assert": "^1.11" + "webmozart/assert": "^1.11 || ^2.0" }, "conflict": { "antecedent/patchwork": "<2.1.25", @@ -9065,18 +9071,21 @@ "require-dev": { "ext-simplexml": "*", "fidry/makefile": "^1.0", + "fig/log-test": "^1.2", + "phpbench/phpbench": "^1.4", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.1", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-strict-rules": "^2.0", "phpstan/phpstan-webmozart-assert": "^2.0", "phpunit/phpunit": "^11.5.27", - "rector/rector": "^2.0", - "shipmonk/dead-code-detector": "^0.12.0", + "rector/rector": "^2.2.4", + "shipmonk/dead-code-detector": "^0.14.0", "shipmonk/name-collision-detector": "^2.1", "sidz/phpstan-rules": "^0.5.1", - "symfony/yaml": "^6.4 || ^7.0", - "thecodingmachine/phpstan-safe-rule": "^1.4" + "symfony/yaml": "^6.4 || ^7.0 || ^8.0", + "thecodingmachine/phpstan-safe-rule": "^1.4", + "webmozarts/strict-phpunit": "^7.15" }, "bin": [ "bin/infection" @@ -9132,7 +9141,7 @@ ], "support": { "issues": "https://github.com/infection/infection/issues", - "source": "https://github.com/infection/infection/tree/0.31.9" + "source": "https://github.com/infection/infection/tree/0.32.6" }, "funding": [ { @@ -9144,7 +9153,7 @@ "type": "open_collective" } ], - "time": "2025-10-27T12:00:54+00:00" + "time": "2026-02-26T14:34:26+00:00" }, { "name": "infection/mutator", @@ -9201,32 +9210,35 @@ }, { "name": "jangregor/phpstan-prophecy", - "version": "1.0.2", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/Jan0707/phpstan-prophecy.git", - "reference": "5ee56c7db1d58f0578c82a35e3c1befe840e85a9" + "reference": "42b6e62dc0fa5724a667cb9a10c7245580f22033" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Jan0707/phpstan-prophecy/zipball/5ee56c7db1d58f0578c82a35e3c1befe840e85a9", - "reference": "5ee56c7db1d58f0578c82a35e3c1befe840e85a9", + "url": "https://api.github.com/repos/Jan0707/phpstan-prophecy/zipball/42b6e62dc0fa5724a667cb9a10c7245580f22033", + "reference": "42b6e62dc0fa5724a667cb9a10c7245580f22033", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0", - "phpstan/phpstan": "^1.0.0" + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.5" }, "conflict": { - "phpspec/prophecy": "<1.7.0 || >=2.0.0", - "phpunit/phpunit": "<6.0.0 || >=12.0.0" + "phpspec/prophecy": "<1.17.0 || >=2.0.0", + "phpspec/prophecy-phpunit": "<2.3.0 || >=3.0.0", + "phpunit/phpunit": "<9.1.0 || >=14.0.0" }, "require-dev": { - "ergebnis/composer-normalize": "^2.1.1", - "ergebnis/license": "^1.0.0", - "ergebnis/php-cs-fixer-config": "~2.2.0", + "ergebnis/composer-normalize": "^2.50.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.59.0", + "php-cs-fixer/shim": "^3.93.0", "phpspec/prophecy": "^1.7.0", - "phpunit/phpunit": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" + "phpspec/prophecy-phpunit": "^2.3", + "phpunit/phpunit": "^9.1.0" }, "type": "phpstan-extension", "extra": { @@ -9254,9 +9266,9 @@ "description": "Provides a phpstan/phpstan extension for phpspec/prophecy", "support": { "issues": "https://github.com/Jan0707/phpstan-prophecy/issues", - "source": "https://github.com/Jan0707/phpstan-prophecy/tree/1.0.2" + "source": "https://github.com/Jan0707/phpstan-prophecy/tree/2.3.0" }, - "time": "2024-04-03T08:15:54+00:00" + "time": "2026-02-10T08:07:43+00:00" }, { "name": "jean85/pretty-package-versions", @@ -9320,16 +9332,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.8.2", + "version": "6.9.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76" + "reference": "bd1bda2ebfc8bff418565941771ea8f03c557886" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2c89ebb95ca9cedc9347f780333f7b25792dcb76", - "reference": "2c89ebb95ca9cedc9347f780333f7b25792dcb76", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/bd1bda2ebfc8bff418565941771ea8f03c557886", + "reference": "bd1bda2ebfc8bff418565941771ea8f03c557886", "shasum": "" }, "require": { @@ -9339,7 +9351,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "3.3.0", - "json-schema/json-schema-test-suite": "dev-main", + "json-schema/json-schema-test-suite": "^23.2", "marc-mabe/php-enum-phpstan": "^2.0", "phpspec/prophecy": "^1.19", "phpstan/phpstan": "^1.12", @@ -9389,9 +9401,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.8.2" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.9.0" }, - "time": "2026-05-05T05:39:01+00:00" + "time": "2026-06-05T14:05:24+00:00" }, { "name": "kelunik/certificate", @@ -11026,15 +11038,15 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.33", + "version": "2.2.2", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", - "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", "shasum": "" }, "require": { - "php": "^7.2|^8.0" + "php": "^7.4|^8.0" }, "conflict": { "phpstan/phpstan-shim": "*" @@ -11053,6 +11065,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -11075,30 +11098,30 @@ "type": "github" } ], - "time": "2026-02-28T20:30:03+00:00" + "time": "2026-06-05T09:00:01+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", - "version": "1.2.1", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan-deprecation-rules.git", - "reference": "f94d246cc143ec5a23da868f8f7e1393b50eaa82" + "reference": "6b5571001a7f04fa0422254c30a0017ec2f2cacc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/f94d246cc143ec5a23da868f8f7e1393b50eaa82", - "reference": "f94d246cc143ec5a23da868f8f7e1393b50eaa82", + "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/6b5571001a7f04fa0422254c30a0017ec2f2cacc", + "reference": "6b5571001a7f04fa0422254c30a0017ec2f2cacc", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^1.12" + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.39" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/phpstan-phpunit": "^1.0", - "phpunit/phpunit": "^9.5" + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6" }, "type": "phpstan-extension", "extra": { @@ -11118,11 +11141,14 @@ "MIT" ], "description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.", + "keywords": [ + "static analysis" + ], "support": { "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues", - "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/1.2.1" + "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.4" }, - "time": "2024-09-11T15:52:35+00:00" + "time": "2026-02-09T13:21:14+00:00" }, { "name": "phpunit/php-code-coverage", @@ -11754,16 +11780,16 @@ }, { "name": "sanmai/di-container", - "version": "0.1.12", + "version": "0.1.17", "source": { "type": "git", "url": "https://github.com/sanmai/di-container.git", - "reference": "8b9ad72f6ac1f9e185e5bd060dc9479cb5191d8b" + "reference": "a901c4a8778c9212ef4d66607525281af2f787bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sanmai/di-container/zipball/8b9ad72f6ac1f9e185e5bd060dc9479cb5191d8b", - "reference": "8b9ad72f6ac1f9e185e5bd060dc9479cb5191d8b", + "url": "https://api.github.com/repos/sanmai/di-container/zipball/a901c4a8778c9212ef4d66607525281af2f787bd", + "reference": "a901c4a8778c9212ef4d66607525281af2f787bd", "shasum": "" }, "require": { @@ -11821,7 +11847,7 @@ ], "support": { "issues": "https://github.com/sanmai/di-container/issues", - "source": "https://github.com/sanmai/di-container/tree/0.1.12" + "source": "https://github.com/sanmai/di-container/tree/0.1.17" }, "funding": [ { @@ -11829,7 +11855,7 @@ "type": "github" } ], - "time": "2026-01-27T08:25:46+00:00" + "time": "2026-06-01T08:52:14+00:00" }, { "name": "sanmai/duoclock", @@ -13950,23 +13976,23 @@ }, { "name": "webmozart/assert", - "version": "1.12.1", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.2" }, "suggest": { "ext-intl": "", @@ -13975,8 +14001,12 @@ }, "type": "library", "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, "branch-alias": { - "dev-master": "1.10-dev" + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -13992,6 +14022,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -14002,9 +14036,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/2.4.0" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2026-05-20T13:07:01+00:00" } ], "aliases": [ @@ -14029,5 +14063,5 @@ "platform-overrides": { "php": "8.2.29" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/infection.json5 b/infection.json5 index 6c8aec239..d557b0291 100644 --- a/infection.json5 +++ b/infection.json5 @@ -14,7 +14,10 @@ "mutators": { "@default": true, "global-ignoreSourceCodeByRegex": [ - "\\$this->logger.*" + "\\$this->logger.*", + // Cache TTLs only affect expiry timing, which is not observable in a + // unit test without manipulating the clock. + ".*->expiresAfter\\(.*" ] }, "timeout": 30, diff --git a/src/Command/Api/ApiCommandHelper.php b/src/Command/Api/ApiCommandHelper.php index aaa2236fd..e00eced0a 100644 --- a/src/Command/Api/ApiCommandHelper.php +++ b/src/Command/Api/ApiCommandHelper.php @@ -4,9 +4,10 @@ namespace Acquia\Cli\Command\Api; +use Acquia\Cli\Command\Acsf\AcsfListCommand; use Acquia\Cli\CommandFactoryInterface; use Acquia\Cli\Exception\AcquiaCliException; -use Symfony\Component\Cache\Adapter\NullAdapter; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\Adapter\PhpArrayAdapter; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputDefinition; @@ -15,6 +16,11 @@ class ApiCommandHelper { + /** + * @var array> + */ + private array $loadedSpecs = []; + public function __construct( private ConsoleLogger $logger ) { @@ -339,14 +345,21 @@ private function isApiSpecChecksumCacheValid(\Symfony\Component\Cache\CacheItem */ private function getCloudApiSpec(string $specFilePath): array { + // Memoize within the process so building many commands (e.g. for the + // command list) loads the spec at most once. + if (isset($this->loadedSpecs[$specFilePath])) { + return $this->loadedSpecs[$specFilePath]; + } $cacheKey = basename($specFilePath); - $cache = new PhpArrayAdapter(__DIR__ . '/../../../var/cache/' . $cacheKey . '.cache', new NullAdapter()); + // Fall back to a filesystem cache so the parsed spec is still cached + // between invocations when the warmed PHP cache file is absent. + $cache = new PhpArrayAdapter(__DIR__ . '/../../../var/cache/' . $cacheKey . '.cache', new FilesystemAdapter()); $cacheItemChecksum = $cache->getItem($cacheKey . '.checksum'); $cacheItemSpec = $cache->getItem($cacheKey); // When running the phar, the original file may not exist. In that case, always use the cache. if (!file_exists($specFilePath) && $cacheItemSpec->isHit()) { - return $cacheItemSpec->get(); + return $this->loadedSpecs[$specFilePath] = $cacheItemSpec->get(); } // Otherwise, only use cache when it is valid. @@ -356,19 +369,26 @@ private function getCloudApiSpec(string $specFilePath): array $this->useCloudApiSpecCache() && $this->isApiSpecChecksumCacheValid($cacheItemChecksum, $checksum) && $cacheItemSpec->isHit() ) { - return $cacheItemSpec->get(); + return $this->loadedSpecs[$specFilePath] = $cacheItemSpec->get(); } // Parse file. This can take a long while! $this->logger->debug("Rebuilding caches..."); $spec = json_decode(file_get_contents($specFilePath), true); + // Populate the fallback cache so the parse result survives even if + // the warmed PHP cache file is later deleted. + $cacheItemSpec->set($spec); + $cache->save($cacheItemSpec); + $cacheItemChecksum->set($checksum); + $cache->save($cacheItemChecksum); + $cache->warmUp([ $cacheKey => $spec, $cacheKey . '.checksum' => $checksum, ]); - return $spec; + return $this->loadedSpecs[$specFilePath] = $spec; } /** @@ -377,45 +397,168 @@ private function getCloudApiSpec(string $specFilePath): array private function generateApiCommandsFromSpec(array $acquiaCloudSpec, string $commandPrefix, CommandFactoryInterface $commandFactory): array { $apiCommands = []; + $skippedApiCommands = $this->getSkippedApiCommands(); foreach ($acquiaCloudSpec['paths'] as $path => $endpoint) { foreach ($endpoint as $method => $schema) { if (!array_key_exists('x-cli-name', $schema)) { continue; } - if (in_array($schema['x-cli-name'], $this->getSkippedApiCommands(), true)) { + if (in_array($schema['x-cli-name'], $skippedApiCommands, true)) { continue; } - $commandName = $commandPrefix . ':' . $schema['x-cli-name']; - $command = $commandFactory->createCommand(); - $command->setName($commandName); - $command->setDescription($schema['summary']); - $command->setMethod($method); - $command->setResponses($schema['responses']); - $command->setHidden( - self::isDeprecated($schema) || self::isPreRelease($schema) - ); - if (array_key_exists('servers', $acquiaCloudSpec)) { - $command->setServers($acquiaCloudSpec['servers']); - } - $command->setPath($path); + $apiCommands[] = $this->buildApiCommand($path, $method, $schema, $acquiaCloudSpec, $commandPrefix, $commandFactory); + } + } - $helpText = "For more help, see https://cloudapi-docs.acquia.com/ or https://dev.acquia.com/api-documentation/acquia-cloud-site-factory-api for acsf commands."; - if (self::isPreRelease($schema)) { - $helpText .= "\n\nThis endpoint is pre-release and therefore unsupported and may be changed or removed without notice."; - } - if (self::isDeprecated($schema)) { - $helpText .= "\n\nThis endpoint is deprecated and may be removed without notice."; - } - $command->setHelp($helpText); + return $apiCommands; + } - $this->addApiCommandParameters($schema, $acquiaCloudSpec, $command); - $apiCommands[] = $command; + /** + * Build a single fully-configured API command from its spec entry. + */ + private function buildApiCommand(string $path, string $method, array $schema, array $acquiaCloudSpec, string $commandPrefix, CommandFactoryInterface $commandFactory): ApiBaseCommand + { + $command = $commandFactory->createCommand(); + $command->setName($commandPrefix . ':' . $schema['x-cli-name']); + $command->setDescription($schema['summary']); + $command->setMethod($method); + $command->setResponses($schema['responses']); + $command->setHidden( + self::isDeprecated($schema) || self::isPreRelease($schema) + ); + if (array_key_exists('servers', $acquiaCloudSpec)) { + $command->setServers($acquiaCloudSpec['servers']); + } + $command->setPath($path); + + $helpText = "For more help, see https://cloudapi-docs.acquia.com/ or https://dev.acquia.com/api-documentation/acquia-cloud-site-factory-api for acsf commands."; + if (self::isPreRelease($schema)) { + $helpText .= "\n\nThis endpoint is pre-release and therefore unsupported and may be changed or removed without notice."; + } + if (self::isDeprecated($schema)) { + $helpText .= "\n\nThis endpoint is deprecated and may be removed without notice."; + } + $command->setHelp($helpText); + + $this->addApiCommandParameters($schema, $acquiaCloudSpec, $command); + return $command; + } + + /** + * Build a lazy command-loader map for every spec-derived command. + * + * Each entry is a closure that fully configures one command only when it is + * actually requested. Registration is driven by a lightweight manifest + * (~30 KB) instead of the full ~1 MB spec, so invoking a non-API command + * (the common case) never loads or parses the full spec at all. + * + * @return array + */ + public function getApiCommandFactories(string $acquiaCloudSpecFilePath, string $commandPrefix, CommandFactoryInterface $commandFactory): array + { + $manifest = $this->getApiSpecManifest($acquiaCloudSpecFilePath); + $factories = []; + // Tracks whether each namespace has at least one visible command, so + // the namespace list commands match generateApiListCommands() exactly. + $namespaceHasVisibleCommand = []; + foreach ($manifest as $cliName => $entry) { + $name = $commandPrefix . ':' . $cliName; + $path = $entry['path']; + $method = $entry['method']; + $factories[$name] = function () use ($acquiaCloudSpecFilePath, $path, $method, $commandPrefix, $commandFactory): ApiBaseCommand { + $acquiaCloudSpec = $this->getCloudApiSpec($acquiaCloudSpecFilePath); + return $this->buildApiCommand($path, $method, $acquiaCloudSpec['paths'][$path][$method], $acquiaCloudSpec, $commandPrefix, $commandFactory); + }; + + $nameParts = explode(':', $name); + if (count($nameParts) < 3) { + continue; + } + $namespace = $nameParts[1]; + $namespaceHasVisibleCommand[$namespace] ??= false; + if (!$entry['deprecated'] && !$entry['prerelease']) { + $namespaceHasVisibleCommand[$namespace] = true; + } + } + foreach ($namespaceHasVisibleCommand as $namespace => $hasVisibleCommand) { + if (!$hasVisibleCommand) { + continue; } + $listName = $commandPrefix . ':' . $namespace; + $factories[$listName] = fn (): ApiListCommand|AcsfListCommand => $this->buildApiListCommand($listName, $namespace, $commandFactory); } + return $factories; + } - return $apiCommands; + /** + * Build (and cache) a lightweight manifest of the spec for command + * registration: command name, its path/method, and visibility flags. + * + * The manifest is stored in its own cache file, separate from the full + * spec, so loading it does not pull the full spec into memory. + * + * @return array + * @infection-ignore-all + */ + private function getApiSpecManifest(string $specFilePath): array + { + $cacheKey = basename($specFilePath) . '.manifest'; + $cache = new PhpArrayAdapter(__DIR__ . '/../../../var/cache/' . $cacheKey . '.cache', new FilesystemAdapter()); + $manifestItem = $cache->getItem($cacheKey); + $checksumItem = $cache->getItem($cacheKey . '.checksum'); + + // When running the phar, the original spec file may not exist; rely on + // the bundled cache. + if (!file_exists($specFilePath) && $manifestItem->isHit()) { + return $manifestItem->get(); + } + + $checksum = md5_file($specFilePath); + if ( + $this->useCloudApiSpecCache() + && $this->isApiSpecChecksumCacheValid($checksumItem, $checksum) + && $manifestItem->isHit() + ) { + return $manifestItem->get(); + } + + $manifest = $this->buildApiSpecManifest($this->getCloudApiSpec($specFilePath)); + $cache->warmUp([ + $cacheKey => $manifest, + $cacheKey . '.checksum' => $checksum, + ]); + return $manifest; + } + + /** + * Reduce a full spec to the manifest entries used for command registration. + * + * @param array $acquiaCloudSpec + * @return array + */ + private function buildApiSpecManifest(array $acquiaCloudSpec): array + { + $skippedApiCommands = $this->getSkippedApiCommands(); + $manifest = []; + foreach ($acquiaCloudSpec['paths'] as $path => $endpoint) { + foreach ($endpoint as $method => $schema) { + if (!array_key_exists('x-cli-name', $schema)) { + continue; + } + if (in_array($schema['x-cli-name'], $skippedApiCommands, true)) { + continue; + } + $manifest[$schema['x-cli-name']] = [ + 'deprecated' => self::isDeprecated($schema), + 'method' => $method, + 'path' => $path, + 'prerelease' => self::isPreRelease($schema), + ]; + } + } + return $manifest; } /** @@ -555,62 +698,48 @@ public static function restoreRenamedParameter(string $propKey): string */ private function generateApiListCommands(array $apiCommands, string $commandPrefix, CommandFactoryInterface $commandFactory): array { - $apiListCommands = []; + // List commands (api:{namespace}) are only registered when at least one + // sub-command under that namespace exists and is visible. If every + // sub-command is hidden (deprecated/pre-release), the namespace list is + // omitted. Index namespace visibility in a single pass to avoid + // re-scanning the full command list for every command. + $namespaceHasVisibleCommand = []; foreach ($apiCommands as $apiCommand) { $commandNameParts = explode(':', $apiCommand->getName()); if (count($commandNameParts) < 3) { continue; } $namespace = $commandNameParts[1]; - $name = $commandPrefix . ':' . $namespace; - $hasVisibleCommand = $this->namespaceHasVisibleCommand($apiCommands, $namespace); - if (!array_key_exists($name, $apiListCommands) && $hasVisibleCommand) { - /** @var \Acquia\Cli\Command\Acsf\AcsfListCommand|\Acquia\Cli\Command\Api\ApiListCommand $command */ - $command = $commandFactory->createListCommand(); - $command->setName($name); - $command->setNamespace($name); - $command->setAliases([]); - $command->setDescription("List all API commands for the $namespace resource"); - $apiListCommands[$name] = $command; + if (!array_key_exists($namespace, $namespaceHasVisibleCommand)) { + $namespaceHasVisibleCommand[$namespace] = false; + } + if (!$apiCommand->isHidden()) { + $namespaceHasVisibleCommand[$namespace] = true; } } + + $apiListCommands = []; + foreach ($namespaceHasVisibleCommand as $namespace => $hasVisibleCommand) { + if (!$hasVisibleCommand) { + continue; + } + $name = $commandPrefix . ':' . $namespace; + $apiListCommands[$name] = $this->buildApiListCommand($name, $namespace, $commandFactory); + } return $apiListCommands; } /** - * Whether any API command in the given namespace is visible (not hidden). - * - * List commands (api:{namespace}) are only registered when at least one sub-command - * under that namespace exists and is visible. If every sub-command is hidden - * (deprecated/pre-release), the namespace list is omitted. - * - * @param ApiBaseCommand[] $apiCommands + * Build a single namespace list command (e.g. api:accounts). */ - private function namespaceHasVisibleCommand(array $apiCommands, string $namespace): bool + private function buildApiListCommand(string $name, string $namespace, CommandFactoryInterface $commandFactory): ApiListCommand|AcsfListCommand { - $commandsInNamespace = []; - foreach ($apiCommands as $apiCommand) { - $commandNameParts = explode(':', $apiCommand->getName()); - if (count($commandNameParts) < 3) { - continue; - } - if ($commandNameParts[1] !== $namespace) { - continue; - } - $commandsInNamespace[] = $apiCommand; - } - - if ($commandsInNamespace === []) { - return false; - } - - foreach ($commandsInNamespace as $command) { - if (!$command->isHidden()) { - return true; - } - } - - return false; + $command = $commandFactory->createListCommand(); + $command->setName($name); + $command->setNamespace($name); + $command->setAliases([]); + $command->setDescription("List all API commands for the $namespace resource"); + return $command; } /** diff --git a/src/Command/CommandBase.php b/src/Command/CommandBase.php index 431d5e527..650b0e350 100644 --- a/src/Command/CommandBase.php +++ b/src/Command/CommandBase.php @@ -54,6 +54,7 @@ use AcquiaLogstream\LogstreamManager; use ArrayObject; use Closure; +use DateInterval; use Exception; use JsonException; use loophp\phposinfo\OsInfo; @@ -64,6 +65,8 @@ use Safe\Exceptions\FilesystemException; use SelfUpdate\SelfUpdateManager; use stdClass; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\CacheItem; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\FormatterHelper; use Symfony\Component\Console\Helper\ProgressBar; @@ -99,8 +102,6 @@ abstract class CommandBase extends Command implements LoggerAwareInterface protected FormatterHelper $formatter; - private ApplicationResponse $cloudApplication; - protected string $siteId = ""; protected string $dir; @@ -301,7 +302,7 @@ public function run(InputInterface $input, OutputInterface $output): int $exitCode === 0 && in_array($input->getFirstArgument(), [ 'self-update', 'update', - ]) + ], true) ) { // Exit immediately to avoid loading additional classes breaking updates. // @see https://github.com/acquia/cli/issues/218 @@ -309,9 +310,9 @@ public function run(InputInterface $input, OutputInterface $output): int } $eventProperties = [ 'app_version' => $this->getApplication()->getVersion(), - 'arguments' => $input->getArguments(), + 'arguments' => TelemetryHelper::redactSensitiveData($input->getArguments()), 'exit_code' => $exitCode, - 'options' => $input->getOptions(), + 'options' => TelemetryHelper::redactSensitiveData($input->getOptions()), 'os_name' => OsInfo::os(), 'os_version' => OsInfo::version(), 'platform' => OsInfo::family(), @@ -558,7 +559,7 @@ protected function rsyncFiles(string $sourceDir, string $destinationDir, ?callab // -h output numbers in a human-readable format. // -e specify the remote shell to use. '-avPhze', - 'ssh -o StrictHostKeyChecking=no', + 'ssh -o StrictHostKeyChecking=accept-new', $sourceDir . '/', $destinationDir, ]; @@ -1597,6 +1598,14 @@ public static function getAliasCache(): AliasCache return new AliasCache('acli_aliases'); } + /** + * Return the cache used to throttle update checks. + */ + public static function getUpdateCheckCache(): FilesystemAdapter + { + return new FilesystemAdapter('acli_update_check'); + } + /** * @throws \Acquia\Cli\Exception\AcquiaCliException */ @@ -1683,17 +1692,25 @@ public function checkForNewVersion(): bool|string if (AcquiaDrupalEnvironmentDetector::isAhIdeEnv()) { return false; } - if ($this->getApplication()->getVersion() === 'dev-unknown') { + $version = $this->getApplication()->getVersion(); + if ($version === 'dev-unknown') { return false; } - try { - if (!$this->selfUpdateManager->isUpToDate()) { - return $this->selfUpdateManager->getLatestReleaseFromGithub()['tag_name']; + // Only hit the GitHub API once per day per installed version. + return self::getUpdateCheckCache()->get( + 'latest-version.' . preg_replace('/[^A-Za-z0-9_.]/', '_', $version), + function (CacheItem $item): bool|string { + $item->expiresAfter(new DateInterval('P1D')); + try { + if (!$this->selfUpdateManager->isUpToDate()) { + return $this->selfUpdateManager->getLatestReleaseFromGithub()['tag_name']; + } + } catch (Exception) { + $this->logger->debug("Could not determine if Acquia CLI has a new version available."); + } + return false; } - } catch (Exception) { - $this->logger->debug("Could not determine if Acquia CLI has a new version available."); - } - return false; + ); } /** diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index d50e49fd9..250214398 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -582,7 +582,7 @@ private function cloneFromCloud(EnvironmentResponse $chosenEnvironment, Closure $chosenEnvironment->vcs->url, $this->dir, ]; - $process = $this->localMachineHelper->execute($command, $outputCallback, null, ($this->output->getVerbosity() > OutputInterface::VERBOSITY_NORMAL), null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=no']); + $process = $this->localMachineHelper->execute($command, $outputCallback, null, ($this->output->getVerbosity() > OutputInterface::VERBOSITY_NORMAL), null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new']); $this->checkoutBranchFromEnv($chosenEnvironment, $outputCallback); if (!$process->isSuccessful()) { throw new AcquiaCliException('Failed to clone repository from the Cloud Platform: {message}', ['message' => $process->getErrorOutput()]); diff --git a/src/Command/Push/PushDatabaseCommand.php b/src/Command/Push/PushDatabaseCommand.php index 94b7f7f51..c73bd45b5 100644 --- a/src/Command/Push/PushDatabaseCommand.php +++ b/src/Command/Push/PushDatabaseCommand.php @@ -80,7 +80,7 @@ private function uploadDatabaseDump( $command = [ 'rsync', '-tDvPhe', - 'ssh -o StrictHostKeyChecking=no', + 'ssh -o StrictHostKeyChecking=accept-new', $localFilepath, $environment->sshUrl . ':' . $remoteFilepath, ]; diff --git a/src/Command/Remote/AliasesDownloadCommand.php b/src/Command/Remote/AliasesDownloadCommand.php index 771542ba9..ad9e17394 100644 --- a/src/Command/Remote/AliasesDownloadCommand.php +++ b/src/Command/Remote/AliasesDownloadCommand.php @@ -140,11 +140,26 @@ protected function downloadDrush9Aliases(InputInterface $input, string $aliasVer $drushFiles[] = $baseDir . '/' . $file->getFileName(); } } + // Convert warnings on permissions errors to exceptions so they can + // be caught and reported instead of being silently ignored. + set_error_handler(static function (int $errno, string $errstr): bool { + throw new \RuntimeException($errstr); + }); try { - // Throws warnings on permissions errors. - @$archive->extractTo($drushAliasesDir, $drushFiles, true); - } catch (\Exception) { - throw new AcquiaCliException('Could not extract aliases to {destination}', ['destination' => $drushAliasesDir]); + $extracted = $archive->extractTo($drushAliasesDir, $drushFiles, true); + } catch (\Exception $exception) { + throw new AcquiaCliException('Failed to extract aliases archive at {filepath}: {message}', [ + 'filepath' => $drushArchiveTempFilepath, + 'message' => $exception->getMessage(), + ]); + } finally { + restore_error_handler(); + } + if (!$extracted) { + throw new AcquiaCliException('Failed to extract aliases archive at {filepath}: could not extract to {destination}', [ + 'destination' => $drushAliasesDir, + 'filepath' => $drushArchiveTempFilepath, + ]); } } diff --git a/src/Command/Self/ClearCacheCommand.php b/src/Command/Self/ClearCacheCommand.php index b984aab03..0b71c16b9 100644 --- a/src/Command/Self/ClearCacheCommand.php +++ b/src/Command/Self/ClearCacheCommand.php @@ -33,6 +33,7 @@ public static function clearCaches(): void { $cache = self::getAliasCache(); $cache->clear(); + self::getUpdateCheckCache()->clear(); $systemCacheDir = Path::join(sys_get_temp_dir(), 'symphony-cache'); $fs = new Filesystem(); $fs->remove([$systemCacheDir]); diff --git a/src/Command/Ssh/SshKeyCommandBase.php b/src/Command/Ssh/SshKeyCommandBase.php index 9680e8f94..c7132cb88 100644 --- a/src/Command/Ssh/SshKeyCommandBase.php +++ b/src/Command/Ssh/SshKeyCommandBase.php @@ -265,6 +265,16 @@ private function doCreateSshKey(string $filename, string $password): string throw new AcquiaCliException($process->getOutput() . $process->getErrorOutput()); } + // The ssh-keygen utility sets restrictive permissions itself, but + // enforce them defensively in case of a permissive umask or + // non-standard ssh-keygen implementation. + if (file_exists($filepath)) { + chmod($filepath, 0600); + } + if (file_exists($this->sshDir)) { + chmod($this->sshDir, 0700); + } + return $filepath; } diff --git a/src/DataStore/JsonDataStore.php b/src/DataStore/JsonDataStore.php index 659fae004..9347df15f 100644 --- a/src/DataStore/JsonDataStore.php +++ b/src/DataStore/JsonDataStore.php @@ -34,6 +34,8 @@ public function __construct(string $path, ?ConfigurationInterface $configDefinit public function dump(): void { $this->fileSystem->dumpFile($this->filepath, json_encode($this->data->export(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + // Restrict access since this file may contain credentials. + $this->fileSystem->chmod($this->filepath, 0600); } protected function cleanLegacyConfig(array &$array): bool diff --git a/src/Helpers/LocalMachineHelper.php b/src/Helpers/LocalMachineHelper.php index 8a6557089..44094146c 100644 --- a/src/Helpers/LocalMachineHelper.php +++ b/src/Helpers/LocalMachineHelper.php @@ -110,7 +110,7 @@ public function executeFromCmd(string $cmd, ?callable $callback = null, ?string */ private function configureProcess(Process $process, ?string $cwd = null, ?bool $printOutput = true, ?float $timeout = null, ?array $env = null, bool $stdin = true): Process { - if (function_exists('posix_isatty') && $stdin && !@posix_isatty(STDIN)) { + if (function_exists('posix_isatty') && $stdin && !$this->isTtyStream(STDIN)) { $process->setInput(STDIN); } if ($cwd) { @@ -125,6 +125,32 @@ private function configureProcess(Process $process, ?string $cwd = null, ?bool $ return $process; } + /** + * Determines whether a stream refers to an interactive terminal. + * + * posix_isatty() emits a warning (and returns FALSE) for streams that + * cannot be mapped to a file descriptor, e.g. in-memory streams. Handle + * that case explicitly instead of suppressing errors. + * + * @param resource|int $fileDescriptor + */ + private function isTtyStream(mixed $fileDescriptor): bool + { + if (!function_exists('posix_isatty')) { + return false; + } + set_error_handler(static function (): bool { + // Swallow the warning; posix_isatty() returns FALSE in this + // case, correctly reporting the stream as not a TTY. + return true; + }); + try { + return posix_isatty($fileDescriptor); + } finally { + restore_error_handler(); + } + } + private function executeProcess(Process $process, ?callable $callback = null, ?bool $printOutput = true, ?array $env = null): Process { if ($callback === null && $printOutput !== false) { @@ -400,7 +426,20 @@ public function startBrowser(?string $uri = null, ?string $browser = null): bool } if ($browser) { $this->io->info("Opening $uri"); - $this->executeFromCmd("$browser $uri"); + // Split the browser command on whitespace to support commands + // with arguments (e.g. "open -a Firefox") while passing the URI + // as a single argument so it is never shell-interpreted. + $cmd = array_values(array_filter(explode(' ', $browser), static function (string $part): bool { + return $part !== ''; + })); + if ($cmd[0] === 'start' && OsInfo::isWindows()) { + // On Windows, `start` is a cmd.exe built-in rather than an + // executable, and it treats the first quoted argument as a + // window title, so pass an empty title before the URI. + $cmd = ['cmd', '/c', 'start', '']; + } + $cmd[] = $uri; + $this->execute($cmd, null, null, false); return true; } diff --git a/src/Helpers/SshHelper.php b/src/Helpers/SshHelper.php index 310eddfcf..ec8459648 100644 --- a/src/Helpers/SshHelper.php +++ b/src/Helpers/SshHelper.php @@ -108,7 +108,7 @@ private function getConnectionArgs(string $url): array 'ssh', $url, '-t', - '-o StrictHostKeyChecking=no', + '-o StrictHostKeyChecking=accept-new', '-o AddressFamily inet', '-o LogLevel=ERROR', ]; diff --git a/src/Helpers/TelemetryHelper.php b/src/Helpers/TelemetryHelper.php index 7c6caf9c4..d3b84df10 100644 --- a/src/Helpers/TelemetryHelper.php +++ b/src/Helpers/TelemetryHelper.php @@ -56,6 +56,47 @@ public static function isBuildDateOlderThanMonths(?string $buildDate, int $month return ($now - $buildTimestamp) > $interval; } + /** + * Redact values of sensitive options and arguments in telemetry data. + * + * @param array $data + * An array of option or argument values keyed by name. + * @return array + * The array with sensitive values redacted. + */ + public static function redactSensitiveData(array $data): array + { + $sensitiveNames = [ + 'api-key', + 'api-secret', + 'key', + 'password', + 'secret', + 'token', + ]; + foreach ($data as $name => $value) { + if ($value !== null && in_array($name, $sensitiveNames, true)) { + $data[$name] = 'REDACTED'; + } elseif (is_array($value)) { + $data[$name] = self::redactSensitiveData($value); + } + } + return $data; + } + + /** + * Redact sensitive command-line parameters from a Bugsnag context string. + */ + public static function redactSensitiveContext(string $context): string + { + foreach (['--password', '--key', '--secret'] as $sensitiveOption) { + if (str_contains($context, $sensitiveOption)) { + $context = substr($context, 0, strpos($context, $sensitiveOption) + strlen($sensitiveOption)) . 'REDACTED'; + } + } + return $context; + } + public function initializeBugsnag(): void { if (empty($this->bugSnagKey)) { @@ -103,9 +144,7 @@ public function initializeBugsnag(): void $context = substr($context, strpos($context, 'acli ') + 5); } // Strip sensitive parameters from context. - if (str_contains($context, "--password")) { - $context = substr($context, 0, strpos($context, "--password") + 10) . 'REDACTED'; - } + $context = self::redactSensitiveContext($context); $report->setContext($context); return true; }); diff --git a/tests/phpunit/src/AcsfApi/AcsfServiceTest.php b/tests/phpunit/src/AcsfApi/AcsfServiceTest.php index 6030eae14..363bf3402 100644 --- a/tests/phpunit/src/AcsfApi/AcsfServiceTest.php +++ b/tests/phpunit/src/AcsfApi/AcsfServiceTest.php @@ -9,6 +9,7 @@ use Acquia\Cli\AcsfApi\AcsfCredentials; use Acquia\Cli\Application; use Acquia\Cli\Tests\TestBase; +use PHPUnit\Framework\Attributes\DataProvider; class AcsfServiceTest extends TestBase { @@ -43,9 +44,7 @@ public static function providerTestIsMachineAuthenticated(): array ]; } - /** - * @dataProvider providerTestIsMachineAuthenticated - */ + #[DataProvider('providerTestIsMachineAuthenticated')] public function testIsMachineAuthenticated(array $envVars, bool $isAuthenticated): void { self::setEnvVars($envVars); diff --git a/tests/phpunit/src/Application/ComposerScriptsListenerTest.php b/tests/phpunit/src/Application/ComposerScriptsListenerTest.php index b2565157e..d92374fd4 100644 --- a/tests/phpunit/src/Application/ComposerScriptsListenerTest.php +++ b/tests/phpunit/src/Application/ComposerScriptsListenerTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\HelloWorldCommand; use Acquia\Cli\EventListener\ComposerScriptsListener; use Acquia\Cli\Tests\ApplicationTestBase; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Console\Event\ConsoleCommandEvent; use Symfony\Component\Console\Event\ConsoleTerminateEvent; use Symfony\Component\Filesystem\Path; @@ -19,9 +20,7 @@ */ class ComposerScriptsListenerTest extends ApplicationTestBase { - /** - * @group serial - */ + #[Group('serial')] public function testPreScripts(): void { $json = [ @@ -43,9 +42,7 @@ public function testPreScripts(): void self::assertStringContainsString('pre-acli-hello-world', $buffer); } - /** - * @group serial - */ + #[Group('serial')] public function testPostScripts(): void { $json = [ diff --git a/tests/phpunit/src/Application/ExceptionApplicationTest.php b/tests/phpunit/src/Application/ExceptionApplicationTest.php index a37f9e674..03d751430 100644 --- a/tests/phpunit/src/Application/ExceptionApplicationTest.php +++ b/tests/phpunit/src/Application/ExceptionApplicationTest.php @@ -5,6 +5,7 @@ namespace Acquia\Cli\Tests\Application; use Acquia\Cli\Tests\ApplicationTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Tests exceptions rewritten by the Symfony Event Dispatcher. @@ -17,9 +18,7 @@ */ class ExceptionApplicationTest extends ApplicationTestBase { - /** - * @group serial - */ + #[Group('serial')] public function testInvalidApiCredentials(): void { $this->setInput([ diff --git a/tests/phpunit/src/Application/HelpApplicationTest.php b/tests/phpunit/src/Application/HelpApplicationTest.php index cdbab643f..f57af8cec 100644 --- a/tests/phpunit/src/Application/HelpApplicationTest.php +++ b/tests/phpunit/src/Application/HelpApplicationTest.php @@ -5,6 +5,7 @@ namespace Acquia\Cli\Tests\Application; use Acquia\Cli\Tests\ApplicationTestBase; +use PHPUnit\Framework\Attributes\Group; /** * Test the 'help' command. @@ -15,9 +16,7 @@ */ class HelpApplicationTest extends ApplicationTestBase { - /** - * @group serial - */ + #[Group('serial')] public function testApplicationAliasHelp(): void { $this->setInput([ @@ -35,9 +34,7 @@ public function testApplicationAliasHelp(): void app:link abcd1234-1111-2222-3333-0e02b2c3d470', $buffer); } - /** - * @group serial - */ + #[Group('serial')] public function testEnvironmentAliasHelp(): void { $this->setInput([ diff --git a/tests/phpunit/src/Application/KernelTest.php b/tests/phpunit/src/Application/KernelTest.php index b2c5a1a80..e0ad1a608 100644 --- a/tests/phpunit/src/Application/KernelTest.php +++ b/tests/phpunit/src/Application/KernelTest.php @@ -5,12 +5,11 @@ namespace Acquia\Cli\Tests\Application; use Acquia\Cli\Tests\ApplicationTestBase; +use PHPUnit\Framework\Attributes\Group; class KernelTest extends ApplicationTestBase { - /** - * @group serial - */ + #[Group('serial')] public function testRun(): void { $this->setInput([ diff --git a/tests/phpunit/src/CloudApi/AcsfClientServiceTest.php b/tests/phpunit/src/CloudApi/AcsfClientServiceTest.php index 939152a2e..ad0802a7a 100644 --- a/tests/phpunit/src/CloudApi/AcsfClientServiceTest.php +++ b/tests/phpunit/src/CloudApi/AcsfClientServiceTest.php @@ -11,6 +11,7 @@ use Acquia\Cli\Tests\TestBase; use AcquiaCloudApi\Exception\ApiErrorException; use GuzzleHttp\Psr7\Response; +use PHPUnit\Framework\Attributes\DataProvider; class AcsfClientServiceTest extends TestBase { @@ -43,9 +44,7 @@ public static function providerTestIsMachineAuthenticated(): array ]; } - /** - * @dataProvider providerTestIsMachineAuthenticated - */ + #[DataProvider('providerTestIsMachineAuthenticated')] public function testIsMachineAuthenticated(array $envVars, bool $isAuthenticated): void { self::setEnvVars($envVars); diff --git a/tests/phpunit/src/CloudApi/ClientServiceTest.php b/tests/phpunit/src/CloudApi/ClientServiceTest.php index f859ac41e..f7f5103bf 100644 --- a/tests/phpunit/src/CloudApi/ClientServiceTest.php +++ b/tests/phpunit/src/CloudApi/ClientServiceTest.php @@ -9,6 +9,7 @@ use Acquia\Cli\CloudApi\ConnectorFactory; use Acquia\Cli\DataStore\CloudDataStore; use Acquia\Cli\Tests\TestBase; +use PHPUnit\Framework\Attributes\DataProvider; class ClientServiceTest extends TestBase { @@ -53,9 +54,7 @@ public static function providerTestIsMachineAuthenticated(): array ]; } - /** - * @dataProvider providerTestIsMachineAuthenticated - */ + #[DataProvider('providerTestIsMachineAuthenticated')] public function testIsMachineAuthenticated(array $envVars, bool $isAuthenticated): void { self::setEnvVars($envVars); diff --git a/tests/phpunit/src/CloudApi/ConnectorFactoryTest.php b/tests/phpunit/src/CloudApi/ConnectorFactoryTest.php index 43c9e521e..1af9873ba 100644 --- a/tests/phpunit/src/CloudApi/ConnectorFactoryTest.php +++ b/tests/phpunit/src/CloudApi/ConnectorFactoryTest.php @@ -7,14 +7,15 @@ use Acquia\Cli\CloudApi\ConnectorFactory; use Acquia\Cli\CloudApi\PathRewriteConnector; use AcquiaCloudApi\Connector\Connector; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; /** - * @covers \Acquia\Cli\CloudApi\ConnectorFactory - * * Unit tests for the ConnectorFactory. Ensures that the factory returns the correct * connector type depending on the presence of the AH_CODEBASE_UUID environment variable. */ +#[CoversClass(ConnectorFactory::class)] class ConnectorFactoryTest extends TestCase { /** @@ -32,9 +33,7 @@ protected function setUp(): void } - /** - * @dataProvider connectorFactoryProvider - */ + #[DataProvider('connectorFactoryProvider')] public function testCreateConnectorFactoryBehavior(?string $envValue, string $expectedClass): void { if ($envValue !== null) { diff --git a/tests/phpunit/src/CloudApi/PathRewriteConnectorTest.php b/tests/phpunit/src/CloudApi/PathRewriteConnectorTest.php index 15ca54c21..f09db1b47 100644 --- a/tests/phpunit/src/CloudApi/PathRewriteConnectorTest.php +++ b/tests/phpunit/src/CloudApi/PathRewriteConnectorTest.php @@ -6,16 +6,17 @@ use Acquia\Cli\CloudApi\PathRewriteConnector; use AcquiaCloudApi\Connector\ConnectorInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; /** - * @covers \Acquia\Cli\CloudApi\PathRewriteConnector - * * Unit tests for the PathRewriteConnector decorator. Ensures all path rewriting logic, * delegation, and error handling are correct. */ +#[CoversClass(PathRewriteConnector::class)] class PathRewriteConnectorTest extends TestCase { /** @@ -47,11 +48,11 @@ protected function setUp(): void } /** - * @dataProvider createRequestProvider * @param string $verb The HTTP verb to test. * @param string $inputPath The input path to test. * @param string $expectedPath The expected path after rewriting. */ + #[DataProvider('createRequestProvider')] public function testCreateRequestPathRewriting(string $verb, string $inputPath, string $expectedPath): void { $mock = $this->createMock(RequestInterface::class); @@ -66,12 +67,12 @@ public function testCreateRequestPathRewriting(string $verb, string $inputPath, /** - * @dataProvider sendRequestProvider * @param string $verb The HTTP verb to test. * @param string $inputPath The input path to test. * @param string $expectedPath The expected path after rewriting. * @param array $options The options to pass to sendRequest. */ + #[DataProvider('sendRequestProvider')] public function testSendRequestPathRewriting(string $verb, string $inputPath, string $expectedPath, array $options): void { $mock = $this->createMock(ResponseInterface::class); @@ -84,10 +85,10 @@ public function testSendRequestPathRewriting(string $verb, string $inputPath, st } /** - * @dataProvider delegationProvider * @param string $method The method to test delegation for. * @param mixed $expected The expected return value from the inner connector. */ + #[DataProvider('delegationProvider')] public function testDelegation(string $method, string $expected): void { $this->inner->expects($this->once()) diff --git a/tests/phpunit/src/Commands/Acsf/AcsfApiCommandTest.php b/tests/phpunit/src/Commands/Acsf/AcsfApiCommandTest.php index f1ac31bcb..7039f67ac 100644 --- a/tests/phpunit/src/Commands/Acsf/AcsfApiCommandTest.php +++ b/tests/phpunit/src/Commands/Acsf/AcsfApiCommandTest.php @@ -12,6 +12,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\CommandFactoryInterface; use Acquia\Cli\Exception\AcquiaCliException; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Symfony\Component\Console\Output\OutputInterface; @@ -164,9 +165,7 @@ public static function providerTestAcsfCommandExecutionForHttpGetMultiple(): arr ]; } - /** - * @dataProvider providerTestAcsfCommandExecutionForHttpGetMultiple - */ + #[DataProvider('providerTestAcsfCommandExecutionForHttpGetMultiple')] public function testAcsfCommandExecutionForHttpGetMultiple(string $method, string $specPath, string $path, string $command, array $arguments = [], array $jsonArguments = []): void { $mockBody = self::getMockResponseFromSpec($specPath, $method, '200', true); diff --git a/tests/phpunit/src/Commands/Acsf/AcsfAuthLoginCommandTest.php b/tests/phpunit/src/Commands/Acsf/AcsfAuthLoginCommandTest.php index 94480aaa8..63327cc5e 100644 --- a/tests/phpunit/src/Commands/Acsf/AcsfAuthLoginCommandTest.php +++ b/tests/phpunit/src/Commands/Acsf/AcsfAuthLoginCommandTest.php @@ -9,6 +9,8 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Config\CloudDataConfig; use Acquia\Cli\DataStore\CloudDataStore; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use Symfony\Component\Validator\Exception\ValidatorException; /** @@ -120,10 +122,8 @@ public static function providerTestAuthLoginCommand(): array ]; } - /** - * @dataProvider providerTestAuthLoginCommand - * @requires OS linux|darwin - */ + #[DataProvider('providerTestAuthLoginCommand')] + #[RequiresOperatingSystem('linux|darwin')] public function testAcsfAuthLoginCommand(bool $machineIsAuthenticated, array $inputs, array $args, string $outputToAssert, array $config = []): void { if (!$machineIsAuthenticated) { @@ -167,9 +167,9 @@ public static function providerTestAcsfAuthLoginInvalid(): array } /** - * @dataProvider providerTestAcsfAuthLoginInvalid * @throws \Exception */ + #[DataProvider('providerTestAcsfAuthLoginInvalid')] public function testAcsfAuthLoginInvalid(array $args, string $message): void { $this->clientServiceProphecy->isMachineAuthenticated() @@ -198,9 +198,9 @@ public static function providerTestAcsfAuthLoginInvalidInput(): array } /** - * @dataProvider providerTestAcsfAuthLoginInvalidInput * @throws \JsonException */ + #[DataProvider('providerTestAcsfAuthLoginInvalidInput')] public function testAcsfAuthLoginInvalidInput(array $input): void { $this->removeMockCloudConfigFile(); diff --git a/tests/phpunit/src/Commands/Acsf/AcsfAuthLogoutCommandTest.php b/tests/phpunit/src/Commands/Acsf/AcsfAuthLogoutCommandTest.php index f4621e9f6..783b71910 100644 --- a/tests/phpunit/src/Commands/Acsf/AcsfAuthLogoutCommandTest.php +++ b/tests/phpunit/src/Commands/Acsf/AcsfAuthLogoutCommandTest.php @@ -9,6 +9,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Config\CloudDataConfig; use Acquia\Cli\DataStore\CloudDataStore; +use PHPUnit\Framework\Attributes\DataProvider; /** * @property AcsfAuthLogoutCommandTest $command @@ -49,9 +50,7 @@ public static function providerTestAuthLogoutCommand(): array ]; } - /** - * @dataProvider providerTestAuthLogoutCommand - */ + #[DataProvider('providerTestAuthLogoutCommand')] public function testAcsfAuthLogoutCommand(bool $machineIsAuthenticated, array $inputs, array $config = []): void { if (!$machineIsAuthenticated) { diff --git a/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php b/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php index 1dd469e48..9a3f87631 100644 --- a/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php +++ b/tests/phpunit/src/Commands/Api/ApiCommandHelperTest.php @@ -9,7 +9,11 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Tests\CommandTestBase; use ReflectionMethod; +use Symfony\Component\Cache\Adapter\PhpArrayAdapter; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Logger\ConsoleLogger; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; /** * Tests for ApiCommandHelper::generateApiListCommands (via reflection). @@ -97,6 +101,51 @@ public function testNamespaceVisibleCommandAfterOtherNamespaceStillGetsListComma $this->assertArrayHasKey('api:mix', $listCommands, 'mix has a visible command after other namespace; break would skip it.'); } + /** + * A fully hidden namespace that sorts before visible namespaces must be + * skipped with `continue`, not `break`, so the later visible namespaces are + * still listed. Also asserts that every visible namespace is returned, not + * just the first. + */ + public function testHiddenNamespaceBeforeVisibleNamespacesListsAllVisible(): void + { + $apiCommands = [ + $this->createMockApiCommand('api:hidden:only', true), + $this->createMockApiCommand('api:alpha:create', false), + $this->createMockApiCommand('api:beta:create', false), + ]; + $listCommands = $this->generateApiListCommands($apiCommands); + $this->assertArrayNotHasKey('api:hidden', $listCommands); + // `break` instead of `continue` would stop at the hidden namespace and + // never reach alpha/beta. + $this->assertArrayHasKey('api:alpha', $listCommands); + // Returning only the first element would drop beta. + $this->assertArrayHasKey('api:beta', $listCommands); + $this->assertCount(2, $listCommands); + } + + /** + * The generated list command must have its name, namespace, (empty) aliases, + * and description set. + */ + public function testGeneratedListCommandHasExpectedProperties(): void + { + $apiCommands = [ + $this->createMockApiCommand('api:widgets:create', false), + ]; + $listCommands = $this->generateApiListCommands($apiCommands); + $command = $listCommands['api:widgets']; + $this->assertSame('api:widgets', $command->getName()); + $this->assertSame('List all API commands for the widgets resource', $command->getDescription()); + // createListCommand() returns an ApiListCommand whose AsCommand attribute + // declares aliases ['api']; the helper must clear them. + $this->assertSame([], $command->getAliases()); + $namespaceProperty = (new ReflectionMethod($command, 'setNamespace')) + ->getDeclaringClass() + ->getProperty('namespace'); + $this->assertSame('api:widgets', $namespaceProperty->getValue($command)); + } + /** * When every sub-command under a namespace is hidden, omit the namespace list command. */ @@ -110,6 +159,62 @@ public function testNamespaceWithAllHiddenCommandsDoesNotGetListCommand(): void $this->assertArrayNotHasKey('api:baz', $listCommands); } + /** + * The parsed spec must be served from the fallback cache when the warmed + * PHP cache file is absent, instead of being re-parsed on every invocation. + */ + public function testGetCloudApiSpecUsesFallbackCacheWhenWarmedCacheFileIsMissing(): void + { + $specFilePath = tempnam(sys_get_temp_dir(), 'acli_spec_test_'); + file_put_contents($specFilePath, json_encode([ + 'components' => [], + 'paths' => [], + ], JSON_THROW_ON_ERROR)); + $warmedCacheFilePath = dirname(__DIR__, 5) . '/var/cache/' . basename($specFilePath) . '.cache'; + putenv('ACQUIA_CLI_USE_CLOUD_API_SPEC_CACHE=1'); + + try { + $output = new BufferedOutput(OutputInterface::VERBOSITY_DEBUG); + $helper = new ApiCommandHelper(new ConsoleLogger($output)); + $method = new ReflectionMethod(ApiCommandHelper::class, 'getCloudApiSpec'); + + // First call parses the spec file and warms the caches. + $firstSpec = $method->invoke($helper, $specFilePath); + $this->assertStringContainsString('Rebuilding caches', $output->fetch()); + + // Even with the warmed cache file gone, the fallback cache must + // serve the parsed spec rather than re-parsing the spec file. Use a + // fresh helper so the per-process memoization does not short-circuit + // the fallback-cache lookup under test. + unlink($warmedCacheFilePath); + $this->resetPhpArrayAdapterStaticCache(); + $freshHelper = new ApiCommandHelper(new ConsoleLogger($output)); + $secondSpec = $method->invoke($freshHelper, $specFilePath); + $this->assertSame($firstSpec, $secondSpec); + $this->assertStringNotContainsString('Rebuilding caches', $output->fetch()); + } finally { + putenv('ACQUIA_CLI_USE_CLOUD_API_SPEC_CACHE'); + if (file_exists($specFilePath)) { + unlink($specFilePath); + } + if (file_exists($warmedCacheFilePath)) { + unlink($warmedCacheFilePath); + } + } + } + + /** + * Forget warmed cache files in PhpArrayAdapter's static in-process cache. + * + * Simulates a fresh CLI invocation, in which a deleted warmed cache file + * would no longer be readable. + */ + private function resetPhpArrayAdapterStaticCache(): void + { + $property = new \ReflectionProperty(PhpArrayAdapter::class, 'valuesCache'); + $property->setValue(null, []); + } + /** * Calls private or protected method of ApiCommandHelper class via reflection. * @@ -122,6 +227,139 @@ private function invokeApiCommandHelperMethod(string $methodName, array $args = return $refClass->invokeArgs($commandHelper, $args); } + /** + * The lazy factory map must register exactly the same set of commands as the + * eager getApiCommands(), so switching to lazy registration changes nothing + * a user can observe. + */ + public function testGetApiCommandFactoriesMatchEagerCommands(): void + { + // Build the registration manifest from source rather than the cache, so + // the manifest-building logic is actually exercised under test. + putenv('ACQUIA_CLI_USE_CLOUD_API_SPEC_CACHE=0'); + try { + $helper = new ApiCommandHelper($this->logger); + // getApiCommands() returns a flat list that may repeat a name; + // Symfony dedupes on registration, as does the keyed factory map, so + // compare the unique name sets. + $eagerNames = array_unique(array_map(static fn ($c) => $c->getName(), $helper->getApiCommands(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()))); + sort($eagerNames); + + $factories = $helper->getApiCommandFactories(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()); + $lazyNames = array_keys($factories); + sort($lazyNames); + + $this->assertSame(array_values($eagerNames), $lazyNames); + // Every entry must be a closure that is only invoked on demand. + foreach ($factories as $factory) { + $this->assertInstanceOf(\Closure::class, $factory); + } + // Skipped commands are never registered; namespace list commands are. + $this->assertArrayNotHasKey('api:ssh-key:list', $factories); + $this->assertArrayHasKey('api:accounts', $factories); + } finally { + putenv('ACQUIA_CLI_USE_CLOUD_API_SPEC_CACHE'); + } + } + + /** + * Invoking a factory must lazily build a command identical to the one the + * eager path produces (name, description, and input definition). + */ + public function testGetApiCommandFactoriesBuildConfiguredCommandOnDemand(): void + { + putenv('ACQUIA_CLI_USE_CLOUD_API_SPEC_CACHE=0'); + try { + $helper = new ApiCommandHelper($this->logger); + $name = 'api:accounts:find'; + $eager = $helper->getApiCommands(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()); + $eagerCommand = null; + foreach ($eager as $candidate) { + if ($candidate->getName() === $name) { + $eagerCommand = $candidate; + break; + } + } + $this->assertNotNull($eagerCommand); + + $factories = $helper->getApiCommandFactories(self::$apiSpecFixtureFilePath, 'api', $this->getCommandFactory()); + $this->assertArrayHasKey($name, $factories); + $lazyCommand = $factories[$name](); + + $this->assertSame($eagerCommand->getName(), $lazyCommand->getName()); + $this->assertSame($eagerCommand->getDescription(), $lazyCommand->getDescription()); + $this->assertNotEmpty($lazyCommand->getDescription()); + $this->assertSame( + array_keys($eagerCommand->getDefinition()->getOptions()), + array_keys($lazyCommand->getDefinition()->getOptions()) + ); + $this->assertSame( + array_keys($eagerCommand->getDefinition()->getArguments()), + array_keys($lazyCommand->getDefinition()->getArguments()) + ); + } finally { + putenv('ACQUIA_CLI_USE_CLOUD_API_SPEC_CACHE'); + } + } + + /** + * A built command must carry its responses, servers, and the right help + * text for normal, pre-release, and deprecated endpoints. + */ + public function testBuildApiCommandConfiguresResponsesServersAndHelp(): void + { + $normal = $this->getApiCommandByName('api:accounts:find'); + $this->assertNotNull($normal); + $this->assertNotEmpty($this->readProtected($normal, 'responses')); + $this->assertNotEmpty($this->readProtected($normal, 'servers')); + $this->assertStringContainsString('For more help', $normal->getHelp()); + $this->assertStringNotContainsString('pre-release', $normal->getHelp()); + $this->assertStringNotContainsString('deprecated and may be removed', $normal->getHelp()); + + $preRelease = $this->getApiCommandByName('api:codebases:applications:list'); + $this->assertNotNull($preRelease); + // The notice must be appended to (not replace) the base help text. + $this->assertStringContainsString('For more help', $preRelease->getHelp()); + $this->assertStringContainsString('pre-release', $preRelease->getHelp()); + + $deprecated = $this->getApiCommandByName('api:applications:hosting-settings-list'); + $this->assertNotNull($deprecated); + $this->assertStringContainsString('For more help', $deprecated->getHelp()); + $this->assertStringContainsString('deprecated and may be removed', $deprecated->getHelp()); + } + + private function readProtected(object $object, string $property): mixed + { + return (new \ReflectionProperty($object, $property))->getValue($object); + } + + /** + * The manifest builder must skip (continue past), not break out of, both + * methods that lack an x-cli-name and methods whose command is skipped, so + * that a later valid method on the same path is still registered. + */ + public function testBuildApiSpecManifestContinuesPastIgnoredMethods(): void + { + $spec = [ + 'paths' => [ + // 'get' has no x-cli-name and must be skipped without dropping 'post'. + '/a' => [ + 'get' => ['responses' => []], + 'post' => ['x-cli-name' => 'a:create', 'summary' => 's', 'responses' => []], + ], + // 'get' is an explicitly skipped command and must not drop 'post'. + '/b' => [ + 'get' => ['x-cli-name' => 'ide:create', 'summary' => 's', 'responses' => []], + 'post' => ['x-cli-name' => 'b:create', 'summary' => 's', 'responses' => []], + ], + ], + ]; + $manifest = $this->invokeApiCommandHelperMethod('buildApiSpecManifest', [$spec]); + $this->assertArrayHasKey('a:create', $manifest); + $this->assertArrayHasKey('b:create', $manifest); + $this->assertArrayNotHasKey('ide:create', $manifest); + } + /** * Test that addPostArgumentUsageToExample correctly formats a flat array with a single item. */ diff --git a/tests/phpunit/src/Commands/Api/ApiCommandTest.php b/tests/phpunit/src/Commands/Api/ApiCommandTest.php index 8a3ac9a11..5defe3002 100644 --- a/tests/phpunit/src/Commands/Api/ApiCommandTest.php +++ b/tests/phpunit/src/Commands/Api/ApiCommandTest.php @@ -10,6 +10,8 @@ use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\CommandTestBase; use AcquiaCloudApi\Exception\ApiErrorException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Console\Exception\MissingInputException; use Symfony\Component\Filesystem\Path; use Symfony\Component\Validator\Exception\ValidatorException; @@ -245,10 +247,8 @@ public static function providerTestConvertApplicationAliasToUuidArgument(): arra ]; } - /** - * @dataProvider providerTestConvertApplicationAliasToUuidArgument - * @group serial - */ + #[DataProvider('providerTestConvertApplicationAliasToUuidArgument')] + #[Group('serial')] public function testConvertApplicationAliasToUuidArgument(bool $support): void { ClearCacheCommand::clearCaches(); @@ -379,9 +379,7 @@ public function testConvertEnvironmentAliasToUuidArgument(): void $this->assertEquals(0, $this->getStatusCode()); } - /** - * @group serial - */ + #[Group('serial')] public function testConvertInvalidEnvironmentAliasToUuidArgument(): void { ClearCacheCommand::clearCaches(); @@ -494,9 +492,7 @@ public static function providerTestApiCommandDefinitionParameters(): array ]; } - /** - * @dataProvider providerTestApiCommandDefinitionParameters - */ + #[DataProvider('providerTestApiCommandDefinitionParameters')] public function testApiCommandDefinitionParameters(string $useSpecCache, string $commandName, string $method, string $usage): void { putenv('ACQUIA_CLI_USE_CLOUD_API_SPEC_CACHE=' . $useSpecCache); @@ -591,12 +587,7 @@ public static function providerTestApiCommandDefinitionRequestBody(): array ]; } - /** - * @dataProvider providerTestApiCommandDefinitionRequestBody - * @param $commandName - * @param $method - * @param $usage - */ + #[DataProvider('providerTestApiCommandDefinitionRequestBody')] public function testApiCommandDefinitionRequestBody(string $commandName, string $method, array $usage): void { $this->command = $this->getApiCommandByName($commandName); diff --git a/tests/phpunit/src/Commands/App/AppVcsInfoTest.php b/tests/phpunit/src/Commands/App/AppVcsInfoTest.php index 7f03c148e..c22936e82 100644 --- a/tests/phpunit/src/Commands/App/AppVcsInfoTest.php +++ b/tests/phpunit/src/Commands/App/AppVcsInfoTest.php @@ -8,6 +8,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; /** * @property \Acquia\Cli\Command\App\AppVcsInfo $command @@ -19,9 +20,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(AppVcsInfo::class); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testNoEnvAvailableCommand(): void { $applications = $this->mockRequest('getApplications'); @@ -45,9 +44,7 @@ public function testNoEnvAvailableCommand(): void ); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testNoVcsAvailableCommand(): void { $applications = $this->mockRequest('getApplications'); @@ -70,9 +67,7 @@ public function testNoVcsAvailableCommand(): void ); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testShowVcsListCommand(): void { $applications = $this->mockRequest('getApplications'); @@ -103,9 +98,7 @@ public function testShowVcsListCommand(): void self::assertStringContainsStringIgnoringLineEndings($expected, $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testNoDeployedVcs(): void { $applications = $this->mockRequest('getApplications'); @@ -134,9 +127,7 @@ public function testNoDeployedVcs(): void ); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testListOnlyDeployedVcs(): void { $applications = $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/App/From/AbandonmentRecommendationTest.php b/tests/phpunit/src/Commands/App/From/AbandonmentRecommendationTest.php index c7bed4ba6..e60b2fc47 100644 --- a/tests/phpunit/src/Commands/App/From/AbandonmentRecommendationTest.php +++ b/tests/phpunit/src/Commands/App/From/AbandonmentRecommendationTest.php @@ -6,11 +6,10 @@ use Acquia\Cli\Command\App\From\Recommendation\AbandonmentRecommendation; use Acquia\Cli\Command\App\From\Recommendation\DefinedRecommendation; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -/** - * @coversDefaultClass \Acquia\Cli\Command\App\From\Recommendation\AbandonmentRecommendation - */ +#[CoversClass(AbandonmentRecommendation::class)] class AbandonmentRecommendationTest extends TestCase { private AbandonmentRecommendation $sut; @@ -31,63 +30,42 @@ protected function setUp(): void // phpcs:enable } - /** - * @covers ::getPackageName - */ public function testPackageName(): void { $this->expectException(\LogicException::class); $this->sut->getPackageName(); } - /** - * @covers ::getVersionConstraint - */ public function testVersionConstraint(): void { $this->expectException(\LogicException::class); $this->sut->getVersionConstraint(); } - /** - * @covers ::hasModulesToInstall - */ public function testHasModulesToInstall(): void { $this->expectException(\LogicException::class); $this->sut->hasModulesToInstall(); } - /** - * @covers ::getModulesToInstall - */ public function testGetModulesToInstall(): void { $this->expectException(\LogicException::class); $this->sut->getModulesToInstall(); } - /** - * @covers ::hasPatches - */ public function testHasPatches(): void { $this->expectException(\LogicException::class); $this->sut->hasPatches(); } - /** - * @covers ::isVetted - */ public function testIsVetted(): void { $this->expectException(\LogicException::class); $this->sut->isVetted(); } - /** - * @covers ::getPatches - */ public function testGetPatches(): void { $this->expectException(\LogicException::class); diff --git a/tests/phpunit/src/Commands/App/From/ConfigurationTest.php b/tests/phpunit/src/Commands/App/From/ConfigurationTest.php index db588e56b..41ff9d78c 100644 --- a/tests/phpunit/src/Commands/App/From/ConfigurationTest.php +++ b/tests/phpunit/src/Commands/App/From/ConfigurationTest.php @@ -8,6 +8,7 @@ use DomainException; use Exception; use JsonException; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -20,8 +21,8 @@ class ConfigurationTest extends TestCase /** * @param string $configuration * A JSON string from which to create a configuration object. - * @dataProvider getTestConfigurations */ + #[DataProvider('getTestConfigurations')] public function test(string $configuration, Exception $expected_exception): void { $test_stream = fopen('php://memory', 'rw'); diff --git a/tests/phpunit/src/Commands/App/From/DefinedRecommendationTest.php b/tests/phpunit/src/Commands/App/From/DefinedRecommendationTest.php index 1211c944e..1312eeb2f 100755 --- a/tests/phpunit/src/Commands/App/From/DefinedRecommendationTest.php +++ b/tests/phpunit/src/Commands/App/From/DefinedRecommendationTest.php @@ -10,6 +10,7 @@ use Acquia\Cli\Command\App\From\Recommendation\RecommendationInterface; use Acquia\Cli\Command\App\From\Recommendation\UniversalRecommendation; use Acquia\Cli\Command\App\From\SourceSite\ExtensionInterface; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -17,9 +18,7 @@ class DefinedRecommendationTest extends TestCase { use ProphecyTrait; - /** - * @dataProvider getTestConfigurations - */ + #[DataProvider('getTestConfigurations')] public function test(mixed $configuration, RecommendationInterface $expected): void { $actual = DefinedRecommendation::createFromDefinition($configuration); diff --git a/tests/phpunit/src/Commands/App/From/ProjectBuilderTest.php b/tests/phpunit/src/Commands/App/From/ProjectBuilderTest.php index 8d6910e58..e59473c94 100644 --- a/tests/phpunit/src/Commands/App/From/ProjectBuilderTest.php +++ b/tests/phpunit/src/Commands/App/From/ProjectBuilderTest.php @@ -8,15 +8,16 @@ use Acquia\Cli\Command\App\From\Configuration; use Acquia\Cli\Command\App\From\Recommendation\Recommendations; use Acquia\Cli\Command\App\From\Recommendation\Resolver; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class ProjectBuilderTest extends TestCase { /** - * @dataProvider getTestResources * @param resource $configuration_resource * @param resource $recommendations_resource */ + #[DataProvider('getTestResources')] public function test($configuration_resource, $recommendations_resource, array $expected_project_definition): void { assert(is_resource($configuration_resource)); diff --git a/tests/phpunit/src/Commands/App/From/RecommendationsTest.php b/tests/phpunit/src/Commands/App/From/RecommendationsTest.php index 753ab41ca..86249c223 100644 --- a/tests/phpunit/src/Commands/App/From/RecommendationsTest.php +++ b/tests/phpunit/src/Commands/App/From/RecommendationsTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\App\From\Recommendation\RecommendationInterface; use Acquia\Cli\Command\App\From\Recommendation\Recommendations; use Acquia\Cli\Command\App\From\SourceSite\ExtensionInterface; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -23,8 +24,8 @@ class RecommendationsTest extends TestCase * An expected recommendation or a JSON exception in the case that the * given * $configuration is malformed. - * @dataProvider getTestConfigurations */ + #[DataProvider('getTestConfigurations')] public function test(string $configuration, mixed $expectation): void { $test_stream = fopen('php://memory', 'rw'); diff --git a/tests/phpunit/src/Commands/App/LinkCommandTest.php b/tests/phpunit/src/Commands/App/LinkCommandTest.php index 1f9a518f7..9398e7490 100644 --- a/tests/phpunit/src/Commands/App/LinkCommandTest.php +++ b/tests/phpunit/src/Commands/App/LinkCommandTest.php @@ -8,6 +8,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; /** * @property \Acquia\Cli\Command\App\LinkCommand $command @@ -52,9 +53,7 @@ public function testLinkCommandAlreadyLinked(): void $this->assertEquals(1, $this->getStatusCode()); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testLinkCommandInvalidDir(): void { $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/App/NewCommandTest.php b/tests/phpunit/src/Commands/App/NewCommandTest.php index 7bab45b67..4d322ff42 100644 --- a/tests/phpunit/src/Commands/App/NewCommandTest.php +++ b/tests/phpunit/src/Commands/App/NewCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\App\NewCommand; use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Filesystem\Path; use Symfony\Component\Process\Process; @@ -45,9 +46,7 @@ public static function provideTestNewDrupalCommand(): array ]; } - /** - * @dataProvider provideTestNewDrupalCommand - */ + #[DataProvider('provideTestNewDrupalCommand')] public function testNewDrupalCommand(array $package, string $directory = 'drupal'): void { $this->newProjectDir = Path::makeAbsolute($directory, $this->projectDir); diff --git a/tests/phpunit/src/Commands/App/NewFromDrupal7CommandTest.php b/tests/phpunit/src/Commands/App/NewFromDrupal7CommandTest.php index 2d57eddd4..cf7ac5b95 100644 --- a/tests/phpunit/src/Commands/App/NewFromDrupal7CommandTest.php +++ b/tests/phpunit/src/Commands/App/NewFromDrupal7CommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\App\NewFromDrupal7Command; use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Process\Process; @@ -80,8 +81,8 @@ protected function assertValidDateFormat(string $date, string $format): void * that would be provided to the --recommendations CLI option. * @param string $expected_json * The expected output. - * @dataProvider provideTestNewFromDrupal7Command */ + #[DataProvider('provideTestNewFromDrupal7Command')] public function testNewFromDrupal7Command(string $extensions_json, string $recommendations_json, string $expected_json): void { foreach (func_get_args() as $file) { diff --git a/tests/phpunit/src/Commands/App/TaskWaitCommandTest.php b/tests/phpunit/src/Commands/App/TaskWaitCommandTest.php index 2e343fc30..6d194dcac 100644 --- a/tests/phpunit/src/Commands/App/TaskWaitCommandTest.php +++ b/tests/phpunit/src/Commands/App/TaskWaitCommandTest.php @@ -8,6 +8,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Console\Command\Command; /** @@ -20,9 +21,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(TaskWaitCommand::class); } - /** - * @dataProvider providerTestTaskWaitCommand - */ + #[DataProvider('providerTestTaskWaitCommand')] public function testTaskWaitCommand(string $notification): void { $notificationUuid = '1bd3487e-71d1-4fca-a2d9-5f969b3d35c1'; @@ -113,9 +112,7 @@ public function testTaskWaitCommandWithInvalidUrl(): void // Assert. } - /** - * @dataProvider providerTestTaskWaitCommandWithInvalidJson - */ + #[DataProvider('providerTestTaskWaitCommandWithInvalidJson')] public function testTaskWaitCommandWithInvalidJson(string $notification): void { $this->expectException(AcquiaCliException::class); diff --git a/tests/phpunit/src/Commands/Auth/AuthLoginCommandTest.php b/tests/phpunit/src/Commands/Auth/AuthLoginCommandTest.php index 3f9875621..7a7900567 100644 --- a/tests/phpunit/src/Commands/Auth/AuthLoginCommandTest.php +++ b/tests/phpunit/src/Commands/Auth/AuthLoginCommandTest.php @@ -12,6 +12,7 @@ use Acquia\Cli\Tests\CommandTestBase; use AcquiaCloudApi\Connector\Connector; use Generator; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Symfony\Component\Validator\Exception\ValidatorException; @@ -87,9 +88,7 @@ public static function providerTestAuthLoginInvalidInputCommand(): Generator ]; } - /** - * @dataProvider providerTestAuthLoginInvalidInputCommand - */ + #[DataProvider('providerTestAuthLoginInvalidInputCommand')] public function testAuthLoginInvalidInputCommand(array $inputs, array $args): void { $this->clientServiceProphecy->isMachineAuthenticated() diff --git a/tests/phpunit/src/Commands/CodeStudio/CodeStudioPhpVersionCommandTest.php b/tests/phpunit/src/Commands/CodeStudio/CodeStudioPhpVersionCommandTest.php index 761a7df3a..ec2fbda86 100644 --- a/tests/phpunit/src/Commands/CodeStudio/CodeStudioPhpVersionCommandTest.php +++ b/tests/phpunit/src/Commands/CodeStudio/CodeStudioPhpVersionCommandTest.php @@ -9,6 +9,8 @@ use Acquia\Cli\Tests\CommandTestBase; use Gitlab\Client; use Gitlab\Exception\RuntimeException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Prophecy\Argument; use Symfony\Component\Validator\Exception\ValidatorException; @@ -45,9 +47,8 @@ public static function providerTestPhpVersionFailure(): array /** * Test for the wrong PHP version passed as argument. - * - * @dataProvider providerTestPhpVersionFailure */ + #[DataProvider('providerTestPhpVersionFailure')] public function testPhpVersionFailure(mixed $phpVersion): void { $this->expectException(ValidatorException::class); @@ -120,9 +121,7 @@ public function testPhpVersionAddFail(): void $this->assertStringContainsString('Unable to update the PHP version to 8.1', $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testPhpVersionAdd(): void { $this->mockApplicationRequest(); @@ -196,9 +195,7 @@ public function testPhpVersionUpdateFail(): void $this->assertStringContainsString('Unable to update the PHP version to 8.1', $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testPhpVersionUpdate(): void { $this->mockApplicationRequest(); diff --git a/tests/phpunit/src/Commands/CodeStudio/CodeStudioPipelinesMigrateCommandTest.php b/tests/phpunit/src/Commands/CodeStudio/CodeStudioPipelinesMigrateCommandTest.php index 359d6784c..bde612306 100644 --- a/tests/phpunit/src/Commands/CodeStudio/CodeStudioPipelinesMigrateCommandTest.php +++ b/tests/phpunit/src/Commands/CodeStudio/CodeStudioPipelinesMigrateCommandTest.php @@ -12,6 +12,9 @@ use Gitlab\Api\ProjectNamespaces; use Gitlab\Api\Projects; use Gitlab\Client; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use Prophecy\Argument; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; @@ -20,8 +23,8 @@ /** * @property \Acquia\Cli\Command\CodeStudio\CodeStudioPipelinesMigrateCommand * $command - * @requires OS linux|darwin */ +#[RequiresOperatingSystem('linux|darwin')] class CodeStudioPipelinesMigrateCommandTest extends CommandTestBase { use IdeRequiredTestTrait; @@ -99,13 +102,8 @@ public static function providerTestCommand(): array ]; } - /** - * @dataProvider providerTestCommand - * @param $mockedGitlabProjects - * @param $args - * @param $inputs - * @group brokenProphecy - */ + #[DataProvider('providerTestCommand')] + #[Group('brokenProphecy')] public function testCommand(mixed $mockedGitlabProjects, mixed $inputs, mixed $args): void { copy( diff --git a/tests/phpunit/src/Commands/CodeStudio/CodeStudioWizardCommandTest.php b/tests/phpunit/src/Commands/CodeStudio/CodeStudioWizardCommandTest.php index 6645e52dd..478a64a57 100644 --- a/tests/phpunit/src/Commands/CodeStudio/CodeStudioWizardCommandTest.php +++ b/tests/phpunit/src/Commands/CodeStudio/CodeStudioWizardCommandTest.php @@ -16,13 +16,16 @@ use Gitlab\Api\ProjectNamespaces; use Gitlab\Api\Schedules; use Gitlab\Client; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; /** * @property \Acquia\Cli\Command\CodeStudio\CodeStudioWizardCommand $command - * @requires OS linux|darwin */ +#[RequiresOperatingSystem('linux|darwin')] class CodeStudioWizardCommandTest extends WizardTestBase { use IdeRequiredTestTrait; @@ -187,9 +190,7 @@ public static function providerTestCommand(): array ]; } - /** - * @dataProvider providerTestCommand - */ + #[DataProvider('providerTestCommand')] public function testCommand(array $mockedGitlabProjects, array $inputs, array $args): void { $this->clientServiceProphecy->setConnector(Argument::type(Connector::class)) @@ -331,9 +332,7 @@ public static function providerTestCommandCodebase(): array ]; } - /** - * @dataProvider providerTestCommandCodebase - */ + #[DataProvider('providerTestCommandCodebase')] public function testCommandCodebase(array $mockedGitlabProjects, array $inputs, array $args): void { $this->clientServiceProphecy->setConnector(Argument::type(Connector::class)) @@ -437,9 +436,7 @@ public function testCommandCodebase(array $mockedGitlabProjects, array $inputs, self::assertStringContainsString('Codebase', $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testInvalidGitLabCredentials(): void { $localMachineHelper = $this->mockLocalMachineHelper(); @@ -455,9 +452,7 @@ public function testInvalidGitLabCredentials(): void ]); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testMissingGitLabCredentials(): void { $localMachineHelper = $this->mockLocalMachineHelper(); diff --git a/tests/phpunit/src/Commands/CommandBaseTest.php b/tests/phpunit/src/Commands/CommandBaseTest.php index 1ba70b09c..df2bf0684 100644 --- a/tests/phpunit/src/Commands/CommandBaseTest.php +++ b/tests/phpunit/src/Commands/CommandBaseTest.php @@ -18,6 +18,8 @@ use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\Commands\Ide\IdeHelper; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\Exception\ValidatorException; /** @@ -107,10 +109,8 @@ public static function providerTestCloudAppUuidArg(): array ]; } - /** - * @dataProvider providerTestCloudAppUuidArg - * @group brokenProphecy - */ + #[DataProvider('providerTestCloudAppUuidArg')] + #[Group('brokenProphecy')] public function testCloudAppUuidArg(string $uuid): void { $this->mockApplicationRequest(); @@ -134,9 +134,7 @@ public static function providerTestInvalidCloudAppUuidArg(): array ]; } - /** - * @dataProvider providerTestInvalidCloudAppUuidArg - */ + #[DataProvider('providerTestInvalidCloudAppUuidArg')] public function testInvalidCloudAppUuidArg(string $uuid, string $message): void { $this->expectException(ValidatorException::class); @@ -165,9 +163,7 @@ public static function providerTestInvalidCloudEnvironmentAlias(): array ]; } - /** - * @dataProvider providerTestInvalidCloudEnvironmentAlias - */ + #[DataProvider('providerTestInvalidCloudEnvironmentAlias')] public function testInvalidCloudEnvironmentAlias(string $alias, string $message): void { $this->expectException(ValidatorException::class); diff --git a/tests/phpunit/src/Commands/DocsCommandTest.php b/tests/phpunit/src/Commands/DocsCommandTest.php index 590f77528..eb4966541 100644 --- a/tests/phpunit/src/Commands/DocsCommandTest.php +++ b/tests/phpunit/src/Commands/DocsCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\DocsCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; /** @@ -19,9 +20,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(DocsCommand::class); } - /** - * @dataProvider providerTestDocsCommand - */ + #[DataProvider('providerTestDocsCommand')] public function testDocsCommand(int $input, string $expectedOutput): void { $localMachineHelper = $this->mockLocalMachineHelper(); diff --git a/tests/phpunit/src/Commands/Env/EnvCreateCommandTest.php b/tests/phpunit/src/Commands/Env/EnvCreateCommandTest.php index 015aa2258..fff897aec 100644 --- a/tests/phpunit/src/Commands/Env/EnvCreateCommandTest.php +++ b/tests/phpunit/src/Commands/Env/EnvCreateCommandTest.php @@ -8,6 +8,8 @@ use Acquia\Cli\Command\Env\EnvCreateCommand; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Prophecy\Argument; /** @@ -102,10 +104,8 @@ public static function providerTestCreateCde(): array ]; } - /** - * @dataProvider providerTestCreateCde - * @group brokenProphecy - */ + #[DataProvider('providerTestCreateCde')] + #[Group('brokenProphecy')] public function testCreateCde(mixed $args, mixed $input): void { $domain = $this->setupCdeTest(self::$validLabel); @@ -124,9 +124,7 @@ public function testCreateCde(mixed $args, mixed $input): void $this->assertStringContainsString("Your CDE URL: $domain", $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testCreateCdeNonUniqueLabel(): void { $label = 'Dev'; @@ -143,9 +141,7 @@ public function testCreateCdeNonUniqueLabel(): void ); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testCreateCdeInvalidTag(): void { $this->setupCdeTest(self::$validLabel); @@ -161,9 +157,7 @@ public function testCreateCdeInvalidTag(): void ); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testCreateCdeApiFailure(): void { $this->setupCdeTest(self::$validLabel, false); diff --git a/tests/phpunit/src/Commands/Env/EnvDeleteCommandTest.php b/tests/phpunit/src/Commands/Env/EnvDeleteCommandTest.php index eb3d0e890..218c31271 100644 --- a/tests/phpunit/src/Commands/Env/EnvDeleteCommandTest.php +++ b/tests/phpunit/src/Commands/Env/EnvDeleteCommandTest.php @@ -8,6 +8,8 @@ use Acquia\Cli\Command\Env\EnvDeleteCommand; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; /** * @property \Acquia\Cli\Command\Env\EnvDeleteCommand $command @@ -32,10 +34,8 @@ public static function providerTestDeleteCde(): array ]; } - /** - * @dataProvider providerTestDeleteCde - * @group brokenProphecy - */ + #[DataProvider('providerTestDeleteCde')] + #[Group('brokenProphecy')] public function testDeleteCde(mixed $environmentId): void { $applicationsResponse = $this->mockApplicationsRequest(); @@ -90,9 +90,7 @@ public function testDeleteCde(mixed $environmentId): void $this->assertStringContainsString("The $cde->label environment is being deleted", $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testNoExistingCDEEnvironment(): void { $applicationsResponse = $this->mockApplicationsRequest(); @@ -113,9 +111,7 @@ public function testNoExistingCDEEnvironment(): void ); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testNoEnvironmentArgumentPassed(): void { $applications = $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/Ide/IdeCreateCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeCreateCommandTest.php index b0af2e4e5..fa7a67206 100644 --- a/tests/phpunit/src/Commands/Ide/IdeCreateCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeCreateCommandTest.php @@ -9,6 +9,7 @@ use Acquia\Cli\Tests\CommandTestBase; use GuzzleHttp\Client; use GuzzleHttp\Psr7\Response; +use PHPUnit\Framework\Attributes\Group; use Prophecy\Prophecy\ObjectProphecy; /** @@ -38,9 +39,7 @@ protected function createCommand(): CommandBase ); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testCreate(): void { $applicationsResponse = $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/Ide/IdeInfoCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeInfoCommandTest.php index 3c13937e5..bdb618a29 100644 --- a/tests/phpunit/src/Commands/Ide/IdeInfoCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeInfoCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\IdeInfoCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; /** * @property \Acquia\Cli\Command\Ide\IdeListCommand $command @@ -18,9 +19,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(IdeInfoCommand::class); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeInfoCommand(): void { $applications = $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/Ide/IdeListCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeListCommandTest.php index 28e0909bc..f169521be 100644 --- a/tests/phpunit/src/Commands/Ide/IdeListCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeListCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\IdeListCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; /** * @property \Acquia\Cli\Command\Ide\IdeListCommand $command @@ -18,9 +19,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(IdeListCommand::class); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeListCommand(): void { $applications = $this->mockRequest('getApplications'); @@ -49,9 +48,7 @@ public function testIdeListCommand(): void $this->assertStringContainsString('IDE URL: https://feea197a-9503-4441-9f49-b4d420b0ecf8.ides.acquia.com', $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeListEmptyCommand(): void { $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/Ide/IdeOpenCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeOpenCommandTest.php index 4326d1c88..f4ddf1706 100644 --- a/tests/phpunit/src/Commands/Ide/IdeOpenCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeOpenCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\IdeOpenCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; /** * @property IdeOpenCommand $command @@ -18,9 +19,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(IdeOpenCommand::class); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeOpenCommand(): void { $applications = $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/Ide/IdePhpVersionCommandTest.php b/tests/phpunit/src/Commands/Ide/IdePhpVersionCommandTest.php index 4b9fe0ced..1dedd819a 100644 --- a/tests/phpunit/src/Commands/Ide/IdePhpVersionCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdePhpVersionCommandTest.php @@ -9,6 +9,7 @@ use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Helpers\LocalMachineHelper; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Process\Process; @@ -38,9 +39,7 @@ public static function providerTestIdePhpVersionCommand(): array ]; } - /** - * @dataProvider providerTestIdePhpVersionCommand - */ + #[DataProvider('providerTestIdePhpVersionCommand')] public function testIdePhpVersionCommand(string $version): void { $localMachineHelper = $this->mockLocalMachineHelper(); @@ -73,9 +72,7 @@ public static function providerTestIdePhpVersionCommandFailure(): array ]; } - /** - * @dataProvider providerTestIdePhpVersionCommandFailure - */ + #[DataProvider('providerTestIdePhpVersionCommandFailure')] public function testIdePhpVersionCommandFailure(string $version, string $exceptionClass): void { $this->expectException($exceptionClass); diff --git a/tests/phpunit/src/Commands/Ide/IdeServiceRestartCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeServiceRestartCommandTest.php index 4847608ac..d39d021a8 100644 --- a/tests/phpunit/src/Commands/Ide/IdeServiceRestartCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeServiceRestartCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\IdeServiceRestartCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\Exception\ValidatorException; /** @@ -33,9 +34,7 @@ public function testIdeServiceRestartCommand(): void $this->assertStringContainsString('Restarted php', $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeServiceRestartCommandInvalid(): void { $localMachineHelper = $this->mockLocalMachineHelper(); diff --git a/tests/phpunit/src/Commands/Ide/IdeServiceStartCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeServiceStartCommandTest.php index e6f68b1e4..474138b55 100644 --- a/tests/phpunit/src/Commands/Ide/IdeServiceStartCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeServiceStartCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\IdeServiceStartCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\Exception\ValidatorException; /** @@ -33,9 +34,7 @@ public function testIdeServiceStartCommand(): void $this->assertStringContainsString('Starting php', $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeServiceStartCommandInvalid(): void { $localMachineHelper = $this->mockLocalMachineHelper(); diff --git a/tests/phpunit/src/Commands/Ide/IdeServiceStopCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeServiceStopCommandTest.php index 9f9c210d9..94be52673 100644 --- a/tests/phpunit/src/Commands/Ide/IdeServiceStopCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeServiceStopCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\IdeServiceStopCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Validator\Exception\ValidatorException; /** @@ -33,9 +34,7 @@ public function testIdeServiceStopCommand(): void $this->assertStringContainsString('Stopping php', $output); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeServiceStopCommandInvalid(): void { $localMachineHelper = $this->mockLocalMachineHelper(); diff --git a/tests/phpunit/src/Commands/Ide/IdeXdebugToggleCommandTest.php b/tests/phpunit/src/Commands/Ide/IdeXdebugToggleCommandTest.php index 93d9e1287..86c31dee6 100644 --- a/tests/phpunit/src/Commands/Ide/IdeXdebugToggleCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/IdeXdebugToggleCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\IdeXdebugToggleCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Process\Process; /** @@ -55,9 +56,7 @@ public static function providerTestXdebugCommandEnable(): array ]; } - /** - * @dataProvider providerTestXdebugCommandEnable - */ + #[DataProvider('providerTestXdebugCommandEnable')] public function testXdebugCommandEnable(mixed $phpVersion): void { $this->setUpXdebug($phpVersion); @@ -69,9 +68,7 @@ public function testXdebugCommandEnable(mixed $phpVersion): void $this->assertStringContainsString("Xdebug PHP extension enabled", $this->getDisplay()); } - /** - * @dataProvider providerTestXdebugCommandEnable - */ + #[DataProvider('providerTestXdebugCommandEnable')] public function testXdebugCommandDisable(mixed $phpVersion): void { $this->setUpXdebug($phpVersion); diff --git a/tests/phpunit/src/Commands/Ide/Wizard/IdeWizardCreateSshKeyCommandTest.php b/tests/phpunit/src/Commands/Ide/Wizard/IdeWizardCreateSshKeyCommandTest.php index c19c4b074..7e573b923 100644 --- a/tests/phpunit/src/Commands/Ide/Wizard/IdeWizardCreateSshKeyCommandTest.php +++ b/tests/phpunit/src/Commands/Ide/Wizard/IdeWizardCreateSshKeyCommandTest.php @@ -7,12 +7,14 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ide\Wizard\IdeWizardCreateSshKeyCommand; use Acquia\Cli\Tests\Commands\Ide\IdeHelper; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; /** * @property \Acquia\Cli\Command\Ide\Wizard\IdeWizardCreateSshKeyCommand * $command - * @requires OS linux|darwin */ +#[RequiresOperatingSystem('linux|darwin')] class IdeWizardCreateSshKeyCommandTest extends IdeWizardTestBase { public function setUp(): void @@ -38,9 +40,7 @@ public function testCreate(): void $this->runTestCreate(); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testSshKeyAlreadyUploaded(): void { $this->runTestSshKeyAlreadyUploaded(); @@ -61,9 +61,7 @@ public function testPromptWaitForSshReturnsFalse(): void $this->runTestPromptWaitForSshReturnsFalse(); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testIdeWizardCreateSshKeyCommandHelpContainsIdeHelperText(): void { $help = $this->command->getHelp(); diff --git a/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php b/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php index 2bb2af62c..f042e5f38 100644 --- a/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullCodeCommandTest.php @@ -9,8 +9,10 @@ use Acquia\Cli\Command\Pull\PullCodeCommand; use Acquia\Cli\Tests\Commands\Ide\IdeHelper; use GuzzleHttp\Client; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Filesystem\Path; use Symfony\Component\Finder\Finder; @@ -73,6 +75,36 @@ public function testCloneRepo(): void ], $inputs); } + public function testCloneRepoQuietForwardsNoOutput(): void + { + // At normal verbosity the git clone must run with output forwarding + // disabled (printOutput=false). This pins the `>` verbosity comparison + // so it cannot be loosened to `>=`. + $this->acliRepoRoot = ''; + $this->command = $this->createCommand(); + $environment = $this->mockGetEnvironment(); + $localMachineHelper = $this->mockReadIdePhpVersion(); + $process = $this->mockProcess(); + $dir = Path::join($this->vfsRoot->url(), 'empty-dir-quiet'); + mkdir($dir); + $localMachineHelper->checkRequiredBinariesExist(["git"]) + ->shouldBeCalled(); + $this->mockExecuteGitClone($localMachineHelper, $environment, $process, $dir, false); + $this->mockExecuteGitCheckout($localMachineHelper, $environment->vcs->path, $dir, $process); + $localMachineHelper->getFinder()->willReturn(new Finder()); + + $inputs = [ + 'y', + self::$INPUT_DEFAULT_CHOICE, + 'n', + self::$INPUT_DEFAULT_CHOICE, + ]; + $this->executeCommand([ + '--dir' => $dir, + '--no-scripts' => true, + ], $inputs, OutputInterface::VERBOSITY_NORMAL); + } + public function testPullCode(): void { $environment = $this->mockGetEnvironment(); @@ -233,9 +265,7 @@ public static function providerTestMatchPhpVersion(): array ]; } - /** - * @dataProvider providerTestMatchPhpVersion - */ + #[DataProvider('providerTestMatchPhpVersion')] public function testMatchPhpVersion(string $phpVersion): void { IdeHelper::setCloudIdeEnvVars(); @@ -292,7 +322,8 @@ protected function mockExecuteGitClone( ObjectProphecy $localMachineHelper, object $environmentsResponse, ObjectProphecy $process, - mixed $dir + mixed $dir, + bool $printOutput = true ): void { $command = [ 'git', @@ -300,7 +331,7 @@ protected function mockExecuteGitClone( $environmentsResponse->vcs->url, $dir, ]; - $localMachineHelper->execute($command, Argument::type('callable'), null, true, null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=no']) + $localMachineHelper->execute($command, Argument::type('callable'), null, $printOutput, null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new']) ->willReturn($process->reveal()) ->shouldBeCalled(); } diff --git a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php index 2c3fb295b..d77f6ad93 100644 --- a/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php +++ b/tests/phpunit/src/Commands/Pull/PullCommandTestBase.php @@ -248,7 +248,7 @@ protected function mockExecuteRsync( $command = [ 'rsync', '-avPhze', - 'ssh -o StrictHostKeyChecking=no', + 'ssh -o StrictHostKeyChecking=accept-new', $environment->ssh_url . ':' . $sourceDir, $destinationDir, ]; diff --git a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php index b17002ce2..83719629a 100644 --- a/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Pull/PullDatabaseCommandTest.php @@ -15,6 +15,7 @@ use AcquiaCloudApi\Response\SiteInstanceDatabaseConnectionResponse; use AcquiaCloudApi\Response\SiteInstanceDatabaseResponse; use GuzzleHttp\Client; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Console\Output\BufferedOutput; @@ -339,9 +340,7 @@ public function testPullDatabaseWithMySqlImportError(): void ], self::inputChooseEnvironment()); } - /** - * @dataProvider providerTestPullDatabaseWithInvalidSslCertificate - */ + #[DataProvider('providerTestPullDatabaseWithInvalidSslCertificate')] public function testPullDatabaseWithInvalidSslCertificate(int $errorCode): void { $this->setupPullDatabase(true, false, false, true, false, $errorCode); @@ -426,8 +425,9 @@ public function testDownloadProgressDisplay(): void PullCommandBase::displayDownloadProgress(100, 0, $progress, $output); $this->assertStringContainsString('0/100 [💧---------------------------] 0%', $output->fetch()); - // Need to sleep to prevent the default redraw frequency from skipping display. - sleep(1); + // Disable time-based redraw throttling so subsequent progress updates + // are always displayed, deterministically. + $progress->minSecondsBetweenRedraws(0); PullCommandBase::displayDownloadProgress(100, 50, $progress, $output); $this->assertStringContainsString('50/100 [==============💧-------------] 50%', $output->fetch()); diff --git a/tests/phpunit/src/Commands/Push/PushArtifactCommandTest.php b/tests/phpunit/src/Commands/Push/PushArtifactCommandTest.php index 4e2a1c3fd..d5b738c18 100644 --- a/tests/phpunit/src/Commands/Push/PushArtifactCommandTest.php +++ b/tests/phpunit/src/Commands/Push/PushArtifactCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Push\PushArtifactCommand; use Acquia\Cli\Tests\Commands\Pull\PullCommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Console\Output\OutputInterface; @@ -51,9 +52,7 @@ public static function providerTestPushArtifact(): array ]; } - /** - * @dataProvider providerTestPushArtifact - */ + #[DataProvider('providerTestPushArtifact')] public function testPushArtifact(int $verbosity, bool $printOutput): void { $applications = $this->mockRequest('getApplications'); diff --git a/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php b/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php index 03d788389..d25fdae95 100644 --- a/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php +++ b/tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php @@ -10,6 +10,7 @@ use Acquia\Cli\Helpers\LocalMachineHelper; use Acquia\Cli\Helpers\SshHelper; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Symfony\Component\Console\Output\OutputInterface; @@ -49,9 +50,7 @@ public function setUp(): void parent::setUp(); } - /** - * @dataProvider providerTestPushDatabase - */ + #[DataProvider('providerTestPushDatabase')] public function testPushDatabase(int $verbosity, bool $printOutput, bool $pv): void { $applications = $this->mockRequest('getApplications'); @@ -143,7 +142,7 @@ protected function mockUploadDatabaseDump( $command = [ 'rsync', '-tDvPhe', - 'ssh -o StrictHostKeyChecking=no', + 'ssh -o StrictHostKeyChecking=accept-new', sys_get_temp_dir() . '/acli-mysql-dump-drupal.sql.gz', 'profserv2.01dev@profserv201dev.ssh.enterprise-g1.acquia-sites.com:/mnt/tmp/profserv2.01dev/acli-mysql-dump-drupal.sql.gz', ]; @@ -180,7 +179,7 @@ protected function mockGetAcsfSitesLMH(ObjectProphecy $localMachineHelper): void 0 => 'ssh', 1 => 'profserv2.01dev@profserv201dev.ssh.enterprise-g1.acquia-sites.com', 2 => '-t', - 3 => '-o StrictHostKeyChecking=no', + 3 => '-o StrictHostKeyChecking=accept-new', 4 => '-o AddressFamily inet', 5 => '-o LogLevel=ERROR', 6 => 'cat', @@ -197,7 +196,7 @@ private function mockImportDatabaseDumpOnRemote(ObjectProphecy|LocalMachineHelpe 0 => 'ssh', 1 => 'profserv2.01dev@profserv201dev.ssh.enterprise-g1.acquia-sites.com', 2 => '-t', - 3 => '-o StrictHostKeyChecking=no', + 3 => '-o StrictHostKeyChecking=accept-new', 4 => '-o AddressFamily inet', 5 => '-o LogLevel=ERROR', 6 => "bash -o pipefail -c 'pv '/mnt/tmp/profserv2.01dev/acli-mysql-dump-drupal.sql.gz' --bytes --rate | gunzip | MYSQL_PWD='password' mysql --host='fsdb-74.enterprise-g1.hosting.acquia.com.enterprise-g1.hosting.acquia.com' --user='s164' 'profserv2db14390''", @@ -269,7 +268,7 @@ private function mockImportDatabaseDumpOnRemoteWithSpecialChars(ObjectProphecy|L 0 => 'ssh', 1 => 'profserv2.01dev@profserv201dev.ssh.enterprise-g1.acquia-sites.com', 2 => '-t', - 3 => '-o StrictHostKeyChecking=no', + 3 => '-o StrictHostKeyChecking=accept-new', 4 => '-o AddressFamily inet', 5 => '-o LogLevel=ERROR', 6 => "bash -o pipefail -c 'pv '/mnt/tmp/profserv2.01dev/acli-mysql-dump-drupal.sql.gz' --bytes --rate | gunzip | MYSQL_PWD='pass'\\''word' mysql --host='db'\\''host.enterprise-g1.hosting.acquia.com' --user='user'\\''name' 'db'\\''name''", diff --git a/tests/phpunit/src/Commands/Push/PushFilesCommandTest.php b/tests/phpunit/src/Commands/Push/PushFilesCommandTest.php index 07dae5af7..e25726724 100644 --- a/tests/phpunit/src/Commands/Push/PushFilesCommandTest.php +++ b/tests/phpunit/src/Commands/Push/PushFilesCommandTest.php @@ -144,7 +144,7 @@ protected function mockExecuteCloudRsync( $command = [ 'rsync', '-avPhze', - 'ssh -o StrictHostKeyChecking=no', + 'ssh -o StrictHostKeyChecking=accept-new', $this->projectDir . '/docroot/sites/default/files/', $environment->ssh_url . ':/mnt/files/' . $sitegroup . '.' . $environment->name . '/sites/default/files', ]; @@ -163,7 +163,7 @@ protected function mockExecuteAcsfRsync( $command = [ 'rsync', '-avPhze', - 'ssh -o StrictHostKeyChecking=no', + 'ssh -o StrictHostKeyChecking=accept-new', $this->projectDir . '/docroot/sites/' . $site . '/files/', 'profserv2.01dev@profserv201dev.ssh.enterprise-g1.acquia-sites.com:/mnt/files/profserv2.01dev/sites/g/files/' . $site . '/files', ]; diff --git a/tests/phpunit/src/Commands/Remote/AliasesDownloadCommandTest.php b/tests/phpunit/src/Commands/Remote/AliasesDownloadCommandTest.php index d3c4780a1..66da5b396 100644 --- a/tests/phpunit/src/Commands/Remote/AliasesDownloadCommandTest.php +++ b/tests/phpunit/src/Commands/Remote/AliasesDownloadCommandTest.php @@ -11,6 +11,8 @@ use GuzzleHttp\Psr7\Utils; use Phar; use PharData; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use Symfony\Component\Filesystem\Path; /** @@ -47,8 +49,8 @@ public static function providerTestRemoteAliasesDownloadCommand(): array /** * @param bool $all Download aliases for all applications. - * @dataProvider providerTestRemoteAliasesDownloadCommand */ + #[DataProvider('providerTestRemoteAliasesDownloadCommand')] public function testRemoteAliasesDownloadCommand(array $inputs, array $args, ?string $destinationDir = null, bool $all = false): void { $aliasVersion = $inputs[0]; @@ -91,9 +93,7 @@ public function testRemoteAliasesDownloadCommand(array $inputs, array $args, ?st $this->assertStringContainsString('Cloud Platform Drush aliases installed into ' . $destinationDir, $output); } - /** - * @requires OS linux|darwin - */ + #[RequiresOperatingSystem('linux|darwin')] public function testRemoteAliasesDownloadFailed(): void { $drushAliasesFixture = Path::canonicalize(__DIR__ . '/../../../../fixtures/drush-aliases'); @@ -107,12 +107,13 @@ public function testRemoteAliasesDownloadFailed(): void $this->clientProphecy->stream('get', '/account/drush-aliases/download') ->willReturn($stream); + $drushArchiveFilepath = $this->command->getDrushArchiveTempFilepath(); $destinationDir = Path::join($this->acliRepoRoot, 'drush'); $sitesDir = Path::join($destinationDir, 'sites'); mkdir($sitesDir, 0777, true); chmod($sitesDir, 000); $this->expectException(AcquiaCliException::class); - $this->expectExceptionMessage("Could not extract aliases to $destinationDir"); + $this->expectExceptionMessage("Failed to extract aliases archive at $drushArchiveFilepath:"); $this->executeCommand([ '--all' => true, '--destination-dir' => $destinationDir, diff --git a/tests/phpunit/src/Commands/Remote/DrushCommandTest.php b/tests/phpunit/src/Commands/Remote/DrushCommandTest.php index 4eba72b9a..3dca467a8 100644 --- a/tests/phpunit/src/Commands/Remote/DrushCommandTest.php +++ b/tests/phpunit/src/Commands/Remote/DrushCommandTest.php @@ -7,6 +7,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Remote\DrushCommand; use Acquia\Cli\Helpers\SshHelper; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; /** @@ -38,9 +39,7 @@ public static function providerTestRemoteDrushCommand(): array ]; } - /** - * @dataProvider providerTestRemoteDrushCommand - */ + #[DataProvider('providerTestRemoteDrushCommand')] public function testRemoteDrushCommand(array $args): void { $this->mockGetEnvironment(); @@ -51,7 +50,7 @@ public function testRemoteDrushCommand(array $args): void 'ssh', 'site.dev@sitedev.ssh.hosted.acquia-sites.com', '-t', - '-o StrictHostKeyChecking=no', + '-o StrictHostKeyChecking=accept-new', '-o AddressFamily inet', '-o LogLevel=ERROR', 'cd /var/www/html/site.dev/docroot; ', diff --git a/tests/phpunit/src/Commands/Remote/SshCommandTest.php b/tests/phpunit/src/Commands/Remote/SshCommandTest.php index e0014354c..6010e3df5 100644 --- a/tests/phpunit/src/Commands/Remote/SshCommandTest.php +++ b/tests/phpunit/src/Commands/Remote/SshCommandTest.php @@ -8,6 +8,7 @@ use Acquia\Cli\Command\Remote\SshCommand; use Acquia\Cli\Command\Self\ClearCacheCommand; use Acquia\Cli\Helpers\SshHelper; +use PHPUnit\Framework\Attributes\Group; use Prophecy\Argument; /** @@ -20,9 +21,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(SshCommand::class); } - /** - * @group serial - */ + #[Group('serial')] public function testRemoteAliasesDownloadCommand(): void { ClearCacheCommand::clearCaches(); @@ -34,7 +33,7 @@ public function testRemoteAliasesDownloadCommand(): void 'ssh', 'site.dev@sitedev.ssh.hosted.acquia-sites.com', '-t', - '-o StrictHostKeyChecking=no', + '-o StrictHostKeyChecking=accept-new', '-o AddressFamily inet', '-o LogLevel=ERROR', 'cd /var/www/html/devcloud2.dev; exec $SHELL -l', diff --git a/tests/phpunit/src/Commands/Self/ClearCacheCommandTest.php b/tests/phpunit/src/Commands/Self/ClearCacheCommandTest.php index 2cc9de103..23ff014ad 100644 --- a/tests/phpunit/src/Commands/Self/ClearCacheCommandTest.php +++ b/tests/phpunit/src/Commands/Self/ClearCacheCommandTest.php @@ -8,6 +8,7 @@ use Acquia\Cli\Command\Ide\IdeListCommand; use Acquia\Cli\Command\Self\ClearCacheCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\Group; /** * @property \Acquia\Cli\Command\App\UnlinkCommand $command @@ -19,9 +20,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(ClearCacheCommand::class); } - /** - * @group serial - */ + #[Group('serial')] public function testAliasesAreCached(): void { ClearCacheCommand::clearCaches(); @@ -63,11 +62,19 @@ public function testAliasesAreCached(): void public function testClearCaches(): void { + // Seed both caches so we can prove each is cleared. + $aliasCache = CommandBase::getAliasCache(); + $aliasItem = $aliasCache->getItem('some-alias'); + $aliasCache->save($aliasItem->set('value')); + $updateCache = CommandBase::getUpdateCheckCache(); + $updateItem = $updateCache->getItem('latest-version.1_0_0'); + $updateCache->save($updateItem->set('2.0.0')); + $this->executeCommand(); $output = $this->getDisplay(); $this->assertStringContainsString('Acquia CLI caches were cleared', $output); - $cache = CommandBase::getAliasCache(); - $this->assertCount(0, iterator_to_array($cache->getItems(), false)); + $this->assertCount(0, iterator_to_array($aliasCache->getItems(), false)); + $this->assertFalse(CommandBase::getUpdateCheckCache()->getItem('latest-version.1_0_0')->isHit()); } } diff --git a/tests/phpunit/src/Commands/Self/TelemetryCommandTest.php b/tests/phpunit/src/Commands/Self/TelemetryCommandTest.php index 2da3bd3b6..cd2e9a0a2 100644 --- a/tests/phpunit/src/Commands/Self/TelemetryCommandTest.php +++ b/tests/phpunit/src/Commands/Self/TelemetryCommandTest.php @@ -5,11 +5,18 @@ namespace Acquia\Cli\Tests\Commands\Self; use Acquia\Cli\Command\App\LinkCommand; +use Acquia\Cli\Command\Auth\AuthLoginCommand; use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Self\TelemetryCommand; use Acquia\Cli\Helpers\DataStoreContract; use Acquia\Cli\Tests\CommandTestBase; +use AcquiaCloudApi\Connector\Connector; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use Prophecy\Argument; +use ReflectionClass; use Symfony\Component\Filesystem\Path; +use Zumba\Amplitude\Amplitude; /** * @property \Acquia\Cli\Command\Self\TelemetryCommand $command @@ -32,9 +39,7 @@ protected function createCommand(): CommandBase return $this->injectCommand(TelemetryCommand::class); } - /** - * @group brokenProphecy - */ + #[Group('brokenProphecy')] public function testTelemetryCommand(): void { $this->mockRequest('getAccount'); @@ -63,9 +68,9 @@ public static function providerTestTelemetryPrompt(): array /** * Tests telemetry prompt. * - * @dataProvider providerTestTelemetryPrompt * @param $message */ + #[DataProvider('providerTestTelemetryPrompt')] public function testTelemetryPrompt(array $inputs, mixed $message): void { $this->createMockCloudConfigFile([DataStoreContract::SEND_TELEMETRY => null]); @@ -90,6 +95,39 @@ public function testAmplitudeDisabled(): void $this->assertEquals(0, $this->getStatusCode()); } + /** + * Tests that sensitive option values are redacted from telemetry events. + */ + public function testTelemetryEventRedactsSensitiveOptions(): void + { + $amplitude = Amplitude::getInstance(); + $amplitude->setOptOut(false); + $amplitude->resetQueue(); + + $this->mockRequest('getAccount'); + $this->clientServiceProphecy->setConnector(Argument::type(Connector::class)) + ->shouldBeCalled(); + $this->clientServiceProphecy->isMachineAuthenticated() + ->willReturn(false); + $this->removeMockCloudConfigFile(); + $this->createDataStores(); + $this->command = $this->injectCommand(AuthLoginCommand::class); + + $this->executeCommand([ + '--key' => self::$key, + '--secret' => self::$secret, + ]); + + $this->assertTrue($amplitude->hasQueuedEvents()); + $queueProperty = (new ReflectionClass($amplitude))->getProperty('queue'); + $queuedEvents = $queueProperty->getValue($amplitude); + $event = json_encode(end($queuedEvents), JSON_THROW_ON_ERROR); + $this->assertStringContainsString('REDACTED', $event); + $this->assertStringNotContainsString(self::$key, $event); + $this->assertStringNotContainsString(self::$secret, $event); + $amplitude->resetQueue(); + } + public function testMigrateLegacyTelemetryPreference(): void { $this->createMockCloudConfigFile([DataStoreContract::SEND_TELEMETRY => null]); diff --git a/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php b/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php index bbbe1edb5..13eda3365 100644 --- a/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php +++ b/tests/phpunit/src/Commands/Ssh/SshKeyCreateCommandTest.php @@ -7,6 +7,8 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Ssh\SshKeyCreateCommand; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use Prophecy\Argument; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; @@ -99,9 +101,7 @@ public static function providerTestCreate(): array ]; } - /** - * @dataProvider providerTestCreate - */ + #[DataProvider('providerTestCreate')] public function testCreate(mixed $sshAddSuccess, mixed $args, mixed $inputs): void { $sshKeyFilepath = Path::join($this->sshDir, '/' . self::$filename); @@ -129,6 +129,54 @@ public function testCreate(mixed $sshAddSuccess, mixed $args, mixed $inputs): vo $this->executeCommand($args, $inputs); } + /** + * Test that restrictive permissions are enforced on the private key and + * SSH directory after key generation, even if ssh-keygen (or whatever + * created the files) left them too permissive. + * + * Unix file mode bits do not apply on Windows, where chmod() is a no-op. + */ + #[RequiresOperatingSystem('linux|darwin')] + public function testCreateEnforcesSecureKeyFilePermissions(): void + { + $privateKeyFilepath = Path::join($this->sshDir, self::$filename); + $publicKeyFilepath = $privateKeyFilepath . '.pub'; + $this->fs->remove($privateKeyFilepath); + $this->fs->remove($publicKeyFilepath); + $this->fs->chmod($this->sshDir, 0775); + $localMachineHelper = $this->mockLocalMachineHelper(); + $localMachineHelper->getLocalFilepath('~/.passphrase') + ->willReturn('~/.passphrase'); + + // Key is already in the agent, so addSshKeyToAgent is not called. + $this->mockSshAgentList($localMachineHelper, true); + $localMachineHelper->readFile(Argument::containingString(self::$filename)) + ->willReturn('thekey!'); + + // Mock ssh-keygen, simulating key files created with loose permissions. + $process = $this->prophet->prophesize(Process::class); + $process->isSuccessful()->willReturn(true); + $localMachineHelper->checkRequiredBinariesExist(['ssh-keygen']) + ->shouldBeCalled(); + $localMachineHelper->execute(Argument::withEntry(0, 'ssh-keygen'), null, null, false) + ->will(function () use ($process, $privateKeyFilepath, $publicKeyFilepath) { + file_put_contents($privateKeyFilepath, 'the private key'); + chmod($privateKeyFilepath, 0644); + file_put_contents($publicKeyFilepath, 'thekey!'); + return $process->reveal(); + }) + ->shouldBeCalled(); + + $this->executeCommand([ + '--filename' => self::$filename, + '--password' => 'acli123', + ], []); + + clearstatcache(); + $this->assertSame(0600, fileperms($privateKeyFilepath) & 0777, 'The private key must be chmod 0600 after creation.'); + $this->assertSame(0700, fileperms($this->sshDir) & 0777, 'The SSH directory must be chmod 0700 after key creation.'); + } + /** * Test that passwords with spaces are properly escaped in shell commands. */ diff --git a/tests/phpunit/src/Commands/Ssh/SshKeyUploadCommandTest.php b/tests/phpunit/src/Commands/Ssh/SshKeyUploadCommandTest.php index f5139616c..e43721f36 100644 --- a/tests/phpunit/src/Commands/Ssh/SshKeyUploadCommandTest.php +++ b/tests/phpunit/src/Commands/Ssh/SshKeyUploadCommandTest.php @@ -8,6 +8,7 @@ use Acquia\Cli\Command\Ssh\SshKeyUploadCommand; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\CommandTestBase; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Path; @@ -63,9 +64,7 @@ public static function providerTestUpload(): array ]; } - /** - * @dataProvider providerTestUpload - */ + #[DataProvider('providerTestUpload')] public function testUpload(array $args, array $inputs, bool $perms): void { $sshKeysRequestBody = self::getMockRequestBodyFromSpec('/account/ssh-keys'); diff --git a/tests/phpunit/src/Commands/UpdateCommandTest.php b/tests/phpunit/src/Commands/UpdateCommandTest.php index 454d5ec8a..6d864d002 100644 --- a/tests/phpunit/src/Commands/UpdateCommandTest.php +++ b/tests/phpunit/src/Commands/UpdateCommandTest.php @@ -28,6 +28,30 @@ public function testSelfUpdate(): void self::assertStringContainsString("Acquia CLI $this->endVersion is available", $this->getDisplay()); } + public function testUpdateCheckIsCached(): void + { + $this->application->setVersion($this->startVersion); + $this->mockSelfUpdateCommand(false, 1); + $this->executeCommand(); + self::assertStringContainsString("Acquia CLI $this->endVersion is available", $this->getDisplay()); + // The second run must use the cached result rather than hitting the + // GitHub API again; shouldBeCalledTimes(1) on the mock enforces it. + $this->executeCommand(); + self::assertEquals(0, $this->getStatusCode()); + self::assertStringContainsString("Acquia CLI $this->endVersion is available", $this->getDisplay()); + } + + public function testNoUpdateMessageWhenUpToDate(): void + { + CommandBase::getUpdateCheckCache()->clear(); + $this->application->setVersion($this->startVersion); + // The default mock reports the CLI as up to date; no upgrade message + // should be shown (and checkForNewVersion() must return false, not true). + $this->executeCommand(); + self::assertEquals(0, $this->getStatusCode()); + self::assertStringNotContainsString('is available', $this->getDisplay()); + } + public function testBadResponseFailsSilently(): void { $this->application->setVersion($this->startVersion); @@ -40,15 +64,20 @@ public function testBadResponseFailsSilently(): void /** * @throws \GuzzleHttp\Exception\GuzzleException */ - private function mockSelfUpdateCommand(bool $exception = false): void + private function mockSelfUpdateCommand(bool $exception = false, ?int $expectedChecks = null): void { + CommandBase::getUpdateCheckCache()->clear(); $selfUpdateManagerProphecy = $this->prophet->prophesize(SelfUpdateManager::class); if ($exception) { $selfUpdateManagerProphecy->isUpToDate()->willThrow(new Exception())->shouldBeCalled(); } else { - $selfUpdateManagerProphecy->isUpToDate() - ->willReturn(false) - ->shouldBeCalled(); + $isUpToDate = $selfUpdateManagerProphecy->isUpToDate() + ->willReturn(false); + if ($expectedChecks === null) { + $isUpToDate->shouldBeCalled(); + } else { + $isUpToDate->shouldBeCalledTimes($expectedChecks); + } $selfUpdateManagerProphecy->getLatestReleaseFromGithub() ->willReturn(['tag_name' => $this->endVersion]) ->shouldBeCalled(); diff --git a/tests/phpunit/src/DataStore/JsonDataStoreTest.php b/tests/phpunit/src/DataStore/JsonDataStoreTest.php new file mode 100644 index 000000000..2d09c6c92 --- /dev/null +++ b/tests/phpunit/src/DataStore/JsonDataStoreTest.php @@ -0,0 +1,62 @@ +tempDir = sys_get_temp_dir() . '/acli_json_datastore_' . uniqid(); + (new Filesystem())->mkdir($this->tempDir); + $this->filepath = $this->tempDir . '/cloud_api.conf'; + } + + protected function tearDown(): void + { + (new Filesystem())->remove($this->tempDir); + parent::tearDown(); + } + + public function testDumpRestrictsFilePermissions(): void + { + $store = new JsonDataStore($this->filepath); + $store->set('acli_key', 'test-key'); + $store->dump(); + + clearstatcache(true, $this->filepath); + $this->assertTrue(is_readable($this->filepath)); + $this->assertSame(0600, fileperms($this->filepath) & 0777); + $this->assertSame('test-key', json_decode(file_get_contents($this->filepath), true)['acli_key']); + } + + public function testDumpRestrictsPermissionsOnExistingFile(): void + { + file_put_contents($this->filepath, json_encode(['acli_key' => 'test-key'], JSON_THROW_ON_ERROR)); + chmod($this->filepath, 0644); + + $store = new JsonDataStore($this->filepath); + $store->dump(); + + clearstatcache(true, $this->filepath); + $this->assertSame(0600, fileperms($this->filepath) & 0777); + } +} diff --git a/tests/phpunit/src/Misc/ChecklistTest.php b/tests/phpunit/src/Misc/ChecklistTest.php index 2e71dde5c..c51b9462e 100644 --- a/tests/phpunit/src/Misc/ChecklistTest.php +++ b/tests/phpunit/src/Misc/ChecklistTest.php @@ -6,6 +6,7 @@ use Acquia\Cli\Output\Checklist; use Acquia\Cli\Tests\TestBase; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; @@ -23,9 +24,7 @@ public function setUp(): void $this->output = new ConsoleOutput(); } - /** - * @group serial - */ + #[Group('serial')] public function testSpinner(): void { putenv('PHPUNIT_RUNNING=1'); diff --git a/tests/phpunit/src/Misc/ExceptionListenerTest.php b/tests/phpunit/src/Misc/ExceptionListenerTest.php index bec130e45..ec86753b4 100644 --- a/tests/phpunit/src/Misc/ExceptionListenerTest.php +++ b/tests/phpunit/src/Misc/ExceptionListenerTest.php @@ -10,6 +10,7 @@ use Acquia\Cli\Tests\TestBase; use AcquiaCloudApi\Exception\ApiErrorException; use League\OAuth2\Client\Provider\Exception\IdentityProviderException; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Event\ConsoleErrorEvent; use Symfony\Component\Console\Exception\RuntimeException; @@ -21,9 +22,7 @@ class ExceptionListenerTest extends TestBase private static string $appAliasHelp = 'The applicationUuid argument must be a valid UUID or unique application alias accessible to your Cloud Platform user.' . PHP_EOL . PHP_EOL . 'An alias consists of an application name optionally prefixed with a hosting realm, e.g. myapp or prod.myapp.' . PHP_EOL . PHP_EOL . 'Run acli remote:aliases:list to see a list of all available aliases.'; - /** - * @dataProvider providerTestHelp - */ + #[DataProvider('providerTestHelp')] public function testHelp(Throwable $error, string|array $helpText): void { $exceptionListener = new ExceptionListener(); diff --git a/tests/phpunit/src/Misc/LocalMachineHelperTest.php b/tests/phpunit/src/Misc/LocalMachineHelperTest.php index 30439f07d..5b848d1d2 100644 --- a/tests/phpunit/src/Misc/LocalMachineHelperTest.php +++ b/tests/phpunit/src/Misc/LocalMachineHelperTest.php @@ -7,6 +7,8 @@ use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Helpers\LocalMachineHelper; use Acquia\Cli\Tests\TestBase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Process\Process; @@ -14,10 +16,9 @@ * This test class must run serially because its tests have unavoidable side effects * on the environment (e.g., modifying environment variables like DISPLAY) and the filesystem * (e.g., setting up and tearing down fixtures). Running these tests in parallel could cause - * interference and unpredictable failures. Do not remove the @group serial annotation. - * - * @group serial + * interference and unpredictable failures. Do not remove the serial group attribute. */ +#[Group('serial')] class LocalMachineHelperTest extends TestBase { public function testStartBrowser(): void @@ -29,6 +30,48 @@ public function testStartBrowser(): void putenv('DISPLAY'); } + public function testStartBrowserDoesNotShellInterpretUri(): void + { + putenv('DISPLAY=1'); + $markerFile = tempnam(sys_get_temp_dir(), 'acli_injection_'); + unlink($markerFile); + $uri = 'https://google.com/$(touch ' . $markerFile . ')'; + $opened = $this->localMachineHelper->startBrowser($uri, 'echo'); + $this->assertTrue($opened, 'Failed to open browser'); + $this->assertFileDoesNotExist($markerFile, 'The URI must be passed as a single argument and never be shell-interpreted'); + putenv('DISPLAY'); + } + + public function testStartBrowserWithMultiWordBrowserCommand(): void + { + putenv('DISPLAY=1'); + $opened = $this->localMachineHelper->startBrowser('https://google.com', 'echo -n'); + $this->assertTrue($opened, 'Failed to open browser with a multi-word browser command'); + putenv('DISPLAY'); + } + + public function testIsTtyStreamHandlesUnusableStreamWithoutErrorSuppression(): void + { + $errors = []; + set_error_handler(static function (int $errno, string $errstr) use (&$errors): bool { + $errors[] = $errstr; + return true; + }); + try { + $reflection = new \ReflectionClass($this->localMachineHelper); + $method = $reflection->getMethod('isTtyStream'); + // posix_isatty() emits a warning for streams that cannot be + // mapped to a file descriptor, such as in-memory streams. + $stream = fopen('php://memory', 'rb'); + $result = $method->invoke($this->localMachineHelper, $stream); + fclose($stream); + } finally { + restore_error_handler(); + } + $this->assertFalse($result, 'An unusable stream must be reported as not a TTY'); + $this->assertSame([], $errors, 'No PHP warning may leak out of isTtyStream()'); + } + /** * @return bool[][] */ @@ -41,9 +84,7 @@ public static function providerTestExecuteFromCmd(): array ]; } - /** - * @dataProvider providerTestExecuteFromCmd() - */ + #[DataProvider('providerTestExecuteFromCmd')] public function testExecuteFromCmd(bool $interactive, bool|null $isTty, bool|null $printOutput): void { $localMachineHelper = $this->localMachineHelper; diff --git a/tests/phpunit/src/Misc/TelemetryHelperTest.php b/tests/phpunit/src/Misc/TelemetryHelperTest.php index bbd933e3b..bfe4c1603 100644 --- a/tests/phpunit/src/Misc/TelemetryHelperTest.php +++ b/tests/phpunit/src/Misc/TelemetryHelperTest.php @@ -6,6 +6,8 @@ use Acquia\Cli\Helpers\TelemetryHelper; use Acquia\Cli\Tests\TestBase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; class TelemetryHelperTest extends TestBase { @@ -52,10 +54,8 @@ public static function providerTestEnvironmentProvider(): array return $providersArray; } - /** - * @group serial - * @dataProvider providerTestEnvironmentProvider() - */ + #[DataProvider('providerTestEnvironmentProvider')] + #[Group('serial')] public function testEnvironmentProvider(string $provider, array $envVars): void { $this->unsetGitHubEnvVars(); @@ -66,9 +66,8 @@ public function testEnvironmentProvider(string $provider, array $envVars): void /** * Test the getEnvironmentProvider method when no environment provider is * detected. - * - * @group serial */ + #[Group('serial')] public function testGetEnvironmentProviderWithoutAnyEnvSet(): void { $this->unsetGitHubEnvVars(); @@ -96,19 +95,76 @@ public static function providerTestAhEnvNormalization(): array } /** - * @dataProvider providerTestAhEnvNormalization * @param string $ah_env * The Acquia hosting environment. * @param string $expected * The expected normalized environment. - * @group serial */ + #[DataProvider('providerTestAhEnvNormalization')] + #[Group('serial')] public function testAhEnvNormalization(string $ah_env, string $expected): void { $normalized_ah_env = TelemetryHelper::normalizeAhEnv($ah_env); $this->assertEquals($expected, $normalized_ah_env); } + /** + * @return array + */ + public static function providerTestRedactSensitiveData(): array + { + return [ + [['key' => 'mykey'], ['key' => 'REDACTED']], + [['secret' => 'mysecret'], ['secret' => 'REDACTED']], + [['password' => 'mypassword'], ['password' => 'REDACTED']], + [['token' => 'mytoken'], ['token' => 'REDACTED']], + [['api-key' => 'mykey'], ['api-key' => 'REDACTED']], + [['api-secret' => 'mysecret'], ['api-secret' => 'REDACTED']], + // Unset (null) values should not be redacted, lest it appear + // that a value was actually passed. + [['key' => null], ['key' => null]], + // Non-sensitive values should be left untouched. + [ + ['filename' => 'id_rsa', 'password' => 'foo'], + ['filename' => 'id_rsa', 'password' => 'REDACTED'], + ], + // Sensitive keys nested in array values should be redacted. + [ + ['params' => ['password' => 'foo', 'filename' => 'id_rsa']], + ['params' => ['password' => 'REDACTED', 'filename' => 'id_rsa']], + ], + ]; + } + + /** + * @param array $data + * @param array $expected + */ + #[DataProvider('providerTestRedactSensitiveData')] + public function testRedactSensitiveData(array $data, array $expected): void + { + $this->assertSame($expected, TelemetryHelper::redactSensitiveData($data)); + } + + /** + * @return array + */ + public static function providerTestRedactSensitiveContext(): array + { + return [ + ['ssh-key:create --password=foo', 'ssh-key:create --passwordREDACTED'], + ['auth:login --key=foo --secret=bar', 'auth:login --keyREDACTED'], + ['auth:login --secret=bar', 'auth:login --secretREDACTED'], + ['app:link myapp', 'app:link myapp'], + ]; + } + + #[DataProvider('providerTestRedactSensitiveContext')] + public function testRedactSensitiveContext(string $context, string $expected): void + { + $this->assertSame($expected, TelemetryHelper::redactSensitiveContext($context)); + } + public function testIsBuildDateOlderThanMonthsNullDate(): void { $this->assertFalse(TelemetryHelper::isBuildDateOlderThanMonths(null, 3));