Files
coolify/tests/Feature/SecretManagers/SecretManagerServicesTest.php

285 lines
11 KiB
PHP

<?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')
&& $request['expandSecretReferences'] === 'true'
&& $request['viewSecretValue'] === 'true');
});
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')
&& $request['expandSecretReferences'] === 'true');
});
test('merges imported secrets, with own secrets and later imports taking precedence', 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' => 'APP_KEY', 'secretValue' => 'app'],
['secretKey' => 'OVERRIDDEN', 'secretValue' => 'own'],
],
'imports' => [
['secretPath' => '/shared', 'environment' => 'prod', 'secrets' => [
['secretKey' => 'SHARED_KEY', 'secretValue' => 'shared'],
['secretKey' => 'OVERRIDDEN', 'secretValue' => 'from-shared'],
['secretKey' => 'IMPORT_CONFLICT', 'secretValue' => 'first-import'],
]],
['secretPath' => '/common', 'environment' => 'prod', 'secrets' => [
['secretKey' => 'IMPORT_CONFLICT', 'secretValue' => 'last-import'],
]],
],
]),
]);
$service = new InfisicalService('https://example.com/infisical', 'client-id', 'client-secret');
expect($service->fetchSecrets('project-1', 'prod', '/apps/api'))->toBe([
'SHARED_KEY' => 'shared',
'OVERRIDDEN' => 'own',
'IMPORT_CONFLICT' => 'last-import',
'APP_KEY' => 'app',
]);
Http::assertSent(fn ($request) => str_contains($request->url(), '/api/v4/secrets')
&& $request['includeImports'] === 'true');
});
test('requests imported secrets from the v3 raw 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([], 404),
'https://example.com/infisical/api/v3/secrets/raw*' => Http::response([
'secrets' => [['secretKey' => 'APP_KEY', 'secretValue' => 'app']],
'imports' => [['secretPath' => '/shared', 'secrets' => [
['secretKey' => 'SHARED_KEY', 'secretValue' => 'shared'],
]]],
]),
]);
$service = new InfisicalService('https://example.com/infisical', 'client-id', 'client-secret');
expect($service->fetchSecrets('project-1', 'prod', '/apps/api'))->toBe([
'SHARED_KEY' => 'shared',
'APP_KEY' => 'app',
]);
Http::assertSent(fn ($request) => str_contains($request->url(), '/api/v3/secrets/raw')
&& $request['include_imports'] === 'true');
});
test('uses a 5 second connect timeout and a 10 second request timeout by default', function (?int $timeout, array $expected) {
$timeouts = [];
Http::fake(function ($request, array $options) use (&$timeouts) {
$timeouts[] = [$options['connect_timeout'], $options['timeout']];
return Http::response(['accessToken' => 'short-lived-token']);
});
expect((new InfisicalService('https://example.com', 'client-id', 'client-secret', $timeout))->validate())->toBeTrue()
->and($timeouts)->toBe([$expected]);
})->with([
'default' => [null, [5, 10]],
'custom' => [60, [60, 60]],
]);
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();
});
});