From 9d9b9874861fb6092fbe008ed961f36207512dbd Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 7 Oct 2026 09:23:14 +0200 Subject: [PATCH 1/8] feat: add app:apikeys:add command to generate API keys --- README.md | 9 ++ src/Command/ApiKeysAddCommand.php | 123 ++++++++++++++++++++++++ tests/Command/ApiKeysAddCommandTest.php | 107 +++++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 src/Command/ApiKeysAddCommand.php create mode 100644 tests/Command/ApiKeysAddCommandTest.php diff --git a/README.md b/README.md index 409440e..df0aa90 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,15 @@ APP_API_KEYS='[ ]' ``` +To generate a new API key for a user and add it to `.env.local`, run: + +``` shell +docker compose exec phpfpm bin/console app:apikeys:add user_3 +``` + +The key is printed so it can be sent to the user. If `.env.local.php` exists, `composer dump-env prod` must be run for +the new key to take effect. + ## Production When installing composer and Symfony based application in production, you should not install development packages, diff --git a/src/Command/ApiKeysAddCommand.php b/src/Command/ApiKeysAddCommand.php new file mode 100644 index 0000000..8139ac2 --- /dev/null +++ b/src/Command/ApiKeysAddCommand.php @@ -0,0 +1,123 @@ +addArgument('username', InputArgument::REQUIRED, 'Username to create an API key for'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $username = trim((string) $input->getArgument('username')); + $envFile = $this->projectDir.'/.env.local'; + + if ('' === $username) { + $io->error('Username must not be empty.'); + + return Command::FAILURE; + } + + $original = file_exists($envFile) ? (string) file_get_contents($envFile) : ''; + + try { + $apikeys = $this->parseApiKeys($original); + } catch (FormatException|\JsonException $e) { + $io->error(sprintf('Could not read %s from %s: %s', self::ENV_VAR, $envFile, $e->getMessage())); + + return Command::FAILURE; + } + + if (in_array($username, array_column($apikeys, 'username'), true)) { + $io->error(sprintf('User "%s" already has an API key.', $username)); + + return Command::FAILURE; + } + + $apikey = bin2hex(random_bytes(32)); + $updated = [...$apikeys, ['username' => $username, 'apikey' => $apikey]]; + + // JSON_HEX_APOS keeps the value safe inside single quotes. + $line = self::ENV_VAR."='".json_encode($updated, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_HEX_APOS)."'"; + if (preg_match(self::ENV_LINE_PATTERN, $original)) { + $contents = (string) preg_replace_callback(self::ENV_LINE_PATTERN, fn () => $line, $original, 1); + } else { + $contents = $original.('' === $original || str_ends_with($original, "\n") ? '' : "\n").$line."\n"; + } + + file_put_contents($envFile, $contents); + + // Never leave .env.local in a state where existing keys are lost. + try { + $written = $this->parseApiKeys($contents); + } catch (FormatException|\JsonException) { + $written = null; + } + + if ($written !== $updated) { + file_put_contents($envFile, $original); + $io->error(sprintf('Failed to update %s, the file has been restored.', $envFile)); + + return Command::FAILURE; + } + + $io->success(sprintf('API key for "%s" added to %s', $username, $envFile)); + $io->writeln($apikey); + + $compiledFile = $this->projectDir.'/.env.local.php'; + if (file_exists($compiledFile)) { + $io->warning(sprintf('%s exists and takes precedence over .env.local. Run "composer dump-env prod" for the new key to take effect.', $compiledFile)); + + $compiled = (include $compiledFile)[self::ENV_VAR] ?? '[]'; + if (json_decode((string) $compiled, true) !== $apikeys) { + $io->warning(sprintf('%s in %s differs from .env.local. Reconcile them before running "composer dump-env prod", or keys may be lost.', self::ENV_VAR, $compiledFile)); + } + } + + return Command::SUCCESS; + } + + /** + * @return list + * + * @throws FormatException + * @throws \JsonException + */ + private function parseApiKeys(string $contents): array + { + $value = (new Dotenv())->parse($contents)[self::ENV_VAR] ?? '[]'; + + return json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Command/ApiKeysAddCommandTest.php b/tests/Command/ApiKeysAddCommandTest.php new file mode 100644 index 0000000..320d5c5 --- /dev/null +++ b/tests/Command/ApiKeysAddCommandTest.php @@ -0,0 +1,107 @@ +dir = sys_get_temp_dir().'/apikeys-'.bin2hex(random_bytes(4)); + mkdir($this->dir); + } + + protected function tearDown(): void + { + array_map('unlink', (array) glob($this->dir.'/{,.}*.*', GLOB_BRACE)); + rmdir($this->dir); + } + + public function testCreatesEnvLocalWhenMissing(): void + { + $tester = $this->runCommand('user_1'); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + $keys = $this->keys(); + self::assertCount(1, $keys); + self::assertSame('user_1', $keys[0]['username']); + self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $keys[0]['apikey']); + self::assertStringContainsString($keys[0]['apikey'], $tester->getDisplay()); + } + + public function testReplacesMultiLineValueAndKeepsOtherLines(): void + { + file_put_contents($this->dir.'/.env.local', <<<'ENV' + INDEX_URL=http://elasticsearch:9200 + APP_API_KEYS='[ + {"username": "user_1", "apikey": "api_key_1"} + ]' + OTHER=value + ENV); + + self::assertSame(Command::SUCCESS, $this->runCommand("o'brien")->getStatusCode()); + + $env = (new Dotenv())->parse((string) file_get_contents($this->dir.'/.env.local')); + self::assertSame('http://elasticsearch:9200', $env['INDEX_URL']); + self::assertSame('value', $env['OTHER']); + $keys = $this->keys(); + self::assertSame(['username' => 'user_1', 'apikey' => 'api_key_1'], $keys[0]); + self::assertSame("o'brien", $keys[1]['username']); + } + + public function testRejectsDuplicateUsername(): void + { + $original = "APP_API_KEYS='[{\"username\": \"user_1\", \"apikey\": \"api_key_1\"}]'\n"; + file_put_contents($this->dir.'/.env.local', $original); + + self::assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); + self::assertSame($original, file_get_contents($this->dir.'/.env.local')); + } + + public function testRestoresFileWhenRewriteBreaksIt(): void + { + // A trailing comment after a multi-line value defeats the line pattern. + $original = "APP_API_KEYS='[\n{\"username\": \"user_1\", \"apikey\": \"api_key_1\"}\n]' # comment\n"; + file_put_contents($this->dir.'/.env.local', $original); + + self::assertSame(Command::FAILURE, $this->runCommand('user_2')->getStatusCode()); + self::assertSame($original, file_get_contents($this->dir.'/.env.local')); + } + + public function testAdvisesDumpEnvWhenCompiledFileExists(): void + { + file_put_contents($this->dir.'/.env.local.php', " '[{\"username\":\"other\",\"apikey\":\"k\"}]'];\n"); + + $display = (string) preg_replace('/\s+/', ' ', $this->runCommand('user_1')->getDisplay()); + + self::assertStringContainsString('composer dump-env prod', $display); + self::assertStringContainsString('differs from .env.local', $display); + } + + private function runCommand(string $username): CommandTester + { + $tester = new CommandTester(new ApiKeysAddCommand($this->dir)); + $tester->execute(['username' => $username]); + + return $tester; + } + + /** + * @return list + */ + private function keys(): array + { + $env = (new Dotenv())->parse((string) file_get_contents($this->dir.'/.env.local')); + + return json_decode($env['APP_API_KEYS'], true, 512, JSON_THROW_ON_ERROR); + } +} From 904d6db732f3aefc807c9afb78620f63cf121a72 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 7 Oct 2026 09:23:39 +0200 Subject: [PATCH 2/8] docs: update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92e3ab8..0d99808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +- [PR-63](https://github.com/itk-dev/event-database-api/pull/63) + Add `app:apikeys:add` command to generate API keys + ## [1.2.2] - 2026-05-22 - [PR-26](https://github.com/itk-dev/event-database-api/pull/26) From 94d107fb3715076375557b64af25775f42c77837 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 7 Oct 2026 09:31:46 +0200 Subject: [PATCH 3/8] feat: prompt for username when omitted in app:apikeys:add --- src/Command/ApiKeysAddCommand.php | 24 +++++++++++++++++++++++- tests/Command/ApiKeysAddCommandTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/Command/ApiKeysAddCommand.php b/src/Command/ApiKeysAddCommand.php index 8139ac2..1e12f76 100644 --- a/src/Command/ApiKeysAddCommand.php +++ b/src/Command/ApiKeysAddCommand.php @@ -6,9 +6,11 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Dotenv\Dotenv; @@ -34,7 +36,27 @@ public function __construct( protected function configure(): void { - $this->addArgument('username', InputArgument::REQUIRED, 'Username to create an API key for'); + $this->addArgument('username', InputArgument::OPTIONAL, 'Username to create an API key for (prompted for if omitted)'); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + if ('' !== trim((string) $input->getArgument('username'))) { + return; + } + + $question = new Question('Username to create an API key for: '); + $question->setValidator(function (?string $answer): string { + if ('' === trim((string) $answer)) { + throw new \RuntimeException('Username must not be empty.'); + } + + return trim((string) $answer); + }); + + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $input->setArgument('username', $helper->ask($input, $output, $question)); } protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/tests/Command/ApiKeysAddCommandTest.php b/tests/Command/ApiKeysAddCommandTest.php index 320d5c5..09b8cba 100644 --- a/tests/Command/ApiKeysAddCommandTest.php +++ b/tests/Command/ApiKeysAddCommandTest.php @@ -7,6 +7,8 @@ use App\Command\ApiKeysAddCommand; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Dotenv\Dotenv; @@ -58,6 +60,28 @@ public function testReplacesMultiLineValueAndKeepsOtherLines(): void self::assertSame("o'brien", $keys[1]['username']); } + public function testPromptsForUsernameWhenOmitted(): void + { + $command = new ApiKeysAddCommand($this->dir); + $command->setHelperSet(new HelperSet([new QuestionHelper()])); + $tester = new CommandTester($command); + $tester->setInputs(['', 'user_1']); + $tester->execute([]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertStringContainsString('Username must not be empty.', $tester->getDisplay()); + self::assertSame('user_1', $this->keys()[0]['username']); + } + + public function testFailsWithoutUsernameWhenNonInteractive(): void + { + $tester = new CommandTester(new ApiKeysAddCommand($this->dir)); + $tester->execute([], ['interactive' => false]); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertFileDoesNotExist($this->dir.'/.env.local'); + } + public function testRejectsDuplicateUsername(): void { $original = "APP_API_KEYS='[{\"username\": \"user_1\", \"apikey\": \"api_key_1\"}]'\n"; From d1c8ad91a482a434e3736317fb6a5709d43b6d74 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 7 Oct 2026 09:31:59 +0200 Subject: [PATCH 4/8] docs: mention username prompt in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index df0aa90..c7d0eff 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ To generate a new API key for a user and add it to `.env.local`, run: docker compose exec phpfpm bin/console app:apikeys:add user_3 ``` -The key is printed so it can be sent to the user. If `.env.local.php` exists, `composer dump-env prod` must be run for +If the username is omitted, the command prompts for it. The key is printed so it can be sent to the user. If `.env.local.php` exists, `composer dump-env prod` must be run for the new key to take effect. ## Production From e6f343e8a70376bae49b84bc202b4872171bae57 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 7 Oct 2026 09:32:14 +0200 Subject: [PATCH 5/8] docs: wrap README line --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c7d0eff..e107c57 100644 --- a/README.md +++ b/README.md @@ -104,8 +104,8 @@ To generate a new API key for a user and add it to `.env.local`, run: docker compose exec phpfpm bin/console app:apikeys:add user_3 ``` -If the username is omitted, the command prompts for it. The key is printed so it can be sent to the user. If `.env.local.php` exists, `composer dump-env prod` must be run for -the new key to take effect. +If the username is omitted, the command prompts for it. The key is printed so it can be sent to the user. If +`.env.local.php` exists, `composer dump-env prod` must be run for the new key to take effect. ## Production From 75291002196bd6b1c7d4892812cbf5738cb83ce2 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 7 Oct 2026 09:50:15 +0200 Subject: [PATCH 6/8] feat: write .env.local via verified temp file with backup --- .gitignore | 4 ++ README.md | 3 ++ src/Command/ApiKeysAddCommand.php | 58 +++++++++++++++++++++--- tests/Command/ApiKeysAddCommandTest.php | 59 +++++++++++++++++++++++-- 4 files changed, 114 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 0f3aac4..c8a8aed 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ /vendor/ ###< symfony/framework-bundle ### +# Written by app:apikeys:add +/.env.local.temp +/.env.local.backup + ###> friendsofphp/php-cs-fixer ### /.php-cs-fixer.php /.php-cs-fixer.cache diff --git a/README.md b/README.md index e107c57..294ee9b 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,9 @@ docker compose exec phpfpm bin/console app:apikeys:add user_3 If the username is omitted, the command prompts for it. The key is printed so it can be sent to the user. If `.env.local.php` exists, `composer dump-env prod` must be run for the new key to take effect. +The command writes `.env.local.temp`, verifies it, copies `.env.local` to `.env.local.backup` and then replaces +`.env.local`. It offers to delete the backup afterwards and refuses to run while a backup or temp file exists. + ## Production When installing composer and Symfony based application in production, you should not install development packages, diff --git a/src/Command/ApiKeysAddCommand.php b/src/Command/ApiKeysAddCommand.php index 1e12f76..8016cff 100644 --- a/src/Command/ApiKeysAddCommand.php +++ b/src/Command/ApiKeysAddCommand.php @@ -64,6 +64,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new SymfonyStyle($input, $output); $username = trim((string) $input->getArgument('username')); $envFile = $this->projectDir.'/.env.local'; + $tempFile = $envFile.'.temp'; + $backupFile = $envFile.'.backup'; if ('' === $username) { $io->error('Username must not be empty.'); @@ -71,6 +73,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } + if (is_link($envFile)) { + $io->error(sprintf('%s is a symlink. Edit the target file by hand instead.', $envFile)); + + return Command::FAILURE; + } + + if (file_exists($backupFile) || is_link($backupFile)) { + $io->error(sprintf('%s exists from a previous run. Check it and delete it, then retry.', $backupFile)); + + return Command::FAILURE; + } + $original = file_exists($envFile) ? (string) file_get_contents($envFile) : ''; try { @@ -98,18 +112,44 @@ protected function execute(InputInterface $input, OutputInterface $output): int $contents = $original.('' === $original || str_ends_with($original, "\n") ? '' : "\n").$line."\n"; } - file_put_contents($envFile, $contents); + // Mode 'x' fails if the file exists, which also stops concurrent runs. + $handle = @fopen($tempFile, 'x'); + if (false === $handle) { + $io->error(sprintf('Could not create %s. If it is left over from a previous run, delete it and retry.', $tempFile)); + + return Command::FAILURE; + } + $written = fwrite($handle, $contents) === strlen($contents); + $written = fclose($handle) && $written; - // Never leave .env.local in a state where existing keys are lost. + // Verify what landed on disk before .env.local is touched. try { - $written = $this->parseApiKeys($contents); + $written = $written && $this->parseApiKeys((string) file_get_contents($tempFile)) === $updated; } catch (FormatException|\JsonException) { - $written = null; + $written = false; } - if ($written !== $updated) { - file_put_contents($envFile, $original); - $io->error(sprintf('Failed to update %s, the file has been restored.', $envFile)); + if (!$written) { + unlink($tempFile); + $io->error(sprintf('Failed to write %s, %s is unchanged.', $tempFile, $envFile)); + + return Command::FAILURE; + } + + $hasBackup = file_exists($envFile); + if ($hasBackup && !(chmod($tempFile, fileperms($envFile) & 0777) && copy($envFile, $backupFile))) { + @unlink($tempFile); + @unlink($backupFile); + $io->error(sprintf('Failed to back up %s, the file is unchanged.', $envFile)); + + return Command::FAILURE; + } + + // Atomic replace, .env.local never goes missing. + if (!rename($tempFile, $envFile)) { + @unlink($tempFile); + @unlink($backupFile); + $io->error(sprintf('Failed to replace %s, the file is unchanged.', $envFile)); return Command::FAILURE; } @@ -127,6 +167,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } + if ($hasBackup && $io->confirm(sprintf('Delete %s?', $backupFile), true)) { + unlink($backupFile); + } + return Command::SUCCESS; } diff --git a/tests/Command/ApiKeysAddCommandTest.php b/tests/Command/ApiKeysAddCommandTest.php index 09b8cba..d4137e2 100644 --- a/tests/Command/ApiKeysAddCommandTest.php +++ b/tests/Command/ApiKeysAddCommandTest.php @@ -49,8 +49,12 @@ public function testReplacesMultiLineValueAndKeepsOtherLines(): void ]' OTHER=value ENV); + chmod($this->dir.'/.env.local', 0600); self::assertSame(Command::SUCCESS, $this->runCommand("o'brien")->getStatusCode()); + self::assertSame(0600, fileperms($this->dir.'/.env.local') & 0777); + self::assertFileDoesNotExist($this->dir.'/.env.local.backup'); + self::assertFileDoesNotExist($this->dir.'/.env.local.temp'); $env = (new Dotenv())->parse((string) file_get_contents($this->dir.'/.env.local')); self::assertSame('http://elasticsearch:9200', $env['INDEX_URL']); @@ -91,7 +95,7 @@ public function testRejectsDuplicateUsername(): void self::assertSame($original, file_get_contents($this->dir.'/.env.local')); } - public function testRestoresFileWhenRewriteBreaksIt(): void + public function testLeavesFileUntouchedWhenRewriteBreaksIt(): void { // A trailing comment after a multi-line value defeats the line pattern. $original = "APP_API_KEYS='[\n{\"username\": \"user_1\", \"apikey\": \"api_key_1\"}\n]' # comment\n"; @@ -99,6 +103,51 @@ public function testRestoresFileWhenRewriteBreaksIt(): void self::assertSame(Command::FAILURE, $this->runCommand('user_2')->getStatusCode()); self::assertSame($original, file_get_contents($this->dir.'/.env.local')); + self::assertFileDoesNotExist($this->dir.'/.env.local.temp'); + self::assertFileDoesNotExist($this->dir.'/.env.local.backup'); + } + + public function testKeepsBackupWhenDeclined(): void + { + $original = "APP_API_KEYS='[]'\n"; + file_put_contents($this->dir.'/.env.local', $original); + + self::assertSame(Command::SUCCESS, $this->runCommand('user_1', ['no'])->getStatusCode()); + self::assertSame($original, file_get_contents($this->dir.'/.env.local.backup')); + self::assertSame('user_1', $this->keys()[0]['username']); + } + + public function testFailsWhenBackupExists(): void + { + $original = "APP_API_KEYS='[]'\n"; + file_put_contents($this->dir.'/.env.local', $original); + file_put_contents($this->dir.'/.env.local.backup', 'old'); + + self::assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); + self::assertSame($original, file_get_contents($this->dir.'/.env.local')); + self::assertSame('old', file_get_contents($this->dir.'/.env.local.backup')); + } + + public function testFailsWhenTempFileExists(): void + { + $original = "APP_API_KEYS='[]'\n"; + file_put_contents($this->dir.'/.env.local', $original); + file_put_contents($this->dir.'/.env.local.temp', 'other run'); + + self::assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); + self::assertSame($original, file_get_contents($this->dir.'/.env.local')); + self::assertSame('other run', file_get_contents($this->dir.'/.env.local.temp')); + } + + public function testFailsWhenEnvLocalIsSymlink(): void + { + $original = "APP_API_KEYS='[]'\n"; + file_put_contents($this->dir.'/target.env', $original); + symlink($this->dir.'/target.env', $this->dir.'/.env.local'); + + self::assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); + self::assertTrue(is_link($this->dir.'/.env.local')); + self::assertSame($original, file_get_contents($this->dir.'/target.env')); } public function testAdvisesDumpEnvWhenCompiledFileExists(): void @@ -111,10 +160,14 @@ public function testAdvisesDumpEnvWhenCompiledFileExists(): void self::assertStringContainsString('differs from .env.local', $display); } - private function runCommand(string $username): CommandTester + /** + * @param list $answers runs non-interactively (default answers) when empty + */ + private function runCommand(string $username, array $answers = []): CommandTester { $tester = new CommandTester(new ApiKeysAddCommand($this->dir)); - $tester->execute(['username' => $username]); + $tester->setInputs($answers); + $tester->execute(['username' => $username], ['interactive' => [] !== $answers]); return $tester; } From 5e1f9674400b0ec6a9e7735d9df5f28df0b4db3c Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 7 Oct 2026 10:12:14 +0200 Subject: [PATCH 7/8] docs: changelog for 1.2.3 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d99808..3b4b20d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ See [keep a changelog] for information about writing changes to this log. ## [Unreleased] +## [1.2.3] - 2026-10-07 + - [PR-63](https://github.com/itk-dev/event-database-api/pull/63) Add `app:apikeys:add` command to generate API keys From e2c915ab290876092c100c1fe5b94ba2a84c123a Mon Sep 17 00:00:00 2001 From: turegjorup Date: Wed, 7 Oct 2026 10:19:09 +0200 Subject: [PATCH 8/8] refactor: apply develop's Rector and PHPStan rules to apikeys command --- src/Command/ApiKeysAddCommand.php | 6 +- tests/Command/ApiKeysAddCommandTest.php | 82 ++++++++++++------------- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/src/Command/ApiKeysAddCommand.php b/src/Command/ApiKeysAddCommand.php index 8016cff..04d8262 100644 --- a/src/Command/ApiKeysAddCommand.php +++ b/src/Command/ApiKeysAddCommand.php @@ -106,8 +106,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int // JSON_HEX_APOS keeps the value safe inside single quotes. $line = self::ENV_VAR."='".json_encode($updated, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_HEX_APOS)."'"; - if (preg_match(self::ENV_LINE_PATTERN, $original)) { - $contents = (string) preg_replace_callback(self::ENV_LINE_PATTERN, fn () => $line, $original, 1); + if (1 === preg_match(self::ENV_LINE_PATTERN, $original)) { + $contents = (string) preg_replace_callback(self::ENV_LINE_PATTERN, fn (): string => $line, $original, 1); } else { $contents = $original.('' === $original || str_ends_with($original, "\n") ? '' : "\n").$line."\n"; } @@ -137,7 +137,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $hasBackup = file_exists($envFile); - if ($hasBackup && !(chmod($tempFile, fileperms($envFile) & 0777) && copy($envFile, $backupFile))) { + if ($hasBackup && (!chmod($tempFile, fileperms($envFile) & 0777) || !copy($envFile, $backupFile))) { @unlink($tempFile); @unlink($backupFile); $io->error(sprintf('Failed to back up %s, the file is unchanged.', $envFile)); diff --git a/tests/Command/ApiKeysAddCommandTest.php b/tests/Command/ApiKeysAddCommandTest.php index d4137e2..6f8bdec 100644 --- a/tests/Command/ApiKeysAddCommandTest.php +++ b/tests/Command/ApiKeysAddCommandTest.php @@ -12,7 +12,7 @@ use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Dotenv\Dotenv; -class ApiKeysAddCommandTest extends TestCase +final class ApiKeysAddCommandTest extends TestCase { private string $dir; @@ -24,7 +24,7 @@ protected function setUp(): void protected function tearDown(): void { - array_map('unlink', (array) glob($this->dir.'/{,.}*.*', GLOB_BRACE)); + array_map(unlink(...), (array) glob($this->dir.'/{,.}*.*', GLOB_BRACE)); rmdir($this->dir); } @@ -32,12 +32,12 @@ public function testCreatesEnvLocalWhenMissing(): void { $tester = $this->runCommand('user_1'); - self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); $keys = $this->keys(); - self::assertCount(1, $keys); - self::assertSame('user_1', $keys[0]['username']); - self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $keys[0]['apikey']); - self::assertStringContainsString($keys[0]['apikey'], $tester->getDisplay()); + $this->assertCount(1, $keys); + $this->assertSame('user_1', $keys[0]['username']); + $this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $keys[0]['apikey']); + $this->assertStringContainsString($keys[0]['apikey'], $tester->getDisplay()); } public function testReplacesMultiLineValueAndKeepsOtherLines(): void @@ -51,17 +51,17 @@ public function testReplacesMultiLineValueAndKeepsOtherLines(): void ENV); chmod($this->dir.'/.env.local', 0600); - self::assertSame(Command::SUCCESS, $this->runCommand("o'brien")->getStatusCode()); - self::assertSame(0600, fileperms($this->dir.'/.env.local') & 0777); - self::assertFileDoesNotExist($this->dir.'/.env.local.backup'); - self::assertFileDoesNotExist($this->dir.'/.env.local.temp'); + $this->assertSame(Command::SUCCESS, $this->runCommand("o'brien")->getStatusCode()); + $this->assertSame(0600, fileperms($this->dir.'/.env.local') & 0777); + $this->assertFileDoesNotExist($this->dir.'/.env.local.backup'); + $this->assertFileDoesNotExist($this->dir.'/.env.local.temp'); $env = (new Dotenv())->parse((string) file_get_contents($this->dir.'/.env.local')); - self::assertSame('http://elasticsearch:9200', $env['INDEX_URL']); - self::assertSame('value', $env['OTHER']); + $this->assertSame('http://elasticsearch:9200', $env['INDEX_URL']); + $this->assertSame('value', $env['OTHER']); $keys = $this->keys(); - self::assertSame(['username' => 'user_1', 'apikey' => 'api_key_1'], $keys[0]); - self::assertSame("o'brien", $keys[1]['username']); + $this->assertSame(['username' => 'user_1', 'apikey' => 'api_key_1'], $keys[0]); + $this->assertSame("o'brien", $keys[1]['username']); } public function testPromptsForUsernameWhenOmitted(): void @@ -72,9 +72,9 @@ public function testPromptsForUsernameWhenOmitted(): void $tester->setInputs(['', 'user_1']); $tester->execute([]); - self::assertSame(Command::SUCCESS, $tester->getStatusCode()); - self::assertStringContainsString('Username must not be empty.', $tester->getDisplay()); - self::assertSame('user_1', $this->keys()[0]['username']); + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertStringContainsString('Username must not be empty.', $tester->getDisplay()); + $this->assertSame('user_1', $this->keys()[0]['username']); } public function testFailsWithoutUsernameWhenNonInteractive(): void @@ -82,8 +82,8 @@ public function testFailsWithoutUsernameWhenNonInteractive(): void $tester = new CommandTester(new ApiKeysAddCommand($this->dir)); $tester->execute([], ['interactive' => false]); - self::assertSame(Command::FAILURE, $tester->getStatusCode()); - self::assertFileDoesNotExist($this->dir.'/.env.local'); + $this->assertSame(Command::FAILURE, $tester->getStatusCode()); + $this->assertFileDoesNotExist($this->dir.'/.env.local'); } public function testRejectsDuplicateUsername(): void @@ -91,8 +91,8 @@ public function testRejectsDuplicateUsername(): void $original = "APP_API_KEYS='[{\"username\": \"user_1\", \"apikey\": \"api_key_1\"}]'\n"; file_put_contents($this->dir.'/.env.local', $original); - self::assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); - self::assertSame($original, file_get_contents($this->dir.'/.env.local')); + $this->assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); + $this->assertSame($original, file_get_contents($this->dir.'/.env.local')); } public function testLeavesFileUntouchedWhenRewriteBreaksIt(): void @@ -101,10 +101,10 @@ public function testLeavesFileUntouchedWhenRewriteBreaksIt(): void $original = "APP_API_KEYS='[\n{\"username\": \"user_1\", \"apikey\": \"api_key_1\"}\n]' # comment\n"; file_put_contents($this->dir.'/.env.local', $original); - self::assertSame(Command::FAILURE, $this->runCommand('user_2')->getStatusCode()); - self::assertSame($original, file_get_contents($this->dir.'/.env.local')); - self::assertFileDoesNotExist($this->dir.'/.env.local.temp'); - self::assertFileDoesNotExist($this->dir.'/.env.local.backup'); + $this->assertSame(Command::FAILURE, $this->runCommand('user_2')->getStatusCode()); + $this->assertSame($original, file_get_contents($this->dir.'/.env.local')); + $this->assertFileDoesNotExist($this->dir.'/.env.local.temp'); + $this->assertFileDoesNotExist($this->dir.'/.env.local.backup'); } public function testKeepsBackupWhenDeclined(): void @@ -112,9 +112,9 @@ public function testKeepsBackupWhenDeclined(): void $original = "APP_API_KEYS='[]'\n"; file_put_contents($this->dir.'/.env.local', $original); - self::assertSame(Command::SUCCESS, $this->runCommand('user_1', ['no'])->getStatusCode()); - self::assertSame($original, file_get_contents($this->dir.'/.env.local.backup')); - self::assertSame('user_1', $this->keys()[0]['username']); + $this->assertSame(Command::SUCCESS, $this->runCommand('user_1', ['no'])->getStatusCode()); + $this->assertSame($original, file_get_contents($this->dir.'/.env.local.backup')); + $this->assertSame('user_1', $this->keys()[0]['username']); } public function testFailsWhenBackupExists(): void @@ -123,9 +123,9 @@ public function testFailsWhenBackupExists(): void file_put_contents($this->dir.'/.env.local', $original); file_put_contents($this->dir.'/.env.local.backup', 'old'); - self::assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); - self::assertSame($original, file_get_contents($this->dir.'/.env.local')); - self::assertSame('old', file_get_contents($this->dir.'/.env.local.backup')); + $this->assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); + $this->assertSame($original, file_get_contents($this->dir.'/.env.local')); + $this->assertSame('old', file_get_contents($this->dir.'/.env.local.backup')); } public function testFailsWhenTempFileExists(): void @@ -134,9 +134,9 @@ public function testFailsWhenTempFileExists(): void file_put_contents($this->dir.'/.env.local', $original); file_put_contents($this->dir.'/.env.local.temp', 'other run'); - self::assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); - self::assertSame($original, file_get_contents($this->dir.'/.env.local')); - self::assertSame('other run', file_get_contents($this->dir.'/.env.local.temp')); + $this->assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); + $this->assertSame($original, file_get_contents($this->dir.'/.env.local')); + $this->assertSame('other run', file_get_contents($this->dir.'/.env.local.temp')); } public function testFailsWhenEnvLocalIsSymlink(): void @@ -145,9 +145,9 @@ public function testFailsWhenEnvLocalIsSymlink(): void file_put_contents($this->dir.'/target.env', $original); symlink($this->dir.'/target.env', $this->dir.'/.env.local'); - self::assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); - self::assertTrue(is_link($this->dir.'/.env.local')); - self::assertSame($original, file_get_contents($this->dir.'/target.env')); + $this->assertSame(Command::FAILURE, $this->runCommand('user_1')->getStatusCode()); + $this->assertTrue(is_link($this->dir.'/.env.local')); + $this->assertSame($original, file_get_contents($this->dir.'/target.env')); } public function testAdvisesDumpEnvWhenCompiledFileExists(): void @@ -156,8 +156,8 @@ public function testAdvisesDumpEnvWhenCompiledFileExists(): void $display = (string) preg_replace('/\s+/', ' ', $this->runCommand('user_1')->getDisplay()); - self::assertStringContainsString('composer dump-env prod', $display); - self::assertStringContainsString('differs from .env.local', $display); + $this->assertStringContainsString('composer dump-env prod', $display); + $this->assertStringContainsString('differs from .env.local', $display); } /** @@ -179,6 +179,6 @@ private function keys(): array { $env = (new Dotenv())->parse((string) file_get_contents($this->dir.'/.env.local')); - return json_decode($env['APP_API_KEYS'], true, 512, JSON_THROW_ON_ERROR); + return json_decode((string) $env['APP_API_KEYS'], true, 512, JSON_THROW_ON_ERROR); } }