feat(secrets): resolve integrations across deployments and databases

Add secret manager integration links and API support, resolve referenced credentials in database startup commands, and improve environment variable handling and filtering.
This commit is contained in:
Andras Bacsai
2026-08-23 21:33:00 +02:00
parent 4cd75e05b5
commit 91d4467322
37 changed files with 594 additions and 109 deletions
@@ -15,17 +15,40 @@ use Illuminate\Support\Collection;
uses(RefreshDatabase::class);
it('does not persist environment write commands or generated Dockerfiles in deployment logs', function () {
$source = file_get_contents(app_path('Jobs/ApplicationDeploymentJob.php'));
$finalDockerfileWrite = str($source)
->after("addLogEntry('Final Dockerfile:'")
->before('private function modify_dockerfile_for_secrets')
->toString();
[$application, $server] = makeDeploymentControlVarFixture();
expect($source)
->and(substr_count($source, "'skip_command_log' => true"))->toBeGreaterThanOrEqual(4);
createApplicationEnvironmentVariable($application, [
'key' => 'APP_SECRET',
'value' => 'sensitive-value',
]);
expect($finalDockerfileWrite)
->not->toContain('executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}")');
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'configuration_dir' => '/data/coolify/applications/test-app',
'remote_secrets_cache' => [],
'saved_outputs' => [
'dockerfile' => "FROM php:8.4-cli\nRUN php -v",
],
]);
invokeDeploymentJobMethod($job, $reflection, 'save_runtime_environment_variables');
invokeDeploymentJobMethod($job, $reflection, 'save_buildtime_environment_variables');
invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');
$writeCommands = collect($job->recordedCommands)
->flatMap(fn (array $commands): array => $commands)
->filter(function (mixed $command): bool {
if (! is_array($command)) {
return false;
}
$commandString = $command['command'] ?? $command[0] ?? null;
return is_string($commandString) && str_contains($commandString, 'base64 -d | tee');
})
->values();
expect($writeCommands)->toHaveCount(4)
->each->toHaveKey('skip_command_log', true);
});
it('redacts resolved remote secrets from command output', function () {
@@ -38,6 +61,21 @@ it('redacts resolved remote secrets from command output', function () {
->toBe('token='.REDACTED);
});
it('ignores empty and non-string remote secrets when redacting command output', function () {
[$application, $server] = makeDeploymentControlVarFixture();
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'remote_secrets_cache' => [
'EMPTY_SECRET' => '',
'NULL_SECRET' => null,
'NUMERIC_SECRET' => 123,
'API_TOKEN' => 'remote-secret-value',
],
]);
expect(invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'id=123 token=remote-secret-value'))
->toBe('id=123 token='.REDACTED);
});
class TestableControlVarFilteringDeploymentJob extends ApplicationDeploymentJob
{
public array $recordedCommands = [];
@@ -432,6 +470,45 @@ it('filters buildpack control vars from dockerfile arg injection', function () {
expect($job->writtenDockerfile)->not->toContain('ARG RAILPACK_NODE_VERSION=');
});
it('injects raw escaped remote secrets into Dockerfile args and hashes the same values', function (int $pullRequestId, bool $isPreview) {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'SECRET_TOKEN',
'value' => '{{vault.API_TOKEN}}',
'is_preview' => $isPreview,
'is_runtime' => false,
'is_buildtime' => true,
]);
$secret = "secret\$value'quoted";
$escapedSecret = escapeBashEnvValue($secret);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'pull_request_id' => $pullRequestId,
'remote_secrets_cache' => ['API_TOKEN' => $secret],
'saved_outputs' => [
'dockerfile' => "FROM php:8.4-cli\nRUN php -v",
],
]);
invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');
$expectedHash = invokeDeploymentJobMethod(
$job,
$reflection,
'generate_secrets_hash',
collect(['SECRET_TOKEN' => $escapedSecret]),
);
expect($job->writtenDockerfile)
->toContain("ARG SECRET_TOKEN={$escapedSecret}")
->toContain("ARG COOLIFY_BUILD_SECRETS_HASH={$expectedHash}")
->not->toContain('$$');
})->with([
'production' => [0, false],
'preview' => [99, true],
]);
it('builds railpack variables from generic buildtime vars railpack vars and coolify vars only', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'railpack',
+22
View File
@@ -18,6 +18,28 @@ it('keeps the environment variable input enabled while secret manager keys load'
expect($view)->toContain('wire:target.except="fetchSecretManagerKeys"');
});
it('allows secret manager key loading to retry after a failed request', function () {
$view = file_get_contents(resource_path('views/components/forms/env-var-input.blade.php'));
$failureHandler = explode('});', explode('.catch(() => {', $view, 2)[1], 2)[0];
expect($failureHandler)
->toContain('this.vaultKeysLoading = false;')
->not->toContain("this.availableVars['vault'] = [];");
});
it('authorizes secret-enabled environment variable inputs at the component boundary', function () {
$addView = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/add.blade.php'));
$showView = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
foreach ([$addView, $showView] as $view) {
preg_match('/<x-forms\.env-var-input[\s\S]*?\/>/', $view, $matches);
expect($matches[0] ?? '')
->toContain('canGate="manageEnvironment"')
->toContain(':canResource="$resource"');
}
});
it('passes the remove source warning without compiling remote secret syntax as blade', function () {
$view = file_get_contents(resource_path('views/livewire/project/shared/secret-manager-links.blade.php'));
@@ -144,6 +144,23 @@ test('is_shared attribute detects variable without spaces', function () {
expect($env->is_shared)->toBeTrue();
});
test('is_shared persisted value rejects unsupported reference types', function () {
$env = EnvironmentVariable::create([
'key' => 'TEST',
'value' => '{{vault.KEY}}',
'resource_id' => $this->application->id,
'resource_type' => $this->application->getMorphClass(),
]);
$env->refresh();
expect($env->is_shared)->toBeFalse()
->and(EnvironmentVariable::query()
->whereKey($env->id)
->where('is_shared', false)
->exists())->toBeTrue();
});
test('non-shared variable preserves spaces', function () {
$env = EnvironmentVariable::create([
'key' => 'REGULAR',
+19
View File
@@ -58,6 +58,25 @@ test('a secret manager integration token can be created through the api', functi
->and($token->capabilities)->toBe(['secrets']);
});
test('secret manager provider base urls only accept http and https', function (string $provider, array $metadata) {
Http::fake();
$this->withHeaders(secretManagerApiHeaders($this->bearerToken))
->postJson('/api/v1/security/integration-tokens', [
'provider' => $provider,
'name' => 'Invalid base URL',
'token' => 'token',
'metadata' => $metadata,
])
->assertUnprocessable()
->assertJsonValidationErrors('metadata.base_url');
Http::assertNothingSent();
})->with([
'infisical' => ['infisical', ['base_url' => 'ftp://infisical.example.com', 'client_id' => 'client-1']],
'vault' => ['vault', ['base_url' => 'ftp://vault.example.com']],
]);
test('an application can be configured to use a secret manager through the api', function () {
$token = IntegrationToken::query()->create([
'team_id' => $this->team->id,
@@ -0,0 +1,16 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
test('resourceable columns have one composite unique index', function () {
$resourceableIndexes = collect(Schema::getIndexes('secret_manager_links'))
->filter(fn (array $index): bool => $index['columns'] === ['resourceable_type', 'resourceable_id'])
->values();
expect($resourceableIndexes)
->toHaveCount(1)
->and($resourceableIndexes->first()['unique'])->toBeTrue();
});
@@ -1,5 +1,6 @@
<?php
use App\Actions\Database\StartRedis;
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Livewire\Security\IntegrationTokens;
@@ -12,6 +13,7 @@ use App\Models\Project;
use App\Models\SecretManagerLink;
use App\Models\Server;
use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
@@ -149,14 +151,14 @@ test('a doppler link fetches secrets with the stored token', function () {
test('a vault link uses the base url and namespace from the token metadata', function () {
Http::fake([
'https://vault.internal:8200/v1/kv/data/apps/web' => Http::response([
'https://example.com:8200/v1/kv/data/apps/web' => Http::response([
'data' => ['data' => ['KEY' => 'value']],
]),
]);
$link = createSecretManagerLink('vault',
['mount' => 'kv', 'path' => 'apps/web'],
['base_url' => 'https://vault.internal:8200', 'namespace' => 'team-a'],
['base_url' => 'https://example.com:8200', 'namespace' => 'team-a'],
);
expect($link->fetchSecrets())->toBe(['KEY' => 'value']);
@@ -192,6 +194,58 @@ test('services resolve environment variables from their configured secret manage
expect($service->resolveSecretManagerEnvironmentVariable($environmentVariable))->toBe('remote-service-value');
});
test('redis remote credentials stay deployment-local and use raw values in the start command', function () {
Http::fake([
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
'REDIS_PASSWORD' => 'p4$$word',
'REDIS_USERNAME' => 'remote-user',
]),
]);
$redis = StandaloneRedis::forceCreate([
'uuid' => 'redis-secret-test',
'name' => 'Redis secret test',
'image' => 'redis:7-alpine',
'environment_id' => $this->application->environment_id,
'destination_id' => $this->application->destination_id,
'destination_type' => $this->application->destination_type,
]);
$token = IntegrationToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'doppler',
'name' => 'Redis secrets',
'token' => 'the-secret-token',
'capabilities' => ['secrets'],
]);
$redis->secretManagerLink()->create(['integration_token_id' => $token->id]);
$sharedPassword = SharedEnvironmentVariable::query()->create([
'key' => 'REDIS_PASSWORD',
'value' => '{{vault.REDIS_PASSWORD}}',
'type' => 'team',
'team_id' => $this->team->id,
]);
$password = $redis->runtime_environment_variables()->create([
'key' => 'REDIS_PASSWORD',
'value' => '{{team.REDIS_PASSWORD}}',
]);
$username = $redis->runtime_environment_variables()->create([
'key' => 'REDIS_USERNAME',
'value' => '{{vault.REDIS_USERNAME}}',
]);
$action = new StartRedis;
$action->database = $redis;
$environmentVariables = (new ReflectionMethod($action, 'generate_environment_variables'))->invoke($action);
$startCommand = (new ReflectionMethod($action, 'buildStartCommand'))->invoke($action);
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_USERNAME=remote-user')
->and($startCommand)->toContain('--requirepass p4$$word');
});
test('all deployable environment-variable resources support secret managers', function (string $resourceClass) {
expect(class_uses_recursive($resourceClass))->toContain(HasSecretManager::class);
})->with([
@@ -295,6 +349,15 @@ test('variables without references never contact the secret manager', function (
Http::assertNothingSent();
});
test('a null environment variable value remains null', function () {
$env = $this->application->environment_variables()->create([
'key' => 'EMPTY',
'value' => null,
]);
expect($this->application->resolveSecretManagerEnvironmentVariable($env))->toBeNull();
});
test('a missing secret key fails the deployment and names the variable', function () {
Http::fake([
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
@@ -386,7 +449,7 @@ test('remote secret values are formatted as dotenv literals', function () {
expect($format('simple'))->toBe("'simple'")
->and($format('with $dollar and spaces'))->toBe("'with \$dollar and spaces'")
->and($format("it's quoted"))->toBe('"it\'s quoted"')
->and($format('{"json": true}'))->toBe('{"json": true}');
->and($format('{"json": true}'))->toBe('\'{"json": true}\'');
});
test('deleting an integration token is blocked while links exist', function () {
@@ -12,6 +12,7 @@ use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Js;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -167,6 +168,27 @@ test('browse keys shows key names only and search filters them', function () {
->assertDontSee('API_KEY');
});
test('browse key actions encode apostrophes and backslashes', function () {
Http::fake([
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
"TEAM'S_KEY" => 'apostrophe-secret',
'TEAM\\KEY' => 'backslash-secret',
]),
]);
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
$apostropheExpression = 'addReference('.Js::from("TEAM'S_KEY").')';
$backslashExpression = 'addReference('.Js::from('TEAM\\KEY').')';
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
->call('loadKeys')
->assertSeeHtml('wire:click="'.$apostropheExpression.'"')
->assertSeeHtml('wire:target="'.$apostropheExpression.'"')
->assertSeeHtml('wire:click="'.$backslashExpression.'"')
->assertSeeHtml('wire:target="'.$backslashExpression.'"');
});
test('add reference creates a variable with a secret reference value', function () {
Http::fake([
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
@@ -225,7 +247,8 @@ test('members without update permission cannot save a source', function () {
session(['currentTeam' => $this->team]);
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
->set('integration_token_uuid', $this->token->uuid);
->set('integration_token_uuid', $this->token->uuid)
->assertDispatched('error', 'You need at least admin or owner permissions to update this application.');
$this->assertDatabaseCount('secret_manager_links', 0);
});
@@ -245,10 +268,19 @@ test('the edit modal value autocomplete offers the vault scope with lazy key fet
->assertSeeHtml('hasVaultSource: true');
expect($component->instance()->fetchSecretManagerKeys())->toBe(['DB_PASSWORD']);
});
$trait = file_get_contents(app_path('Traits/HasSecretManagerAutocomplete.php'));
test('the edit modal value autocomplete reports secret provider failures', function () {
Http::fake([
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([], 503),
]);
expect($trait)->toContain('$this->skipRender();');
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
$env = $this->application->environment_variables()->create(['key' => 'MY_VAR', 'value' => 'plain']);
$component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application']);
expect(fn () => $component->instance()->fetchSecretManagerKeys())
->toThrow(RuntimeException::class, 'Unable to fetch secret manager keys.');
});
test('the edit modal value autocomplete has no vault scope without a source', function () {
@@ -4,6 +4,7 @@ use App\Services\DopplerService;
use App\Services\InfisicalService;
use App\Services\VaultService;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
describe('DopplerService', function () {
test('downloads secrets as a flat key value map', function () {
@@ -66,12 +67,21 @@ describe('DopplerService', function () {
});
describe('InfisicalService', function () {
test('rejects an unapproved endpoint before sending credentials', function () {
Http::fake();
expect(fn () => new InfisicalService('http://127.0.0.1:8080', 'client-id', 'client-secret'))
->toThrow(ValidationException::class);
Http::assertNothingSent();
});
test('logs in with universal auth and fetches secrets from the v4 endpoint', function () {
Http::fake([
'https://infisical.example.com/api/v1/auth/universal-auth/login' => Http::response([
'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([
'accessToken' => 'short-lived-token',
]),
'https://infisical.example.com/api/v4/secrets*' => Http::response([
'https://example.com/infisical/api/v4/secrets*' => Http::response([
'secrets' => [
['secretKey' => 'DB_PASSWORD', 'secretValue' => 's3cret'],
['secretKey' => 'API_KEY', 'secretValue' => 'abc'],
@@ -79,7 +89,7 @@ describe('InfisicalService', function () {
]),
]);
$service = new InfisicalService('https://infisical.example.com/', 'client-id', 'client-secret');
$service = new InfisicalService('https://example.com/infisical/', 'client-id', 'client-secret');
$secrets = $service->fetchSecrets('project-1', 'prod', '/');
expect($secrets)->toBe([
@@ -94,18 +104,18 @@ describe('InfisicalService', function () {
test('falls back to the v3 raw endpoint on older self-hosted instances', function () {
Http::fake([
'https://infisical.example.com/api/v1/auth/universal-auth/login' => Http::response([
'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([
'accessToken' => 'short-lived-token',
]),
'https://infisical.example.com/api/v4/secrets*' => Http::response([], 404),
'https://infisical.example.com/api/v3/secrets/raw*' => Http::response([
'https://example.com/infisical/api/v4/secrets*' => Http::response([], 404),
'https://example.com/infisical/api/v3/secrets/raw*' => Http::response([
'secrets' => [
['secretKey' => 'LEGACY_KEY', 'secretValue' => 'legacy-value'],
],
]),
]);
$service = new InfisicalService('https://infisical.example.com', 'client-id', 'client-secret');
$service = new InfisicalService('https://example.com/infisical', 'client-id', 'client-secret');
expect($service->fetchSecrets('project-1', 'prod'))->toBe(['LEGACY_KEY' => 'legacy-value']);
@@ -114,12 +124,12 @@ describe('InfisicalService', function () {
test('throws when the login fails', function () {
Http::fake([
'https://infisical.example.com/api/v1/auth/universal-auth/login' => Http::response([
'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([
'message' => 'Invalid credentials',
], 401),
]);
$service = new InfisicalService('https://infisical.example.com', 'client-id', 'wrong');
$service = new InfisicalService('https://example.com/infisical', 'client-id', 'wrong');
expect($service->validate())->toBeFalse()
->and(fn () => $service->fetchSecrets('project-1', 'prod'))
@@ -128,9 +138,18 @@ describe('InfisicalService', function () {
});
describe('VaultService', function () {
test('rejects an unapproved endpoint before sending the token', function () {
Http::fake();
expect(fn () => new VaultService('http://127.0.0.1:8200', 'hvs.token'))
->toThrow(ValidationException::class);
Http::assertNothingSent();
});
test('reads a kv v2 secret and stringifies non-string values', function () {
Http::fake([
'https://vault.example.com:8200/v1/secret/data/my-app/production' => Http::response([
'https://example.com:8200/vault/v1/secret/data/my-app/production' => Http::response([
'data' => [
'data' => [
'DB_PASSWORD' => 's3cret',
@@ -140,7 +159,7 @@ describe('VaultService', function () {
]),
]);
$secrets = (new VaultService('https://vault.example.com:8200/', 'hvs.token'))
$secrets = (new VaultService('https://example.com:8200/vault/', 'hvs.token'))
->fetchSecrets('secret', '/my-app/production/');
expect($secrets)->toBe([
@@ -154,12 +173,12 @@ describe('VaultService', function () {
test('sends the namespace header when configured', function () {
Http::fake([
'https://vault.example.com:8200/v1/secret/data/my-app' => Http::response([
'https://example.com:8200/vault/v1/secret/data/my-app' => Http::response([
'data' => ['data' => ['KEY' => 'value']],
]),
]);
(new VaultService('https://vault.example.com:8200', 'hvs.token', 'admin/team-a'))
(new VaultService('https://example.com:8200/vault', 'hvs.token', 'admin/team-a'))
->fetchSecrets('secret', 'my-app');
Http::assertSent(fn ($request) => $request->hasHeader('X-Vault-Namespace', 'admin/team-a'));
@@ -167,20 +186,20 @@ describe('VaultService', function () {
test('throws a readable error when the read fails', function () {
Http::fake([
'https://vault.example.com:8200/v1/secret/data/missing' => Http::response([
'https://example.com:8200/vault/v1/secret/data/missing' => Http::response([
'errors' => ['permission denied'],
], 403),
]);
expect(fn () => (new VaultService('https://vault.example.com:8200', 'hvs.token'))->fetchSecrets('secret', 'missing'))
expect(fn () => (new VaultService('https://example.com:8200/vault', 'hvs.token'))->fetchSecrets('secret', 'missing'))
->toThrow(RuntimeException::class, 'Vault API error: permission denied');
});
test('validates the token with lookup-self', function () {
Http::fake([
'https://vault.example.com:8200/v1/auth/token/lookup-self' => Http::response(['data' => []]),
'https://example.com:8200/vault/v1/auth/token/lookup-self' => Http::response(['data' => []]),
]);
expect((new VaultService('https://vault.example.com:8200', 'hvs.token'))->validate())->toBeTrue();
expect((new VaultService('https://example.com:8200/vault', 'hvs.token'))->validate())->toBeTrue();
});
});
@@ -100,6 +100,14 @@ test('at least one capability is required when adding a cloudflare token', funct
Http::assertNothingSent();
});
test('provider validation uses the provider names declared by the model', function () {
$component = file_get_contents(app_path('Livewire/Security/IntegrationTokenForm.php'));
expect($component)
->toContain("implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))")
->not->toContain('in:cloudflare,doppler,infisical,vault');
});
test('integration tokens page lists saved provider and capabilities', function () {
IntegrationToken::query()->create([
'team_id' => $this->team->id,
@@ -62,9 +62,10 @@ test('an invalid doppler token is not saved', function () {
Livewire::test(IntegrationTokenForm::class)
->set('provider', 'doppler')
->set('name', 'Bad token')
->set('token', 'wrong')
->set('token', 'dp.st.rejected')
->call('addToken')
->assertHasErrors(['token']);
->assertHasNoErrors()
->assertDispatched('error');
$this->assertDatabaseCount('integration_tokens', 0);
});
@@ -113,6 +114,24 @@ test('an infisical token requires a base url and a client id', function () {
$this->assertDatabaseCount('integration_tokens', 0);
});
test('secret manager provider base urls only accept http and https', function (string $provider, array $metadata) {
Http::fake();
Livewire::test(IntegrationTokenForm::class)
->set('provider', $provider)
->set('name', 'Invalid base URL')
->set('token', 'token')
->set('metadata', $metadata)
->call('addToken')
->assertHasErrors(['metadata.base_url']);
Http::assertNothingSent();
$this->assertDatabaseCount('integration_tokens', 0);
})->with([
'infisical' => ['infisical', ['base_url' => 'ftp://infisical.example.com', 'client_id' => 'client-1']],
'vault' => ['vault', ['base_url' => 'ftp://vault.example.com']],
]);
test('the infisical fields put the client id before the client secret', function () {
Livewire::test(IntegrationTokenForm::class)
->set('provider', 'infisical')
@@ -121,7 +140,7 @@ test('the infisical fields put the client id before the client secret', function
test('an infisical token stores its metadata after a successful login', function () {
Http::fake([
'https://infisical.example.com/api/v1/auth/universal-auth/login' => Http::response([
'https://example.com/api/v1/auth/universal-auth/login' => Http::response([
'accessToken' => 'token',
]),
]);
@@ -130,26 +149,26 @@ test('an infisical token stores its metadata after a successful login', function
->set('provider', 'infisical')
->set('name', 'Infisical')
->set('token', 'client-secret')
->set('metadata', ['base_url' => 'https://infisical.example.com', 'client_id' => 'client-1'])
->set('metadata', ['base_url' => 'https://example.com', 'client_id' => 'client-1'])
->call('addToken')
->assertHasNoErrors();
$token = IntegrationToken::query()->where('provider', 'infisical')->firstOrFail();
expect($token->metadata)->toBe(['base_url' => 'https://infisical.example.com', 'client_id' => 'client-1'])
expect($token->metadata)->toBe(['base_url' => 'https://example.com', 'client_id' => 'client-1'])
->and($token->capabilities)->toBe(['secrets']);
});
test('a vault token is validated with lookup-self before it is saved', function () {
Http::fake([
'https://vault.example.com:8200/v1/auth/token/lookup-self' => Http::response(['data' => []]),
'https://example.com:8200/v1/auth/token/lookup-self' => Http::response(['data' => []]),
]);
Livewire::test(IntegrationTokenForm::class)
->set('provider', 'vault')
->set('name', 'Vault')
->set('token', 'hvs.token')
->set('metadata', ['base_url' => 'https://vault.example.com:8200'])
->set('metadata', ['base_url' => 'https://example.com:8200'])
->call('addToken')
->assertHasNoErrors();
@@ -0,0 +1,14 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
it('quotes JSON remote secrets so compose treats their contents literally', function (string $value, string $expected) {
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
$method = new ReflectionMethod(ApplicationDeploymentJob::class, 'format_remote_secret_value');
expect($method->invoke($job, $value))->toBe($expected);
})->with([
'object containing a variable reference' => ['{"password":"$ecret"}', '\'{"password":"$ecret"}\''],
'array containing a comment marker' => ['["value # not a comment"]', '\'["value # not a comment"]\''],
'object containing an apostrophe' => ['{"password":"it\'s $ecret"}', '"{\\"password\\":\\"it\'s $$ecret\\"}"'],
]);
@@ -0,0 +1,39 @@
<?php
it('reuses resolved environment credentials in database startup integrations', function (string $action, array $expected, array $unexpected) {
$source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
expect($source)->toContain(...$expected)
->not->toContain(...$unexpected);
})->with([
'clickhouse' => [
'StartClickhouse',
['$this->resolvedClickhouseUser', '$this->resolvedClickhousePassword'],
['$this->database->clickhouse_admin_user, \'--password\'', '$this->database->clickhouse_admin_password, \'--query\''],
],
'dragonfly' => [
'StartDragonfly',
['$this->resolvedRedisPassword'],
['$this->database->dragonfly_password, \'ping\'', 'requirepass {$this->database->dragonfly_password}'],
],
'keydb' => [
'StartKeydb',
['$this->resolvedRedisPassword'],
['$this->database->keydb_password, \'ping\'', 'requirepass {$this->database->keydb_password}'],
],
'mongodb' => [
'StartMongodb',
['$this->resolvedMongoDatabase', '$this->resolvedMongoUsername', '$this->resolvedMongoPassword'],
['json_encode($this->database->mongo_initdb_database', 'json_encode($this->database->mongo_initdb_root_username', 'json_encode($this->database->mongo_initdb_root_password'],
],
'mysql' => [
'StartMysql',
['$this->resolvedMysqlRootPassword'],
['-p{$this->database->mysql_root_password}'],
],
'postgresql' => [
'StartPostgresql',
['$this->resolvedPostgresUser', '$this->resolvedPostgresDatabase'],
['$this->database->postgres_user, \'-d\'', '$this->database->postgres_db, \'-c\''],
],
]);
@@ -26,6 +26,14 @@ test('extracts unique referenced keys in order', function () {
expect(RemoteSecretReferences::referencedKeys($value))->toBe(['A']);
});
test('handles padded reference syntax consistently', function () {
expect(RemoteSecretReferences::referencedKeys('{{ vault.A }}'))->toBe(['A'])
->and(RemoteSecretReferences::substitute('{{ vault.A }} {{ vault.MISSING }}', ['A' => 'value-a']))
->toBe('value-a {{ vault.MISSING }}')
->and(RemoteSecretReferences::missingKeys('{{ vault.A }} {{ vault.MISSING }}', ['A' => 'value-a']))
->toBe(['MISSING']);
});
test('substitutes references and leaves unknown keys untouched', function () {
$secrets = ['A' => 'value-a'];