Skip to content

Commit 468632a

Browse files
committed
refactor: migrate key:generate command as modern command
1 parent f300ca0 commit 468632a

2 files changed

Lines changed: 178 additions & 86 deletions

File tree

system/Commands/Encryption/GenerateKey.php

Lines changed: 78 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -13,88 +13,108 @@
1313

1414
namespace CodeIgniter\Commands\Encryption;
1515

16-
use CodeIgniter\CLI\BaseCommand;
16+
use CodeIgniter\CLI\AbstractCommand;
17+
use CodeIgniter\CLI\Attributes\Command;
1718
use CodeIgniter\CLI\CLI;
19+
use CodeIgniter\CLI\Input\Option;
1820
use CodeIgniter\Config\DotEnv;
1921
use CodeIgniter\Encryption\Encryption;
2022
use Config\Paths;
2123

2224
/**
23-
* Generates a new encryption key.
25+
* Generates a new encryption key and writes it in an `.env` file.
2426
*/
25-
class GenerateKey extends BaseCommand
27+
#[Command(name: 'key:generate', description: 'Generates a new encryption key and writes it in an `.env` file.', group: 'Encryption')]
28+
class GenerateKey extends AbstractCommand
2629
{
2730
/**
28-
* The Command's group.
29-
*
30-
* @var string
31+
* @var list<string>
3132
*/
32-
protected $group = 'Encryption';
33+
private const VALID_PREFIXES = ['hex2bin', 'base64'];
3334

34-
/**
35-
* The Command's name.
36-
*
37-
* @var string
38-
*/
39-
protected $name = 'key:generate';
35+
protected function configure(): void
36+
{
37+
$this
38+
->addOption(new Option(
39+
name: 'force',
40+
shortcut: 'f',
41+
description: 'Force overwrite existing key in `.env` file.',
42+
))
43+
->addOption(new Option(
44+
name: 'length',
45+
description: 'The length of the random string that should be returned in bytes.',
46+
requiresValue: true,
47+
default: '32',
48+
))
49+
->addOption(new Option(
50+
name: 'prefix',
51+
description: 'Prefix to prepend to encoded key (either hex2bin or base64).',
52+
requiresValue: true,
53+
default: 'hex2bin',
54+
))
55+
->addOption(new Option(
56+
name: 'show',
57+
description: 'Shows the generated key in the terminal instead of storing in the `.env` file.',
58+
));
59+
}
4060

41-
/**
42-
* The Command's usage.
43-
*
44-
* @var string
45-
*/
46-
protected $usage = 'key:generate [options]';
61+
protected function interact(array &$arguments, array &$options): void
62+
{
63+
$prefix = $this->getUnboundOption('prefix', $options);
4764

48-
/**
49-
* The Command's short description.
50-
*
51-
* @var string
52-
*/
53-
protected $description = 'Generates a new encryption key and writes it in an `.env` file.';
65+
if (is_string($prefix) && ! in_array($prefix, self::VALID_PREFIXES, true)) {
66+
$options['prefix'] = CLI::prompt('Please provide a valid prefix to use.', self::VALID_PREFIXES, 'required');
67+
}
5468

55-
/**
56-
* The command's options
57-
*
58-
* @var array<string, string>
59-
*/
60-
protected $options = [
61-
'--force' => 'Force overwrite existing key in `.env` file.',
62-
'--length' => 'The length of the random string that should be returned in bytes. Defaults to 32.',
63-
'--prefix' => 'Prefix to prepend to encoded key (either hex2bin or base64). Defaults to hex2bin.',
64-
'--show' => 'Shows the generated key in the terminal instead of storing in the `.env` file.',
65-
];
69+
if ($this->hasUnboundOption('show', $options)) {
70+
return;
71+
}
6672

67-
/**
68-
* Actually execute the command.
69-
*/
70-
public function run(array $params)
71-
{
72-
$prefix = $params['prefix'] ?? CLI::getOption('prefix');
73+
if ($this->hasUnboundOption('force', $options)) {
74+
return;
75+
}
7376

74-
if (in_array($prefix, [null, true], true)) {
75-
$prefix = 'hex2bin';
76-
} elseif (! in_array($prefix, ['hex2bin', 'base64'], true)) {
77-
$prefix = CLI::prompt('Please provide a valid prefix to use.', ['hex2bin', 'base64'], 'required'); // @codeCoverageIgnore
77+
if (env('encryption.key', '') === '') {
78+
return;
7879
}
7980

80-
$length = $params['length'] ?? CLI::getOption('length');
81+
if (CLI::prompt('Overwrite existing key?', ['n', 'y']) === 'y') {
82+
$options['force'] = null; // simulate the presence of the --force option
83+
}
84+
}
85+
86+
protected function execute(array $arguments, array $options): int
87+
{
88+
$prefix = $options['prefix'];
8189

82-
if (in_array($length, [null, true], true)) {
83-
$length = 32;
90+
if (! in_array($prefix, self::VALID_PREFIXES, true)) {
91+
CLI::error(sprintf('Invalid prefix "%s". Use either "hex2bin" or "base64".', $prefix));
92+
93+
return EXIT_ERROR;
8494
}
8595

86-
$encodedKey = $this->generateRandomKey($prefix, $length);
96+
$encodedKey = $this->generateRandomKey($prefix, (int) $options['length']);
8797

88-
if (array_key_exists('show', $params) || (bool) CLI::getOption('show')) {
98+
if ($options['show'] === true) {
8999
CLI::write($encodedKey, 'yellow');
90-
CLI::newLine();
91100

92101
return EXIT_SUCCESS;
93102
}
94103

95-
if (! $this->setNewEncryptionKey($encodedKey, $params)) {
96-
CLI::write('Error in setting new encryption key to .env file.', 'light_gray', 'red');
97-
CLI::newLine();
104+
$currentKey = env('encryption.key', '');
105+
106+
if ($currentKey !== '' && $options['force'] === false) {
107+
CLI::error('Setting new encryption key aborted.');
108+
109+
if (! $this->isInteractive()) {
110+
CLI::error('If you want, use the "--force" option to force overwrite the existing key.');
111+
}
112+
113+
return EXIT_ERROR;
114+
}
115+
116+
if (! $this->writeNewEncryptionKeyToFile($currentKey, $encodedKey)) {
117+
CLI::write('Error in setting new encryption key to .env file.');
98118

99119
return EXIT_ERROR;
100120
}
@@ -114,7 +134,7 @@ public function run(array $params)
114134
/**
115135
* Generates a key and encodes it.
116136
*/
117-
protected function generateRandomKey(string $prefix, int $length): string
137+
private function generateRandomKey(string $prefix, int $length): string
118138
{
119139
$key = Encryption::createKey($length);
120140

@@ -125,37 +145,10 @@ protected function generateRandomKey(string $prefix, int $length): string
125145
return 'base64:' . base64_encode($key);
126146
}
127147

128-
/**
129-
* Sets the new encryption key in your .env file.
130-
*
131-
* @param array<int|string, string|null> $params
132-
*/
133-
protected function setNewEncryptionKey(string $key, array $params): bool
134-
{
135-
$currentKey = env('encryption.key', '');
136-
137-
if ($currentKey !== '' && ! $this->confirmOverwrite($params)) {
138-
// Not yet testable since it requires keyboard input
139-
return false; // @codeCoverageIgnore
140-
}
141-
142-
return $this->writeNewEncryptionKeyToFile($currentKey, $key);
143-
}
144-
145-
/**
146-
* Checks whether to overwrite existing encryption key.
147-
*
148-
* @param array<int|string, string|null> $params
149-
*/
150-
protected function confirmOverwrite(array $params): bool
151-
{
152-
return (array_key_exists('force', $params) || CLI::getOption('force')) || CLI::prompt('Overwrite existing key?', ['n', 'y']) === 'y';
153-
}
154-
155148
/**
156149
* Writes the new encryption key to .env file.
157150
*/
158-
protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): bool
151+
private function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): bool
159152
{
160153
$baseEnv = ROOTPATH . 'env';
161154
$envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property
@@ -164,7 +157,6 @@ protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey):
164157
if (! is_file($baseEnv)) {
165158
CLI::write('Both default shipped `env` file and custom `.env` are missing.', 'yellow');
166159
CLI::write('Here\'s your new key instead: ' . CLI::color($newKey, 'yellow'));
167-
CLI::newLine();
168160

169161
return false;
170162
}
@@ -195,7 +187,7 @@ protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey):
195187
/**
196188
* Get the regex of the current encryption key.
197189
*/
198-
protected function keyPattern(string $oldKey): string
190+
private function keyPattern(string $oldKey): string
199191
{
200192
$escaped = preg_quote($oldKey, '/');
201193

tests/system/Commands/Encryption/GenerateKeyTest.php

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313

1414
namespace CodeIgniter\Commands\Encryption;
1515

16+
use CodeIgniter\CLI\CLI;
1617
use CodeIgniter\Config\Services;
1718
use CodeIgniter\Superglobals;
1819
use CodeIgniter\Test\CIUnitTestCase;
1920
use CodeIgniter\Test\Filters\CITestStreamFilter;
21+
use CodeIgniter\Test\Mock\MockInputOutput;
2022
use CodeIgniter\Test\StreamFilterTrait;
2123
use PHPUnit\Framework\Attributes\Group;
2224
use PHPUnit\Framework\Attributes\PreserveGlobalState;
@@ -39,6 +41,7 @@ protected function setUp(): void
3941
{
4042
parent::setUp();
4143

44+
CLI::resetLastWrite();
4245
Services::injectMock('superglobals', new Superglobals());
4346

4447
$this->envPath = ROOTPATH . '.env';
@@ -62,6 +65,9 @@ protected function tearDown(): void
6265
}
6366

6467
$this->resetEnvironment();
68+
69+
CLI::resetLastWrite();
70+
CLI::reset();
6571
}
6672

6773
/**
@@ -169,4 +175,98 @@ public function testKeyGenerateWhenNewBase64KeyIsSubsequentlyCommentedOut(): voi
169175
$this->assertStringContainsString('was successfully set.', $this->getBuffer());
170176
$this->assertNotSame($key, env('encryption.key', $key), 'Failed replacing the commented out key.');
171177
}
178+
179+
/**
180+
* Simulates a stale env cache: the `.env` file has a valid key, but
181+
* `env('encryption.key')` resolves to '' because nothing has loaded it
182+
* into the superglobals. The primary regex (built from `oldKey`) cannot
183+
* locate the line, so the fallback regex must replace the existing entry.
184+
*/
185+
public function testKeyGenerateReplacesUnloadedKeyInDotEnvFile(): void
186+
{
187+
$existingKey = 'hex2bin:' . str_repeat('a', 64);
188+
file_put_contents($this->envPath, "encryption.key = {$existingKey}\n");
189+
190+
$this->assertSame('', env('encryption.key', ''));
191+
192+
command('key:generate --force');
193+
194+
$this->assertStringContainsString('was successfully set.', $this->getBuffer());
195+
196+
$contents = (string) file_get_contents($this->envPath);
197+
$this->assertStringNotContainsString($existingKey, $contents);
198+
$this->assertStringContainsString('encryption.key = ' . env('encryption.key'), $contents);
199+
}
200+
201+
public function testKeyGenerateAbortsWhenOverwritePromptIsDeclined(): void
202+
{
203+
command('key:generate');
204+
$key = env('encryption.key', '');
205+
$this->assertNotSame('', $key);
206+
207+
$io = new MockInputOutput();
208+
$io->setInputs(['n']);
209+
CLI::setInputOutput($io);
210+
211+
command('key:generate');
212+
213+
$this->assertSame($key, env('encryption.key', ''), 'Existing key should not change.');
214+
$this->assertStringContainsString($key, (string) file_get_contents($this->envPath));
215+
$this->assertStringContainsString('Overwrite existing key?', $io->getOutput());
216+
$this->assertStringContainsString('Setting new encryption key aborted.', $io->getOutput());
217+
}
218+
219+
public function testKeyGenerateOverwritesWhenOverwritePromptIsConfirmed(): void
220+
{
221+
command('key:generate');
222+
$oldKey = env('encryption.key', '');
223+
$this->assertNotSame('', $oldKey);
224+
225+
$io = new MockInputOutput();
226+
$io->setInputs(['y']);
227+
CLI::setInputOutput($io);
228+
229+
command('key:generate --prefix base64');
230+
231+
$this->assertNotSame($oldKey, env('encryption.key', $oldKey));
232+
$this->assertStringContainsString('base64:', (string) file_get_contents($this->envPath));
233+
$this->assertStringContainsString('Overwrite existing key?', $io->getOutput());
234+
$this->assertStringContainsString('successfully set.', $io->getOutput());
235+
}
236+
237+
#[PreserveGlobalState(false)]
238+
#[RunInSeparateProcess]
239+
public function testKeyGenerateAbortsNonInteractivelyWithExistingKey(): void
240+
{
241+
command('key:generate');
242+
$key = env('encryption.key', '');
243+
$this->assertNotSame('', $key);
244+
245+
$this->resetStreamFilterBuffer();
246+
247+
command('key:generate --no-interaction');
248+
249+
$this->assertSame($key, env('encryption.key', ''), 'Existing key should not change.');
250+
$this->assertStringContainsString('Setting new encryption key aborted.', $this->getBuffer());
251+
$this->assertStringContainsString('--force', $this->getBuffer());
252+
}
253+
254+
public function testKeyGenerateErrorsOnInvalidPrefixNonInteractively(): void
255+
{
256+
command('key:generate --prefix invalid --show --no-interaction');
257+
258+
$this->assertStringContainsString('Invalid prefix "invalid"', $this->getBuffer());
259+
}
260+
261+
public function testKeyGeneratePromptsForInvalidPrefix(): void
262+
{
263+
$io = new MockInputOutput();
264+
$io->setInputs(['hex2bin']);
265+
CLI::setInputOutput($io);
266+
267+
command('key:generate --prefix invalid --show');
268+
269+
$this->assertStringContainsString('Please provide a valid prefix to use.', $io->getOutput());
270+
$this->assertStringContainsString('hex2bin:', $io->getOutput());
271+
}
172272
}

0 commit comments

Comments
 (0)