diff --git a/AGENTS.md b/AGENTS.md index bb68ebc..2ce2f04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,12 +7,12 @@ It targets Quickpay API v10. Composer and GitHub releases distribute the same tracked standalone PHAR without installing Laravel Zero at runtime. Use `php quickpay` when running from source. See `README.md` for the public -command reference and `skills/quickpay/SKILL.md` for the agent-facing usage +command reference and `skills/quickpay-cli/SKILL.md` for the agent-facing usage guide. ## Structure -- `app/Commands` — thin command adapters grouped by API, authentication, +- `app/Commands` — thin command adapters grouped by API, merchants, callbacks, and payments. - `app/Callbacks` — callback replay/watch workflows and their delivery, resolution, signing, and input capabilities. @@ -65,7 +65,7 @@ validation, and the locked-dependency audit. - Preserve raw API JSON in `--json` mode after sanitization. Keep machine-readable output on stdout and diagnostics or prompts on stderr. - Restore environment variables and other global state changed by a test. -- Update `README.md`, command help, and `skills/quickpay/SKILL.md` when a command +- Update `README.md`, command help, and `skills/quickpay-cli/SKILL.md` when a command or safety behavior changes. - Check current Quickpay documentation before changing API behavior; do not infer the remote contract solely from existing code. @@ -74,9 +74,15 @@ validation, and the locked-dependency audit. - Never expose an API key in output, errors, logs, fixtures, or command arguments. Preserve redaction of raw, Basic-auth, and encoded forms. -- Credentials come from a non-empty `QUICKPAY_API_KEY` before - `~/.config/quickpay/config.json`. Stored credentials use atomic writes, a - `0700` directory, and a `0600` file. +- Named credentials live in `~/.config/quickpay-cli/config.json`; never read or + migrate the old `~/.config/quickpay/config.json`. Stored credentials use locked + atomic writes, a `0700` directory, and a `0600` file. +- Stored selection is explicit `--merchant`, nearest `.quickpay-cli.json`, then + global default. Invalid or stale selection never falls back silently. +- A non-empty `QUICKPAY_API_KEY` bypasses saved storage when no explicit or + project selection exists; combining them fails before API requests. +- Resolve credentials once per command, including long-running watchers. Plain + `merchants:active` is local status; `--check` verifies merchant API access. - The client owns the Quickpay host, authentication, API version, and JSON headers. Raw API input must not override them or escape the `api.quickpay.net` origin. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b06ce84..6aacf65 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,7 @@ alignment and the main structural boundaries. - Preserve original API JSON in `--json` mode unless credential sanitization is required. - Use integer minor units for amounts. -- Update the README, command help, and `skills/quickpay/SKILL.md` when the CLI +- Update the README, command help, and `skills/quickpay-cli/SKILL.md` when the CLI contract or safety workflow changes. - Check current Quickpay documentation before changing API behavior; do not infer the remote contract solely from existing code. diff --git a/README.md b/README.md index ecbc576..b90ac09 100644 --- a/README.md +++ b/README.md @@ -29,19 +29,30 @@ Quickpay CLI requires PHP 8.4 or newer. composer global require tehwave/quickpay-cli ``` -Install the bundled skill for coding agents: +Install the bundled [quickpay-cli skill](skills/quickpay-cli/SKILL.md) for coding agents: ```bash skills add tehwave/quickpay-cli ``` -Authenticate with a Quickpay merchant API key: +If you previously installed this project's skill as `quickpay`, remove that +installed copy and run the installation command again. Check its origin before +removal so an unrelated Quickpay skill stays intact. + +Save access to each merchant once. The CLI privately prompts for its API key: ```bash -quickpay login +quickpay merchants:add shop-a ``` -For automation, supply the API key through `QUICKPAY_API_KEY`. +Credentials are stored in `~/.config/quickpay-cli/config.json` with a `0700` +directory and `0600` file. Merchant names are local labels you choose, not verified +Quickpay account names or merchant IDs. Names contain 1-64 lowercase ASCII +letters, digits or single separating hyphens, starting with a letter. + +For automation, supply the API key through `QUICKPAY_API_KEY`. It cannot be +combined with `--merchant` or a project binding; unset it before selecting a +saved merchant. ## Usage @@ -55,6 +66,55 @@ Use `--help` to explore any command and its options: quickpay callbacks:watch --help ``` +### Select a merchant + +```bash +quickpay merchants:add shop-b +quickpay merchants:list +quickpay merchants:select shop-a +quickpay merchants:select # Interactive picker +quickpay merchants:active # Local configuration, no API request +quickpay merchants:active --check --json # Verify API access for automation +quickpay payments:list --merchant=shop-b +``` + +The first saved merchant becomes the global default. Adding another merchant +keeps that default. Select a different default with `merchants:select`, or use +`--merchant` for one payment, callback or raw API command. + +Bind the current project without changing the global default: + +```bash +quickpay merchants:select shop-b --local +``` + +Run this from the project root. It writes `.quickpay-cli.json` containing only +`{"merchant":"shop-b"}`. Commands search the current directory and its parents +for the nearest binding. Ignore this file in Git when the selection is specific +to one developer. The selection order is an explicit `--merchant`, then the +nearest project binding, then the global default. Unknown names and invalid +bindings fail without silently switching to another merchant. + +Each invocation resolves its merchant once. A running callback watcher stays +attached to that merchant when another command changes selection or removes the +saved credential. Mutation confirmations and watcher startup show the saved +label and credential source; payment confirmations also show the API's merchant +ID when available. These messages go to stderr in JSON mode. + +`merchants:active` reports configuration. Add `--check` to verify the key against +Quickpay; a missing or rejected credential exits non-zero. `--json` keeps the +status machine-readable, with diagnostics on stderr. + +Rotate a saved key with `quickpay merchants:add shop-a --replace`. The old key +remains intact if validation fails. `quickpay merchants:remove shop-a` removes +only local access, never the remote account. Removing the default leaves no +default; it does not select another saved merchant. Any binding to a removed +merchant will fail until you select a valid one. + +This pre-v1 change replaces `login`, `logout` and `auth`. The old +`~/.config/quickpay/config.json` is not read or migrated. Add each merchant again +with `merchants:add`; after setup, you may remove the old file yourself. + ### Watch for callbacks and relay them ```bash @@ -69,6 +129,10 @@ payment changed after it becomes ready. Pass a payment ID or `--order-id` to narrow the watch to one payment. Existing operations are not replayed. The watcher runs in the foreground and has no JSON mode. +An explicit `QUICKPAY_PRIVATE_KEY` must belong to the selected merchant. Without +that override, the CLI fetches the signing key through the same merchant's API +access and keeps it only in memory. + ```bash quickpay callbacks:replay --to= diff --git a/SECURITY.md b/SECURITY.md index 526e6d8..4af9e03 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -39,6 +39,22 @@ Security-relevant areas include: - callback private-key retrieval and in-memory-only handling; - release artifact contents, checksums, and build provenance. +## Merchant credentials and selection + +Saved merchant API keys live in `~/.config/quickpay-cli/config.json`, with a +`0700` directory and `0600` file. Credential updates use a persistent sibling +lock and atomic file replacement. `.quickpay-cli.json` selects an already saved +merchant by its local label; it cannot provide credentials, API headers or URLs. +Neither a binding nor selecting a merchant authorizes a payment mutation. + +Explicit `--merchant` selection overrides a project binding and global default. +Malformed or stale bindings fail without selecting another merchant. A non-empty +`QUICKPAY_API_KEY` cannot be combined with an explicit or project selection. +Each command resolves its key once, so running watchers and inspect/confirm +workflows stay attached to the same credential even if local configuration +changes. Saved labels identify local configuration; actual merchant IDs come +from Quickpay resources, when available. + ## Local callback forwarding `callbacks:replay` and `callbacks:watch` send a signed payment resource to the @@ -53,7 +69,8 @@ API key, Basic authorization, or Quickpay API request headers to the callback destination. The account private key comes from a non-empty `QUICKPAY_PRIVATE_KEY` or the authenticated `/account/private-key` endpoint and is retained in process memory only. It is never written to the CLI config, -stdout, stderr, or local response summaries. +stdout, stderr, or local response summaries. A manually supplied +`QUICKPAY_PRIVATE_KEY` must belong to the selected merchant. Because `--to` may name a public service, inspect it before running the command. The explicit destination is the authorization to POST the payment payload; the diff --git a/app/Commands/Api/ApiRequestCommand.php b/app/Commands/Api/ApiRequestCommand.php index 0aefc25..de57ea7 100644 --- a/app/Commands/Api/ApiRequestCommand.php +++ b/app/Commands/Api/ApiRequestCommand.php @@ -48,6 +48,7 @@ public function handle( ); if ($request->mutation) { + $this->writeMerchantContext($authenticated); $this->writeSafetyLine("Quickpay API request: {$request->method}"); if (! $confirmation->approve( diff --git a/app/Commands/Authentication/AuthCommand.php b/app/Commands/Authentication/AuthCommand.php deleted file mode 100644 index c9f7bdd..0000000 --- a/app/Commands/Authentication/AuthCommand.php +++ /dev/null @@ -1,94 +0,0 @@ -resolve(); - } catch (CredentialException $exception) { - return $this->failure($exception->getMessage()); - } - - if (! $this->option('json')) { - $this->writeHumanStatusHeader($apiKey); - } - - if ($apiKey === null) { - if ($this->option('json')) { - $this->writeJsonStatus(null, null); - } else { - $this->line('Scope: not authenticated'); - $this->comment('Run quickpay login to store a merchant API key.'); - } - - return $this->option('check') ? self::FAILURE : self::SUCCESS; - } - - try { - $response = $quickpay->from($apiKey)->client->get('/ping'); - } catch (QuickpayRequestException $exception) { - return $this->failure($exception->getMessage()); - } - - if (! $response->successful()) { - return $this->failure('Authentication check failed: '.$apiKey->redact($response->errorSummary())); - } - - $scope = is_array($response->json) && is_string($response->json['scope'] ?? null) - ? $response->json['scope'] - : null; - - if ($scope === null || $scope === '') { - return $this->failure('Quickpay authentication check did not return an active scope.'); - } - - if ($apiKey->appearsIn($scope) - || preg_match('/\A[a-z][a-z0-9_-]{0,31}\z/D', $scope) !== 1) { - return $this->failure('Quickpay returned an invalid scope.'); - } - - if ($this->option('json')) { - $this->writeJsonStatus($apiKey, $scope); - } else { - $this->line("Scope: {$scope}"); - } - - return self::SUCCESS; - } - - private function writeHumanStatusHeader(?ApiKey $apiKey): void - { - $this->line('Credential source: '.($apiKey?->source() ?? 'none')); - $this->line('API base URL: '.QuickpayClient::BASE_URL); - $this->line('API version: '.QuickpayClient::API_VERSION); - } - - private function writeJsonStatus(?ApiKey $apiKey, ?string $scope): void - { - $this->getOutput()->write(JsonOutput::value([ - 'authenticated' => $scope !== null, - 'credential_source' => $apiKey?->source(), - 'api_base_url' => QuickpayClient::BASE_URL, - 'api_version' => QuickpayClient::API_VERSION, - 'scope' => $scope, - ], '')); - } -} diff --git a/app/Commands/Authentication/LoginCommand.php b/app/Commands/Authentication/LoginCommand.php deleted file mode 100644 index 87a8257..0000000 --- a/app/Commands/Authentication/LoginCommand.php +++ /dev/null @@ -1,60 +0,0 @@ -secret('API key'); - $apiKey = is_string($answer) ? trim($answer) : ''; - - if ($apiKey === '') { - return $this->failure('API key cannot be empty.'); - } - - $credential = new ApiKey($apiKey, 'provided'); - - try { - $response = $quickpay->from($credential)->client->get('/ping'); - } catch (QuickpayRequestException $exception) { - return $this->failure($exception->getMessage()); - } - - if (! $response->successful()) { - return $this->failure('Authentication failed: '.$credential->redact($response->errorSummary())); - } - - $scope = is_array($response->json) && is_string($response->json['scope'] ?? null) - ? $response->json['scope'] - : null; - - if ($scope !== 'merchant') { - return $this->failure('This credential does not have merchant scope. Only merchant API keys are supported.'); - } - - try { - $credentials->save($apiKey); - } catch (CredentialException $exception) { - return $this->failure($exception->getMessage()); - } - - $this->info('Credentials stored securely.'); - $this->line('Scope: merchant'); - $this->line('Version: '.QuickpayClient::API_VERSION); - - return self::SUCCESS; - } -} diff --git a/app/Commands/Authentication/LogoutCommand.php b/app/Commands/Authentication/LogoutCommand.php deleted file mode 100644 index 8d9556c..0000000 --- a/app/Commands/Authentication/LogoutCommand.php +++ /dev/null @@ -1,36 +0,0 @@ -forget(); - } catch (CredentialException $exception) { - return $this->failure($exception->getMessage()); - } - - if ($removed) { - $this->info('Stored credentials removed.'); - } else { - $this->line('No stored credentials found.'); - } - - if ($resolver->resolve()?->source() === 'environment') { - $this->comment('QUICKPAY_API_KEY is still active; unset it to fully log out.'); - } - - return self::SUCCESS; - } -} diff --git a/app/Commands/Callbacks/WatchCallbacksCommand.php b/app/Commands/Callbacks/WatchCallbacksCommand.php index 4170558..6f854bb 100644 --- a/app/Commands/Callbacks/WatchCallbacksCommand.php +++ b/app/Commands/Callbacks/WatchCallbacksCommand.php @@ -55,6 +55,7 @@ function (AuthenticatedQuickpay $authenticated) use ($watch): int { $interval = $this->interval(); $deliveryAttempts = $this->deliveryAttempts(); $apiKey = $authenticated->apiKey->value(); + $this->writeMerchantContext($authenticated); if ($request->paymentId !== null || $request->orderId !== null) { $this->info(ResponseBodySanitizer::terminalLine( diff --git a/app/Commands/Merchants/ActiveMerchantCommand.php b/app/Commands/Merchants/ActiveMerchantCommand.php new file mode 100644 index 0000000..479781b --- /dev/null +++ b/app/Commands/Merchants/ActiveMerchantCommand.php @@ -0,0 +1,69 @@ +option('merchant'); + $key = $resolver->resolve(is_string($merchant) ? $merchant : null); + } catch (CredentialException $exception) { + return $this->failure($exception->getMessage()); + } + $requested = (bool) $this->option('check'); + $error = null; + if ($requested) { + if ($key === null) { + $error = 'No Quickpay credentials found. Run quickpay merchants:add first.'; + } else { + try { + $check->check($key); + } catch (QuickpayRequestException|UnexpectedValueException $exception) { + $error = $key->redact($exception->getMessage()); + } + } + } + $authenticated = $requested ? $error === null : null; + $status = [ + 'merchant' => $key?->merchant(), + 'selection_source' => $key?->selectionSource(), + 'credential_source' => $key?->source(), + 'configured' => $key !== null, + 'checked' => $requested, + 'authenticated' => $authenticated, + 'api_base_url' => QuickpayClient::BASE_URL, + 'api_version' => QuickpayClient::API_VERSION, + 'scope' => $authenticated === true ? 'merchant' : null, + ]; + if ($this->option('json')) { + $this->getOutput()->write(JsonOutput::value($status, '')); + } else { + $this->line('Merchant: '.($status['merchant'] ?? 'not associated with a saved merchant')); + $this->line('Selection source: '.($status['selection_source'] ?? 'none')); + $this->line('Credential source: '.($status['credential_source'] ?? 'none')); + $this->line('Credentials configured: '.($status['configured'] ? 'yes' : 'no')); + $this->line('API base URL: '.$status['api_base_url']); + $this->line('API version: '.$status['api_version']); + $this->line('API access: '.($requested ? ($authenticated ? 'verified' : 'failed') : 'not checked')); + } + + return $error === null ? self::SUCCESS : $this->failure($error); + } +} diff --git a/app/Commands/Merchants/AddMerchantCommand.php b/app/Commands/Merchants/AddMerchantCommand.php new file mode 100644 index 0000000..eb33c2a --- /dev/null +++ b/app/Commands/Merchants/AddMerchantCommand.php @@ -0,0 +1,53 @@ +argument('name')); + $config = $store->read(); + if (isset($config['merchants'][$name]) && ! $this->option('replace')) { + return $this->failure('Merchant is already saved. Use --replace to replace its credential.'); + } + if (! $this->input->isInteractive() || ! $terminal->isTty()) { + return $this->failure('Adding a merchant requires an interactive terminal. Use QUICKPAY_API_KEY for automation.'); + } + $answer = $this->secret('API key'); + $apiKey = is_string($answer) ? trim($answer) : ''; + if ($apiKey === '') { + return $this->failure('API key cannot be empty.'); + } + $credential = new ApiKey($apiKey, 'provided'); + if ($credential->appearsIn($name)) { + return $this->failure('Merchant name must not contain the API key.'); + } + $check->check($credential); + $store->add($name, $apiKey, (bool) $this->option('replace')); + $this->info('Merchant saved: '.$name); + + return self::SUCCESS; + } catch (CredentialException|QuickpayRequestException|UnexpectedValueException $exception) { + return $this->failure($credential?->redact($exception->getMessage()) ?? $exception->getMessage()); + } + } +} diff --git a/app/Commands/Merchants/ListMerchantsCommand.php b/app/Commands/Merchants/ListMerchantsCommand.php new file mode 100644 index 0000000..a850fd5 --- /dev/null +++ b/app/Commands/Merchants/ListMerchantsCommand.php @@ -0,0 +1,36 @@ +read(); + } catch (CredentialException $exception) { + return $this->failure($exception->getMessage()); + } + $names = array_keys($config['merchants']); + sort($names, SORT_STRING); + $rows = array_map(fn (string $name): array => ['name' => $name, 'default' => $config['default_merchant'] === $name], $names); + if ($this->option('json')) { + $this->getOutput()->write(JsonOutput::value($rows, '')); + } elseif ($rows === []) { + $this->line('No saved merchants. Run quickpay merchants:add first.'); + } else { + $this->table(['Merchant', 'Global default'], array_map(fn (array $row): array => [$row['name'], $row['default'] ? 'yes' : ''], $rows)); + } + + return self::SUCCESS; + } +} diff --git a/app/Commands/Merchants/RemoveMerchantCommand.php b/app/Commands/Merchants/RemoveMerchantCommand.php new file mode 100644 index 0000000..7791b35 --- /dev/null +++ b/app/Commands/Merchants/RemoveMerchantCommand.php @@ -0,0 +1,30 @@ +remove((string) $this->argument('name')); + } catch (CredentialException $exception) { + return $this->failure($exception->getMessage()); + } + $this->info('Saved merchant removed.'); + if ($environment->get('QUICKPAY_API_KEY') !== null) { + $this->comment('QUICKPAY_API_KEY remains configured; unset it to stop using that credential.'); + } + + return self::SUCCESS; + } +} diff --git a/app/Commands/Merchants/SelectMerchantCommand.php b/app/Commands/Merchants/SelectMerchantCommand.php new file mode 100644 index 0000000..01dc3d6 --- /dev/null +++ b/app/Commands/Merchants/SelectMerchantCommand.php @@ -0,0 +1,65 @@ +get('QUICKPAY_API_KEY') !== null) { + return $this->failure('Unset QUICKPAY_API_KEY before selecting a saved merchant.'); + } + $config = $store->read(); + $name = $this->argument('name'); + if ($name === null) { + if ($config['merchants'] === []) { + return $this->failure('No saved merchants. Run quickpay merchants:add first.'); + } + if (! $this->input->isInteractive() || ! $terminal->isTty()) { + return $this->failure('Specify a merchant name when selecting non-interactively.'); + } + $names = array_keys($config['merchants']); + sort($names, SORT_STRING); + $name = $this->choice('Select merchant', [...$names, 'Cancel']); + if ($name === 'Cancel') { + $this->line('Cancelled.'); + + return self::SUCCESS; + } + } + if (! is_string($name)) { + return $this->failure('Select exactly one merchant name.'); + } + $name = MerchantName::validate($name); + if (! isset($config['merchants'][$name])) { + return $this->failure('Merchant is not saved. Run quickpay merchants:list to see saved names.'); + } + if ($this->option('local')) { + $project->save($name); + $this->info('Project merchant selected: '.$name); + } else { + $store->select($name); + $this->info('Global default merchant selected: '.$name); + } + + return self::SUCCESS; + } catch (CredentialException $exception) { + return $this->failure($exception->getMessage()); + } + } +} diff --git a/app/Commands/Payments/AbstractPaymentMutationCommand.php b/app/Commands/Payments/AbstractPaymentMutationCommand.php index b18407c..f6d5f56 100644 --- a/app/Commands/Payments/AbstractPaymentMutationCommand.php +++ b/app/Commands/Payments/AbstractPaymentMutationCommand.php @@ -39,11 +39,11 @@ public function handle( body: $body, query: $query, headers: $headers, - confirm: function (array $payment) use ($apiKey, $body, $confirmation, $presenter): bool { + confirm: function (array $payment) use ($authenticated, $body, $confirmation, $presenter): bool { $this->writeMutationSummary( $payment, $body['amount'] ?? null, - $apiKey, + $authenticated, $presenter, ); @@ -89,9 +89,11 @@ abstract protected function mutationBody(): array; private function writeMutationSummary( array $payment, mixed $amount, - string $apiKey, + AuthenticatedQuickpay $authenticated, PaymentPresenter $presenter, ): void { + $this->writeMerchantContext($authenticated); + $apiKey = $authenticated->apiKey->value(); $fields = [ 'Payment ID' => $payment['id'] ?? null, 'Order ID' => $payment['order_id'] ?? null, @@ -102,6 +104,10 @@ private function writeMutationSummary( 'Operation' => $this->operation(), ]; + if (isset($payment['merchant_id'])) { + $fields = ['Merchant ID' => $payment['merchant_id'], ...$fields]; + } + if ($amount !== null) { $fields['Amount (minor units)'] = $amount; } diff --git a/app/Console/AuthenticatedCommand.php b/app/Console/AuthenticatedCommand.php index 2df5719..edfa01f 100644 --- a/app/Console/AuthenticatedCommand.php +++ b/app/Console/AuthenticatedCommand.php @@ -10,21 +10,30 @@ use App\Quickpay\QuickpayResponse; use Closure; use InvalidArgumentException; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; use UnexpectedValueException; abstract class AuthenticatedCommand extends BaseCommand { + public function __construct() + { + parent::__construct(); + $this->addOption('merchant', null, InputOption::VALUE_REQUIRED, 'Saved merchant to select for this command'); + } + /** @param Closure(AuthenticatedQuickpay): int $callback */ protected function withQuickpay(AuthenticatedQuickpayFactory $quickpay, Closure $callback): int { try { - $authenticated = $quickpay->resolve(); + $merchant = $this->option('merchant'); + $authenticated = $quickpay->resolve(is_string($merchant) ? $merchant : null); } catch (CredentialException $exception) { return $this->failure($exception->getMessage()); } if ($authenticated === null) { - return $this->failure('No Quickpay credentials found. Run quickpay login first.'); + return $this->failure('No Quickpay credentials found. Run quickpay merchants:add first.'); } try { @@ -38,4 +47,17 @@ protected function responseFailure(QuickpayResponse $response, string $apiKey): { return $this->failure(ResponseBodySanitizer::terminalLine($response->errorSummary(), $apiKey)); } + + protected function writeMerchantContext(AuthenticatedQuickpay $authenticated): void + { + $output = $this->input->hasOption('json') && $this->input->getOption('json') + ? $this->getOutput()->getErrorStyle() + : $this->getOutput(); + foreach ([ + 'Merchant: '.($authenticated->apiKey->merchant() ?? 'not associated with a saved merchant'), + 'Credential source: '.$authenticated->apiKey->source(), + ] as $message) { + $output->writeln($authenticated->safeLine($message), OutputInterface::OUTPUT_RAW); + } + } } diff --git a/app/Credentials/ApiKey.php b/app/Credentials/ApiKey.php index 291d699..ecfe203 100644 --- a/app/Credentials/ApiKey.php +++ b/app/Credentials/ApiKey.php @@ -10,6 +10,8 @@ public function __construct( private string $value, private string $source, + private ?string $merchant = null, + private ?string $selectionSource = null, ) { if (trim($value) === '') { throw new InvalidArgumentException('Quickpay API key must not be empty.'); @@ -26,6 +28,16 @@ public function source(): string return $this->source; } + public function merchant(): ?string + { + return $this->merchant; + } + + public function selectionSource(): ?string + { + return $this->selectionSource; + } + public function redact(string $value): string { return CredentialRedactor::redact($value, $this->value); diff --git a/app/Credentials/ApiKeyResolver.php b/app/Credentials/ApiKeyResolver.php index db04fd1..f4b3aa6 100644 --- a/app/Credentials/ApiKeyResolver.php +++ b/app/Credentials/ApiKeyResolver.php @@ -2,23 +2,43 @@ namespace App\Credentials; +use App\Credentials\Exceptions\CredentialException; + final readonly class ApiKeyResolver { public function __construct( private EnvironmentVariables $environment, - private CredentialFile $file, + private MerchantStore $store, + private ProjectMerchantFile $project, ) {} - public function resolve(): ?ApiKey + public function resolve(?string $merchant = null): ?ApiKey { + if ($merchant !== null) { + MerchantName::validate($merchant); + } $environmentKey = $this->environment->get('QUICKPAY_API_KEY'); - + if ($environmentKey !== null && $merchant !== null) { + throw new CredentialException('Unset QUICKPAY_API_KEY before selecting a saved merchant.'); + } + $binding = $merchant === null ? $this->project->find() : null; + if ($environmentKey !== null && $binding !== null) { + throw new CredentialException('Unset QUICKPAY_API_KEY before using a project merchant binding.'); + } if ($environmentKey !== null) { - return new ApiKey($environmentKey, 'environment'); + return new ApiKey($environmentKey, 'environment', null, 'environment'); } - $storedKey = $this->file->read(); + $config = $this->store->read(); + $source = $merchant !== null ? 'explicit' : ($binding !== null ? 'project' : 'default'); + $name = $merchant ?? $binding['merchant'] ?? $config['default_merchant']; + if ($name === null) { + return null; + } + if (! isset($config['merchants'][$name])) { + throw new CredentialException('Selected merchant is not saved. Run quickpay merchants:list to see saved names.'); + } - return $storedKey === null ? null : new ApiKey($storedKey, 'config file'); + return new ApiKey($config['merchants'][$name]['api_key'], 'config file', $name, $source); } } diff --git a/app/Credentials/CredentialFile.php b/app/Credentials/CredentialFile.php deleted file mode 100644 index fed547d..0000000 --- a/app/Credentials/CredentialFile.php +++ /dev/null @@ -1,111 +0,0 @@ -path(), DIRECTORY_SEPARATOR).'/.config/quickpay/config.json'); - } - - public function path(): string - { - return $this->path; - } - - public function exists(): bool - { - return is_file($this->path); - } - - public function read(): ?string - { - if (! $this->exists()) { - return null; - } - - $contents = @file_get_contents($this->path); - - if (! is_string($contents)) { - throw new CredentialException('Quickpay credential file is invalid or cannot be read.'); - } - - try { - $config = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); - } catch (JsonException) { - throw new CredentialException('Quickpay credential file is invalid JSON.'); - } - - if (! is_array($config) - || ! isset($config['api_key']) - || ! is_string($config['api_key']) - || trim($config['api_key']) === '') { - throw new CredentialException('Quickpay credential file is invalid: expected a non-empty api_key string.'); - } - - return $config['api_key']; - } - - public function save(string $apiKey): void - { - $apiKey = trim($apiKey); - - if ($apiKey === '') { - throw new CredentialException('Unable to store Quickpay credentials: the API key is empty.'); - } - - $directory = dirname($this->path); - - if ((! is_dir($directory) && ! @mkdir($directory, 0700, true)) - || ! @chmod($directory, 0700)) { - throw new CredentialException('Unable to store Quickpay credentials: the config directory is not writable.'); - } - - if (is_dir($this->path)) { - throw new CredentialException('Unable to store Quickpay credentials atomically.'); - } - - $temporaryPath = @tempnam($directory, '.'.basename($this->path).'.'); - - if (! is_string($temporaryPath)) { - throw new CredentialException('Unable to store Quickpay credentials: a temporary file could not be created.'); - } - - try { - $json = json_encode(['api_key' => $apiKey], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR).PHP_EOL; - - if (@file_put_contents($temporaryPath, $json, LOCK_EX) === false - || ! @chmod($temporaryPath, 0600) - || ! @rename($temporaryPath, $this->path)) { - throw new CredentialException('Unable to store Quickpay credentials atomically.'); - } - - $temporaryPath = ''; - } catch (JsonException) { - throw new CredentialException('Unable to store Quickpay credentials.'); - } finally { - if ($temporaryPath !== '' && is_file($temporaryPath)) { - @unlink($temporaryPath); - } - } - } - - public function forget(): bool - { - if (! $this->exists()) { - return false; - } - - if (! @unlink($this->path)) { - throw new CredentialException('Unable to remove the stored Quickpay credential.'); - } - - return true; - } -} diff --git a/app/Credentials/MerchantConfigurationJson.php b/app/Credentials/MerchantConfigurationJson.php new file mode 100644 index 0000000..58b1f83 --- /dev/null +++ b/app/Credentials/MerchantConfigurationJson.php @@ -0,0 +1,35 @@ + $token) { + if ($token === '{') { + $objects[] = []; + } elseif ($token === '}') { + array_pop($objects); + } elseif ($token[0] === '"' && ($tokens[$index + 1] ?? null) === ':') { + $name = json_decode($token, flags: JSON_THROW_ON_ERROR); + $scope = array_key_last($objects); + if (! is_string($name) || $scope === null || isset($objects[$scope][$name])) { + throw new JsonException('Ambiguous configuration contains duplicate JSON properties.'); + } + $objects[$scope][$name] = true; + } + } + + return $decoded; + } +} diff --git a/app/Credentials/MerchantName.php b/app/Credentials/MerchantName.php new file mode 100644 index 0000000..ca4ff77 --- /dev/null +++ b/app/Credentials/MerchantName.php @@ -0,0 +1,17 @@ + 64 || preg_match('/\A[a-z][a-z0-9]*(?:-[a-z0-9]+)*\z/D', $name) !== 1) { + throw new CredentialException('Merchant names must contain 1-64 lowercase ASCII letters, digits or single separating hyphens, starting with a letter.'); + } + + return $name; + } +} diff --git a/app/Credentials/MerchantStore.php b/app/Credentials/MerchantStore.php new file mode 100644 index 0000000..99b8922 --- /dev/null +++ b/app/Credentials/MerchantStore.php @@ -0,0 +1,195 @@ +} */ +final readonly class MerchantStore +{ + public function __construct(private string $path) {} + + public static function inHome(HomeDirectory $home): self + { + return new self(rtrim($home->path(), DIRECTORY_SEPARATOR).'/.config/quickpay-cli/config.json'); + } + + public function path(): string + { + return $this->path; + } + + /** @return MerchantConfig */ + public function read(): array + { + if (! file_exists($this->path) && ! is_link($this->path)) { + return ['default_merchant' => null, 'merchants' => []]; + } + + $contents = is_file($this->path) ? @file_get_contents($this->path) : false; + if ($contents === false) { + throw new CredentialException('Quickpay CLI merchant configuration cannot be read.'); + } + + try { + $config = MerchantConfigurationJson::decode($contents); + } catch (JsonException) { + throw new CredentialException('Quickpay CLI merchant configuration is invalid JSON.'); + } + + if ($config instanceof stdClass && property_exists($config, 'api_key')) { + throw new CredentialException('Unsupported single-key configuration. Add merchants again with quickpay merchants:add after removing this unsupported file.'); + } + + if (! $config instanceof stdClass + || count(get_object_vars($config)) !== 2 + || ! property_exists($config, 'default_merchant') + || ! isset($config->merchants) + || ! $config->merchants instanceof stdClass + || ($config->default_merchant !== null && ! is_string($config->default_merchant))) { + throw new CredentialException('Quickpay CLI merchant configuration has an invalid structure.'); + } + + $merchants = []; + foreach (get_object_vars($config->merchants) as $name => $merchant) { + MerchantName::validate((string) $name); + if (! $merchant instanceof stdClass + || count(get_object_vars($merchant)) !== 1 + || ! isset($merchant->api_key) + || ! is_string($merchant->api_key) + || trim($merchant->api_key) === '') { + throw new CredentialException('Quickpay CLI merchant configuration contains an invalid credential.'); + } + $merchants[$name] = ['api_key' => $merchant->api_key]; + } + $this->validateLabels($merchants); + + if ($config->default_merchant !== null && ! isset($merchants[$config->default_merchant])) { + throw new CredentialException('Quickpay CLI default merchant is not saved.'); + } + + return ['default_merchant' => $config->default_merchant, 'merchants' => $merchants]; + } + + public function add(string $name, string $apiKey, bool $replace = false): void + { + MerchantName::validate($name); + $apiKey = trim($apiKey); + if ($apiKey === '') { + throw new CredentialException('API key cannot be empty.'); + } + $this->validateLabel($name, $apiKey); + + $this->mutate(function (array $config) use ($name, $apiKey, $replace): array { + if (isset($config['merchants'][$name]) && ! $replace) { + throw new CredentialException('Merchant is already saved. Use --replace to replace its credential.'); + } + if ($config['merchants'] === []) { + $config['default_merchant'] = $name; + } + $config['merchants'][$name] = ['api_key' => $apiKey]; + $this->validateLabels($config['merchants']); + + return $config; + }); + } + + public function select(string $name): void + { + MerchantName::validate($name); + $this->mutate(function (array $config) use ($name): array { + $this->requireMerchant($config, $name); + $config['default_merchant'] = $name; + + return $config; + }); + } + + public function remove(string $name): void + { + MerchantName::validate($name); + $this->mutate(function (array $config) use ($name): array { + $this->requireMerchant($config, $name); + unset($config['merchants'][$name]); + if ($config['default_merchant'] === $name) { + $config['default_merchant'] = null; + } + + return $config; + }); + } + + private function validateLabel(string $name, string $apiKey): void + { + if (CredentialRedactor::containsSensitiveValue($name, $apiKey)) { + throw new CredentialException('Merchant name must not contain the API key.'); + } + } + + /** @param array $merchants */ + private function validateLabels(array $merchants): void + { + // ponytail: quadratic check for the small registry; cache a multi-key matcher if large registries become common. + foreach ($merchants as $name => $merchant) { + foreach ($merchants as $credential) { + $this->validateLabel($name, $credential['api_key']); + } + } + } + + /** @param MerchantConfig $config */ + private function requireMerchant(array $config, string $name): void + { + if (! isset($config['merchants'][$name])) { + throw new CredentialException('Merchant is not saved. Run quickpay merchants:list to see saved names.'); + } + } + + /** @param Closure(MerchantConfig): MerchantConfig $change */ + private function mutate(Closure $change): void + { + $directory = dirname($this->path); + if ((! is_dir($directory) && ! @mkdir($directory, 0700, true)) || ! @chmod($directory, 0700)) { + throw new CredentialException('Unable to store merchant credentials: the config directory is not writable.'); + } + + // ponytail: one lock for the small credential registry; split storage if write contention matters. + $lock = @fopen($this->path.'.lock', 'c'); + if ($lock === false) { + throw new CredentialException('Unable to lock merchant credentials.'); + } + + $temporary = ''; + try { + if (! @chmod($this->path.'.lock', 0600) || ! flock($lock, LOCK_EX)) { + throw new CredentialException('Unable to lock merchant credentials.'); + } + $config = $change($this->read()); + $json = json_encode([ + 'default_merchant' => $config['default_merchant'], + 'merchants' => (object) $config['merchants'], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR).PHP_EOL; + $path = @tempnam($directory, '.'.basename($this->path).'.'); + if ($path === false) { + throw new CredentialException('Unable to create a temporary merchant configuration.'); + } + $temporary = $path; + if (@file_put_contents($temporary, $json) === false || ! @chmod($temporary, 0600) || ! @rename($temporary, $this->path)) { + throw new CredentialException('Unable to store merchant credentials atomically.'); + } + $temporary = ''; + } catch (JsonException) { + throw new CredentialException('Unable to encode merchant credentials.'); + } finally { + if ($temporary !== '' && is_file($temporary)) { + @unlink($temporary); + } + flock($lock, LOCK_UN); + fclose($lock); + } + } +} diff --git a/app/Credentials/ProjectMerchantFile.php b/app/Credentials/ProjectMerchantFile.php new file mode 100644 index 0000000..d30fdeb --- /dev/null +++ b/app/Credentials/ProjectMerchantFile.php @@ -0,0 +1,82 @@ +directory(); + while (true) { + $path = $directory.'/.quickpay-cli.json'; + if (file_exists($path) || is_link($path)) { + return ['merchant' => $this->read($path), 'path' => $path]; + } + $parent = dirname($directory); + if ($parent === $directory) { + return null; + } + $directory = $parent; + } + } + + public function save(string $merchant): void + { + MerchantName::validate($merchant); + $directory = $this->directory(); + $path = $directory.'/.quickpay-cli.json'; + if (file_exists($path) || is_link($path)) { + $this->read($path); + } + $temporary = @tempnam($directory, '..quickpay-cli.json.'); + if ($temporary === false) { + throw new CredentialException('Unable to create a temporary project merchant binding.'); + } + try { + $json = json_encode(['merchant' => $merchant], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL; + if (@file_put_contents($temporary, $json) === false || ! @rename($temporary, $path)) { + throw new CredentialException('Unable to save the project merchant binding atomically.'); + } + } finally { + if (is_file($temporary)) { + @unlink($temporary); + } + } + } + + private function directory(): string + { + $directory = $this->directory ?? getcwd(); + $resolved = is_string($directory) ? realpath($directory) : false; + if ($resolved === false || ! is_dir($resolved)) { + throw new CredentialException('Unable to locate the current project directory.'); + } + + return $resolved; + } + + private function read(string $path): string + { + $contents = is_file($path) ? @file_get_contents($path) : false; + if ($contents === false) { + throw new CredentialException('Project merchant binding cannot be read.'); + } + try { + $config = MerchantConfigurationJson::decode($contents); + } catch (JsonException) { + throw new CredentialException('Project merchant binding is invalid JSON.'); + } + if (! $config instanceof stdClass || count(get_object_vars($config)) !== 1 || ! isset($config->merchant) || ! is_string($config->merchant)) { + throw new CredentialException('Project merchant binding must contain only a merchant name.'); + } + + return MerchantName::validate($config->merchant); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 21e8f1e..b184be2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,9 +6,10 @@ use App\Console\Terminal\NativeStdinTerminalDetector; use App\Console\Terminal\StdinTerminalDetector; use App\Credentials\ApiKeyResolver; -use App\Credentials\CredentialFile; use App\Credentials\EnvironmentVariables; use App\Credentials\HomeDirectory; +use App\Credentials\MerchantStore; +use App\Credentials\ProjectMerchantFile; use App\Quickpay\AuthenticatedQuickpayFactory; use Illuminate\Support\ServiceProvider; use NunoMaduro\LaravelConsoleSummary\Contracts\DescriberContract; @@ -27,9 +28,10 @@ public function register(): void $this->app->singleton(EnvironmentVariables::class); $this->app->singleton(HomeDirectory::class); $this->app->singleton( - CredentialFile::class, - fn ($app): CredentialFile => CredentialFile::inHome($app->make(HomeDirectory::class)), + MerchantStore::class, + fn ($app): MerchantStore => MerchantStore::inHome($app->make(HomeDirectory::class)), ); + $this->app->singleton(ProjectMerchantFile::class); $this->app->singleton(ApiKeyResolver::class); $this->app->singleton(AuthenticatedQuickpayFactory::class); $this->app->singleton(StdinTerminalDetector::class, NativeStdinTerminalDetector::class); diff --git a/app/Quickpay/AuthenticatedQuickpayFactory.php b/app/Quickpay/AuthenticatedQuickpayFactory.php index ba8d660..b554f90 100644 --- a/app/Quickpay/AuthenticatedQuickpayFactory.php +++ b/app/Quickpay/AuthenticatedQuickpayFactory.php @@ -14,9 +14,9 @@ public function __construct( private ApiKeyResolver $credentials, ) {} - public function resolve(): ?AuthenticatedQuickpay + public function resolve(?string $merchant = null): ?AuthenticatedQuickpay { - $apiKey = $this->credentials->resolve(); + $apiKey = $this->credentials->resolve($merchant); return $apiKey === null ? null : $this->from($apiKey); } diff --git a/app/Quickpay/MerchantAuthenticationCheck.php b/app/Quickpay/MerchantAuthenticationCheck.php new file mode 100644 index 0000000..22bb68f --- /dev/null +++ b/app/Quickpay/MerchantAuthenticationCheck.php @@ -0,0 +1,24 @@ +quickpay->from($apiKey)->client->get('/ping'); + if (! $response->successful()) { + throw new QuickpayRequestException('Authentication check failed: '.ResponseBodySanitizer::terminalLine($response->errorSummary(), $apiKey->value())); + } + if (! is_array($response->json) || ($response->json['scope'] ?? null) !== 'merchant') { + throw new UnexpectedValueException('Quickpay authentication check did not return a valid merchant scope.'); + } + } +} diff --git a/composer.lock b/composer.lock index 28c9ce3..ad9245f 100644 --- a/composer.lock +++ b/composer.lock @@ -3132,16 +3132,16 @@ }, { "name": "league/flysystem", - "version": "3.35.2", + "version": "3.35.3", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "b277b5dc3d56650b68904117124e79c851e12376" + "reference": "5fc8404762179ae514678487b23494fd69b2309c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", - "reference": "b277b5dc3d56650b68904117124e79c851e12376", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5fc8404762179ae514678487b23494fd69b2309c", + "reference": "5fc8404762179ae514678487b23494fd69b2309c", "shasum": "" }, "require": { @@ -3209,9 +3209,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" + "source": "https://github.com/thephpleague/flysystem/tree/3.35.3" }, - "time": "2026-07-06T14:42:07+00:00" + "time": "2026-08-22T12:55:54+00:00" }, { "name": "league/flysystem-local", diff --git a/skills/quickpay/SKILL.md b/skills/quickpay-cli/SKILL.md similarity index 58% rename from skills/quickpay/SKILL.md rename to skills/quickpay-cli/SKILL.md index 4d9b484..43442e6 100644 --- a/skills/quickpay/SKILL.md +++ b/skills/quickpay-cli/SKILL.md @@ -1,5 +1,5 @@ --- -name: quickpay +name: quickpay-cli description: Use when an agent needs to work with Quickpay payments, payment links, callbacks, capture, refund, cancel, authentication, or safe raw API access through the Quickpay CLI. license: MIT metadata: @@ -14,22 +14,53 @@ Run `quickpay --version` before use. Installation and the complete human-facing ## Credentials -Have the human run interactive `quickpay login` themselves. Never request, print, store, or pass an API key in command arguments, headers, files, logs, or output. Use `quickpay auth --check --json` when automation must verify that a credential is active; plain `quickpay auth` remains a human-readable status command. The CLI uses a non-empty `QUICKPAY_API_KEY` before `~/.config/quickpay/config.json`; `quickpay logout` only removes the stored file. +Have the human run `quickpay merchants:add ` themselves to enter an API +key privately. Never request, print, store, or pass a key in command arguments, +headers, files, logs, or output. Do not read credential files to discover keys. + +Saved merchant access lives in `~/.config/quickpay-cli/config.json`. Names such +as `shop-a` are local labels, not verified merchant IDs. `merchants:list` lists +saved names; `merchants:select ` changes the global default; +`merchants:select --local` writes only the selection to `.quickpay-cli.json` +in the current directory. `merchants:remove ` removes only local access, +never the remote Quickpay account. Only change persistent selection when the +user asks for it; prefer `--merchant=` for one command. + +Use `quickpay merchants:active --check --json` to verify API access for the +resolved credential. Add `--merchant=` when working with a saved merchant, +and keep that same explicit selection for inspection and execution. Plain +`merchants:active` reports local configuration without checking authentication. + +Selection is an explicit `--merchant`, then the nearest `.quickpay-cli.json` +found from the working directory upwards, then the global default. Unknown +names and malformed or stale bindings fail without falling back. A non-empty +`QUICKPAY_API_KEY` works independently of saved storage when there is no explicit +or project selection. Combining that environment key with either selection +fails; report the conflict rather than silently changing the environment or +using another merchant. Environment-only access has no inferred saved label. + +The first saved merchant becomes the default; adding others keeps an established +default. Replacing a key requires `merchants:add --replace` and private +human input; a rejected key preserves existing access. Removing the default +leaves no default, and bindings to removed names fail. The old `login`, `logout` +and `auth` commands and `~/.config/quickpay/config.json` are not supported. +Existing users add their merchants again; no automatic migration occurs. ## Safety workflow - Treat `payments:list`, `payments:get`, and raw `api GET` as reads. Treat every other payment command and every non-`GET` raw API request as a mutation. - Prefer `--json` for automation. - Amounts use the smallest currency unit: `1000` is DKK 10.00. -- Before capture, refund, or cancel, run `quickpay payments:get --json`; verify payment ID, order, currency, state, accepted status, balance, and the requested amount. +- Before capture, refund, or cancel, run `quickpay payments:get --merchant= --json`; verify merchant identity, payment ID, order, currency, state, accepted status, balance, and the requested amount. Keep the same explicit merchant in subsequent commands. A saved label or project binding does not authorize a mutation. - Do not infer mutation permission from read access. Require the user's explicit authorization of the operation, payment ID, and amount before any mutation. - Use `--yes` only after that explicit authorization. It is required for unattended capture/refund/cancel and non-`GET` raw API requests; otherwise the CLI asks for an interactive confirmation. Safe capture example, only after inspection and explicit authorization: ```bash -quickpay payments:get 884201 --json -quickpay payments:capture 884201 2500 --yes --json +quickpay merchants:active --merchant=shop-a --check --json +quickpay payments:get 884201 --merchant=shop-a --json +quickpay payments:capture 884201 2500 --merchant=shop-a --yes --json ``` For a refund or cancellation, use the same inspect → verify → explicit authorization → execute sequence. If inspection is unavailable or the state/amount is wrong, stop and report it. @@ -38,26 +69,30 @@ For a refund or cancellation, use the same inspect → verify → explicit autho ```text quickpay about -quickpay login -quickpay auth [--check] [--json] -quickpay logout -quickpay payments:create [currency=DKK] [--field=key=value]... [--json] +quickpay merchants:add [--replace] +quickpay merchants:list [--json] +quickpay merchants:select [] [--local] +quickpay merchants:active [--merchant=name] [--check] [--json] +quickpay merchants:remove +quickpay payments:create [currency=DKK] [--field=key=value]... [--merchant=name] [--json] quickpay payments:list [--accepted] [--state=value] [--order-id=value] [--created-after=value] [--created-before=value] [--page-size=20] - [--all] [--max-pages=100] [--json] -quickpay payments:get [--operations-size=value] [--json] + [--all] [--max-pages=100] [--merchant=name] [--json] +quickpay payments:get [--operations-size=value] [--merchant=name] [--json] quickpay payments:link [--continue-url=url] [--cancel-url=url] [--callback-url=url] [--language=value] [--payment-methods=value] - [--auto-capture] [--field=key=value]... [--json] -quickpay payments:capture [--synchronized] [--callback-url=url] [--yes] [--json] + [--auto-capture] [--field=key=value]... [--merchant=name] [--json] +quickpay payments:capture [--synchronized] [--callback-url=url] + [--merchant=name] [--yes] [--json] quickpay payments:refund [--vat-rate=value] [--synchronized] - [--callback-url=url] [--yes] [--json] -quickpay payments:cancel [--synchronized] [--callback-url=url] [--yes] [--json] -quickpay callbacks:replay [] --to=url [--order-id=value] [--json] + [--callback-url=url] [--merchant=name] [--yes] [--json] +quickpay payments:cancel [--synchronized] [--callback-url=url] [--merchant=name] [--yes] [--json] +quickpay callbacks:replay [] --to=url [--order-id=value] [--merchant=name] [--json] quickpay callbacks:watch [] --to=url [--order-id=value] [--interval=2] - [--delivery-attempts=5] + [--delivery-attempts=5] [--merchant=name] quickpay api [--query=key=value]... - [--data=key=value]... [--data-json='{}'] [--header='name:value']... [--yes] [--json] + [--data=key=value]... [--data-json='{}'] [--header='name:value']... + [--merchant=name] [--yes] [--json] ``` Without `--all`, `payments:list` returns one page and reports additional pages @@ -101,7 +136,8 @@ quickpay callbacks:replay --to= `QUICKPAY_PRIVATE_KEY` is an optional sensitive environment override; never ask the user to paste it into chat or place it in a command argument. Without it, the CLI retrieves the key through the authenticated API and retains it only -in memory. +in memory. An explicit override must belong to the selected merchant. A watcher +keeps the merchant and key resolved at startup even if saved selection changes. ## Raw API guardrails @@ -119,4 +155,6 @@ Do not use full URLs, hosts, schemes, `..`, backslashes, or fragments. The CLI o - Capture/refund positional order is ` `. - `--yes` is the non-interactive transport flag, not user authorization. - A raw `GET` needs no `--yes`; a raw `POST`, `PUT`, `PATCH`, or `DELETE` does. -- `quickpay auth` is for status; it must never be replaced with printing or testing a key in an argument. +- `quickpay merchants:active --check --json` checks API access; never replace it with printing or testing a key in an argument. +- `merchants:active` without `--check` does not prove that a credential works. +- Use the same `--merchant` for readiness checks, payment inspection and any explicitly authorized mutation. diff --git a/tests/Architecture/DistributionContractTest.php b/tests/Architecture/DistributionContractTest.php index 487fa5e..a21149e 100644 --- a/tests/Architecture/DistributionContractTest.php +++ b/tests/Architecture/DistributionContractTest.php @@ -1,5 +1,10 @@ and($composer['bin'])->toBe(['builds/quickpay']) ->and($box['exclude-dev-files'])->toBeFalse(); }); + +it('rejects malformed paths before the bundled filesystem dispatches them', function (string $path): void { + $adapter = Mockery::mock(FilesystemAdapter::class); + $adapter->allows('fileExists')->andReturn(false); + $filesystem = new Filesystem($adapter); + + expect(fn () => $filesystem->fileExists($path))->toThrow(CorruptedPathDetected::class); +})->with([ + 'malformed UTF-8 and ESC' => "folder/\x80\x1bfile", + 'malformed UTF-8 and newline' => "folder/\xff\nfile", + 'malformed UTF-8 alone' => "folder/\x80file", +]); + +it('preserves ordinary path normalization in the bundled filesystem', function (string $path, string $expected): void { + expect((new WhitespacePathNormalizer)->normalizePath($path))->toBe($expected); +})->with([ + 'backslashes and dot segments' => ['folder\\.\\file', 'folder/file'], + 'printable Unicode and spaces' => ['folder/my résumé.txt', 'folder/my résumé.txt'], + 'relative resolution' => ['folder/../file', 'file'], +]); diff --git a/tests/Architecture/SkillContractTest.php b/tests/Architecture/SkillContractTest.php new file mode 100644 index 0000000..5256f8d --- /dev/null +++ b/tests/Architecture/SkillContractTest.php @@ -0,0 +1,24 @@ +toBe(1); + $names[] = $matches[1]; + expect(basename(dirname($path)))->toBe($matches[1]); + } + expect($names)->toBe(['quickpay-cli']); +}); + +it('keeps documented skill paths resolvable', function (): void { + $root = dirname(__DIR__, 2); + foreach (['README.md', 'AGENTS.md', 'CONTRIBUTING.md'] as $document) { + preg_match_all('~skills/[a-z-]+/SKILL\.md~', file_get_contents($root.'/'.$document), $matches); + foreach ($matches[0] as $path) { + expect(is_file($root.'/'.$path), $document.' references '.$path)->toBeTrue(); + } + } +}); diff --git a/tests/Feature/ApplicationIdentityTest.php b/tests/Feature/ApplicationIdentityTest.php index c99386d..1118faf 100644 --- a/tests/Feature/ApplicationIdentityTest.php +++ b/tests/Feature/ApplicationIdentityTest.php @@ -19,11 +19,13 @@ expect($commands)->toBe([ 'about', 'api', - 'auth', - 'login', - 'logout', 'callbacks:replay', 'callbacks:watch', + 'merchants:active', + 'merchants:add', + 'merchants:list', + 'merchants:remove', + 'merchants:select', 'payments:cancel', 'payments:capture', 'payments:create', diff --git a/tests/Feature/CommandHelpTest.php b/tests/Feature/CommandHelpTest.php index 23b7c5b..2b28eed 100644 --- a/tests/Feature/CommandHelpTest.php +++ b/tests/Feature/CommandHelpTest.php @@ -1,5 +1,7 @@ artisan('help', ['command_name' => 'payments:capture']) ->expectsOutputToContain('positive integer in minor units') @@ -23,3 +25,17 @@ ->expectsOutputToContain('JSON object request body') ->assertExitCode(0); }); + +it('offers explicit merchant selection for every authenticated command', function (string $command): void { + expect(Artisan::call('help', ['command_name' => $command]))->toBe(0); + expect(Artisan::output())->toContain('--merchant')->toContain('Saved merchant to select for this command'); +})->with(['api', 'callbacks:replay', 'callbacks:watch', 'merchants:active', 'payments:cancel', 'payments:capture', 'payments:create', 'payments:get', 'payments:link', 'payments:list', 'payments:refund']); + +it('can resolve command references from the bundled agent skill', function (): void { + $skill = file_get_contents(base_path('skills/quickpay-cli/SKILL.md')); + preg_match_all('/^quickpay ([a-z][a-z:-]*)/m', $skill, $matches); + expect($matches[1])->not->toBeEmpty(); + foreach (array_unique($matches[1]) as $command) { + expect(Artisan::call('help', ['command_name' => $command]), $command)->toBe(0); + } +}); diff --git a/tests/Feature/Commands/Api/ApiRequestCommandTest.php b/tests/Feature/Commands/Api/ApiRequestCommandTest.php index f195384..06fdbdf 100644 --- a/tests/Feature/Commands/Api/ApiRequestCommandTest.php +++ b/tests/Feature/Commands/Api/ApiRequestCommandTest.php @@ -2,11 +2,15 @@ use App\Commands\Api\ApiRequestCommand; use App\Console\Terminal\StdinTerminalDetector; +use App\Credentials\ProjectMerchantFile; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Symfony\Component\Console\Tester\CommandTester; beforeEach(function () { + $this->merchantProjectDirectory = sys_get_temp_dir().'/quickpay-test-project-'.bin2hex(random_bytes(8)); + mkdir($this->merchantProjectDirectory, 0700, true); + app()->instance(ProjectMerchantFile::class, new ProjectMerchantFile($this->merchantProjectDirectory)); $this->originalApiKey = getenv('QUICKPAY_API_KEY'); putenv('QUICKPAY_API_KEY=raw-api-secret'); app()->instance(StdinTerminalDetector::class, new class implements StdinTerminalDetector @@ -19,6 +23,10 @@ public function isTty(): bool }); afterEach(function () { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->merchantProjectDirectory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->merchantProjectDirectory); if ($this->originalApiKey === false) { putenv('QUICKPAY_API_KEY'); } else { diff --git a/tests/Feature/Commands/Authentication/AuthenticationCommandsTest.php b/tests/Feature/Commands/Authentication/AuthenticationCommandsTest.php deleted file mode 100644 index 67dc36f..0000000 --- a/tests/Feature/Commands/Authentication/AuthenticationCommandsTest.php +++ /dev/null @@ -1,291 +0,0 @@ -tempDirectory = sys_get_temp_dir().'/quickpay-commands-'.bin2hex(random_bytes(8)); - mkdir($this->tempDirectory, 0700, true); - $this->credentialPath = $this->tempDirectory.'/config.json'; - $this->originalApiKey = getenv('QUICKPAY_API_KEY'); - putenv('QUICKPAY_API_KEY'); - $file = new CredentialFile($this->credentialPath); - app()->instance(CredentialFile::class, $file); - app()->instance(ApiKeyResolver::class, new ApiKeyResolver(new EnvironmentVariables, $file)); -}); - -afterEach(function () { - if ($this->originalApiKey === false) { - putenv('QUICKPAY_API_KEY'); - } else { - putenv('QUICKPAY_API_KEY='.$this->originalApiKey); - } - - if (is_dir($this->tempDirectory)) { - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($this->tempDirectory, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST, - ); - - foreach ($iterator as $item) { - $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); - } - - rmdir($this->tempDirectory); - } -}); - -it('logs in after validating a merchant credential and never displays the key', function () { - Http::fake(['https://api.quickpay.net/ping' => Http::response([ - 'msg' => 'pong', - 'scope' => 'merchant', - 'version' => 'v10', - 'params' => [], - ])]); - - $this->artisan('login') - ->expectsQuestion('API key', ' merchant-secret ') - ->expectsOutputToContain('Credentials stored') - ->expectsOutputToContain('Scope: merchant') - ->expectsOutputToContain('Version: v10') - ->doesntExpectOutputToContain('merchant-secret') - ->assertExitCode(0); - - expect(json_decode(file_get_contents($this->credentialPath), true, flags: JSON_THROW_ON_ERROR)) - ->toBe(['api_key' => 'merchant-secret']); - - Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.quickpay.net/ping' - && $request->header('Authorization') === ['Basic '.base64_encode(':merchant-secret')]); -}); - -it('does not render a reflected credential from the successful login version field', function () { - Http::fake(['https://api.quickpay.net/ping' => Http::response([ - 'scope' => 'merchant', - 'version' => 'reflected-secret', - ])]); - - $this->artisan('login') - ->expectsQuestion('API key', 'reflected-secret') - ->expectsOutputToContain('Version: v10') - ->doesntExpectOutputToContain('reflected-secret') - ->assertExitCode(0); -}); - -it('rejects an empty login credential before making a request', function () { - Http::fake(); - - $this->artisan('login') - ->expectsQuestion('API key', ' ') - ->expectsOutputToContain('API key cannot be empty') - ->assertExitCode(1); - - Http::assertNothingSent(); - expect(file_exists($this->credentialPath))->toBeFalse(); -}); - -it('does not save login credentials rejected by quickpay', function () { - Http::fake(['https://api.quickpay.net/ping' => Http::response(['message' => 'Invalid API key rejected-secret'], 401)]); - - $this->artisan('login') - ->expectsQuestion('API key', 'rejected-secret') - ->expectsOutputToContain('HTTP 401') - ->doesntExpectOutputToContain('rejected-secret') - ->assertExitCode(1); - - expect(file_exists($this->credentialPath))->toBeFalse(); -}); - -it('rejects a valid login credential without merchant scope', function () { - Http::fake(['https://api.quickpay.net/ping' => Http::response(['scope' => 'acquirer', 'version' => 'v10'])]); - - $this->artisan('login') - ->expectsQuestion('API key', 'non-merchant-secret') - ->expectsOutputToContain('merchant scope') - ->doesntExpectOutputToContain('non-merchant-secret') - ->assertExitCode(1); - - expect(file_exists($this->credentialPath))->toBeFalse(); -}); - -it('handles a login network failure without leaking the credential', function () { - Http::fake(fn () => throw new ConnectionException('Network failure for network-secret')); - - $this->artisan('login') - ->expectsQuestion('API key', 'network-secret') - ->expectsOutputToContain('Unable to connect to Quickpay') - ->doesntExpectOutputToContain('network-secret') - ->assertExitCode(1); - - expect(file_exists($this->credentialPath))->toBeFalse(); -}); - -it('shows unauthenticated status and a login hint when no credential exists', function () { - Http::fake(); - - $this->artisan('auth') - ->expectsOutputToContain('Credential source: none') - ->expectsOutputToContain('API base URL: https://api.quickpay.net') - ->expectsOutputToContain('API version: v10') - ->expectsOutputToContain('Scope: not authenticated') - ->expectsOutputToContain('quickpay login') - ->assertExitCode(0); - - Http::assertNothingSent(); -}); - -it('can fail an unauthenticated readiness check without contacting quickpay', function () { - Http::fake(); - - $this->artisan('auth', ['--check' => true]) - ->expectsOutputToContain('Scope: not authenticated') - ->assertExitCode(1); - - Http::assertNothingSent(); -}); - -it('writes unauthenticated status as json for automation', function () { - Http::fake(); - $command = new AuthCommand; - $command->setLaravel(app()); - $tester = new CommandTester($command); - - $status = $tester->execute(['--json' => true], ['capture_stderr_separately' => true]); - - expect($status)->toBe(0) - ->and(json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR))->toBe([ - 'authenticated' => false, - 'credential_source' => null, - 'api_base_url' => 'https://api.quickpay.net', - 'api_version' => 'v10', - 'scope' => null, - ]) - ->and($tester->getErrorOutput())->toBe(''); - - Http::assertNothingSent(); -}); - -it('shows the current authenticated scope without displaying the credential', function () { - file_put_contents($this->credentialPath, json_encode(['api_key' => 'stored-secret'], JSON_THROW_ON_ERROR)); - Http::fake(['https://api.quickpay.net/ping' => Http::response(['scope' => 'merchant', 'version' => 'v10'])]); - - $this->artisan('auth') - ->expectsOutputToContain('Credential source: config file') - ->expectsOutputToContain('API base URL: https://api.quickpay.net') - ->expectsOutputToContain('API version: v10') - ->expectsOutputToContain('Scope: merchant') - ->doesntExpectOutputToContain('stored-secret') - ->assertExitCode(0); -}); - -it('writes authenticated status as json and passes a readiness check', function () { - file_put_contents($this->credentialPath, json_encode(['api_key' => 'stored-secret'], JSON_THROW_ON_ERROR)); - Http::fake(['https://api.quickpay.net/ping' => Http::response(['scope' => 'merchant', 'version' => 'v10'])]); - $command = new AuthCommand; - $command->setLaravel(app()); - $tester = new CommandTester($command); - - $status = $tester->execute([ - '--json' => true, - '--check' => true, - ], ['capture_stderr_separately' => true]); - - expect($status)->toBe(0) - ->and(json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR))->toBe([ - 'authenticated' => true, - 'credential_source' => 'config file', - 'api_base_url' => 'https://api.quickpay.net', - 'api_version' => 'v10', - 'scope' => 'merchant', - ]) - ->and($tester->getErrorOutput())->toBe('') - ->and($tester->getDisplay())->not->toContain('stored-secret'); -}); - -it('does not render a reflected credential from a successful auth scope field', function () { - file_put_contents($this->credentialPath, json_encode(['api_key' => 'stored-secret'], JSON_THROW_ON_ERROR)); - Http::fake(['https://api.quickpay.net/ping' => Http::response(['scope' => 'stored-secret', 'version' => 'v10'])]); - - $this->artisan('auth') - ->expectsOutputToContain('Quickpay returned an invalid scope') - ->doesntExpectOutputToContain('stored-secret') - ->assertExitCode(1); -}); - -it('reports auth api errors safely and exits non-zero', function () { - putenv('QUICKPAY_API_KEY=environment-secret'); - Http::fake(['https://api.quickpay.net/ping' => Http::response(['message' => 'Invalid API key environment-secret'], 401)]); - - $this->artisan('auth') - ->expectsOutputToContain('Credential source: environment') - ->expectsOutputToContain('Invalid API key [redacted]') - ->doesntExpectOutputToContain('environment-secret') - ->assertExitCode(1); -}); - -it('routes command failures to stderr', function () { - putenv('QUICKPAY_API_KEY=stderr-secret'); - Http::fake(['https://api.quickpay.net/ping' => Http::response(['message' => 'Invalid API key'], 401)]); - - $command = new AuthCommand; - $command->setLaravel(app()); - $tester = new CommandTester($command); - - $status = $tester->execute([], ['capture_stderr_separately' => true]); - - expect($status)->toBe(1) - ->and($tester->getErrorOutput())->toContain('Authentication check failed: HTTP 401:') - ->toContain('Invalid API key') - ->and($tester->getDisplay())->not->toContain('Authentication check failed'); -}); - -it('reports auth network errors safely and exits non-zero', function () { - putenv('QUICKPAY_API_KEY=environment-secret'); - Http::fake(fn () => throw new ConnectionException('Failure for environment-secret')); - - $this->artisan('auth') - ->expectsOutputToContain('Unable to connect to Quickpay') - ->doesntExpectOutputToContain('environment-secret') - ->assertExitCode(1); -}); - -it('logs out by deleting the stored config credential', function () { - file_put_contents($this->credentialPath, json_encode(['api_key' => 'stored-secret'], JSON_THROW_ON_ERROR)); - Http::fake(); - - $this->artisan('logout') - ->expectsOutputToContain('Stored credentials removed') - ->doesntExpectOutputToContain('stored-secret') - ->assertExitCode(0); - - expect(file_exists($this->credentialPath))->toBeFalse(); - Http::assertNothingSent(); -}); - -it('treats logout without stored credentials as a successful no-op', function () { - Http::fake(); - - $this->artisan('logout') - ->expectsOutputToContain('No stored credentials found') - ->assertExitCode(0); -}); - -it('explains that an environment credential remains active after logout', function () { - file_put_contents($this->credentialPath, json_encode(['api_key' => 'stored-secret'], JSON_THROW_ON_ERROR)); - putenv('QUICKPAY_API_KEY=environment-secret'); - Http::fake(); - - $this->artisan('logout') - ->expectsOutputToContain('Stored credentials removed') - ->expectsOutputToContain('QUICKPAY_API_KEY is still active') - ->doesntExpectOutputToContain('environment-secret') - ->doesntExpectOutputToContain('stored-secret') - ->assertExitCode(0); - - expect(file_exists($this->credentialPath))->toBeFalse(); -}); diff --git a/tests/Feature/Commands/Callbacks/ReplayCallbackCommandTest.php b/tests/Feature/Commands/Callbacks/ReplayCallbackCommandTest.php index fa7ee8d..0ec9ee3 100644 --- a/tests/Feature/Commands/Callbacks/ReplayCallbackCommandTest.php +++ b/tests/Feature/Commands/Callbacks/ReplayCallbackCommandTest.php @@ -4,6 +4,7 @@ use App\Callbacks\Resolution\PrivateKeyResolver; use App\Commands\Callbacks\ReplayCallbackCommand; use App\Credentials\EnvironmentVariables; +use App\Credentials\ProjectMerchantFile; use App\Quickpay\QuickpayClient; use Illuminate\Http\Client\Factory; use Illuminate\Http\Client\Request; @@ -11,6 +12,9 @@ use Symfony\Component\Console\Tester\CommandTester; beforeEach(function () { + $this->merchantProjectDirectory = sys_get_temp_dir().'/quickpay-test-project-'.bin2hex(random_bytes(8)); + mkdir($this->merchantProjectDirectory, 0700, true); + app()->instance(ProjectMerchantFile::class, new ProjectMerchantFile($this->merchantProjectDirectory)); $this->originalApiKey = getenv('QUICKPAY_API_KEY'); $this->originalPrivateKey = getenv('QUICKPAY_PRIVATE_KEY'); putenv('QUICKPAY_API_KEY=replay-api-secret'); @@ -18,6 +22,10 @@ }); afterEach(function () { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->merchantProjectDirectory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->merchantProjectDirectory); foreach ([ 'QUICKPAY_API_KEY' => $this->originalApiKey, 'QUICKPAY_PRIVATE_KEY' => $this->originalPrivateKey, diff --git a/tests/Feature/Commands/Callbacks/WatchCallbacksCommandTest.php b/tests/Feature/Commands/Callbacks/WatchCallbacksCommandTest.php index b2986f0..a4fb032 100644 --- a/tests/Feature/Commands/Callbacks/WatchCallbacksCommandTest.php +++ b/tests/Feature/Commands/Callbacks/WatchCallbacksCommandTest.php @@ -11,6 +11,7 @@ use App\Commands\Payments\CapturePaymentCommand; use App\Commands\Payments\CreatePaymentLinkCommand; use App\Commands\Payments\RefundPaymentCommand; +use App\Credentials\ProjectMerchantFile; use App\Quickpay\QuickpayClient; use Illuminate\Contracts\Console\Kernel; use Illuminate\Http\Client\Factory; @@ -18,6 +19,9 @@ use Symfony\Component\Console\Tester\CommandTester; beforeEach(function () { + $this->merchantProjectDirectory = sys_get_temp_dir().'/quickpay-test-project-'.bin2hex(random_bytes(8)); + mkdir($this->merchantProjectDirectory, 0700, true); + app()->instance(ProjectMerchantFile::class, new ProjectMerchantFile($this->merchantProjectDirectory)); $this->originalApiKey = getenv('QUICKPAY_API_KEY'); $this->originalPrivateKey = getenv('QUICKPAY_PRIVATE_KEY'); putenv('QUICKPAY_API_KEY=watch-api-key'); @@ -25,6 +29,10 @@ }); afterEach(function () { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->merchantProjectDirectory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->merchantProjectDirectory); foreach ([ 'QUICKPAY_API_KEY' => $this->originalApiKey, 'QUICKPAY_PRIVATE_KEY' => $this->originalPrivateKey, diff --git a/tests/Feature/Commands/Merchants/MerchantCommandsTest.php b/tests/Feature/Commands/Merchants/MerchantCommandsTest.php new file mode 100644 index 0000000..64a5933 --- /dev/null +++ b/tests/Feature/Commands/Merchants/MerchantCommandsTest.php @@ -0,0 +1,306 @@ + $class */ +function merchantCommandResult(string $class, array $input = []): array +{ + $command = new $class; + $command->setLaravel(app()); + $tester = new CommandTester($command); + $exit = $tester->execute($input, ['capture_stderr_separately' => true]); + + return [$exit, $tester->getDisplay(), $tester->getErrorOutput()]; +} + +beforeEach(function (): void { + $this->tempDirectory = sys_get_temp_dir().'/quickpay-merchant-command-'.bin2hex(random_bytes(8)); + mkdir($this->tempDirectory.'/project/nested', 0700, true); + $this->originalDirectory = getcwd(); + $this->originalApiKey = getenv('QUICKPAY_API_KEY'); + putenv('QUICKPAY_API_KEY'); + chdir($this->tempDirectory.'/project'); + $this->store = new MerchantStore($this->tempDirectory.'/config.json'); + $this->project = new ProjectMerchantFile; + app()->instance(MerchantStore::class, $this->store); + app()->instance(ProjectMerchantFile::class, $this->project); + app()->instance(ApiKeyResolver::class, new ApiKeyResolver(new EnvironmentVariables, $this->store, $this->project)); + app()->instance(StdinTerminalDetector::class, new class implements StdinTerminalDetector + { + public function isTty(): bool + { + return true; + } + }); + Http::preventStrayRequests(); +}); + +afterEach(function (): void { + chdir($this->originalDirectory); + $this->originalApiKey === false ? putenv('QUICKPAY_API_KEY') : putenv('QUICKPAY_API_KEY='.$this->originalApiKey); + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->tempDirectory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->tempDirectory); +}); + +it('adds a merchant after a private credential check', function (): void { + Http::fake(['https://api.quickpay.net/ping' => Http::response(['msg' => 'pong', 'scope' => 'merchant', 'version' => 'v10', 'params' => []])]); + $this->artisan('merchants:add', ['name' => 'shop-a']) + ->expectsQuestion('API key', ' fake-secret-a ') + ->expectsOutputToContain('Merchant saved: shop-a') + ->doesntExpectOutputToContain('fake-secret-a')->assertExitCode(0); + expect($this->store->read()['merchants']['shop-a']['api_key'])->toBe('fake-secret-a'); + Http::assertSentCount(1); + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.quickpay.net/ping' + && $request->method() === 'GET' && $request->header('Authorization') === ['Basic '.base64_encode(':fake-secret-a')]); +}); + +it('rejects duplicate and invalid names before prompting', function (string $name): void { + $this->store->add('shop-a', 'fake-original'); + $before = file_get_contents($this->store->path()); + $this->artisan('merchants:add', ['name' => $name])->assertExitCode(1); + Http::assertNothingSent(); + expect(file_get_contents($this->store->path()))->toBe($before); +})->with(['duplicate' => 'shop-a', 'path' => '../secret', 'uppercase' => 'Shop-a']); + +it('replaces a credential only after successful validation', function (): void { + Http::fake(['https://api.quickpay.net/ping' => Http::response(['msg' => 'pong', 'scope' => 'merchant', 'version' => 'v10', 'params' => []])]); + $this->store->add('shop-a', 'fake-original'); + $this->artisan('merchants:add', ['name' => 'shop-a', '--replace' => true]) + ->expectsQuestion('API key', 'fake-replacement')->assertExitCode(0); + expect($this->store->read()['merchants']['shop-a']['api_key'])->toBe('fake-replacement'); +}); + +it('preserves the original credential after a rejected replacement', function (): void { + $this->store->add('shop-a', 'fake-original'); + Http::fake(['*' => Http::response(['message' => 'fake-rejected'], 401)]); + $this->artisan('merchants:add', ['name' => 'shop-a', '--replace' => true]) + ->expectsQuestion('API key', 'fake-rejected')->doesntExpectOutputToContain('fake-rejected')->assertExitCode(1); + expect($this->store->read()['merchants']['shop-a']['api_key'])->toBe('fake-original'); +}); + +it('requires interactive input before requesting credentials', function (bool $interactive): void { + if ($interactive) { + app()->instance(StdinTerminalDetector::class, new class implements StdinTerminalDetector + { + public function isTty(): bool + { + return false; + } + }); + } + $this->artisan('merchants:add', ['name' => 'shop-a', '--no-interaction' => ! $interactive])->assertExitCode(1); + Http::assertNothingSent(); + expect(file_exists($this->store->path()))->toBeFalse(); +})->with(['not interactive' => false, 'not a tty' => true]); + +it('rejects blank keys and secret labels before HTTP', function (string $name, string $key): void { + $this->artisan('merchants:add', ['name' => $name])->expectsQuestion('API key', $key)->assertExitCode(1); + Http::assertNothingSent(); + expect(file_exists($this->store->path()))->toBeFalse(); +})->with(['blank' => ['shop-a', ' '], 'label' => ['secret-name', 'secret']]); + +it('rejects malformed merchant scope without saving or reflecting secrets', function (mixed $response): void { + Http::fake(['*' => Http::response($response)]); + $this->artisan('merchants:add', ['name' => 'shop-a'])->expectsQuestion('API key', 'fake-secret-a') + ->doesntExpectOutputToContain('fake-secret-a')->assertExitCode(1); + expect(file_exists($this->store->path()))->toBeFalse(); +})->with(['non-json' => 'not json', 'missing' => [[]], 'other scope' => [['scope' => 'user']], 'reflected' => [['scope' => 'fake-secret-a']]]); + +it('does not render a reflected version after successful validation', function (): void { + Http::fake(['*' => Http::response(['scope' => 'merchant', 'version' => 'fake-secret-a'])]); + $this->artisan('merchants:add', ['name' => 'shop-a'])->expectsQuestion('API key', 'fake-secret-a') + ->doesntExpectOutputToContain('fake-secret-a')->assertExitCode(0); +}); + +it('sanitizes authentication errors on stderr', function (string $form): void { + $this->store->add('shop-a', 'fake/secret'); + Http::fake(['*' => Http::response(['message' => 'Rejected '.$form], 401)]); + [$exit, $stdout, $stderr] = merchantCommandResult(ActiveMerchantCommand::class, ['--check' => true, '--json' => true]); + expect($exit)->toBe(1)->and($stderr)->toContain('[redacted]') + ->and($stdout.$stderr)->not->toContain('fake/secret')->not->toContain($form); + expect(json_decode($stdout, true, flags: JSON_THROW_ON_ERROR)['authenticated'])->toBeFalse(); +})->with(['raw' => 'fake/secret', 'basic' => 'Basic '.base64_encode(':fake/secret'), 'url encoded' => 'fake%2Fsecret']); + +it('handles network failure without leaking or storing credentials', function (): void { + Http::fake(fn () => throw new ConnectionException('Network failure fake-secret-a')); + $this->artisan('merchants:add', ['name' => 'shop-a'])->expectsQuestion('API key', 'fake-secret-a') + ->expectsOutputToContain('Unable to connect to Quickpay')->doesntExpectOutputToContain('fake-secret-a')->assertExitCode(1); + expect(file_exists($this->store->path()))->toBeFalse(); +}); + +it('lists only sorted names and the global default', function (): void { + $this->store->add('shop-b', 'fake-secret-b'); + $this->store->add('shop-a', 'fake-secret-a'); + [$exit, $output, $errors] = merchantCommandResult(ListMerchantsCommand::class, ['--json' => true]); + expect($exit)->toBe(0)->and($errors)->toBe('')->and(json_decode($output, true, flags: JSON_THROW_ON_ERROR)) + ->toBe([['name' => 'shop-a', 'default' => false], ['name' => 'shop-b', 'default' => true]]); + Http::assertNothingSent(); +}); + +it('lists an empty store without contacting Quickpay', function (): void { + [$exit, $output] = merchantCommandResult(ListMerchantsCommand::class, ['--json' => true]); + expect($exit)->toBe(0)->and($output)->toBe('[]'); + Http::assertNothingSent(); +}); + +it('selects saved merchants globally without re-entering credentials', function (): void { + $this->store->add('shop-a', 'fake-secret-a'); + $this->store->add('shop-b', 'fake-secret-b'); + $this->artisan('merchants:select', ['name' => 'shop-b'])->assertExitCode(0); + expect($this->store->read()['default_merchant'])->toBe('shop-b'); + Http::assertNothingSent(); +}); + +it('rejects multiple merchant names without changing selection', function (): void { + $this->store->add('shop-a', 'fake-secret-a'); + $before = file_get_contents($this->store->path()); + $this->artisan('merchants:select', ['name' => ['shop-a', 'shop-b']])->assertExitCode(1); + expect(file_get_contents($this->store->path()))->toBe($before); + Http::assertNothingSent(); +}); + +it('offers a sorted merchant picker with cancellation', function (string $selection): void { + $this->store->add('shop-a', 'fake-secret-a'); + $this->store->add('shop-b', 'fake-secret-b'); + $this->artisan('merchants:select')->expectsChoice('Select merchant', $selection, ['shop-a', 'shop-b', 'Cancel'])->assertExitCode(0); + expect($this->store->read()['default_merchant'])->toBe($selection === 'Cancel' ? 'shop-a' : 'shop-b'); + Http::assertNothingSent(); +})->with(['selection' => 'shop-b', 'cancel' => 'Cancel']); + +it('selects a project merchant without changing the global default', function (): void { + $this->store->add('shop-a', 'fake-secret-a'); + $this->store->add('shop-b', 'fake-secret-b'); + $this->artisan('merchants:select', ['name' => 'shop-b', '--local' => true])->assertExitCode(0); + chdir($this->tempDirectory.'/project/nested'); + [$exit, $output] = merchantCommandResult(ActiveMerchantCommand::class, ['--json' => true]); + expect($exit)->toBe(0)->and(json_decode($output, true)['merchant'])->toBe('shop-b') + ->and($this->store->read()['default_merchant'])->toBe('shop-a'); + mkdir($this->tempDirectory.'/other'); + chdir($this->tempDirectory.'/other'); + [$exit, $output] = merchantCommandResult(ActiveMerchantCommand::class, ['--json' => true]); + expect(json_decode($output, true)['merchant'])->toBe('shop-a'); + Http::assertNothingSent(); +}); + +it('rejects environment overrides before changing selection', function (bool $named): void { + $this->store->add('shop-a', 'fake-secret-a'); + putenv('QUICKPAY_API_KEY=fake-environment'); + $before = file_get_contents($this->store->path()); + $this->artisan('merchants:select', $named ? ['name' => 'shop-a'] : []) + ->expectsOutputToContain('QUICKPAY_API_KEY')->assertExitCode(1); + expect(file_get_contents($this->store->path()))->toBe($before); + Http::assertNothingSent(); +})->with(['named' => true, 'picker' => false]); + +it('rejects empty unknown and unattended picker selections', function (array $arguments): void { + $this->artisan('merchants:select', $arguments)->assertExitCode(1); + expect(file_exists($this->store->path()))->toBeFalse(); + Http::assertNothingSent(); +})->with(['empty' => [[]], 'unknown' => [['name' => 'shop-missing']], 'unattended' => [['--no-interaction' => true]]]); + +it('reports configuration without claiming unverified authentication', function (): void { + $this->store->add('shop-a', 'fake-secret-a'); + [$exit, $output, $errors] = merchantCommandResult(ActiveMerchantCommand::class, ['--json' => true]); + expect($exit)->toBe(0)->and($errors)->toBe('')->and(json_decode($output, true, flags: JSON_THROW_ON_ERROR))->toBe([ + 'merchant' => 'shop-a', 'selection_source' => 'default', 'credential_source' => 'config file', + 'configured' => true, 'checked' => false, 'authenticated' => null, + 'api_base_url' => 'https://api.quickpay.net', 'api_version' => 'v10', 'scope' => null, + ]); + $this->artisan('merchants:active')->expectsOutputToContain('shop-a')->expectsOutputToContain('not checked')->assertExitCode(0); + Http::assertNothingSent(); +}); + +it('reports absent credentials and fails only a requested readiness check', function (): void { + [$exit, $output] = merchantCommandResult(ActiveMerchantCommand::class, ['--json' => true]); + expect($exit)->toBe(0)->and(json_decode($output, true)['configured'])->toBeFalse(); + [$exit, $output, $errors] = merchantCommandResult(ActiveMerchantCommand::class, ['--json' => true, '--check' => true]); + expect($exit)->toBe(1)->and($errors)->toContain('merchants:add') + ->and(json_decode($output, true)['authenticated'])->toBeFalse(); + Http::assertNothingSent(); +}); + +it('reports environment-only credentials without inferring a merchant label', function (): void { + putenv('QUICKPAY_API_KEY=fake-environment'); + [$exit, $output] = merchantCommandResult(ActiveMerchantCommand::class, ['--json' => true]); + $data = json_decode($output, true); + expect($exit)->toBe(0)->and($data['merchant'])->toBeNull()->and($data['selection_source'])->toBe('environment') + ->and($data['credential_source'])->toBe('environment')->and($data['authenticated'])->toBeNull(); + Http::assertNothingSent(); +}); + +it('checks the same explicitly selected key and keeps JSON stdout clean', function (): void { + Http::fake(['https://api.quickpay.net/ping' => Http::response(['msg' => 'pong', 'scope' => 'merchant', 'version' => 'v10', 'params' => []])]); + $this->store->add('shop-a', 'fake-secret-a'); + $this->store->add('shop-b', 'fake-secret-b'); + [$exit, $output, $errors] = merchantCommandResult(ActiveMerchantCommand::class, ['--merchant' => 'shop-b', '--check' => true, '--json' => true]); + $data = json_decode($output, true, flags: JSON_THROW_ON_ERROR); + expect($exit)->toBe(0)->and($errors)->toBe('')->and($data['authenticated'])->toBeTrue() + ->and($data['checked'])->toBeTrue()->and($data['scope'])->toBe('merchant')->and($data['merchant'])->toBe('shop-b'); + Http::assertSentCount(1); + Http::assertSent(fn (Request $request): bool => $request->header('Authorization') === ['Basic '.base64_encode(':fake-secret-b')]); +}); + +it('fails invalid selection before writing status or making requests', function (bool $environment): void { + $this->store->add('shop-a', 'fake-secret-a'); + if ($environment) { + putenv('QUICKPAY_API_KEY=fake-environment'); + } + [$exit, $output, $errors] = merchantCommandResult(ActiveMerchantCommand::class, ['--merchant' => $environment ? 'shop-a' : 'shop-missing', '--json' => true]); + expect($exit)->toBe(1)->and($output)->toBe('')->and($errors)->not->toBe(''); + Http::assertNothingSent(); +})->with(['unknown' => false, 'conflict' => true]); + +it('removes only the named credential without choosing another default', function (): void { + $this->store->add('shop-a', 'fake-secret-a'); + $this->store->add('shop-b', 'fake-secret-b'); + $this->artisan('merchants:remove', ['name' => 'shop-a'])->assertExitCode(0); + expect($this->store->read())->toBe(['default_merchant' => null, 'merchants' => ['shop-b' => ['api_key' => 'fake-secret-b']]]); + $this->artisan('merchants:remove', ['name' => 'shop-missing'])->assertExitCode(1); + $this->artisan('merchants:remove', ['name' => 'shop-b'])->assertExitCode(0); + expect($this->store->read()['merchants'])->toBe([]); + Http::assertNothingSent(); +}); + +it('explains that environment credentials remain after local removal', function (): void { + $this->store->add('shop-a', 'fake-secret-a'); + putenv('QUICKPAY_API_KEY=fake-environment'); + $this->artisan('merchants:remove', ['name' => 'shop-a'])->expectsOutputToContain('QUICKPAY_API_KEY remains configured')->assertExitCode(0); + expect(getenv('QUICKPAY_API_KEY'))->toBe('fake-environment'); + Http::assertNothingSent(); +}); + +it('rejects ambiguous configuration before checking API access', function (bool $project): void { + $this->store->add('shop-a', 'fake-first'); + $this->store->add('shop-b', 'fake-last'); + $path = $project ? getcwd().'/.quickpay-cli.json' : $this->store->path(); + $contents = $project + ? '{"merchant":"shop-a","merch\\u0061nt":"shop-b"}' + : '{"default_merchant":"shop-a","merchants":{"shop-a":{"api_key":"fake-first"},"sh\\u006fp-a":{"api_key":"fake-last"}}}'; + file_put_contents($path, $contents); + Http::fake(['*' => Http::response(['scope' => 'merchant'])]); + [$exit, $output, $errors] = merchantCommandResult(ActiveMerchantCommand::class, ['--check' => true, '--json' => true]); + expect($exit)->toBe(1)->and($output)->toBe('')->and($errors)->not->toBe('') + ->and(file_get_contents($path))->toBe($contents); + Http::assertNothingSent(); +})->with(['store' => false, 'project' => true]); + +it('never lists labels that expose another saved API key', function (): void { + file_put_contents($this->store->path(), '{"default_merchant":"shop-a","merchants":{"shop-a":{"api_key":"fake-secret-a"},"fake-secret-a":{"api_key":"fake-secret-b"}}}'); + [$exit, $output, $errors] = merchantCommandResult(ListMerchantsCommand::class, ['--json' => true]); + expect($exit)->toBe(1)->and($output)->toBe('') + ->and($errors)->not->toContain('fake-secret-a', 'fake-secret-b'); + Http::assertNothingSent(); +}); diff --git a/tests/Feature/Commands/Payments/CreatePaymentCommandTest.php b/tests/Feature/Commands/Payments/CreatePaymentCommandTest.php index 5a5d701..68957a5 100644 --- a/tests/Feature/Commands/Payments/CreatePaymentCommandTest.php +++ b/tests/Feature/Commands/Payments/CreatePaymentCommandTest.php @@ -1,16 +1,24 @@ merchantProjectDirectory = sys_get_temp_dir().'/quickpay-test-project-'.bin2hex(random_bytes(8)); + mkdir($this->merchantProjectDirectory, 0700, true); + app()->instance(ProjectMerchantFile::class, new ProjectMerchantFile($this->merchantProjectDirectory)); $this->originalApiKey = getenv('QUICKPAY_API_KEY'); putenv('QUICKPAY_API_KEY=create-secret'); }); afterEach(function () { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->merchantProjectDirectory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->merchantProjectDirectory); if ($this->originalApiKey === false) { putenv('QUICKPAY_API_KEY'); } else { diff --git a/tests/Feature/Commands/Payments/CreatePaymentLinkCommandTest.php b/tests/Feature/Commands/Payments/CreatePaymentLinkCommandTest.php index 5a206d0..a5549d5 100644 --- a/tests/Feature/Commands/Payments/CreatePaymentLinkCommandTest.php +++ b/tests/Feature/Commands/Payments/CreatePaymentLinkCommandTest.php @@ -1,16 +1,24 @@ merchantProjectDirectory = sys_get_temp_dir().'/quickpay-test-project-'.bin2hex(random_bytes(8)); + mkdir($this->merchantProjectDirectory, 0700, true); + app()->instance(ProjectMerchantFile::class, new ProjectMerchantFile($this->merchantProjectDirectory)); $this->originalApiKey = getenv('QUICKPAY_API_KEY'); putenv('QUICKPAY_API_KEY=link-secret'); }); afterEach(function () { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->merchantProjectDirectory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->merchantProjectDirectory); if ($this->originalApiKey === false) { putenv('QUICKPAY_API_KEY'); } else { diff --git a/tests/Feature/Commands/Payments/GetPaymentCommandTest.php b/tests/Feature/Commands/Payments/GetPaymentCommandTest.php index 1449896..b6135b1 100644 --- a/tests/Feature/Commands/Payments/GetPaymentCommandTest.php +++ b/tests/Feature/Commands/Payments/GetPaymentCommandTest.php @@ -1,16 +1,24 @@ merchantProjectDirectory = sys_get_temp_dir().'/quickpay-test-project-'.bin2hex(random_bytes(8)); + mkdir($this->merchantProjectDirectory, 0700, true); + app()->instance(ProjectMerchantFile::class, new ProjectMerchantFile($this->merchantProjectDirectory)); $this->originalApiKey = getenv('QUICKPAY_API_KEY'); putenv('QUICKPAY_API_KEY=get-secret'); }); afterEach(function () { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->merchantProjectDirectory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->merchantProjectDirectory); if ($this->originalApiKey === false) { putenv('QUICKPAY_API_KEY'); } else { diff --git a/tests/Feature/Commands/Payments/ListPaymentsCommandTest.php b/tests/Feature/Commands/Payments/ListPaymentsCommandTest.php index 20eca2b..a4d6824 100644 --- a/tests/Feature/Commands/Payments/ListPaymentsCommandTest.php +++ b/tests/Feature/Commands/Payments/ListPaymentsCommandTest.php @@ -2,18 +2,26 @@ use App\Commands\Payments\ListPaymentsCommand; use App\Credentials\ApiKeyResolver; -use App\Credentials\CredentialFile; use App\Credentials\EnvironmentVariables; +use App\Credentials\MerchantStore; +use App\Credentials\ProjectMerchantFile; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Symfony\Component\Console\Tester\CommandTester; beforeEach(function () { + $this->merchantProjectDirectory = sys_get_temp_dir().'/quickpay-test-project-'.bin2hex(random_bytes(8)); + mkdir($this->merchantProjectDirectory, 0700, true); + app()->instance(ProjectMerchantFile::class, new ProjectMerchantFile($this->merchantProjectDirectory)); $this->originalApiKey = getenv('QUICKPAY_API_KEY'); putenv('QUICKPAY_API_KEY=list-secret'); }); afterEach(function () { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->merchantProjectDirectory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->merchantProjectDirectory); if ($this->originalApiKey === false) { putenv('QUICKPAY_API_KEY'); } else { @@ -355,15 +363,15 @@ [['--max-pages' => 'nope'], 'max-pages'], ]); -it('fails safely with a login hint when credentials are missing', function () { +it('fails safely with a merchant setup hint when credentials are missing', function () { putenv('QUICKPAY_API_KEY'); - $file = new CredentialFile(sys_get_temp_dir().'/missing-quickpay-'.bin2hex(random_bytes(6)).'.json'); - app()->instance(CredentialFile::class, $file); - app()->instance(ApiKeyResolver::class, new ApiKeyResolver(new EnvironmentVariables, $file)); + $file = new MerchantStore(sys_get_temp_dir().'/missing-quickpay-'.bin2hex(random_bytes(6)).'.json'); + app()->instance(MerchantStore::class, $file); + app()->instance(ApiKeyResolver::class, new ApiKeyResolver(new EnvironmentVariables, $file, app(ProjectMerchantFile::class))); Http::fake(); $this->artisan('payments:list') - ->expectsOutputToContain('quickpay login') + ->expectsOutputToContain('quickpay merchants:add ') ->assertExitCode(1); Http::assertNothingSent(); diff --git a/tests/Feature/Commands/Payments/PaymentMutationCommandsTest.php b/tests/Feature/Commands/Payments/PaymentMutationCommandsTest.php index 3d81cf9..6cb9a61 100644 --- a/tests/Feature/Commands/Payments/PaymentMutationCommandsTest.php +++ b/tests/Feature/Commands/Payments/PaymentMutationCommandsTest.php @@ -4,11 +4,15 @@ use App\Commands\Payments\CapturePaymentCommand; use App\Commands\Payments\RefundPaymentCommand; use App\Console\Terminal\StdinTerminalDetector; +use App\Credentials\ProjectMerchantFile; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Symfony\Component\Console\Tester\CommandTester; beforeEach(function () { + $this->merchantProjectDirectory = sys_get_temp_dir().'/quickpay-test-project-'.bin2hex(random_bytes(8)); + mkdir($this->merchantProjectDirectory, 0700, true); + app()->instance(ProjectMerchantFile::class, new ProjectMerchantFile($this->merchantProjectDirectory)); $this->originalApiKey = getenv('QUICKPAY_API_KEY'); putenv('QUICKPAY_API_KEY=mutation-secret'); app()->instance(StdinTerminalDetector::class, new class implements StdinTerminalDetector @@ -21,6 +25,10 @@ public function isTty(): bool }); afterEach(function () { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->merchantProjectDirectory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->merchantProjectDirectory); if ($this->originalApiKey === false) { putenv('QUICKPAY_API_KEY'); } else { diff --git a/tests/Feature/Console/AuthenticatedCommandTest.php b/tests/Feature/Console/AuthenticatedCommandTest.php new file mode 100644 index 0000000..493e280 --- /dev/null +++ b/tests/Feature/Console/AuthenticatedCommandTest.php @@ -0,0 +1,193 @@ +directory = sys_get_temp_dir().'/quickpay-command-selection-'.bin2hex(random_bytes(8)); + mkdir($this->directory, 0700, true); + $this->originalApiKey = getenv('QUICKPAY_API_KEY'); + $this->originalPrivateKey = getenv('QUICKPAY_PRIVATE_KEY'); + putenv('QUICKPAY_API_KEY'); + putenv('QUICKPAY_PRIVATE_KEY=fake-signing-key'); + $this->store = new MerchantStore($this->directory.'/config.json'); + $this->store->add('shop-a', 'fake-secret-a'); + $this->store->add('shop-b', 'fake-secret-b'); + $this->project = new ProjectMerchantFile($this->directory); + app()->instance(MerchantStore::class, $this->store); + app()->instance(ProjectMerchantFile::class, $this->project); + app()->instance(ApiKeyResolver::class, new ApiKeyResolver(new EnvironmentVariables, $this->store, $this->project)); + app()->instance(StdinTerminalDetector::class, new class implements StdinTerminalDetector + { + public function isTty(): bool + { + return true; + } + }); + Http::preventStrayRequests(); +}); + +afterEach(function (): void { + foreach (['QUICKPAY_API_KEY' => $this->originalApiKey, 'QUICKPAY_PRIVATE_KEY' => $this->originalPrivateKey] as $name => $value) { + $value === false ? putenv($name) : putenv($name.'='.$value); + } + foreach (glob($this->directory.'/*') as $file) { + unlink($file); + } + rmdir($this->directory); +}); + +it('identifies the selected merchant in payment confirmation without changing response JSON', function (string $class, string $operation, array $arguments, bool $json, bool $yes): void { + $raw = '{"id":42,"state":"processed","custom":"unchanged"}'; + Http::fake([ + 'https://api.quickpay.net/payments/42' => Http::response(['id' => 42, 'merchant_id' => 123, 'order_id' => 'order-42', 'currency' => 'DKK', 'state' => 'authorized', 'accepted' => true, 'balance' => 2000]), + 'https://api.quickpay.net/payments/42/'.$operation => Http::response($raw), + ]); + $command = new $class; + $command->setLaravel(app()); + $tester = new CommandTester($command); + $tester->setInputs(['yes']); + $exit = $tester->execute([...$arguments, '--merchant' => 'shop-b', '--json' => $json, '--yes' => $yes], ['capture_stderr_separately' => true]); + $summary = $json ? $tester->getErrorOutput() : $tester->getDisplay(); + expect($exit)->toBe(0)->and($summary)->toContain('Merchant: shop-b')->toContain('Credential source: config file')->toContain('Merchant ID: 123'); + if ($json) { + expect($tester->getDisplay())->toBe($raw); + } + $requests = Http::recorded(); + expect($requests)->toHaveCount(2)->and($requests[0][0]->method())->toBe('GET')->and($requests[1][0]->method())->toBe('POST'); + foreach ($requests as [$request]) { + expect($request->header('Authorization'))->toBe(['Basic '.base64_encode(':fake-secret-b')]); + } +})->with([ + 'capture JSON yes' => [CapturePaymentCommand::class, 'capture', ['id' => '42', 'amount' => '1250'], true, true], + 'capture JSON confirmation' => [CapturePaymentCommand::class, 'capture', ['id' => '42', 'amount' => '1250'], true, false], + 'capture human confirmation' => [CapturePaymentCommand::class, 'capture', ['id' => '42', 'amount' => '1250'], false, false], + 'refund JSON yes' => [RefundPaymentCommand::class, 'refund', ['id' => '42', 'amount' => '1250'], true, true], + 'cancel JSON yes' => [CancelPaymentCommand::class, 'cancel', ['id' => '42'], true, true], +]); + +it('keeps the inspected merchant pinned when its saved credential changes before mutation', function (): void { + $store = $this->store; + Http::fake(function (Request $request) use ($store) { + if ($request->method() === 'GET') { + $store->select('shop-a'); + $store->remove('shop-b'); + } + + return Http::response(['id' => 42, 'merchant_id' => 123, 'state' => 'processed']); + }); + $this->artisan('payments:capture', ['id' => '42', 'amount' => '1250', '--merchant' => 'shop-b', '--yes' => true, '--json' => true])->assertExitCode(0); + Http::assertSentCount(2); + foreach (Http::recorded() as [$request]) { + expect($request->header('Authorization'))->toBe(['Basic '.base64_encode(':fake-secret-b')]); + } +}); + +it('never mutates after declining a selected merchant confirmation', function (): void { + Http::fake(['*' => Http::response(['id' => 42, 'merchant_id' => 123])]); + $command = new CapturePaymentCommand; + $command->setLaravel(app()); + $tester = new CommandTester($command); + $tester->setInputs(['no']); + expect($tester->execute(['id' => '42', 'amount' => '1250', '--merchant' => 'shop-b', '--json' => true], ['capture_stderr_separately' => true]))->toBe(0) + ->and($tester->getDisplay())->toBe('')->and($tester->getErrorOutput())->toContain('Merchant: shop-b')->toContain('Cancelled'); + Http::assertSentCount(1); + Http::assertNotSent(fn (Request $request): bool => $request->method() !== 'GET'); +}); + +it('identifies a selected merchant before confirming a raw mutation', function (): void { + $raw = '{"id":42,"state":"captured"}'; + Http::fake(['*' => Http::response($raw)]); + $command = new ApiRequestCommand; + $command->setLaravel(app()); + $tester = new CommandTester($command); + $tester->setInputs(['yes']); + expect($tester->execute(['method' => 'POST', 'path' => '/payments/42/capture', '--merchant' => 'shop-b', '--json' => true], ['capture_stderr_separately' => true]))->toBe(0) + ->and($tester->getDisplay())->toBe($raw)->and($tester->getErrorOutput())->toContain('Merchant: shop-b')->toContain('Credential source: config file')->toContain('Continue?'); + expect(strpos($tester->getErrorOutput(), 'Merchant:'))->toBeLessThan(strpos($tester->getErrorOutput(), 'Continue?')); + Http::assertSent(fn (Request $request): bool => $request->header('Authorization') === ['Basic '.base64_encode(':fake-secret-b')]); +}); + +it('selects the same saved key for every other authenticated command family', function (string $command, array $arguments, string $path, mixed $body): void { + Http::fake([ + 'https://api.quickpay.net'.$path.'*' => Http::response($body), + 'http://localhost/callback' => Http::response('', 204), + ]); + $this->artisan($command, [...$arguments, '--merchant' => 'shop-b', '--json' => true])->assertExitCode(0); + Http::assertSent(fn (Request $request): bool => str_starts_with($request->url(), 'https://api.quickpay.net'.$path) + && $request->header('Authorization') === ['Basic '.base64_encode(':fake-secret-b')]); + Http::assertNotSent(fn (Request $request): bool => $request->url() === 'http://localhost/callback' && $request->hasHeader('Authorization')); +})->with([ + 'create' => ['payments:create', ['order-id' => 'order-42'], '/payments', ['id' => 42]], + 'get' => ['payments:get', ['id' => '42'], '/payments/42', ['id' => 42]], + 'link' => ['payments:link', ['id' => '42', 'amount' => '1000'], '/payments/42/link', ['url' => 'https://payment.quickpay.net/link/abc']], + 'list' => ['payments:list', [], '/payments', [['id' => 42]]], + 'raw GET' => ['api', ['method' => 'GET', 'path' => '/payments/42'], '/payments/42', ['id' => 42]], + 'replay' => ['callbacks:replay', ['payment-id' => '42', '--to' => 'http://localhost/callback'], '/payments/42', ['id' => 42, 'merchant_id' => 123, 'order_id' => 'order-42', 'operations' => []]], +]); + +it('rejects unresolved or conflicting merchant selection before HTTP', function (string $command, array $arguments, bool $environment): void { + if ($environment) { + putenv('QUICKPAY_API_KEY=fake-environment'); + } + Http::fake(); + $this->artisan($command, [...$arguments, '--merchant' => $environment ? 'shop-b' : 'shop-missing'])->assertExitCode(1); + Http::assertNothingSent(); +})->with([ + 'capture unknown' => ['payments:capture', ['id' => '42', 'amount' => '1250', '--yes' => true], false], + 'raw conflict' => ['api', ['method' => 'POST', 'path' => '/payments/42/capture', '--yes' => true], true], + 'watch unknown' => ['callbacks:watch', ['--to' => 'http://localhost/callback'], false], + 'replay conflict' => ['callbacks:replay', ['payment-id' => '42', '--to' => 'http://localhost/callback'], true], +]); + +it('keeps scoped and account-wide watchers pinned after selection and removal', function (bool $scoped): void { + Http::fake(['https://api.quickpay.net/ping' => Http::response(['scope' => 'merchant'])]); + app()->instance(CallbackWatcherFactory::class, new class($this->store) extends CallbackWatcherFactory + { + public function __construct(private MerchantStore $store) {} + + public function make(QuickpayClient $quickpay, Factory $http): CallbackWatcher + { + return new class($quickpay, $this->store) implements CallbackWatcher + { + public function __construct(private QuickpayClient $quickpay, private MerchantStore $store) {} + + public function run(?string $paymentId, ?string $orderId, CallbackTarget $target, string $apiKey, string $privateKey, int $interval, int $deliveryAttempts, Closure $observer): void + { + $this->store->select('shop-a'); + $this->store->remove('shop-b'); + $this->quickpay->get('/ping'); + $this->quickpay->get('/ping'); + if ($paymentId === null) { + $observer('watching-all', ['ready_at' => '2026-09-30T10:00:00+00:00']); + } + } + }; + } + }); + $command = new WatchCallbacksCommand; + $command->setLaravel(app()); + $tester = new CommandTester($command); + expect($tester->execute(['--merchant' => 'shop-b', '--to' => 'http://localhost/callback', ...($scoped ? ['payment-id' => '42'] : [])]))->toBe(0) + ->and($tester->getDisplay())->toContain('Merchant: shop-b')->toContain('Credential source: config file'); + Http::assertSentCount(2); + foreach (Http::recorded() as [$request]) { + expect($request->header('Authorization'))->toBe(['Basic '.base64_encode(':fake-secret-b')]); + } +})->with(['scoped' => true, 'account-wide' => false]); diff --git a/tests/Feature/Quickpay/AuthenticatedQuickpayFactoryTest.php b/tests/Feature/Quickpay/AuthenticatedQuickpayFactoryTest.php new file mode 100644 index 0000000..3965447 --- /dev/null +++ b/tests/Feature/Quickpay/AuthenticatedQuickpayFactoryTest.php @@ -0,0 +1,34 @@ +add('shop-a', 'fake-secret-a'); + $store->add('shop-b', 'fake-secret-b'); + $factory = new AuthenticatedQuickpayFactory(Http::getFacadeRoot(), new ApiKeyResolver(new EnvironmentVariables, $store, new ProjectMerchantFile($directory))); + $authenticated = $factory->resolve('shop-b'); + $store->remove('shop-b'); + Http::fake(['*' => Http::response(['scope' => 'merchant'])]); + $authenticated->client->get('/ping'); + expect($authenticated->apiKey->merchant())->toBe('shop-b')->and($authenticated->apiKey->selectionSource())->toBe('explicit'); + Http::assertSent(fn (Request $request): bool => $request->header('Authorization') === ['Basic '.base64_encode(':fake-secret-b')]); + } finally { + $original === false ? putenv('QUICKPAY_API_KEY') : putenv('QUICKPAY_API_KEY='.$original); + foreach (glob($directory.'/*') as $file) { + unlink($file); + } + rmdir($directory); + } +}); diff --git a/tests/Feature/Quickpay/MerchantAuthenticationCheckTest.php b/tests/Feature/Quickpay/MerchantAuthenticationCheckTest.php new file mode 100644 index 0000000..c07baa4 --- /dev/null +++ b/tests/Feature/Quickpay/MerchantAuthenticationCheckTest.php @@ -0,0 +1,13 @@ + Http::response(['scope' => 'user'])]); + expect(fn () => (new MerchantAuthenticationCheck(app(AuthenticatedQuickpayFactory::class)))->check(new ApiKey('fake-key', 'provided'))) + ->toThrow(UnexpectedValueException::class, 'merchant'); + Http::assertSentCount(1); +}); diff --git a/tests/Integration/Credentials/CredentialFileTest.php b/tests/Integration/Credentials/CredentialFileTest.php deleted file mode 100644 index 68fd57c..0000000 --- a/tests/Integration/Credentials/CredentialFileTest.php +++ /dev/null @@ -1,70 +0,0 @@ -tempDirectory = sys_get_temp_dir().'/quickpay-file-'.bin2hex(random_bytes(8)); - mkdir($this->tempDirectory, 0700, true); - $this->path = $this->tempDirectory.'/nested/config.json'; -}); - -afterEach(function (): void { - if (! is_dir($this->tempDirectory)) { - return; - } - - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($this->tempDirectory, FilesystemIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST, - ); - - foreach ($iterator as $item) { - $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); - } - - rmdir($this->tempDirectory); -}); - -it('atomically persists and reads a credential with restrictive modes', function (): void { - $file = new CredentialFile($this->path); - $file->save('stored-secret'); - clearstatcache(true, $this->path); - - expect($file->read())->toBe('stored-secret') - ->and(fileperms(dirname($this->path)) & 0777)->toBe(0700) - ->and(fileperms($this->path) & 0777)->toBe(0600) - ->and(glob(dirname($this->path).'/.config.json.*'))->toBe([]); -}); - -it('rejects malformed credential files', function (string $contents): void { - mkdir(dirname($this->path), 0700, true); - file_put_contents($this->path, $contents); - - expect(fn (): ?string => (new CredentialFile($this->path))->read()) - ->toThrow(CredentialException::class, 'Quickpay credential file is invalid'); -})->with([ - '{"api_key":', - '{"other":"value"}', - '{"api_key":123}', - '{"api_key":""}', -]); - -it('forgets only the stored credential', function (): void { - $file = new CredentialFile($this->path); - $file->save('stored-key'); - - expect($file->forget())->toBeTrue() - ->and($file->forget())->toBeFalse(); -}); - -it('never includes the credential in persistence errors', function (): void { - mkdir($this->path, 0700, true); - - try { - (new CredentialFile($this->path))->save('highly-sensitive-key'); - $this->fail('Expected credential storage to fail.'); - } catch (CredentialException $exception) { - expect($exception->getMessage())->not->toContain('highly-sensitive-key'); - } -}); diff --git a/tests/Integration/Credentials/MerchantStoreTest.php b/tests/Integration/Credentials/MerchantStoreTest.php new file mode 100644 index 0000000..f1ce816 --- /dev/null +++ b/tests/Integration/Credentials/MerchantStoreTest.php @@ -0,0 +1,240 @@ +tempDirectory = sys_get_temp_dir().'/quickpay-merchants-'.bin2hex(random_bytes(8)); + mkdir($this->tempDirectory, 0700, true); + $this->path = $this->tempDirectory.'/nested/config.json'; +}); + +afterEach(function (): void { + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->tempDirectory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->tempDirectory); +}); + +it('keeps the first default when adding another merchant', function (): void { + $store = new MerchantStore($this->path); + $store->add('shop-a', 'fake-secret-a'); + $store->add('shop-b', 'fake-secret-b'); + expect($store->read())->toBe([ + 'default_merchant' => 'shop-a', + 'merchants' => ['shop-a' => ['api_key' => 'fake-secret-a'], 'shop-b' => ['api_key' => 'fake-secret-b']], + ]); +}); + +it('does not silently choose a default after removing it', function (): void { + $store = new MerchantStore($this->path); + $store->add('shop-a', 'fake-secret-a'); + $store->add('shop-b', 'fake-secret-b'); + $store->remove('shop-a'); + $store->add('shop-c', 'fake-secret-c'); + expect($store->read()['default_merchant'])->toBeNull() + ->and(array_keys($store->read()['merchants']))->toBe(['shop-b', 'shop-c']); +}); + +it('retains existing credentials on duplicate or invalid additions', function (string $name, string $key): void { + $store = new MerchantStore($this->path); + $store->add('shop-a', 'fake-original'); + $before = file_get_contents($this->path); + expect(fn () => $store->add($name, $key))->toThrow(CredentialException::class); + expect(file_get_contents($this->path))->toBe($before); +})->with(['duplicate' => ['shop-a', 'fake-new'], 'empty key' => ['shop-b', ' '], 'secret label' => ['secret-name', 'secret']]); + +it('replaces only the explicitly named credential', function (): void { + $store = new MerchantStore($this->path); + $store->add('shop-a', 'fake-secret-a'); + $store->add('shop-b', 'fake-secret-b'); + $store->add('shop-b', ' fake-replacement ', true); + expect($store->read())->toBe([ + 'default_merchant' => 'shop-a', + 'merchants' => ['shop-a' => ['api_key' => 'fake-secret-a'], 'shop-b' => ['api_key' => 'fake-replacement']], + ]); +}); + +it('rejects labels colliding with any saved credential without changing storage', function (bool $incomingKey): void { + $store = new MerchantStore($this->path); + $store->add('shop-a', 'fake-secret-a'); + $before = file_get_contents($this->path); + expect(fn () => $store->add($incomingKey ? 'shop-b' : 'fake-secret-a', $incomingKey ? 'shop-a' : 'fake-secret-b')) + ->toThrow(CredentialException::class); + expect(file_get_contents($this->path))->toBe($before); +})->with(['new label contains old key' => false, 'new key matches old label' => true]); + +it('writes object-shaped empty storage and restrictive modes', function (): void { + $store = new MerchantStore($this->path); + $store->add('shop-a', 'fake-secret-a'); + $store->remove('shop-a'); + clearstatcache(); + $decoded = json_decode(file_get_contents($this->path), flags: JSON_THROW_ON_ERROR); + expect($decoded->merchants)->toBeInstanceOf(stdClass::class) + ->and($decoded->default_merchant)->toBeNull() + ->and(fileperms(dirname($this->path)) & 0777)->toBe(0700) + ->and(fileperms($this->path) & 0777)->toBe(0600) + ->and(fileperms($this->path.'.lock') & 0777)->toBe(0600) + ->and(glob(dirname($this->path).'/.config.json.*'))->toBe([]); +}); + +it('rejects invalid storage without treating it as empty', function (string $contents): void { + mkdir(dirname($this->path), 0700, true); + file_put_contents($this->path, $contents); + $store = new MerchantStore($this->path); + foreach ([fn () => $store->read(), fn () => $store->add('shop-a', 'fake-new'), fn () => $store->select('shop-a'), fn () => $store->remove('shop-a')] as $action) { + expect($action)->toThrow(CredentialException::class); + expect(file_get_contents($this->path))->toBe($contents); + } +})->with([ + 'broken' => '{broken', 'root list' => '[]', 'missing' => '{}', + 'merchant list' => '{"default_merchant":null,"merchants":[]}', + 'numeric key' => '{"default_merchant":null,"merchants":{"shop-a":{"api_key":123}}}', + 'empty key' => '{"default_merchant":null,"merchants":{"shop-a":{"api_key":" "}}}', + 'invalid name' => '{"default_merchant":null,"merchants":{"../a":{"api_key":"fake-key"}}}', + 'dangling default' => '{"default_merchant":"shop-a","merchants":{}}', + 'old shape' => '{"api_key":"fake-legacy"}', + 'secret label' => '{"default_merchant":null,"merchants":{"secret-name":{"api_key":"secret"}}}', + 'duplicate merchant' => '{"default_merchant":"shop-a","merchants":{"shop-a":{"api_key":"fake-first"},"shop-a":{"api_key":"fake-last"}}}', + 'escaped duplicate merchant' => '{"default_merchant":"shop-a","merchants":{"shop-a":{"api_key":"fake-first"},"sh\\u006fp-a":{"api_key":"fake-last"}}}', + 'duplicate key' => '{"default_merchant":"shop-a","merchants":{"shop-a":{"api_key":"fake-first","api_key":"fake-last"}}}', + 'duplicate default' => '{"default_merchant":"shop-a","default_merchant":null,"merchants":{"shop-a":{"api_key":"fake-first"}}}', +]); + +it('does not mistake quoted key-like credential content for JSON properties', function (): void { + $key = 'fake-{"same":1,"same":2}-[]'; + $store = new MerchantStore($this->path); + $store->add('shop-a', $key); + expect($store->read()['merchants']['shop-a']['api_key'])->toBe($key); +}); + +it('does not read old namespaced storage', function (): void { + $old = $this->tempDirectory.'/.config/quickpay/config.json'; + mkdir(dirname($old), 0700, true); + file_put_contents($old, '{"api_key":"fake-legacy"}'); + $store = MerchantStore::inHome(new HomeDirectory(new EnvironmentVariables, $this->tempDirectory)); + expect($store->path())->toBe($this->tempDirectory.'/.config/quickpay-cli/config.json') + ->and($store->read())->toBe(['default_merchant' => null, 'merchants' => []]) + ->and(file_get_contents($old))->toBe('{"api_key":"fake-legacy"}'); +}); + +it('rejects unknown select and remove targets', function (): void { + $store = new MerchantStore($this->path); + $store->add('shop-a', 'fake-secret-a'); + $store->add('shop-b', 'fake-secret-b'); + $before = file_get_contents($this->path); + expect(fn () => $store->select('shop-c'))->toThrow(CredentialException::class); + expect(fn () => $store->remove('shop-c'))->toThrow(CredentialException::class); + expect(file_get_contents($this->path))->toBe($before); + $store->select('shop-b'); + $store->remove('shop-a'); + expect($store->read()['default_merchant'])->toBe('shop-b'); +}); + +it('preserves simultaneous writer updates', function (): void { + $store = new MerchantStore($this->path); + $store->add('initial', 'fake-initial'); + $script = $this->tempDirectory.'/writer.php'; + file_put_contents($script, <<<'PHP' + add($name, 'fake-key-'.$name); + } + PHP); + $processes = []; + try { + foreach (['left', 'right'] as $name) { + $process = proc_open([PHP_BINARY, $script, base_path('vendor/autoload.php'), $this->path, $name], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + $processes[] = [$process, $pipes]; + stream_set_timeout($pipes[1], 5); + expect(fgets($pipes[1]))->toBe("ready\n"); + } + foreach ($processes as [$process, $pipes]) { + fwrite($pipes[0], "go\n"); + fclose($pipes[0]); + } + foreach ($processes as [$process, $pipes]) { + stream_get_contents($pipes[1]); + expect(stream_get_meta_data($pipes[1])['timed_out'])->toBeFalse(); + expect(proc_close($process))->toBe(0); + } + $saved = $store->read()['merchants']; + expect($saved)->toHaveCount(31); + foreach (['left', 'right'] as $name) { + for ($i = 0; $i < 15; $i++) { + expect($saved[$name.'-'.$i]['api_key'])->toBe('fake-key-'.$name.'-'.$i); + } + } + } finally { + foreach ($processes as [$process, $pipes]) { + if (is_resource($process)) { + proc_terminate($process); + proc_close($process); + } + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + } + } +}); + +it('waits on a stable lock during replacement', function (): void { + $store = new MerchantStore($this->path); + $store->add('shop-a', 'fake-secret-a'); + $lock = fopen($this->path.'.lock', 'c'); + flock($lock, LOCK_EX); + $script = $this->tempDirectory.'/locked-writer.php'; + file_put_contents($script, <<<'PHP' + add('shop-b', 'fake-secret-b'); + echo "done\n"; + PHP); + $process = proc_open([PHP_BINARY, $script, base_path('vendor/autoload.php'), $this->path], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + try { + stream_set_timeout($pipes[1], 5); + expect(fgets($pipes[1]))->toBe("ready\n"); + $read = [$pipes[1]]; + $write = $except = []; + expect(stream_select($read, $write, $except, 0, 50000))->toBe(0); + file_put_contents($this->path.'.replacement', '{"default_merchant":"shop-c","merchants":{"shop-c":{"api_key":"fake-secret-c"}}}'); + rename($this->path.'.replacement', $this->path); + flock($lock, LOCK_UN); + expect(fgets($pipes[1]))->toBe("done\n") + ->and(proc_close($process))->toBe(0) + ->and(array_keys($store->read()['merchants']))->toBe(['shop-c', 'shop-b']); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + if (is_resource($process)) { + proc_terminate($process); + proc_close($process); + } + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + } +}); + +it('keeps persistence errors free of credentials', function (): void { + mkdir($this->path, 0700, true); + try { + (new MerchantStore($this->path))->add('shop-a', 'fake-sensitive'); + $this->fail('Expected persistence to fail'); + } catch (CredentialException $exception) { + expect($exception->getMessage())->not->toContain('fake-sensitive'); + } +}); diff --git a/tests/Integration/Credentials/ProjectMerchantFileTest.php b/tests/Integration/Credentials/ProjectMerchantFileTest.php new file mode 100644 index 0000000..7f80e40 --- /dev/null +++ b/tests/Integration/Credentials/ProjectMerchantFileTest.php @@ -0,0 +1,55 @@ +tempDirectory = sys_get_temp_dir().'/quickpay-project-'.bin2hex(random_bytes(8)); + mkdir($this->tempDirectory.'/nested', 0700, true); + $this->tempDirectory = realpath($this->tempDirectory); +}); + +afterEach(function (): void { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->tempDirectory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->tempDirectory); +}); + +it('resolves the nearest binding from nested directories', function (): void { + $parent = new ProjectMerchantFile($this->tempDirectory); + $child = new ProjectMerchantFile($this->tempDirectory.'/nested'); + $parent->save('shop-a'); + expect($child->find())->toBe(['merchant' => 'shop-a', 'path' => $this->tempDirectory.'/.quickpay-cli.json']); + $child->save('shop-b'); + expect($child->find())->toBe(['merchant' => 'shop-b', 'path' => $this->tempDirectory.'/nested/.quickpay-cli.json']) + ->and($parent->find()['merchant'])->toBe('shop-a') + ->and(json_decode(file_get_contents($this->tempDirectory.'/nested/.quickpay-cli.json'), true))->toBe(['merchant' => 'shop-b']) + ->and(glob($this->tempDirectory.'/nested/..quickpay-cli.json.*'))->toBe([]); +}); + +it('rejects malformed nearest bindings without falling back or overwriting', function (string $contents): void { + (new ProjectMerchantFile($this->tempDirectory))->save('shop-a'); + $path = $this->tempDirectory.'/nested/.quickpay-cli.json'; + file_put_contents($path, $contents); + $file = new ProjectMerchantFile($this->tempDirectory.'/nested'); + expect(fn () => $file->find())->toThrow(CredentialException::class); + expect(fn () => $file->save('shop-b'))->toThrow(CredentialException::class); + expect(file_get_contents($path))->toBe($contents); +})->with([ + 'broken' => '{broken', 'list' => '[]', 'numeric' => '{"merchant":123}', 'missing' => '{}', + 'secret' => '{"merchant":"shop-a","api_key":"fake-secret"}', + 'url' => '{"merchant":"shop-a","url":"https://other.test"}', + 'headers' => '{"merchant":"shop-a","headers":{}}', 'invalid name' => '{"merchant":"Shop-a"}', + 'duplicate selector' => '{"merchant":"shop-a","merchant":"shop-b"}', + 'escaped duplicate selector' => '{"merchant":"shop-a","merch\\u0061nt":"shop-b"}', +]); + +it('rejects a directory masquerading as a project file', function (): void { + mkdir($this->tempDirectory.'/nested/.quickpay-cli.json'); + expect(fn () => (new ProjectMerchantFile($this->tempDirectory.'/nested'))->find())->toThrow(CredentialException::class); +}); + +it('returns no selection when no binding exists', function (): void { + expect((new ProjectMerchantFile($this->tempDirectory))->find())->toBeNull(); +}); diff --git a/tests/Unit/Credentials/ApiKeyResolverTest.php b/tests/Unit/Credentials/ApiKeyResolverTest.php index 3d89e19..27e83ac 100644 --- a/tests/Unit/Credentials/ApiKeyResolverTest.php +++ b/tests/Unit/Credentials/ApiKeyResolverTest.php @@ -1,54 +1,86 @@ credentialPath = sys_get_temp_dir().'/quickpay-resolver-'.bin2hex(random_bytes(8)).'.json'; + $this->tempDirectory = sys_get_temp_dir().'/quickpay-resolver-'.bin2hex(random_bytes(8)); + mkdir($this->tempDirectory, 0700, true); $this->originalApiKey = getenv('QUICKPAY_API_KEY'); putenv('QUICKPAY_API_KEY'); + $this->store = new MerchantStore($this->tempDirectory.'/config.json'); + $this->project = new ProjectMerchantFile($this->tempDirectory); + $this->resolver = new ApiKeyResolver(new EnvironmentVariables, $this->store, $this->project); }); afterEach(function (): void { - $this->originalApiKey === false - ? putenv('QUICKPAY_API_KEY') - : putenv('QUICKPAY_API_KEY='.$this->originalApiKey); - - if (is_file($this->credentialPath)) { - unlink($this->credentialPath); + $this->originalApiKey === false ? putenv('QUICKPAY_API_KEY') : putenv('QUICKPAY_API_KEY='.$this->originalApiKey); + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->tempDirectory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST) as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); } + rmdir($this->tempDirectory); }); -it('prefers a non-empty environment key without reading malformed storage', function (): void { - file_put_contents($this->credentialPath, '{malformed'); - putenv('QUICKPAY_API_KEY=environment-key'); +it('returns environment context without reading malformed saved storage', function (): void { + file_put_contents($this->store->path(), '{malformed'); + putenv('QUICKPAY_API_KEY=fake-environment'); + $key = $this->resolver->resolve(); + expect($key?->value())->toBe('fake-environment')->and($key?->source())->toBe('environment') + ->and($key?->merchant())->toBeNull()->and($key?->selectionSource())->toBe('environment'); +}); - $key = (new ApiKeyResolver( - new EnvironmentVariables, - new CredentialFile($this->credentialPath), - ))->resolve(); +it('uses the default only without explicit or project selection', function (): void { + $this->store->add('shop-a', 'fake-secret-a'); + $this->store->add('shop-b', 'fake-secret-b'); + expect($this->resolver->resolve()?->merchant())->toBe('shop-a') + ->and($this->resolver->resolve()?->selectionSource())->toBe('default'); + $this->project->save('shop-b'); + $key = $this->resolver->resolve(); + expect($key?->value())->toBe('fake-secret-b')->and($key?->selectionSource())->toBe('project'); + $key = $this->resolver->resolve('shop-a'); + expect($key?->merchant())->toBe('shop-a')->and($key?->source())->toBe('config file') + ->and($key?->selectionSource())->toBe('explicit'); +}); - expect($key?->value())->toBe('environment-key') - ->and($key?->source())->toBe('environment'); +it('bypasses malformed projects for an explicit selection', function (): void { + $this->store->add('shop-a', 'fake-secret-a'); + file_put_contents($this->tempDirectory.'/.quickpay-cli.json', '{broken'); + expect($this->resolver->resolve('shop-a')?->value())->toBe('fake-secret-a'); + expect(fn () => $this->resolver->resolve())->toThrow(CredentialException::class); }); -it('falls back to a stored key and reports its source', function (): void { - file_put_contents($this->credentialPath, json_encode(['api_key' => 'stored-key'], JSON_THROW_ON_ERROR)); - putenv('QUICKPAY_API_KEY='); +it('rejects stale project and unknown explicit selections', function (): void { + $this->store->add('shop-a', 'fake-secret-a'); + $this->project->save('shop-b'); + expect(fn () => $this->resolver->resolve())->toThrow(CredentialException::class); + expect(fn () => $this->resolver->resolve('shop-b'))->toThrow(CredentialException::class); +}); - $key = (new ApiKeyResolver( - new EnvironmentVariables, - new CredentialFile($this->credentialPath), - ))->resolve(); +it('rejects environment and explicit selection together', function (): void { + putenv('QUICKPAY_API_KEY=fake-environment'); + file_put_contents($this->store->path(), '{malformed'); + expect(fn () => $this->resolver->resolve('shop-a'))->toThrow(CredentialException::class, 'QUICKPAY_API_KEY'); +}); + +it('rejects environment and project selection together', function (): void { + putenv('QUICKPAY_API_KEY=fake-environment'); + $this->project->save('shop-a'); + expect(fn () => $this->resolver->resolve())->toThrow(CredentialException::class, 'QUICKPAY_API_KEY'); +}); - expect($key?->value())->toBe('stored-key') - ->and($key?->source())->toBe('config file'); +it('ignores an empty environment override', function (): void { + putenv('QUICKPAY_API_KEY='); + $this->store->add('shop-a', 'fake-secret-a'); + expect($this->resolver->resolve()?->source())->toBe('config file'); }); -it('returns null when neither credential source is present', function (): void { - expect((new ApiKeyResolver( - new EnvironmentVariables, - new CredentialFile($this->credentialPath), - ))->resolve())->toBeNull(); +it('returns no credential for an absent or null default', function (): void { + expect($this->resolver->resolve())->toBeNull(); + $this->store->add('shop-a', 'fake-secret-a'); + $this->store->add('shop-b', 'fake-secret-b'); + $this->store->remove('shop-a'); + expect($this->resolver->resolve())->toBeNull(); }); diff --git a/tests/Unit/Credentials/MerchantConfigurationJsonTest.php b/tests/Unit/Credentials/MerchantConfigurationJsonTest.php new file mode 100644 index 0000000..5c8b18a --- /dev/null +++ b/tests/Unit/Credentials/MerchantConfigurationJsonTest.php @@ -0,0 +1,17 @@ +left->id)->toBe(1)->and($decoded->right->id)->toBe(2) + ->and($decoded->text)->toBe('{"id":1,"id":2}'); +}); + +it('rejects equivalent duplicate properties within an object', function (string $json): void { + expect(fn () => MerchantConfigurationJson::decode($json))->toThrow(JsonException::class); +})->with([ + 'plain' => '{"merchant":"shop-a","merchant":"shop-b"}', + 'escaped' => '{"merchant":"shop-a","merch\\u0061nt":"shop-b"}', + 'nested in array' => '[{"id":1,"id":2}]', +]); diff --git a/tests/Unit/Credentials/MerchantNameTest.php b/tests/Unit/Credentials/MerchantNameTest.php new file mode 100644 index 0000000..78d350d --- /dev/null +++ b/tests/Unit/Credentials/MerchantNameTest.php @@ -0,0 +1,17 @@ +toBe($name); +})->with(['one letter' => 'a', 'maximum' => str_repeat('a', 64), 'shop' => 'shop-a1']); + +it('rejects unsafe merchant names without echoing input', function (string $name): void { + expect(fn () => MerchantName::validate($name))->toThrow(CredentialException::class); +})->with([ + 'empty' => '', 'too long' => str_repeat('a', 65), 'uppercase' => 'Shop-a', + 'numeric' => '12345', 'leading hyphen' => '-shop', 'trailing hyphen' => 'shop-', + 'double hyphen' => 'shop--a', 'traversal' => '../shop', 'slash' => 'shop/a', + 'backslash' => 'shop\\a', 'space' => 'shop a', 'newline' => "shop\na", 'escape' => "shop\x1ba", +]);