98 lines
3.8 KiB
PHP
98 lines
3.8 KiB
PHP
<?php
|
|
|
|
use App\Models\Host;
|
|
use App\Services\Secrets\SecretCipher;
|
|
use Illuminate\Contracts\Encryption\DecryptException;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Loads a fresh instance of the re-encryption migration every call.
|
|
*
|
|
* `require`, not `require_once`: PHP re-evaluates the `return new class …`
|
|
* expression each time, so every call gets its own instance. The real migrate
|
|
* run RefreshDatabase already performed saw an empty hosts table — these tests
|
|
* put rows there first and then run it, which is the only way to reach the
|
|
* branches that matter.
|
|
*/
|
|
function loadHostTokenKeyMigration(): object
|
|
{
|
|
return require database_path('migrations/2026_07_31_160000_move_host_api_tokens_onto_the_secrets_key.php');
|
|
}
|
|
|
|
/** Writes a raw ciphertext past the model's mutator, the way an old row looks. */
|
|
function storeRawToken(Host $host, string $cipher): void
|
|
{
|
|
DB::table('hosts')->where('id', $host->id)->update(['api_token_ref' => $cipher]);
|
|
}
|
|
|
|
function rawToken(Host $host): ?string
|
|
{
|
|
$value = DB::table('hosts')->where('id', $host->id)->value('api_token_ref');
|
|
|
|
return $value === null ? null : (string) $value;
|
|
}
|
|
|
|
beforeEach(fn () => config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32))));
|
|
|
|
it('moves a token written under APP_KEY onto SECRETS_KEY', function () {
|
|
$host = Host::factory()->create();
|
|
storeRawToken($host, Crypt::encryptString('automation@pve!clupilot=old-secret'));
|
|
|
|
loadHostTokenKeyMigration()->up();
|
|
|
|
$raw = (string) rawToken($host);
|
|
|
|
expect(app(SecretCipher::class)->decrypt($raw))->toBe('automation@pve!clupilot=old-secret')
|
|
// The point of the whole exercise: APP_KEY no longer opens it, so
|
|
// rotating APP_KEY cannot take every host's Proxmox access with it.
|
|
->and(fn () => Crypt::decryptString($raw))->toThrow(DecryptException::class)
|
|
->and($host->fresh()->api_token_ref)->toBe('automation@pve!clupilot=old-secret');
|
|
});
|
|
|
|
it('leaves a row it has already migrated exactly as it is', function () {
|
|
$host = Host::factory()->create(['api_token_ref' => 'automation@pve!clupilot=already-moved']);
|
|
$before = rawToken($host);
|
|
|
|
loadHostTokenKeyMigration()->up();
|
|
|
|
expect(rawToken($host))->toBe($before)
|
|
->and($host->fresh()->api_token_ref)->toBe('automation@pve!clupilot=already-moved');
|
|
});
|
|
|
|
it('never destroys a value neither key can read', function () {
|
|
// Mutation boundary: the plaintext is a credential that exists nowhere else.
|
|
// A token an operator can still recover by hand from an old APP_KEY is worth
|
|
// more than a tidy column, so the row is left alone and reported — never
|
|
// overwritten, never nulled.
|
|
$host = Host::factory()->create();
|
|
storeRawToken($host, 'eyJpdiI6IndyaXR0ZW4tdW5kZXItYS1sb25nLWdvbmUta2V5In0=');
|
|
|
|
loadHostTokenKeyMigration()->up();
|
|
|
|
expect(rawToken($host))->toBe('eyJpdiI6IndyaXR0ZW4tdW5kZXItYS1sb25nLWdvbmUta2V5In0=');
|
|
});
|
|
|
|
it('touches nothing at all when there is no key to re-encrypt with', function () {
|
|
$host = Host::factory()->create();
|
|
$appKeyCipher = Crypt::encryptString('automation@pve!clupilot=still-under-app-key');
|
|
storeRawToken($host, $appKeyCipher);
|
|
|
|
config()->set('admin_access.secrets_key', '');
|
|
|
|
// Does not throw: a fresh install has no SECRETS_KEY yet, and blocking the
|
|
// whole migration run on that would stop an installation that has nothing
|
|
// to migrate from being created at all.
|
|
loadHostTokenKeyMigration()->up();
|
|
|
|
expect(rawToken($host))->toBe($appKeyCipher);
|
|
});
|
|
|
|
it('puts the column back under APP_KEY on a rollback', function () {
|
|
$host = Host::factory()->create(['api_token_ref' => 'automation@pve!clupilot=roundtrip']);
|
|
|
|
loadHostTokenKeyMigration()->down();
|
|
|
|
expect(Crypt::decryptString((string) rawToken($host)))->toBe('automation@pve!clupilot=roundtrip');
|
|
});
|