Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 25 additions & 18 deletions system/Commands/Encryption/GenerateKey.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,17 +104,29 @@ protected function execute(array $arguments, array $options): int
$currentKey = env('encryption.key', '');

if ($currentKey !== '' && $options['force'] === false) {
CLI::error('Setting new encryption key aborted.');
if ($this->isInteractive()) {
CLI::write('Setting new encryption key cancelled.', 'yellow');

if (! $this->isInteractive()) {
CLI::error('If you want, use the "--force" option to force overwrite the existing key.');
return EXIT_SUCCESS;
}

CLI::error('Setting new encryption key aborted: pass --force to overwrite the existing key in non-interactive mode.');

return EXIT_ERROR;
}

$envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property
$baseEnv = ROOTPATH . 'env';

if (! is_file($envFile) && ! is_file($baseEnv)) {
CLI::write('Both default shipped `env` file and custom `.env` are missing.', 'yellow');
CLI::write(sprintf('Here\'s your new key instead: %s', CLI::color($encodedKey, 'yellow')));

return EXIT_ERROR;
}

if (! $this->writeNewEncryptionKeyToFile($currentKey, $encodedKey)) {
CLI::write('Error in setting new encryption key to .env file.');
if (! $this->writeNewEncryptionKeyToFile($currentKey, $encodedKey, $envFile, $baseEnv)) {
CLI::error(sprintf('Failed to write new encryption key to %s.', clean_path($envFile)));

return EXIT_ERROR;
}
Expand All @@ -125,7 +137,7 @@ protected function execute(array $arguments, array $options): int
$dotenv = new DotEnv((new Paths())->envDirectory ?? ROOTPATH); // @phpstan-ignore nullCoalesce.property
$dotenv->load();

CLI::write('Application\'s new encryption key was successfully set.', 'green');
CLI::write(sprintf('New encryption key written to %s.', clean_path($envFile)), 'green');
CLI::newLine();

return EXIT_SUCCESS;
Expand All @@ -146,24 +158,19 @@ private function generateRandomKey(string $prefix, int $length): string
}

/**
* Writes the new encryption key to .env file.
* Writes the new encryption key to .env file. The caller is responsible
* for ensuring at least one of `$envFile` or `$baseEnv` exists.
*/
private function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): bool
private function writeNewEncryptionKeyToFile(string $oldKey, string $newKey, string $envFile, string $baseEnv): bool
{
$baseEnv = ROOTPATH . 'env';
$envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property

if (! is_file($envFile)) {
if (! is_file($baseEnv)) {
CLI::write('Both default shipped `env` file and custom `.env` are missing.', 'yellow');
CLI::write('Here\'s your new key instead: ' . CLI::color($newKey, 'yellow'));

return false;
}

copy($baseEnv, $envFile);
}

if (! is_writable($envFile)) {
return false;
}

$oldFileContents = (string) file_get_contents($envFile);
$replacementKey = "\nencryption.key = {$newKey}";

Expand Down
78 changes: 46 additions & 32 deletions tests/system/Commands/Encryption/GenerateKeyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
use CodeIgniter\Config\Services;
use CodeIgniter\Superglobals;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\Filters\CITestStreamFilter;
use CodeIgniter\Test\Mock\MockInputOutput;
use CodeIgniter\Test\StreamFilterTrait;
use PHPUnit\Framework\Attributes\Group;
Expand Down Expand Up @@ -70,14 +69,6 @@ protected function tearDown(): void
CLI::reset();
}

/**
* Gets buffer contents then releases it.
*/
protected function getBuffer(): string
{
return $this->getStreamFilterBuffer();
}

protected function resetEnvironment(): void
{
putenv('encryption.key');
Expand All @@ -88,31 +79,35 @@ protected function resetEnvironment(): void
public function testGenerateKeyShowsEncodedKey(): void
{
command('key:generate --show');
$this->assertStringContainsString('hex2bin:', $this->getBuffer());
$this->assertStringContainsString('hex2bin:', $this->getStreamFilterBuffer());

$this->resetStreamFilterBuffer();
command('key:generate --prefix base64 --show');
$this->assertStringContainsString('base64:', $this->getBuffer());
$this->assertStringContainsString('base64:', $this->getStreamFilterBuffer());

$this->resetStreamFilterBuffer();
command('key:generate --prefix hex2bin --show');
$this->assertStringContainsString('hex2bin:', $this->getBuffer());
$this->assertStringContainsString('hex2bin:', $this->getStreamFilterBuffer());
}

#[PreserveGlobalState(false)]
#[RunInSeparateProcess]
public function testGenerateKeyCreatesNewKey(): void
{
command('key:generate');
$this->assertStringContainsString('successfully set.', $this->getBuffer());
$this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer());
$this->assertStringContainsString(env('encryption.key'), (string) file_get_contents($this->envPath));
$this->assertStringContainsString('hex2bin:', (string) file_get_contents($this->envPath));

$this->resetStreamFilterBuffer();
command('key:generate --prefix base64 --force');
$this->assertStringContainsString('successfully set.', $this->getBuffer());
$this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer());
$this->assertStringContainsString(env('encryption.key'), (string) file_get_contents($this->envPath));
$this->assertStringContainsString('base64:', (string) file_get_contents($this->envPath));

$this->resetStreamFilterBuffer();
command('key:generate --prefix hex2bin --force');
$this->assertStringContainsString('successfully set.', $this->getBuffer());
$this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer());
$this->assertStringContainsString(env('encryption.key'), (string) file_get_contents($this->envPath));
$this->assertStringContainsString('hex2bin:', (string) file_get_contents($this->envPath));
}
Expand All @@ -123,8 +118,9 @@ public function testDefaultShippedEnvIsMissing(): void
command('key:generate');
rename(ROOTPATH . 'lostenv', ROOTPATH . 'env');

$this->assertStringContainsString('Both default shipped', $this->getBuffer());
$this->assertStringContainsString('Error in setting', $this->getBuffer());
$this->assertStringContainsString('Both default shipped', $this->getStreamFilterBuffer());
$this->assertStringContainsString('Here\'s your new key instead:', $this->getStreamFilterBuffer());
$this->assertStringNotContainsString('Failed to write', $this->getStreamFilterBuffer());
}

/**
Expand All @@ -136,7 +132,7 @@ public function testKeyGenerateWhenKeyIsMissingInDotEnvFile(): void

command('key:generate');

$this->assertStringContainsString('Application\'s new encryption key was successfully set.', $this->getBuffer());
$this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer());
$this->assertSame("\nencryption.key = " . env('encryption.key'), file_get_contents($this->envPath));
}

Expand All @@ -152,9 +148,9 @@ public function testKeyGenerateWhenNewHexKeyIsSubsequentlyCommentedOut(): void
));
$this->assertSame(1, $count, 'Failed commenting out the previously set application key.');

CITestStreamFilter::$buffer = '';
$this->resetStreamFilterBuffer();
command('key:generate --force');
$this->assertStringContainsString('was successfully set.', $this->getBuffer());
$this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer());
$this->assertNotSame($key, env('encryption.key', $key), 'Failed replacing the commented out key.');
}

Expand All @@ -170,9 +166,9 @@ public function testKeyGenerateWhenNewBase64KeyIsSubsequentlyCommentedOut(): voi
));
$this->assertSame(1, $count, 'Failed commenting out the previously set application key.');

CITestStreamFilter::$buffer = '';
$this->resetStreamFilterBuffer();
command('key:generate --force');
$this->assertStringContainsString('was successfully set.', $this->getBuffer());
$this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer());
$this->assertNotSame($key, env('encryption.key', $key), 'Failed replacing the commented out key.');
}

Expand All @@ -190,15 +186,15 @@ public function testKeyGenerateReplacesUnloadedKeyInDotEnvFile(): void
$this->assertSame('', env('encryption.key', ''));

command('key:generate --force');
$this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer());

$this->assertStringContainsString('was successfully set.', $this->getBuffer());

$contents = (string) file_get_contents($this->envPath);
$contents = @file_get_contents($this->envPath);
$this->assertIsString($contents, 'Failed to read .env file contents.');
$this->assertStringNotContainsString($existingKey, $contents);
$this->assertStringContainsString('encryption.key = ' . env('encryption.key'), $contents);
}

public function testKeyGenerateAbortsWhenOverwritePromptIsDeclined(): void
public function testKeyGenerateCancelsWhenOverwritePromptIsDeclined(): void
{
command('key:generate');
$key = env('encryption.key', '');
Expand All @@ -208,12 +204,13 @@ public function testKeyGenerateAbortsWhenOverwritePromptIsDeclined(): void
$io->setInputs(['n']);
CLI::setInputOutput($io);

$this->resetStreamFilterBuffer();
command('key:generate');

$this->assertSame($key, env('encryption.key', ''), 'Existing key should not change.');
$this->assertStringContainsString($key, (string) file_get_contents($this->envPath));
$this->assertStringContainsString('Overwrite existing key?', $io->getOutput());
$this->assertStringContainsString('Setting new encryption key aborted.', $io->getOutput());
$this->assertStringContainsString('Setting new encryption key cancelled.', $io->getOutput());
}

public function testKeyGenerateOverwritesWhenOverwritePromptIsConfirmed(): void
Expand All @@ -226,12 +223,13 @@ public function testKeyGenerateOverwritesWhenOverwritePromptIsConfirmed(): void
$io->setInputs(['y']);
CLI::setInputOutput($io);

$this->resetStreamFilterBuffer();
command('key:generate --prefix base64');

$this->assertNotSame($oldKey, env('encryption.key', $oldKey));
$this->assertStringContainsString('base64:', (string) file_get_contents($this->envPath));
$this->assertStringContainsString('Overwrite existing key?', $io->getOutput());
$this->assertStringContainsString('successfully set.', $io->getOutput());
$this->assertStringContainsString(sprintf('New encryption key written to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $io->getOutput());
}

#[PreserveGlobalState(false)]
Expand All @@ -243,19 +241,20 @@ public function testKeyGenerateAbortsNonInteractivelyWithExistingKey(): void
$this->assertNotSame('', $key);

$this->resetStreamFilterBuffer();

command('key:generate --no-interaction');

$this->assertSame($key, env('encryption.key', ''), 'Existing key should not change.');
$this->assertStringContainsString('Setting new encryption key aborted.', $this->getBuffer());
$this->assertStringContainsString('--force', $this->getBuffer());
$this->assertStringContainsString(
'Setting new encryption key aborted: pass --force to overwrite the existing key in non-interactive mode.',
$this->getStreamFilterBuffer(),
);
}

public function testKeyGenerateErrorsOnInvalidPrefixNonInteractively(): void
{
command('key:generate --prefix invalid --show --no-interaction');

$this->assertStringContainsString('Invalid prefix "invalid"', $this->getBuffer());
$this->assertStringContainsString('Invalid prefix "invalid"', $this->getStreamFilterBuffer());
}

public function testKeyGeneratePromptsForInvalidPrefix(): void
Expand All @@ -269,4 +268,19 @@ public function testKeyGeneratePromptsForInvalidPrefix(): void
$this->assertStringContainsString('Please provide a valid prefix to use.', $io->getOutput());
$this->assertStringContainsString('hex2bin:', $io->getOutput());
}

public function testKeyGenerateErrorsWhenEnvFileIsNotWritable(): void
{
command('key:generate');
chmod($this->envPath, 0o444);

try {
$this->resetStreamFilterBuffer();
command('key:generate --force');

$this->assertStringContainsString(sprintf('Failed to write new encryption key to ROOTPATH%s.env.', DIRECTORY_SEPARATOR), $this->getStreamFilterBuffer());
} finally {
chmod($this->envPath, 0o644);
}
}
}
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Behavior Changes
- **Commands:** The ``filter:check`` command now requires the HTTP method argument to be uppercase (e.g., ``spark filter:check GET /`` instead of ``spark filter:check get /``).
- **Commands:** Several built-in commands have been migrated from ``BaseCommand`` to the modern ``AbstractCommand`` style. Applications that extend a built-in command to override
behaviour may need to re-implement against the modern API (``configure()`` + ``execute()`` and the ``#[Command]`` attribute) once the class it extends is migrated, or, preferably, compose instead of extending. Invocations on the command line are unaffected.
- **Commands:** Declining the ``key:generate`` overwrite prompt interactively now returns ``EXIT_SUCCESS`` instead of ``EXIT_ERROR``. Output messages were also reworded; CI/automation that branches on the exit code or greps the previous wording will need updating.
- **Database:** The Postgre driver's ``$db->error()['code']`` previously always returned ``''``. It now returns the 5-character SQLSTATE string for query and transaction failures (e.g., ``'42P01'``), or ``'08006'`` for connection-level failures. Code that relied on ``$db->error()['code'] === ''`` will need updating.
- **Filters:** HTTP method matching for method-based filters is now case-sensitive. The keys in ``Config\Filters::$methods`` must exactly match the request method
(e.g., ``GET``, ``POST``). Lowercase method names (e.g., ``post``) will no longer match.
Expand Down
Loading