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'); });