From 54a17861ffe1f3cf2654e6291b66281119c5aa99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20J=C3=B8rgensen?= Date: Wed, 30 Sep 2026 20:17:52 +0200 Subject: [PATCH 1/6] feat: add named merchant storage and project bindings --- app/Credentials/MerchantName.php | 17 ++ app/Credentials/MerchantStore.php | 183 +++++++++++++++ app/Credentials/ProjectMerchantFile.php | 82 +++++++ .../Credentials/MerchantStoreTest.php | 220 ++++++++++++++++++ .../Credentials/ProjectMerchantFileTest.php | 53 +++++ tests/Unit/Credentials/MerchantNameTest.php | 17 ++ 6 files changed, 572 insertions(+) create mode 100644 app/Credentials/MerchantName.php create mode 100644 app/Credentials/MerchantStore.php create mode 100644 app/Credentials/ProjectMerchantFile.php create mode 100644 tests/Integration/Credentials/MerchantStoreTest.php create mode 100644 tests/Integration/Credentials/ProjectMerchantFileTest.php create mode 100644 tests/Unit/Credentials/MerchantNameTest.php 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..057d693 --- /dev/null +++ b/app/Credentials/MerchantStore.php @@ -0,0 +1,183 @@ +} */ +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 = json_decode($contents, flags: JSON_THROW_ON_ERROR); + } 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.'); + } + $this->validateLabel((string) $name, $merchant->api_key); + $merchants[$name] = ['api_key' => $merchant->api_key]; + } + + 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]; + + 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 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..30aad01 --- /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 = json_decode($contents, flags: JSON_THROW_ON_ERROR); + } 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/tests/Integration/Credentials/MerchantStoreTest.php b/tests/Integration/Credentials/MerchantStoreTest.php new file mode 100644 index 0000000..e6d19a7 --- /dev/null +++ b/tests/Integration/Credentials/MerchantStoreTest.php @@ -0,0 +1,220 @@ +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('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"}}}', +]); + +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..d848b10 --- /dev/null +++ b/tests/Integration/Credentials/ProjectMerchantFileTest.php @@ -0,0 +1,53 @@ +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"}', +]); + +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/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", +]); From 1ad9ec513a8ad38b1cf581f84d3ef74fd00d4d6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20J=C3=B8rgensen?= Date: Wed, 30 Sep 2026 20:40:45 +0200 Subject: [PATCH 2/6] feat: replace single-key authentication with merchant selection --- AGENTS.md | 14 +- README.md | 56 +++- app/Commands/Authentication/AuthCommand.php | 94 ------ app/Commands/Authentication/LoginCommand.php | 60 ---- app/Commands/Authentication/LogoutCommand.php | 36 --- .../Merchants/ActiveMerchantCommand.php | 69 +++++ app/Commands/Merchants/AddMerchantCommand.php | 53 ++++ .../Merchants/ListMerchantsCommand.php | 36 +++ .../Merchants/RemoveMerchantCommand.php | 30 ++ .../Merchants/SelectMerchantCommand.php | 65 ++++ app/Console/AuthenticatedCommand.php | 12 +- app/Credentials/ApiKey.php | 12 + app/Credentials/ApiKeyResolver.php | 32 +- app/Credentials/CredentialFile.php | 111 ------- app/Providers/AppServiceProvider.php | 8 +- app/Quickpay/AuthenticatedQuickpayFactory.php | 4 +- app/Quickpay/MerchantAuthenticationCheck.php | 24 ++ tests/Feature/ApplicationIdentityTest.php | 8 +- tests/Feature/CommandHelpTest.php | 7 + .../Commands/Api/ApiRequestCommandTest.php | 8 + .../AuthenticationCommandsTest.php | 291 ------------------ .../Callbacks/ReplayCallbackCommandTest.php | 8 + .../Callbacks/WatchCallbacksCommandTest.php | 8 + .../Merchants/MerchantCommandsTest.php | 283 +++++++++++++++++ .../Payments/CreatePaymentCommandTest.php | 8 + .../Payments/CreatePaymentLinkCommandTest.php | 8 + .../Payments/GetPaymentCommandTest.php | 8 + .../Payments/ListPaymentsCommandTest.php | 20 +- .../Payments/PaymentMutationCommandsTest.php | 8 + .../AuthenticatedQuickpayFactoryTest.php | 34 ++ .../MerchantAuthenticationCheckTest.php | 13 + .../Credentials/CredentialFileTest.php | 70 ----- tests/Unit/Credentials/ApiKeyResolverTest.php | 94 ++++-- 33 files changed, 870 insertions(+), 722 deletions(-) delete mode 100644 app/Commands/Authentication/AuthCommand.php delete mode 100644 app/Commands/Authentication/LoginCommand.php delete mode 100644 app/Commands/Authentication/LogoutCommand.php create mode 100644 app/Commands/Merchants/ActiveMerchantCommand.php create mode 100644 app/Commands/Merchants/AddMerchantCommand.php create mode 100644 app/Commands/Merchants/ListMerchantsCommand.php create mode 100644 app/Commands/Merchants/RemoveMerchantCommand.php create mode 100644 app/Commands/Merchants/SelectMerchantCommand.php delete mode 100644 app/Credentials/CredentialFile.php create mode 100644 app/Quickpay/MerchantAuthenticationCheck.php delete mode 100644 tests/Feature/Commands/Authentication/AuthenticationCommandsTest.php create mode 100644 tests/Feature/Commands/Merchants/MerchantCommandsTest.php create mode 100644 tests/Feature/Quickpay/AuthenticatedQuickpayFactoryTest.php create mode 100644 tests/Feature/Quickpay/MerchantAuthenticationCheckTest.php delete mode 100644 tests/Integration/Credentials/CredentialFileTest.php diff --git a/AGENTS.md b/AGENTS.md index bb68ebc..8c80f89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ 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. @@ -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/README.md b/README.md index ecbc576..7066881 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,20 @@ Install the bundled skill for coding agents: skills add tehwave/quickpay-cli ``` -Authenticate with a Quickpay merchant API key: +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 +62,49 @@ 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. + +`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 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/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/Console/AuthenticatedCommand.php b/app/Console/AuthenticatedCommand.php index 2df5719..2cf2868 100644 --- a/app/Console/AuthenticatedCommand.php +++ b/app/Console/AuthenticatedCommand.php @@ -10,21 +10,29 @@ use App\Quickpay\QuickpayResponse; use Closure; use InvalidArgumentException; +use Symfony\Component\Console\Input\InputOption; 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 { 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/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/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..5e46131 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,8 @@ ->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']); 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..44ca0e0 --- /dev/null +++ b/tests/Feature/Commands/Merchants/MerchantCommandsTest.php @@ -0,0 +1,283 @@ + $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(); +}); 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/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/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(); }); From c30fd2ca593baca6169813ebdfb026f706f972ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20J=C3=B8rgensen?= Date: Wed, 30 Sep 2026 20:55:19 +0200 Subject: [PATCH 3/6] feat: identify selected merchants in payment and callback output --- README.md | 10 + SECURITY.md | 19 +- app/Commands/Api/ApiRequestCommand.php | 1 + .../Callbacks/WatchCallbacksCommand.php | 1 + .../AbstractPaymentMutationCommand.php | 12 +- app/Console/AuthenticatedCommand.php | 14 ++ .../Console/AuthenticatedCommandTest.php | 193 ++++++++++++++++++ 7 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/Console/AuthenticatedCommandTest.php diff --git a/README.md b/README.md index 7066881..2c7a569 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,12 @@ 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. @@ -119,6 +125,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/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/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 2cf2868..edfa01f 100644 --- a/app/Console/AuthenticatedCommand.php +++ b/app/Console/AuthenticatedCommand.php @@ -11,6 +11,7 @@ use Closure; use InvalidArgumentException; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; use UnexpectedValueException; abstract class AuthenticatedCommand extends BaseCommand @@ -46,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/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]); From 0ef1a4eb589e4deda1da151fdad1ca8f5c939377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20J=C3=B8rgensen?= Date: Wed, 30 Sep 2026 21:10:59 +0200 Subject: [PATCH 4/6] docs: rename the bundled skill to quickpay-cli --- AGENTS.md | 4 +- CONTRIBUTING.md | 2 +- README.md | 6 +- skills/{quickpay => quickpay-cli}/SKILL.md | 78 ++++++++++++++++------ tests/Architecture/SkillContractTest.php | 24 +++++++ tests/Feature/CommandHelpTest.php | 9 +++ 6 files changed, 99 insertions(+), 24 deletions(-) rename skills/{quickpay => quickpay-cli}/SKILL.md (58%) create mode 100644 tests/Architecture/SkillContractTest.php diff --git a/AGENTS.md b/AGENTS.md index 8c80f89..2ce2f04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ 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 @@ -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. 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 2c7a569..b90ac09 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,16 @@ 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 ``` +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 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/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/CommandHelpTest.php b/tests/Feature/CommandHelpTest.php index 5e46131..2b28eed 100644 --- a/tests/Feature/CommandHelpTest.php +++ b/tests/Feature/CommandHelpTest.php @@ -30,3 +30,12 @@ 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); + } +}); From 271ebaa94e587837f14c1676679b3aafbd729e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20J=C3=B8rgensen?= Date: Wed, 30 Sep 2026 22:00:11 +0200 Subject: [PATCH 5/6] fix: reject ambiguous merchant configuration and secret labels --- app/Credentials/MerchantConfigurationJson.php | 35 +++++++++++++++++++ app/Credentials/MerchantStore.php | 16 +++++++-- app/Credentials/ProjectMerchantFile.php | 2 +- .../Merchants/MerchantCommandsTest.php | 23 ++++++++++++ .../Credentials/MerchantStoreTest.php | 20 +++++++++++ .../Credentials/ProjectMerchantFileTest.php | 2 ++ .../MerchantConfigurationJsonTest.php | 17 +++++++++ 7 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 app/Credentials/MerchantConfigurationJson.php create mode 100644 tests/Unit/Credentials/MerchantConfigurationJsonTest.php 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/MerchantStore.php b/app/Credentials/MerchantStore.php index 057d693..99b8922 100644 --- a/app/Credentials/MerchantStore.php +++ b/app/Credentials/MerchantStore.php @@ -36,7 +36,7 @@ public function read(): array } try { - $config = json_decode($contents, flags: JSON_THROW_ON_ERROR); + $config = MerchantConfigurationJson::decode($contents); } catch (JsonException) { throw new CredentialException('Quickpay CLI merchant configuration is invalid JSON.'); } @@ -64,9 +64,9 @@ public function read(): array || trim($merchant->api_key) === '') { throw new CredentialException('Quickpay CLI merchant configuration contains an invalid credential.'); } - $this->validateLabel((string) $name, $merchant->api_key); $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.'); @@ -92,6 +92,7 @@ public function add(string $name, string $apiKey, bool $replace = false): void $config['default_merchant'] = $name; } $config['merchants'][$name] = ['api_key' => $apiKey]; + $this->validateLabels($config['merchants']); return $config; }); @@ -129,6 +130,17 @@ private function validateLabel(string $name, string $apiKey): void } } + /** @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 { diff --git a/app/Credentials/ProjectMerchantFile.php b/app/Credentials/ProjectMerchantFile.php index 30aad01..d30fdeb 100644 --- a/app/Credentials/ProjectMerchantFile.php +++ b/app/Credentials/ProjectMerchantFile.php @@ -69,7 +69,7 @@ private function read(string $path): string throw new CredentialException('Project merchant binding cannot be read.'); } try { - $config = json_decode($contents, flags: JSON_THROW_ON_ERROR); + $config = MerchantConfigurationJson::decode($contents); } catch (JsonException) { throw new CredentialException('Project merchant binding is invalid JSON.'); } diff --git a/tests/Feature/Commands/Merchants/MerchantCommandsTest.php b/tests/Feature/Commands/Merchants/MerchantCommandsTest.php index 44ca0e0..64a5933 100644 --- a/tests/Feature/Commands/Merchants/MerchantCommandsTest.php +++ b/tests/Feature/Commands/Merchants/MerchantCommandsTest.php @@ -281,3 +281,26 @@ public function isTty(): bool 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/Integration/Credentials/MerchantStoreTest.php b/tests/Integration/Credentials/MerchantStoreTest.php index e6d19a7..f1ce816 100644 --- a/tests/Integration/Credentials/MerchantStoreTest.php +++ b/tests/Integration/Credentials/MerchantStoreTest.php @@ -58,6 +58,15 @@ ]); }); +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'); @@ -89,8 +98,19 @@ '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); diff --git a/tests/Integration/Credentials/ProjectMerchantFileTest.php b/tests/Integration/Credentials/ProjectMerchantFileTest.php index d848b10..7f80e40 100644 --- a/tests/Integration/Credentials/ProjectMerchantFileTest.php +++ b/tests/Integration/Credentials/ProjectMerchantFileTest.php @@ -41,6 +41,8 @@ '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 { 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}]', +]); From fa0233cf371ec1d69958fd1e5ce5184751d9524e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20J=C3=B8rgensen?= Date: Thu, 1 Oct 2026 17:12:22 +0200 Subject: [PATCH 6/6] Test bundled filesystem path validation Extend the distribution contract coverage for the packaged binary by asserting that the bundled Flysystem rejects malformed paths while still normalizing valid paths as expected. This also captures the related Flysystem lockfile update that the new assertions depend on. --- composer.lock | 12 ++++----- .../Architecture/DistributionContractTest.php | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) 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/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'], +]);