CluPilotCloud/tests/Feature/Provisioning/HostTokenKeyMigrationTest.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');
});