mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-09-15 14:57:07 -04:00
Addresses CodeRabbit review on PR #4656: - env:provision CI3 branch was persisting *plaintext* secrets (saveAll($plain)) instead of the CI4 ciphertext, unlike the ConvertToCI4 migration. Now encrypts with encryptAll(), verifies the round trip, and saves the ciphertext. - The ospos_env named volume mounted at /app/.env made .env a directory, so atomicWriteFile's rename() failed and spark env:provision could not start apache. Switch to a bind mount of a host file (./.env) which persists and stays a file. - Add a regression test asserting the command persists ciphertext (not plaintext).
204 lines
7.0 KiB
PHP
204 lines
7.0 KiB
PHP
<?php
|
|
|
|
namespace Tests\Libraries;
|
|
|
|
use App\Libraries\CI3SecretConverter;
|
|
use App\Models\Appconfig;
|
|
use CodeIgniter\Encryption\EncrypterInterface;
|
|
use CodeIgniter\Test\CIUnitTestCase;
|
|
use Config\Encryption as EncryptionConfig;
|
|
use Config\Services;
|
|
|
|
/**
|
|
* Tests for CI3SecretConverter.
|
|
*
|
|
* The converter performs no DB I/O of its own except saveAll(); the Appconfig
|
|
* model is injectable so we can fake get_value()/batch_save() with an anonymous
|
|
* subclass. decryptAll() is the only path that touches the real cipher, so we
|
|
* verify it by encrypting a known plaintext with the *CI3 cipher* and asserting
|
|
* the converter decrypts it back.
|
|
*/
|
|
final class CI3SecretConverterTest extends CIUnitTestCase
|
|
{
|
|
private string $oldKey;
|
|
private array $plain;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// Make sure the app Encryption config has a valid key so CI4 default
|
|
// cipher (encryptAll/verifyAll) works in this test process.
|
|
$env = config('Encryption');
|
|
if (empty($env->key) || strlen((string) $env->key) < 64) {
|
|
$env->key = bin2hex(random_bytes(32));
|
|
}
|
|
|
|
// The CI3-era key this test uses to seed fake legacy ciphertext.
|
|
$this->oldKey = bin2hex(random_bytes(16));
|
|
|
|
$this->plain = [
|
|
'clcdesq_api_key' => 'capi-1234567890abcdef',
|
|
'clcdesq_api_url' => 'https://ci3.example.org/api',
|
|
'mailchimp_api_key' => 'mc-abc-123',
|
|
'mailchimp_list_id' => 'list-5a6b7c',
|
|
'smtp_pass' => 's3cr3t-smtp',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build the CI3 ciphertext the same way the converter decrypts it, so we
|
|
* have a known round-trip fixture without depending on a DB row.
|
|
*/
|
|
private function ci3Encrypt(string $plaintext): string
|
|
{
|
|
$cfg = new EncryptionConfig();
|
|
$cfg->driver = 'OpenSSL';
|
|
$cfg->digest = 'SHA512';
|
|
$cfg->key = $this->oldKey;
|
|
$cfg->cipher = 'AES-128-CBC';
|
|
$cfg->rawData = false;
|
|
$cfg->encryptKeyInfo = 'encryption';
|
|
$cfg->authKeyInfo = 'authentication';
|
|
$cfg->previousKeys = [];
|
|
|
|
return Services::encrypter($cfg)->encrypt($plaintext);
|
|
}
|
|
|
|
/**
|
|
* @return array<string,string>
|
|
*/
|
|
private function ci3Ciphertexts(): array
|
|
{
|
|
return array_map(
|
|
fn ($v) => $v === '' ? '' : $this->ci3Encrypt($v),
|
|
$this->plain
|
|
);
|
|
}
|
|
|
|
private function fake(array $values, bool $saveSucceeds = true): Appconfig
|
|
{
|
|
return new class($values, $saveSucceeds) extends Appconfig {
|
|
public function __construct(
|
|
private array $vals,
|
|
private bool $ok
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
public function get_value(string $key, string $default = ''): string
|
|
{
|
|
return $this->vals[$key] ?? $default;
|
|
}
|
|
|
|
public function batch_save(array $data): bool
|
|
{
|
|
return $this->ok;
|
|
}
|
|
};
|
|
}
|
|
|
|
public function testDecryptAllRoundTripWithCi3Cipher(): void
|
|
{
|
|
$conv = new CI3SecretConverter($this->fake($this->ci3Ciphertexts()));
|
|
$plain = $conv->decryptAll($this->oldKey);
|
|
|
|
foreach ($this->plain as $k => $v) {
|
|
$this->assertSame($v, $plain[$k], "decryptAll mismatch for {$k}");
|
|
}
|
|
}
|
|
|
|
public function testDecryptAllEmptyRowsStayEmpty(): void
|
|
{
|
|
$ct = $this->ci3Ciphertexts();
|
|
$ct['mailchimp_api_key'] = '';
|
|
$ct['mailchimp_list_id'] = '';
|
|
|
|
$plain = (new CI3SecretConverter($this->fake($ct)))->decryptAll($this->oldKey);
|
|
|
|
$this->assertSame('capi-1234567890abcdef', $plain['clcdesq_api_key']);
|
|
$this->assertSame('', $plain['mailchimp_api_key']);
|
|
$this->assertSame('', $plain['mailchimp_list_id']);
|
|
}
|
|
|
|
public function testEncryptVerifyRoundTripWithCi4Cipher(): void
|
|
{
|
|
$conv = new CI3SecretConverter($this->fake([]));
|
|
|
|
$enc = $conv->encryptAll($this->plain);
|
|
foreach ($this->plain as $k => $v) {
|
|
$this->assertNotSame($v, $enc[$k], "encryptAll must change {$k}");
|
|
}
|
|
|
|
$this->assertSame($this->plain, $conv->verifyAll($enc));
|
|
}
|
|
|
|
public function testEncryptAllKeepsEmptyValuesEmpty(): void
|
|
{
|
|
$conv = new CI3SecretConverter($this->fake([]));
|
|
$in = $this->plain;
|
|
$in['smtp_pass'] = '';
|
|
|
|
$enc = $conv->encryptAll($in);
|
|
$this->assertSame('', $enc['smtp_pass']);
|
|
$this->assertNotSame('', $enc['clcdesq_api_key']);
|
|
}
|
|
|
|
public function testCI3BranchSavesCiphertextNotPlaintext(): void
|
|
{
|
|
// Regression guard for the env:provision CI3 branch (mirrored by the
|
|
// ConvertToCI4 migration): after decryptAll() with the legacy CI3 key,
|
|
// the command must persist encryptAll()'s CI4 *ciphertext* -- never the
|
|
// decrypted plaintext. A payload equal to $plain would mean secrets were
|
|
// written to ospos_app_config in the clear.
|
|
$conv = new CI3SecretConverter($this->fake($this->ci3Ciphertexts()));
|
|
$plain = $conv->decryptAll($this->oldKey);
|
|
$payload = $conv->encryptAll($plain); // <- exactly what run() passes to saveAll()
|
|
|
|
// saveAll() must receive the ciphertext form, i.e. a value that differs
|
|
// from every plaintext secret yet round-trips to it under the CI4 cipher.
|
|
foreach ($this->plain as $col => $secret) {
|
|
$this->assertNotSame($secret, $payload[$col], "saveAll() payload for {$col} must be ciphertext, not the plain secret");
|
|
}
|
|
$this->assertSame($plain, $conv->verifyAll($payload), 'persisted ciphertext must verify back to the original plaintext');
|
|
}
|
|
|
|
public function testHasLegacyDataTrueWhenAnyPresent(): void
|
|
{
|
|
$conv = new CI3SecretConverter($this->fake($this->ci3Ciphertexts()));
|
|
$this->assertTrue($conv->hasLegacyData($this->oldKey));
|
|
}
|
|
|
|
public function testHasLegacyDataFalseWhenAllEmpty(): void
|
|
{
|
|
$conv = new CI3SecretConverter($this->fake([]));
|
|
$this->assertFalse($conv->hasLegacyData($this->oldKey));
|
|
}
|
|
|
|
public function testSaveAllPersistsAndReturnsTrue(): void
|
|
{
|
|
$conv = new CI3SecretConverter($this->fake([], true));
|
|
$this->assertTrue($conv->saveAll(['x' => 'y']));
|
|
}
|
|
|
|
public function testSaveAllThrowsWhenModelFails(): void
|
|
{
|
|
$conv = new CI3SecretConverter($this->fake([], false));
|
|
$this->expectException(\RuntimeException::class);
|
|
$this->expectExceptionMessage('Failed to save converted encryption data');
|
|
$conv->saveAll(['x' => 'y']);
|
|
}
|
|
|
|
public function testLegacyKeysConstantExposesExpectedSet(): void
|
|
{
|
|
$expected = [
|
|
'clcdesq_api_key',
|
|
'clcdesq_api_url',
|
|
'mailchimp_api_key',
|
|
'mailchimp_list_id',
|
|
'smtp_pass',
|
|
];
|
|
$this->assertSame($expected, CI3SecretConverter::LEGACY_KEYS);
|
|
}
|
|
}
|