mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
Merge remote-tracking branch 'origin/next' into team-resource-audit-logging
This commit is contained in:
@@ -14,6 +14,68 @@ use Illuminate\Support\Collection;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('does not persist environment write commands or generated Dockerfiles in deployment logs', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_SECRET',
|
||||
'value' => 'sensitive-value',
|
||||
]);
|
||||
|
||||
[$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 () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'remote_secrets_cache' => ['API_TOKEN' => 'remote-secret-value'],
|
||||
]);
|
||||
|
||||
expect(invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'token=remote-secret-value'))
|
||||
->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 = [];
|
||||
@@ -130,12 +192,12 @@ function makeControlVarFilteringJob(Application $application, Server $server, ar
|
||||
return [$job, $reflection];
|
||||
}
|
||||
|
||||
function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method): mixed
|
||||
function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method, mixed ...$arguments): mixed
|
||||
{
|
||||
$reflectionMethod = $reflection->getMethod($method);
|
||||
$reflectionMethod->setAccessible(true);
|
||||
|
||||
return $reflectionMethod->invoke($job);
|
||||
return $reflectionMethod->invoke($job, ...$arguments);
|
||||
}
|
||||
|
||||
function readDeploymentJobProperty(object $job, ReflectionClass $reflection, string $property): mixed
|
||||
@@ -403,10 +465,50 @@ it('filters buildpack control vars from dockerfile arg injection', function () {
|
||||
invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');
|
||||
|
||||
expect($job->writtenDockerfile)->toContain('ARG APP_ENV=production');
|
||||
expect($job->writtenDockerfile)->toContain('ARG COOLIFY_BUILD_SECRETS_HASH=');
|
||||
expect($job->writtenDockerfile)->not->toContain('ARG NIXPACKS_NODE_VERSION=');
|
||||
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',
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\StartDatabase;
|
||||
use App\Jobs\DatabaseStartJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Spatie\Activitylog\ActivityLogStatus;
|
||||
|
||||
it('returns an actionable error when database start activity logging is disabled', function () {
|
||||
config()->set('activitylog.enabled', false);
|
||||
app(ActivityLogStatus::class)->disable();
|
||||
Bus::fake();
|
||||
|
||||
$server = new Server(['ip' => '192.0.2.1']);
|
||||
$server->setRelation('settings', new ServerSetting([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
]));
|
||||
|
||||
$destination = new StandaloneDocker;
|
||||
$destination->setRelation('server', $server);
|
||||
|
||||
$database = new StandaloneRedis;
|
||||
$database->setRelation('destination', $destination);
|
||||
|
||||
$result = (new StartDatabase)->handle($database);
|
||||
|
||||
expect($result)->toBe('Database start could not be queued because activity logging is disabled.');
|
||||
Bus::assertNotDispatched(DatabaseStartJob::class);
|
||||
});
|
||||
@@ -11,3 +11,54 @@ it('uses the current listbox design for environment variable suggestions', funct
|
||||
->toContain('border-emerald-500/25 bg-emerald-500/10')
|
||||
->not->toContain('dark:bg-coolgray-100');
|
||||
});
|
||||
|
||||
it('keeps the environment variable input enabled while secret manager keys load', function () {
|
||||
$view = file_get_contents(resource_path('views/components/forms/env-var-input.blade.php'));
|
||||
|
||||
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'));
|
||||
|
||||
expect($view)
|
||||
->toContain('with {{vault.KEY}}. Values are fetched')
|
||||
->not->toContain('{{doppler.KEY}}')
|
||||
->not->toContain('{{infisical.KEY}}')
|
||||
->toContain(':actions="[$removeSourceWarning]"')
|
||||
->not->toContain(':actions="[\'Existing {{vault.*}}');
|
||||
});
|
||||
|
||||
it('shows secret manager configuration for applications services and databases', function () {
|
||||
$views = [
|
||||
resource_path('views/livewire/project/application/configuration.blade.php'),
|
||||
resource_path('views/livewire/project/service/configuration.blade.php'),
|
||||
resource_path('views/livewire/project/database/configuration.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($views as $view) {
|
||||
expect(file_get_contents($view))->toContain('livewire:project.shared.secret-manager-links');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0, 'is_api_enabled' => true]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->bearerToken = $this->user->createToken('secret-manager-api-test', ['*'])->plainTextToken;
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
});
|
||||
|
||||
function secretManagerApiHeaders(string $token): array
|
||||
{
|
||||
return ['Authorization' => 'Bearer '.$token];
|
||||
}
|
||||
|
||||
test('a secret manager integration token can be created through the api', function () {
|
||||
Http::fake(['https://api.doppler.com/v3/me' => Http::response([], 200)]);
|
||||
|
||||
$response = $this->withHeaders(secretManagerApiHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/security/integration-tokens', [
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Production secrets',
|
||||
'token' => 'dp.st.secret',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonStructure(['uuid']);
|
||||
|
||||
$token = IntegrationToken::query()->whereUuid($response->json('uuid'))->firstOrFail();
|
||||
|
||||
expect($token->team_id)->toBe($this->team->id)
|
||||
->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,
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Production secrets',
|
||||
'token' => 'dp.sa.secret',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
|
||||
$this->withHeaders(secretManagerApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/secret-manager", [
|
||||
'integration_token_uuid' => $token->uuid,
|
||||
'settings' => [
|
||||
'project' => 'website',
|
||||
'config' => 'production',
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('integration_token_uuid', $token->uuid)
|
||||
->assertJsonPath('provider', 'doppler')
|
||||
->assertJsonPath('settings.project', 'website');
|
||||
|
||||
$link = $this->application->secretManagerLink()->firstOrFail();
|
||||
|
||||
expect($link->integration_token_id)->toBe($token->id)
|
||||
->and($link->settings)->toBe(['project' => 'website', 'config' => 'production']);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,471 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\StartRedis;
|
||||
use App\Exceptions\DeploymentException;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Livewire\Security\IntegrationTokens;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\IntegrationToken;
|
||||
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;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Traits\HasSecretManager;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
});
|
||||
|
||||
function createSecretManagerLink(string $provider, array $settings = [], array $metadata = []): SecretManagerLink
|
||||
{
|
||||
$token = IntegrationToken::query()->create([
|
||||
'team_id' => test()->team->id,
|
||||
'provider' => $provider,
|
||||
'name' => ucfirst($provider).' token',
|
||||
'token' => 'the-secret-token',
|
||||
'capabilities' => ['secrets'],
|
||||
'metadata' => $metadata ?: null,
|
||||
]);
|
||||
|
||||
return test()->application->secretManagerLink()->create([
|
||||
'integration_token_id' => $token->id,
|
||||
'settings' => $settings ?: null,
|
||||
]);
|
||||
}
|
||||
|
||||
function makeDeploymentJobForSecrets(): ApplicationDeploymentJob
|
||||
{
|
||||
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
|
||||
|
||||
$queue = ApplicationDeploymentQueue::create([
|
||||
'application_id' => test()->application->id,
|
||||
'deployment_uuid' => 'secrets-test-'.fake()->uuid(),
|
||||
'status' => 'in_progress',
|
||||
'server_id' => test()->application->destination->server->id,
|
||||
'destination_id' => test()->application->destination->id,
|
||||
'commit' => 'HEAD',
|
||||
'pull_request_id' => 0,
|
||||
]);
|
||||
|
||||
$properties = [
|
||||
'application' => test()->application,
|
||||
'application_deployment_queue' => $queue,
|
||||
'mainServer' => test()->application->destination->server,
|
||||
'pull_request_id' => 0,
|
||||
];
|
||||
|
||||
foreach ($properties as $property => $value) {
|
||||
$reflection = new ReflectionProperty($job, $property);
|
||||
$reflection->setValue($job, $value);
|
||||
}
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
function resolveEnvOnJob(ApplicationDeploymentJob $job, $env): ?string
|
||||
{
|
||||
return (new ReflectionMethod($job, 'resolve_environment_variable'))->invoke($job, $env);
|
||||
}
|
||||
|
||||
function deploymentHasRemoteBuildtimeReferences(ApplicationDeploymentJob $job): bool
|
||||
{
|
||||
return (new ReflectionMethod($job, 'has_remote_buildtime_secret_references'))->invoke($job);
|
||||
}
|
||||
|
||||
test('remote build-time secret references prevent same-commit image reuse', function (string $reference) {
|
||||
$this->application->environment_variables()->create([
|
||||
'key' => 'BUILD_SECRET',
|
||||
'value' => $reference,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
expect(deploymentHasRemoteBuildtimeReferences(makeDeploymentJobForSecrets()))->toBeTrue();
|
||||
})->with([
|
||||
'provider-neutral reference' => '{{vault.BUILD_SECRET}}',
|
||||
]);
|
||||
|
||||
test('runtime-only remote secret references still allow same-commit image reuse', function () {
|
||||
$this->application->environment_variables()->create([
|
||||
'key' => 'RUNTIME_SECRET',
|
||||
'value' => '{{vault.RUNTIME_SECRET}}',
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
|
||||
expect(deploymentHasRemoteBuildtimeReferences(makeDeploymentJobForSecrets()))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a doppler link fetches secrets with the stored token', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'DB_PASSWORD' => 's3cret',
|
||||
]),
|
||||
]);
|
||||
|
||||
$link = createSecretManagerLink('doppler', ['project' => 'proj', 'config' => 'prd']);
|
||||
|
||||
expect($link->fetchSecrets())->toBe(['DB_PASSWORD' => 's3cret']);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer the-secret-token')
|
||||
&& str_contains($request->url(), 'project=proj'));
|
||||
});
|
||||
|
||||
test('a vault link uses the base url and namespace from the token metadata', function () {
|
||||
Http::fake([
|
||||
'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://example.com:8200', 'namespace' => 'team-a'],
|
||||
);
|
||||
|
||||
expect($link->fetchSecrets())->toBe(['KEY' => 'value']);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->hasHeader('X-Vault-Namespace', 'team-a'));
|
||||
});
|
||||
|
||||
test('services resolve environment variables from their configured secret manager', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'API_KEY' => 'remote-service-value',
|
||||
]),
|
||||
]);
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'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' => 'Service secrets',
|
||||
'token' => 'the-secret-token',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
$service->secretManagerLink()->create(['integration_token_id' => $token->id]);
|
||||
$environmentVariable = $service->environment_variables()->create([
|
||||
'key' => 'API_KEY',
|
||||
'value' => '{{vault.API_KEY}}',
|
||||
]);
|
||||
|
||||
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([
|
||||
Application::class,
|
||||
Service::class,
|
||||
StandalonePostgresql::class,
|
||||
StandaloneMysql::class,
|
||||
StandaloneMariadb::class,
|
||||
StandaloneMongodb::class,
|
||||
StandaloneRedis::class,
|
||||
StandaloneKeydb::class,
|
||||
StandaloneDragonfly::class,
|
||||
StandaloneClickhouse::class,
|
||||
]);
|
||||
|
||||
test('an application has at most one secret manager source', function () {
|
||||
createSecretManagerLink('doppler');
|
||||
|
||||
$secondToken = IntegrationToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vault',
|
||||
'name' => 'Vault token',
|
||||
'token' => 'other-token',
|
||||
'capabilities' => ['secrets'],
|
||||
'metadata' => ['base_url' => 'https://vault.internal:8200'],
|
||||
]);
|
||||
|
||||
expect(fn () => $this->application->secretManagerLink()->create([
|
||||
'integration_token_id' => $secondToken->id,
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('a secret reference is substituted at deploy time and formatted as a dotenv literal', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'DB_PASSWORD' => 'p4$$word',
|
||||
]),
|
||||
]);
|
||||
|
||||
createSecretManagerLink('doppler');
|
||||
|
||||
$env = $this->application->environment_variables()->create([
|
||||
'key' => 'DATABASE_URL',
|
||||
'value' => 'postgres://app:{{vault.DB_PASSWORD}}@db:5432/app',
|
||||
]);
|
||||
|
||||
$job = makeDeploymentJobForSecrets();
|
||||
|
||||
expect(resolveEnvOnJob($job, $env))->toBe("'postgres://app:p4\$\$word@db:5432/app'");
|
||||
});
|
||||
|
||||
test('provider alias references resolve against the single source', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'API_KEY' => 'abc',
|
||||
]),
|
||||
]);
|
||||
|
||||
createSecretManagerLink('doppler');
|
||||
|
||||
$env = $this->application->environment_variables()->create([
|
||||
'key' => 'API_KEY',
|
||||
'value' => '{{vault.API_KEY}}',
|
||||
]);
|
||||
|
||||
expect(resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))->toBe("'abc'");
|
||||
});
|
||||
|
||||
test('the fetch happens once per deployment even with many references', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'A' => '1',
|
||||
'B' => '2',
|
||||
]),
|
||||
]);
|
||||
|
||||
createSecretManagerLink('doppler');
|
||||
|
||||
$first = $this->application->environment_variables()->create(['key' => 'A', 'value' => '{{vault.A}}']);
|
||||
$second = $this->application->environment_variables()->create(['key' => 'B', 'value' => '{{vault.B}}']);
|
||||
|
||||
$job = makeDeploymentJobForSecrets();
|
||||
resolveEnvOnJob($job, $first);
|
||||
resolveEnvOnJob($job, $second);
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('variables without references never contact the secret manager', function () {
|
||||
Http::fake();
|
||||
|
||||
createSecretManagerLink('doppler');
|
||||
|
||||
$env = $this->application->environment_variables()->create([
|
||||
'key' => 'PLAIN',
|
||||
'value' => 'plain-value',
|
||||
]);
|
||||
|
||||
expect(resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))->toBe('plain-value');
|
||||
|
||||
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([
|
||||
'OTHER' => 'value',
|
||||
]),
|
||||
]);
|
||||
|
||||
createSecretManagerLink('doppler');
|
||||
|
||||
$env = $this->application->environment_variables()->create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => '{{vault.GONE_KEY}}',
|
||||
]);
|
||||
|
||||
expect(fn () => resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))
|
||||
->toThrow(DeploymentException::class, 'Missing secret keys: GONE_KEY (referenced by DB_PASSWORD).');
|
||||
});
|
||||
|
||||
test('a reference without a configured source fails the deployment', function () {
|
||||
Http::fake();
|
||||
|
||||
$env = $this->application->environment_variables()->create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => '{{vault.DB_PASSWORD}}',
|
||||
]);
|
||||
|
||||
expect(fn () => resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))
|
||||
->toThrow(DeploymentException::class, 'no secret manager source is configured');
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('a fetch failure stops the deployment with a clear error', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'messages' => ['Invalid Auth token'],
|
||||
], 401),
|
||||
]);
|
||||
|
||||
createSecretManagerLink('doppler');
|
||||
|
||||
$env = $this->application->environment_variables()->create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => '{{vault.DB_PASSWORD}}',
|
||||
]);
|
||||
|
||||
expect(fn () => resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))
|
||||
->toThrow(DeploymentException::class, 'Could not fetch secrets from Doppler.');
|
||||
});
|
||||
|
||||
test('import creates reference variables for missing keys only', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'EXISTING' => 'value-a',
|
||||
'NEW_KEY' => 'value-b',
|
||||
]),
|
||||
]);
|
||||
|
||||
createSecretManagerLink('doppler');
|
||||
$this->application->environment_variables()->create(['key' => 'EXISTING', 'value' => 'local']);
|
||||
|
||||
$imported = $this->application->secretManagerLink->importMissingReferences();
|
||||
|
||||
expect($imported)->toBe(['NEW_KEY']);
|
||||
|
||||
$created = $this->application->environment_variables()->where('key', 'NEW_KEY')->firstOrFail();
|
||||
expect($created->value)->toBe('{{vault.NEW_KEY}}')
|
||||
->and($this->application->environment_variables()->where('key', 'EXISTING')->firstOrFail()->value)->toBe('local');
|
||||
});
|
||||
|
||||
test('secret references are not marked as shared variables', function () {
|
||||
$secretRef = $this->application->environment_variables()->create([
|
||||
'key' => 'A',
|
||||
'value' => '{{vault.A}}',
|
||||
]);
|
||||
$sharedRef = $this->application->environment_variables()->create([
|
||||
'key' => 'B',
|
||||
'value' => '{{team.B}}',
|
||||
]);
|
||||
|
||||
expect($secretRef->refresh()->is_shared)->toBeFalse()
|
||||
->and($sharedRef->refresh()->is_shared)->toBeTrue();
|
||||
});
|
||||
|
||||
test('remote secret values are formatted as dotenv literals', function () {
|
||||
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
|
||||
$format = fn (string $value) => (new ReflectionMethod($job, 'format_remote_secret_value'))->invoke($job, $value);
|
||||
|
||||
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}\'');
|
||||
});
|
||||
|
||||
test('deleting an integration token is blocked while links exist', function () {
|
||||
$link = createSecretManagerLink('doppler');
|
||||
|
||||
Livewire\Livewire::test(IntegrationTokens::class)
|
||||
->call('deleteToken', $link->integration_token_id)
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(IntegrationToken::query()->whereKey($link->integration_token_id)->exists())->toBeTrue();
|
||||
|
||||
$link->delete();
|
||||
|
||||
Livewire\Livewire::test(IntegrationTokens::class)
|
||||
->call('deleteToken', $link->integration_token_id)
|
||||
->assertDispatched('success');
|
||||
|
||||
expect(IntegrationToken::query()->whereKey($link->integration_token_id)->exists())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
|
||||
use App\Livewire\Project\Shared\SecretManagerLinks;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
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);
|
||||
|
||||
beforeEach(function () {
|
||||
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->token = IntegrationToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Doppler production',
|
||||
'token' => 'dp.st.token',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('selecting a token in the dropdown saves the source automatically', function () {
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->set('integration_token_uuid', $this->token->uuid)
|
||||
->assertDispatched('success');
|
||||
|
||||
$this->assertDatabaseHas('secret_manager_links', [
|
||||
'resourceable_type' => $this->application->getMorphClass(),
|
||||
'resourceable_id' => $this->application->id,
|
||||
'integration_token_id' => $this->token->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('service account settings are required and save automatically on blur', function () {
|
||||
$serviceAccountToken = IntegrationToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Doppler service account',
|
||||
'token' => 'dp.sa.token',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $serviceAccountToken->id]);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->call('saveSettings')
|
||||
->assertHasErrors(['settings.project', 'settings.config']);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->set('settings', ['project' => 'proj', 'config' => 'prd'])
|
||||
->call('saveSettings')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->application->secretManagerLink()->firstOrFail()->settings)
|
||||
->toBe(['project' => 'proj', 'config' => 'prd']);
|
||||
});
|
||||
|
||||
test('doppler settings match the selected token type', function () {
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->assertSee('Project and config are fixed by this service token.')
|
||||
->assertDontSee('Project (required)');
|
||||
|
||||
$serviceAccountToken = IntegrationToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Doppler service account',
|
||||
'token' => 'dp.sa.token',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
|
||||
$this->application->secretManagerLink()->update([
|
||||
'integration_token_id' => $serviceAccountToken->id,
|
||||
]);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->assertSee('Project (required)')
|
||||
->assertSee('Config (required)');
|
||||
});
|
||||
|
||||
test('selecting another token replaces the source and clears provider settings without checking references', function () {
|
||||
$this->application->secretManagerLink()->create([
|
||||
'integration_token_id' => $this->token->id,
|
||||
'settings' => ['project' => 'proj'],
|
||||
]);
|
||||
$this->application->environment_variables()->create(['key' => 'A', 'value' => '{{vault.A}}']);
|
||||
|
||||
$otherToken = IntegrationToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vault',
|
||||
'name' => 'Vault',
|
||||
'token' => 'hvs.token',
|
||||
'capabilities' => ['secrets'],
|
||||
'metadata' => ['base_url' => 'https://vault.internal:8200'],
|
||||
]);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->set('integration_token_uuid', $otherToken->uuid)
|
||||
->assertDispatched('success')
|
||||
->assertSet('settings', []);
|
||||
|
||||
$this->assertDatabaseCount('secret_manager_links', 1);
|
||||
$this->assertDatabaseHas('secret_manager_links', [
|
||||
'integration_token_id' => $otherToken->id,
|
||||
'settings' => null,
|
||||
]);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('browse keys shows key names only and search filters them', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'DB_PASSWORD' => 'super-secret-value',
|
||||
'API_KEY' => 'another-secret',
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
|
||||
$component = Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->call('loadKeys')
|
||||
->assertSee('DB_PASSWORD')
|
||||
->assertSee('API_KEY')
|
||||
->assertSee('{{vault.DB_PASSWORD}}')
|
||||
->assertSeeHtml('class="flex min-w-0 flex-col"')
|
||||
->assertDontSee('{{ $key }}')
|
||||
->assertDontSee('super-secret-value')
|
||||
->assertDontSee('another-secret');
|
||||
|
||||
expect($component->get('keys'))->toBe(['API_KEY', 'DB_PASSWORD']);
|
||||
|
||||
$component->set('search', 'db_pass')
|
||||
->assertSee('DB_PASSWORD')
|
||||
->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([
|
||||
'DB_PASSWORD' => 'super-secret-value',
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->call('loadKeys')
|
||||
->call('addReference', 'DB_PASSWORD')
|
||||
->assertDispatched('refreshEnvs')
|
||||
->assertDispatched('success');
|
||||
|
||||
$created = $this->application->environment_variables()->where('key', 'DB_PASSWORD')->firstOrFail();
|
||||
expect($created->value)->toBe('{{vault.DB_PASSWORD}}');
|
||||
});
|
||||
|
||||
test('import all creates references for missing keys and skips existing ones', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'EXISTING' => 'a',
|
||||
'NEW_KEY' => 'b',
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
$this->application->environment_variables()->create(['key' => 'EXISTING', 'value' => 'local']);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->call('importAll')
|
||||
->assertDispatched('refreshEnvs')
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->application->environment_variables()->where('key', 'NEW_KEY')->firstOrFail()->value)
|
||||
->toBe('{{vault.NEW_KEY}}')
|
||||
->and($this->application->environment_variables()->where('key', 'EXISTING')->firstOrFail()->value)
|
||||
->toBe('local');
|
||||
});
|
||||
|
||||
test('the source can be removed', function () {
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->call('removeSource')
|
||||
->assertDispatched('success');
|
||||
|
||||
$this->assertDatabaseCount('secret_manager_links', 0);
|
||||
});
|
||||
|
||||
test('members without update permission cannot save a source', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->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);
|
||||
});
|
||||
|
||||
test('the edit modal value autocomplete offers the vault scope with lazy key fetch', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'DB_PASSWORD' => 'super-secret-value',
|
||||
]),
|
||||
]);
|
||||
|
||||
$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'])
|
||||
->call('loadValues')
|
||||
->assertSeeHtml('hasVaultSource: true');
|
||||
|
||||
expect($component->instance()->fetchSecretManagerKeys())->toBe(['DB_PASSWORD']);
|
||||
});
|
||||
|
||||
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),
|
||||
]);
|
||||
|
||||
$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 () {
|
||||
$env = $this->application->environment_variables()->create(['key' => 'MY_VAR', 'value' => 'plain']);
|
||||
|
||||
$component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application'])
|
||||
->call('loadValues')
|
||||
->assertSeeHtml('hasVaultSource: false');
|
||||
|
||||
expect($component->instance()->fetchSecretManagerKeys())->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
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 () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'DATABASE_URL' => 'postgres://user:pass@host/db',
|
||||
'API_KEY' => 'secret-value',
|
||||
]),
|
||||
]);
|
||||
|
||||
$secrets = (new DopplerService('dp.st.test'))->fetchSecrets();
|
||||
|
||||
expect($secrets)->toBe([
|
||||
'DATABASE_URL' => 'postgres://user:pass@host/db',
|
||||
'API_KEY' => 'secret-value',
|
||||
]);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer dp.st.test')
|
||||
&& str_contains($request->url(), 'format=json')
|
||||
&& ! str_contains($request->url(), 'project='));
|
||||
});
|
||||
|
||||
test('sends project and config for service account tokens', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response(['KEY' => 'value']),
|
||||
]);
|
||||
|
||||
(new DopplerService('dp.sa.test'))->fetchSecrets('my-project', 'prd');
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'project=my-project')
|
||||
&& str_contains($request->url(), 'config=prd'));
|
||||
});
|
||||
|
||||
test('throws a readable error when the download fails', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'messages' => ['Invalid Auth token'],
|
||||
], 401),
|
||||
]);
|
||||
|
||||
expect(fn () => (new DopplerService('bad-token'))->fetchSecrets())
|
||||
->toThrow(RuntimeException::class, 'Doppler API error: Invalid Auth token');
|
||||
});
|
||||
|
||||
test('validates the token against the me endpoint', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/me' => Http::response(['type' => 'service_token']),
|
||||
]);
|
||||
|
||||
expect((new DopplerService('dp.st.test'))->validate())->toBeTrue();
|
||||
});
|
||||
|
||||
test('validation fails for a rejected token', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/me' => Http::response([], 401),
|
||||
]);
|
||||
|
||||
expect((new DopplerService('bad'))->validate())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
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://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'accessToken' => 'short-lived-token',
|
||||
]),
|
||||
'https://example.com/infisical/api/v4/secrets*' => Http::response([
|
||||
'secrets' => [
|
||||
['secretKey' => 'DB_PASSWORD', 'secretValue' => 's3cret'],
|
||||
['secretKey' => 'API_KEY', 'secretValue' => 'abc'],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$service = new InfisicalService('https://example.com/infisical/', 'client-id', 'client-secret');
|
||||
$secrets = $service->fetchSecrets('project-1', 'prod', '/');
|
||||
|
||||
expect($secrets)->toBe([
|
||||
'DB_PASSWORD' => 's3cret',
|
||||
'API_KEY' => 'abc',
|
||||
]);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/api/v4/secrets')
|
||||
&& $request->hasHeader('Authorization', 'Bearer short-lived-token')
|
||||
&& str_contains($request->url(), 'projectId=project-1'));
|
||||
});
|
||||
|
||||
test('falls back to the v3 raw endpoint on older self-hosted instances', function () {
|
||||
Http::fake([
|
||||
'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'accessToken' => 'short-lived-token',
|
||||
]),
|
||||
'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://example.com/infisical', 'client-id', 'client-secret');
|
||||
|
||||
expect($service->fetchSecrets('project-1', 'prod'))->toBe(['LEGACY_KEY' => 'legacy-value']);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'workspaceId=project-1'));
|
||||
});
|
||||
|
||||
test('throws when the login fails', function () {
|
||||
Http::fake([
|
||||
'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'message' => 'Invalid credentials',
|
||||
], 401),
|
||||
]);
|
||||
|
||||
$service = new InfisicalService('https://example.com/infisical', 'client-id', 'wrong');
|
||||
|
||||
expect($service->validate())->toBeFalse()
|
||||
->and(fn () => $service->fetchSecrets('project-1', 'prod'))
|
||||
->toThrow(RuntimeException::class, 'Infisical login failed: Invalid credentials');
|
||||
});
|
||||
});
|
||||
|
||||
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://example.com:8200/vault/v1/secret/data/my-app/production' => Http::response([
|
||||
'data' => [
|
||||
'data' => [
|
||||
'DB_PASSWORD' => 's3cret',
|
||||
'REPLICAS' => 3,
|
||||
],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$secrets = (new VaultService('https://example.com:8200/vault/', 'hvs.token'))
|
||||
->fetchSecrets('secret', '/my-app/production/');
|
||||
|
||||
expect($secrets)->toBe([
|
||||
'DB_PASSWORD' => 's3cret',
|
||||
'REPLICAS' => '3',
|
||||
]);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->hasHeader('X-Vault-Token', 'hvs.token')
|
||||
&& ! $request->hasHeader('X-Vault-Namespace'));
|
||||
});
|
||||
|
||||
test('sends the namespace header when configured', function () {
|
||||
Http::fake([
|
||||
'https://example.com:8200/vault/v1/secret/data/my-app' => Http::response([
|
||||
'data' => ['data' => ['KEY' => 'value']],
|
||||
]),
|
||||
]);
|
||||
|
||||
(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'));
|
||||
});
|
||||
|
||||
test('throws a readable error when the read fails', function () {
|
||||
Http::fake([
|
||||
'https://example.com:8200/vault/v1/secret/data/missing' => Http::response([
|
||||
'errors' => ['permission denied'],
|
||||
], 403),
|
||||
]);
|
||||
|
||||
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://example.com:8200/vault/v1/auth/token/lookup-self' => Http::response(['data' => []]),
|
||||
]);
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\IntegrationTokenForm;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
$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);
|
||||
});
|
||||
|
||||
test('a doppler token is validated against the doppler api before it is saved', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/me' => Http::response(['type' => 'service_token']),
|
||||
]);
|
||||
|
||||
Livewire::test(IntegrationTokenForm::class, ['modal_mode' => true])
|
||||
->set('provider', 'doppler')
|
||||
->set('name', 'Production secrets')
|
||||
->set('token', 'dp.st.token')
|
||||
->call('addToken')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('close-modal');
|
||||
|
||||
$this->assertDatabaseHas('integration_tokens', [
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Production secrets',
|
||||
]);
|
||||
});
|
||||
|
||||
test('selecting a secret manager provider switches the capability to secrets', function () {
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'doppler')
|
||||
->assertSet('capabilities', ['secrets'])
|
||||
->set('provider', 'cloudflare')
|
||||
->assertSet('capabilities', ['dns']);
|
||||
});
|
||||
|
||||
test('an invalid doppler token is not saved', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/me' => Http::response([], 401),
|
||||
]);
|
||||
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'doppler')
|
||||
->set('name', 'Bad token')
|
||||
->set('token', 'dp.st.rejected')
|
||||
->call('addToken')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('error');
|
||||
|
||||
$this->assertDatabaseCount('integration_tokens', 0);
|
||||
});
|
||||
|
||||
test('doppler only accepts service and service account tokens', function (string $token) {
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'doppler')
|
||||
->set('name', 'Unsupported token')
|
||||
->set('token', $token)
|
||||
->call('addToken')
|
||||
->assertHasErrors(['token']);
|
||||
|
||||
$this->assertDatabaseCount('integration_tokens', 0);
|
||||
})->with([
|
||||
'personal token' => 'dp.pt.token',
|
||||
'unknown token' => 'token',
|
||||
]);
|
||||
|
||||
test('a doppler service account token is accepted', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/me' => Http::response(['type' => 'service_account']),
|
||||
]);
|
||||
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'doppler')
|
||||
->set('name', 'Shared secrets')
|
||||
->set('token', 'dp.sa.token')
|
||||
->call('addToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('integration_tokens', [
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Shared secrets',
|
||||
]);
|
||||
});
|
||||
|
||||
test('an infisical token requires a base url and a client id', function () {
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'infisical')
|
||||
->set('name', 'Infisical')
|
||||
->set('token', 'client-secret')
|
||||
->set('metadata', [])
|
||||
->call('addToken')
|
||||
->assertHasErrors(['metadata.base_url', 'metadata.client_id']);
|
||||
|
||||
$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')
|
||||
->assertSeeInOrder(['Token name', 'Client ID', 'Client secret', 'Base URL']);
|
||||
});
|
||||
|
||||
test('an infisical token stores its metadata after a successful login', function () {
|
||||
Http::fake([
|
||||
'https://example.com/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'accessToken' => 'token',
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'infisical')
|
||||
->set('name', 'Infisical')
|
||||
->set('token', 'client-secret')
|
||||
->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://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://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://example.com:8200'])
|
||||
->call('addToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('integration_tokens', [
|
||||
'provider' => 'vault',
|
||||
'name' => 'Vault',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the dns capability is rejected for secret manager providers', function () {
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'doppler')
|
||||
->set('name', 'Doppler')
|
||||
->set('token', 'dp.st.token')
|
||||
->set('capabilities', ['dns'])
|
||||
->call('addToken')
|
||||
->assertHasErrors(['capabilities.0']);
|
||||
|
||||
$this->assertDatabaseCount('integration_tokens', 0);
|
||||
});
|
||||
@@ -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,83 @@
|
||||
<?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\''],
|
||||
],
|
||||
]);
|
||||
|
||||
it('runs database start commands without persisting them through remote process', function (string $action) {
|
||||
$source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
|
||||
|
||||
expect($source)
|
||||
->toContain('ExecutesDatabaseStartCommands')
|
||||
->toContain('executeDatabaseStartCommands(')
|
||||
->not->toContain('return remote_process(');
|
||||
})->with([
|
||||
'StartClickhouse',
|
||||
'StartDragonfly',
|
||||
'StartKeydb',
|
||||
'StartMariadb',
|
||||
'StartMongodb',
|
||||
'StartMysql',
|
||||
'StartPostgresql',
|
||||
'StartRedis',
|
||||
]);
|
||||
|
||||
it('queues database starts with identifiers instead of generated commands', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartDatabase.php');
|
||||
|
||||
expect($source)
|
||||
->toContain('DatabaseStartJob::dispatch(')
|
||||
->not->toContain('StartPostgresql::run(')
|
||||
->not->toContain('StartRedis::run(');
|
||||
});
|
||||
|
||||
it('keeps raw secret values separate from compose environment formatting', function (string $action, array $rawAssignments) {
|
||||
$source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
|
||||
|
||||
expect($source)
|
||||
->toContain('$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);')
|
||||
->toContain('$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);')
|
||||
->toContain('$environment_variables->push($env->key.\'=\'.$resolvedValue);')
|
||||
->toContain(...$rawAssignments);
|
||||
})->with([
|
||||
'clickhouse' => ['StartClickhouse', ['$this->resolvedClickhouseUser = $rawValue;', '$this->resolvedClickhousePassword = $rawValue;']],
|
||||
'dragonfly' => ['StartDragonfly', ['$this->resolvedRedisPassword = $rawValue;', 'escapeshellarg($this->resolvedRedisPassword)']],
|
||||
'keydb' => ['StartKeydb', ['$this->resolvedRedisPassword = $rawValue;', 'escapeshellarg($this->resolvedRedisPassword)']],
|
||||
'mongodb' => ['StartMongodb', ['$this->resolvedMongoUsername = $rawValue;', '$this->resolvedMongoPassword = $rawValue;', '$this->resolvedMongoDatabase = $rawValue;', 'json_encode($this->resolvedMongoPassword']],
|
||||
'mysql' => ['StartMysql', ['$this->resolvedMysqlRootPassword = $rawValue;']],
|
||||
'postgresql' => ['StartPostgresql', ['$this->resolvedPostgresUser = $rawValue;', '$this->resolvedPostgresDatabase = $rawValue;']],
|
||||
]);
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Events\DatabaseStatusChanged;
|
||||
use App\Jobs\DatabaseStartJob;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('broadcasts failed database starts to the initiating user even when the activity is missing', function () {
|
||||
Event::fake([DatabaseStatusChanged::class]);
|
||||
|
||||
$job = new DatabaseStartJob(
|
||||
databaseClass: 'MissingDatabase',
|
||||
databaseId: 123,
|
||||
teamId: 456,
|
||||
activityId: 789,
|
||||
userId: 42,
|
||||
);
|
||||
|
||||
$job->failed(new RuntimeException('Database start failed.'));
|
||||
|
||||
Event::assertDispatched(
|
||||
DatabaseStatusChanged::class,
|
||||
fn (DatabaseStatusChanged $event): bool => $event->userId === 42,
|
||||
);
|
||||
});
|
||||
|
||||
it('targets normal database start status changes to the initiating user', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../app/Jobs/DatabaseStartJob.php');
|
||||
|
||||
expect($source)
|
||||
->toContain('event(new DatabaseStatusChanged($this->userId));')
|
||||
->not->toContain('event(new DatabaseStatusChanged($database));');
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Support\RemoteSecretReferences;
|
||||
|
||||
test('detects references only for the vault namespace', function () {
|
||||
expect(RemoteSecretReferences::containsReference('{{vault.DB_PASSWORD}}'))->toBeTrue()
|
||||
->and(RemoteSecretReferences::containsReference('{{doppler.KEY}}'))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference('{{infisical.KEY}}'))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference('{{ vault.KEY }}'))->toBeTrue()
|
||||
->and(RemoteSecretReferences::containsReference('pre-{{vault.KEY}}-post'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('ignores the secret namespace, shared variables, and plain values', function () {
|
||||
expect(RemoteSecretReferences::containsReference('{{secret.KEY}}'))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference('{{team.KEY}}'))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference('{{project.KEY}}'))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference('plain'))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference('$OTHER_VAR'))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference(null))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference(''))->toBeFalse();
|
||||
});
|
||||
|
||||
test('extracts unique referenced keys in order', function () {
|
||||
$value = 'a={{vault.A}} ignored={{doppler.B}} again={{vault.A}}';
|
||||
|
||||
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'];
|
||||
|
||||
expect(RemoteSecretReferences::substitute('x={{vault.A}} y={{vault.MISSING}}', $secrets))
|
||||
->toBe('x=value-a y={{vault.MISSING}}');
|
||||
});
|
||||
|
||||
test('reports missing keys', function () {
|
||||
expect(RemoteSecretReferences::missingKeys('{{vault.A}}-{{vault.B}}', ['A' => '1']))->toBe(['B'])
|
||||
->and(RemoteSecretReferences::missingKeys('{{vault.A}}', ['A' => '1']))->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
it('shows environment variables before the optional secret manager for every resource type', function (string $view, string $resource) {
|
||||
$source = file_get_contents(__DIR__."/../../resources/views/livewire/project/{$view}/configuration.blade.php");
|
||||
$environmentVariables = '<livewire:project.shared.environment-variable.all :resource="$'.$resource.'" />';
|
||||
$secretManager = '<livewire:project.shared.secret-manager-links :resource="$'.$resource.'" />';
|
||||
|
||||
expect($source)
|
||||
->toContain($environmentVariables, $secretManager)
|
||||
->and(strpos($source, $environmentVariables))->toBeLessThan(strpos($source, $secretManager));
|
||||
})->with([
|
||||
'application' => ['application', 'application'],
|
||||
'database' => ['database', 'database'],
|
||||
'service' => ['service', 'service'],
|
||||
]);
|
||||
Reference in New Issue
Block a user