diff --git a/app/Actions/Database/RestartDatabase.php b/app/Actions/Database/RestartDatabase.php index e1c531182e..66c7c8c961 100644 --- a/app/Actions/Database/RestartDatabase.php +++ b/app/Actions/Database/RestartDatabase.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Exceptions\RemoteSecretException; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -49,6 +50,12 @@ class RestartDatabase if ($prerequisiteError !== null) { return $prerequisiteError; } + // Check remote secrets before stopping: if the secret manager fails, the database keeps running. + try { + $database->ensureRemoteSecretsResolvable($database->runtime_environment_variables()->get()); + } catch (RemoteSecretException $e) { + return $e->getMessage(); + } StopDatabase::run($database, dockerCleanup: false); // The stop can take a while; keep the reservation for the start that follows. DatabaseOperationReservation::renew($database->uuid, $reservation); diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 912612ae00..7dde9ac21b 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -158,11 +158,13 @@ class StartClickhouse foreach ($this->database->runtime_environment_variables as $env) { $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); + // Credentials below are placed directly in the compose file (healthcheck, command). + $composeFileValue = $this->database->formatComposeFileValue($env, $rawValue); $environment_variables->push($env->key.'='.$resolvedValue); if ($env->key === 'CLICKHOUSE_USER') { - $this->resolvedClickhouseUser = $rawValue; + $this->resolvedClickhouseUser = $composeFileValue; } elseif ($env->key === 'CLICKHOUSE_PASSWORD') { - $this->resolvedClickhousePassword = $rawValue; + $this->resolvedClickhousePassword = $composeFileValue; } } diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index ef0ca2cf3d..a5c9f8515e 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -255,9 +255,11 @@ class StartMysql foreach ($this->database->runtime_environment_variables as $env) { $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); + // Credentials below are placed directly in the compose file (healthcheck, command). + $composeFileValue = $this->database->formatComposeFileValue($env, $rawValue); $environment_variables->push($env->key.'='.$resolvedValue); if ($env->key === 'MYSQL_ROOT_PASSWORD') { - $this->resolvedMysqlRootPassword = $rawValue; + $this->resolvedMysqlRootPassword = $composeFileValue; } } diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index abae4630b1..0f1da0e481 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -264,11 +264,13 @@ class StartPostgresql foreach ($this->database->runtime_environment_variables as $env) { $rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env); $resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue); + // Credentials below are placed directly in the compose file (healthcheck, command). + $composeFileValue = $this->database->formatComposeFileValue($env, $rawValue); $environment_variables->push($env->key.'='.$resolvedValue); if ($env->key === 'POSTGRES_USER') { - $this->resolvedPostgresUser = $rawValue; + $this->resolvedPostgresUser = $composeFileValue; } elseif ($env->key === 'POSTGRES_DB') { - $this->resolvedPostgresDatabase = $rawValue; + $this->resolvedPostgresDatabase = $composeFileValue; } } diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index 14f31f64ac..c5d2d66f8e 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -4,6 +4,7 @@ namespace App\Actions\Database; use App\Exceptions\DatabaseStartException; use App\Helpers\SslHelper; +use App\Models\EnvironmentVariable; use App\Models\SslCertificate; use App\Models\StandaloneRedis; use App\Traits\ExecutesDatabaseStartCommands; @@ -252,7 +253,7 @@ class StartRedis $environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env)); if ($env->key === 'REDIS_PASSWORD') { - $this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $this->resolvedRedisPassword = $this->redisPasswordForComposeFile($env); if (! $usesSecretManager) { $this->database->update(['redis_password' => $this->resolvedRedisPassword]); @@ -274,7 +275,7 @@ class StartRedis } if ($env->key === 'REDIS_PASSWORD') { - $this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env); + $this->resolvedRedisPassword = $this->redisPasswordForComposeFile($env); } elseif ($env->key === 'REDIS_USERNAME') { $this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env); } @@ -288,6 +289,17 @@ class StartRedis return $environment_variables->all(); } + /** + * The password is placed directly in the compose command, so a remote secret value is escaped + * the same way as its environment entry. Other values stay unchanged. + */ + private function redisPasswordForComposeFile(EnvironmentVariable $env): ?string + { + $value = $this->database->resolveSecretManagerEnvironmentVariableValue($env); + + return $value === null ? null : $this->database->formatComposeFileValue($env, $value); + } + private function buildStartCommand(): string { $redisPassword = $this->resolvedRedisPassword ?? $this->database->redis_password; diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 35a154b882..d928866f1d 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -24,6 +24,9 @@ class StartService public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false) { $service->parse(); + // Fetch remote secrets before stopping: if the secret manager fails, the service keeps running. + // saveComposeConfigs() below reuses the fetched secrets. + $service->ensureRemoteSecretsResolvable($service->environment_variables()->get()); if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) { StopService::run(service: $service, dockerCleanup: false); } diff --git a/app/Exceptions/RemoteSecretException.php b/app/Exceptions/RemoteSecretException.php new file mode 100644 index 0000000000..1763a5687e --- /dev/null +++ b/app/Exceptions/RemoteSecretException.php @@ -0,0 +1,12 @@ +getMessage() : 'Database start failed.', + $exception instanceof DatabaseStartException || $exception instanceof RemoteSecretException + ? $exception->getMessage() + : 'Database start failed.', ); } finally { event(new DatabaseStatusChanged($this->userId)); diff --git a/app/Models/Service.php b/app/Models/Service.php index 545250013b..3d795efac0 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -1605,7 +1605,7 @@ class Service extends BaseModel return 3; }); foreach ($sorted as $env) { - $envs->push("{$env->key}={$this->resolveSecretManagerEnvironmentVariable($env)}"); + $envs->push("{$env->key}={$this->resolveSecretManagerDotenvValue($env)}"); } if ($envs->count() === 0) { $commands[] = "touch {$environmentFile}"; diff --git a/app/Support/RemoteSecretValueFormatter.php b/app/Support/RemoteSecretValueFormatter.php new file mode 100644 index 0000000000..fa2d4d5218 --- /dev/null +++ b/app/Support/RemoteSecretValueFormatter.php @@ -0,0 +1,34 @@ +resolveSecretManagerEnvironmentVariableValue($environmentVariable); @@ -53,7 +58,62 @@ trait HasSecretManager return $this->formatEnvironmentVariableValue($environmentVariable, $value); } + /** + * The value for a line of a dotenv file that Docker Compose reads (the service .env file). + */ + public function resolveSecretManagerDotenvValue(EnvironmentVariable $environmentVariable): ?string + { + $value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable); + + if ($value !== null && $this->environmentVariableUsesSecretManager($environmentVariable)) { + return RemoteSecretValueFormatter::dotenv($value); + } + + return $this->formatLocalEnvironmentVariableValue($environmentVariable, $value); + } + + /** + * Formats a resolved value for an `environment:` entry of a generated compose file. Remote secret + * values are used exactly as they are; other values keep their existing format. + */ public function formatEnvironmentVariableValue(EnvironmentVariable $environmentVariable, ?string $value): ?string + { + if ($value !== null && $this->environmentVariableUsesSecretManager($environmentVariable)) { + return RemoteSecretValueFormatter::composeFile($value); + } + + return $this->formatLocalEnvironmentVariableValue($environmentVariable, $value); + } + + /** + * Formats a resolved value that is placed directly in a generated compose file, such as a + * password in a command or healthcheck. Remote secret values are escaped for compose + * interpolation; other values stay unchanged, as before. + */ + public function formatComposeFileValue(EnvironmentVariable $environmentVariable, string $value): string + { + return $this->environmentVariableUsesSecretManager($environmentVariable) + ? RemoteSecretValueFormatter::composeFile($value) + : $value; + } + + /** + * Resolves every remote secret reference of the given variables now, so that an unreachable + * secret manager or a missing key stops an operation before it stops the running resource. + * The fetched secrets stay cached on this model for the start that follows. + * + * @param iterable $environmentVariables + * + * @throws RemoteSecretException + */ + public function ensureRemoteSecretsResolvable(iterable $environmentVariables): void + { + foreach ($environmentVariables as $environmentVariable) { + $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable); + } + } + + private function formatLocalEnvironmentVariableValue(EnvironmentVariable $environmentVariable, ?string $value): ?string { if ($value === null) { return null; @@ -81,7 +141,7 @@ trait HasSecretManager $missing = RemoteSecretReferences::missingKeys($value, $secrets); if ($missing !== []) { - throw new RuntimeException('Missing secret keys: '.implode(', ', $missing)." (referenced by {$environmentVariable->key})."); + throw new RemoteSecretException('Missing secret keys: '.implode(', ', $missing)." (referenced by {$environmentVariable->key})."); } $value = RemoteSecretReferences::substitute($value, $secrets); @@ -116,10 +176,16 @@ trait HasSecretManager $link = $this->secretManagerLink()->with('integrationToken')->first(); if (! $link) { - throw new RuntimeException('Environment variables reference remote secrets, but no secret manager source is configured.'); + throw new RemoteSecretException('Environment variables reference remote secrets, but no secret manager source is configured.'); } - return $this->resolvedSecretManagerValues = $link->fetchSecrets(); + try { + return $this->resolvedSecretManagerValues = $link->fetchSecrets(); + } catch (RemoteSecretException $e) { + throw $e; + } catch (Throwable $e) { + throw new RemoteSecretException('Could not fetch remote secrets: '.$e->getMessage(), previous: $e); + } } /** @return array */ diff --git a/app/Traits/HasSecretManagerAutocomplete.php b/app/Traits/HasSecretManagerAutocomplete.php index 1b46ca2dd5..b210b0be29 100644 --- a/app/Traits/HasSecretManagerAutocomplete.php +++ b/app/Traits/HasSecretManagerAutocomplete.php @@ -3,6 +3,8 @@ namespace App\Traits; use App\Models\SecretManagerLink; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\RateLimiter; /** * Exposes the resource's secret manager source to the env-var-input @@ -17,6 +19,12 @@ trait HasSecretManagerAutocomplete return $this->secretManagerLinkForAutocomplete() !== null; } + /** Seconds that fetched key names are reused for the same secret manager source. */ + private const SECRET_MANAGER_KEYS_CACHE_SECONDS = 60; + + /** Secret manager requests that one user can start from the autocomplete per minute. */ + private const SECRET_MANAGER_KEYS_REQUESTS_PER_MINUTE = 10; + /** * @return list */ @@ -30,15 +38,34 @@ trait HasSecretManagerAutocomplete return []; } - try { - $this->authorize('view', $link->resourceable); - $keys = array_keys($link->fetchSecrets()); - sort($keys); + $this->authorize('view', $link->resourceable); - return $keys; + // Only key names are cached. Another token or other settings use another cache entry. + $cacheKey = 'secret-manager-keys:'.$link->id.':'.hash('sha256', json_encode([ + $link->integration_token_id, + $link->settings, + $link->integrationToken?->updated_at?->getTimestamp(), + ])); + $cachedKeys = Cache::get($cacheKey); + if (is_array($cachedKeys)) { + return $cachedKeys; + } + + $rateLimitKey = 'secret-manager-keys:user:'.auth()->id(); + if (RateLimiter::tooManyAttempts($rateLimitKey, self::SECRET_MANAGER_KEYS_REQUESTS_PER_MINUTE)) { + throw new \RuntimeException('Too many secret manager key requests. Try again in '.RateLimiter::availableIn($rateLimitKey).' seconds.'); + } + RateLimiter::hit($rateLimitKey, 60); + + try { + $keys = array_keys($link->fetchSecrets()); } catch (\Throwable) { throw new \RuntimeException('Unable to fetch secret manager keys.'); } + sort($keys); + Cache::put($cacheKey, $keys, self::SECRET_MANAGER_KEYS_CACHE_SECONDS); + + return $keys; } private function secretManagerLinkForAutocomplete(): ?SecretManagerLink diff --git a/tests/Feature/SecretManagers/RemoteSecretResourceLifecycleTest.php b/tests/Feature/SecretManagers/RemoteSecretResourceLifecycleTest.php new file mode 100644 index 0000000000..c93d3a1849 --- /dev/null +++ b/tests/Feature/SecretManagers/RemoteSecretResourceLifecycleTest.php @@ -0,0 +1,292 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + Storage::fake('ssh-keys'); + Storage::fake('ssh-mux'); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + $this->actingAs($this->user); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server->update(['private_key_id' => $privateKey->id]); + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $this->destination = $this->server->standaloneDockers()->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->token = IntegrationToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'doppler', + 'name' => 'Doppler', + 'token' => 'the-secret-token', + 'capabilities' => ['secrets'], + ]); +}); + +function remoteSecretLifecycleService(): Service +{ + $service = Service::factory()->create([ + 'environment_id' => test()->environment->id, + 'server_id' => test()->server->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + 'docker_compose' => "services:\n app:\n image: alpine\n", + ]); + $service->secretManagerLink()->create(['integration_token_id' => test()->token->id]); + + return $service; +} + +function remoteSecretLifecyclePostgres(): StandalonePostgresql +{ + $database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'db', + 'image' => 'postgres:17-alpine', + 'status' => 'running:healthy', + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + ]); + $database->secretManagerLink()->create(['integration_token_id' => test()->token->id]); + + return $database; +} + +/** + * Returns the .env file content that saveComposeConfigs() writes to the server. + */ +function remoteSecretLifecycleWrittenEnvFile(Service $service): string +{ + $commands = collect(); + Process::fake(function ($process) use ($commands) { + $commands->push($process->command); + + return Process::result(output: ''); + }); + + $service->saveComposeConfigs(); + + preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d \| tee [^ ]+\.env\.tmp/", $commands->implode("\n"), $matches); + + return base64_decode($matches[1] ?? ''); +} + +/* + * Expected values were checked with `docker compose config` (Compose v2 dotenv parser): single quotes + * keep everything literal, including newlines and "#"; a backslash before the closing single quote + * escapes it; double quotes need \\, \" and $$. + */ +dataset('remote secret dotenv values', [ + 'newline that would add a variable' => ["line1\nSERVICE_PASSWORD_APP=injected", "'line1\nSERVICE_PASSWORD_APP=injected'"], + 'single quote and dollar' => ["it's \$HOME", '"it\'s $$HOME"'], + 'double quote and backslash' => ['say "hi" \\o/', "'say \"hi\" \\o/'"], + 'trailing backslash' => ['ends with \\', '"ends with \\\\"'], + 'comment marker' => ['value #not-a-comment', "'value #not-a-comment'"], + 'dollar' => ['p4$$word', "'p4$\$word'"], +]); + +test('remote secret values are written to the service .env file as exact dotenv literals', function (string $secret, string $expected) { + Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['API_KEY' => $secret])]); + $service = remoteSecretLifecycleService(); + $service->environment_variables()->create(['key' => 'API_KEY', 'value' => '{{vault.API_KEY}}']); + + expect(remoteSecretLifecycleWrittenEnvFile($service))->toContain("API_KEY={$expected}"); +})->with('remote secret dotenv values'); + +test('literal service variables with remote secrets are written as valid dotenv literals', function () { + Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['API_KEY' => "it's"])]); + $service = remoteSecretLifecycleService(); + $service->environment_variables()->create(['key' => 'API_KEY', 'value' => '{{vault.API_KEY}}', 'is_literal' => true]); + + expect(remoteSecretLifecycleWrittenEnvFile($service))->toContain('API_KEY="it\'s"'); +}); + +test('service variables without remote secrets keep their .env format', function () { + $service = remoteSecretLifecycleService(); + $service->environment_variables()->create(['key' => 'PLAIN', 'value' => 'say "hi"']); + + expect(remoteSecretLifecycleWrittenEnvFile($service))->toContain('PLAIN=say \"hi\"'); + Http::assertNothingSent(); +}); + +test('the application deployment formats remote secrets with the same dotenv rules', function (string $secret, string $expected) { + $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor(); + + expect((new ReflectionMethod($job, 'format_remote_secret_value'))->invoke($job, $secret))->toBe($expected); +})->with('remote secret dotenv values'); + +test('remote secret values reach the database container environment unchanged', function () { + $secret = "pa\$word \"q\" 'q' \\ #x\nNEXT=1"; + Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['PG_PASSWORD' => $secret])]); + $database = remoteSecretLifecyclePostgres(); + $database->runtime_environment_variables()->create(['key' => 'POSTGRES_PASSWORD', 'value' => '{{vault.PG_PASSWORD}}']); + $database->runtime_environment_variables()->create(['key' => 'LITERAL_PASSWORD', 'value' => '{{vault.PG_PASSWORD}}', 'is_literal' => true]); + + $action = new StartPostgresql; + $action->database = $database; + $environmentVariables = (new ReflectionMethod($action, 'generate_environment_variables'))->invoke($action); + + // Compose environment entries are not dotenv: quotes stay part of the value and only "$" is interpolated. + $composeValue = str_replace('$', '$$', $secret); + expect($environmentVariables)->toContain("POSTGRES_PASSWORD={$composeValue}") + ->and($environmentVariables)->toContain("LITERAL_PASSWORD={$composeValue}"); +}); + +test('redis remote passwords are escaped the same way in the environment and the start command', function () { + Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['REDIS_PASSWORD' => 'p4$word'])]); + $redis = StandaloneRedis::forceCreate([ + 'uuid' => (string) Str::uuid(), + 'name' => 'redis', + 'image' => 'redis:7-alpine', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $redis->secretManagerLink()->create(['integration_token_id' => $this->token->id]); + $redis->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => '{{vault.REDIS_PASSWORD}}']); + + $action = new StartRedis; + $action->database = $redis; + $environmentVariables = (new ReflectionMethod($action, 'generate_environment_variables'))->invoke($action); + $startCommand = (new ReflectionMethod($action, 'buildStartCommand'))->invoke($action); + + expect($environmentVariables)->toContain('REDIS_PASSWORD=p4$$word') + ->and($startCommand)->toContain('--requirepass p4$$word'); +}); + +test('a service restart keeps the service running when remote secrets cannot be fetched', function () { + Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['messages' => ['Doppler is down']], 503)]); + StopService::shouldRun()->never(); + $service = remoteSecretLifecycleService(); + $service->environment_variables()->create(['key' => 'API_KEY', 'value' => '{{vault.API_KEY}}']); + + expect(fn () => StartService::run($service, stopBeforeStart: true)) + ->toThrow(RuntimeException::class, 'Doppler is down'); +}); + +test('a service restart fetches remote secrets only once', function () { + Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['API_KEY' => 'value'])]); + Process::fake(['*' => Process::result(output: '')]); + StopService::shouldRun(); + $service = remoteSecretLifecycleService(); + $service->environment_variables()->create(['key' => 'API_KEY', 'value' => '{{vault.API_KEY}}']); + + StartService::run($service, stopBeforeStart: true); + + Http::assertSentCount(1); +}); + +test('a database restart keeps the database running and reports the secret manager error', function () { + Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['messages' => ['Doppler is down']], 503)]); + StopDatabase::shouldRun()->never(); + $database = remoteSecretLifecyclePostgres(); + $database->runtime_environment_variables()->create(['key' => 'POSTGRES_PASSWORD', 'value' => '{{vault.PG_PASSWORD}}']); + + expect(RestartDatabase::run($database))->toBeString()->toContain('Doppler is down'); +}); + +test('a database start job shows the secret manager error instead of a generic failure', function () { + Event::fake([DatabaseStatusChanged::class]); + Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['messages' => ['Doppler is down']], 503)]); + $database = remoteSecretLifecyclePostgres(); + $database->runtime_environment_variables()->create(['key' => 'POSTGRES_PASSWORD', 'value' => '{{vault.PG_PASSWORD}}']); + $activity = activity() + ->withProperties([ + 'type' => ActivityTypes::INLINE->value, + 'type_uuid' => $database->uuid, + 'status' => ProcessStatus::QUEUED->value, + 'team_id' => $this->team->id, + 'operation' => 'database-start', + ]) + ->event(ActivityTypes::INLINE->value) + ->log('[]'); + + expect(fn () => DatabaseStartJob::dispatchSync(StandalonePostgresql::class, $database->id, $this->team->id, $activity->id, null)) + ->toThrow(RuntimeException::class); + + expect(data_get($activity->refresh(), 'properties.error'))->toContain('Doppler is down'); +}); + +test('the secret key autocomplete reuses recently fetched key names', function () { + Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['B_KEY' => 'b', 'A_KEY' => 'a'])]); + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $application->secretManagerLink()->create(['integration_token_id' => $this->token->id]); + $env = $application->environment_variables()->create(['key' => 'MY_VAR', 'value' => 'plain']); + $component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application'])->instance(); + + expect($component->fetchSecretManagerKeys())->toBe(['A_KEY', 'B_KEY']) + ->and($component->fetchSecretManagerKeys())->toBe(['A_KEY', 'B_KEY']); + Http::assertSentCount(1); +}); + +test('the secret key autocomplete limits how often a user can contact the secret manager', function () { + Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['A_KEY' => 'a'])]); + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $link = $application->secretManagerLink()->create(['integration_token_id' => $this->token->id]); + $env = $application->environment_variables()->create(['key' => 'MY_VAR', 'value' => 'plain']); + $component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application']); + + // A changed source is fetched again, so each request reaches the secret manager. + foreach (range(1, 10) as $attempt) { + $link->update(['settings' => ['project' => "project-{$attempt}", 'config' => 'prd']]); + $component->call('fetchSecretManagerKeys'); + } + $link->update(['settings' => ['project' => 'project-11', 'config' => 'prd']]); + + expect(fn () => $component->call('fetchSecretManagerKeys'))->toThrow(RuntimeException::class, 'Too many'); + Http::assertSentCount(10); +}); diff --git a/tests/Feature/SecretManagers/SecretManagerLinkTest.php b/tests/Feature/SecretManagers/SecretManagerLinkTest.php index d26f00c7c1..3cad814ea6 100644 --- a/tests/Feature/SecretManagers/SecretManagerLinkTest.php +++ b/tests/Feature/SecretManagers/SecretManagerLinkTest.php @@ -262,9 +262,9 @@ test('redis remote credentials stay deployment-local and use raw values in the s expect($password->fresh()->value)->toBe('{{team.REDIS_PASSWORD}}') ->and($sharedPassword->fresh()->value)->toBe('{{vault.REDIS_PASSWORD}}') ->and($username->fresh()->value)->toBe('{{vault.REDIS_USERNAME}}') - ->and($environmentVariables)->toContain('REDIS_PASSWORD=p4$$word') + ->and($environmentVariables)->toContain('REDIS_PASSWORD=p4$$$$word') ->and($environmentVariables)->toContain('REDIS_USERNAME=remote-user') - ->and($startCommand)->toContain('--requirepass p4$$word'); + ->and($startCommand)->toContain('--requirepass p4$$$$word'); }); test('all deployable environment-variable resources support secret managers', function (string $resourceClass) {