Files
coolify/tests/Feature/Api/EnvironmentVariableUpdateApiTest.php
T

647 lines
24 KiB
PHP

<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
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->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
describe('PATCH /api/v1/services/{uuid}/envs', function () {
test('returns the updated environment variable object', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'APP_IMAGE_TAG',
'value' => 'old-value',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'APP_IMAGE_TAG',
'value' => 'new-value',
]);
$response->assertStatus(201);
$response->assertJsonStructure([
'uuid',
'key',
'is_literal',
'is_multiline',
'is_shown_once',
'created_at',
'updated_at',
]);
$response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']);
$response->assertJsonMissing(['message' => 'Environment variable updated.']);
});
test('returns 404 when environment variable does not exist', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'NONEXISTENT_KEY',
'value' => 'some-value',
]);
$response->assertStatus(404);
$response->assertJson(['message' => 'Environment variable not found.']);
});
test('returns 404 when service does not exist', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson('/api/v1/services/non-existent-uuid/envs', [
'key' => 'APP_IMAGE_TAG',
'value' => 'some-value',
]);
$response->assertStatus(404);
});
test('returns 422 when key is missing', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
'value' => 'some-value',
]);
$response->assertStatus(422);
});
test('uses route uuid and ignores uuid in request body', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'TEST_KEY',
'value' => 'old-value',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'TEST_KEY',
'value' => 'new-value',
'uuid' => 'bogus-uuid-from-body',
]);
$response->assertStatus(201);
$response->assertJsonFragment(['key' => 'TEST_KEY']);
});
});
describe('PATCH /api/v1/applications/{uuid}/envs', function () {
test('returns the updated environment variable object', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
EnvironmentVariable::create([
'key' => 'APP_IMAGE_TAG',
'value' => 'old-value',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'APP_IMAGE_TAG',
'value' => 'new-value',
]);
$response->assertStatus(201);
$response->assertJsonStructure([
'uuid',
'key',
'is_literal',
'is_multiline',
'is_shown_once',
'created_at',
'updated_at',
]);
$response->assertJsonFragment(['key' => 'APP_IMAGE_TAG']);
$response->assertJsonMissing(['message' => 'Environment variable updated.']);
});
test('returns 404 when environment variable does not exist', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'NONEXISTENT_KEY',
'value' => 'some-value',
]);
$response->assertStatus(404);
});
test('returns 422 when key is missing', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
'value' => 'some-value',
]);
$response->assertStatus(422);
});
test('rejects unknown fields in request body', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
EnvironmentVariable::create([
'key' => 'TEST_KEY',
'value' => 'old-value',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'TEST_KEY',
'value' => 'new-value',
'uuid' => 'bogus-uuid-from-body',
]);
$response->assertStatus(422);
$response->assertJsonFragment(['uuid' => ['This field is not allowed.']]);
});
test('keeps value and other flags when only is_buildtime is sent', function (bool $isPreview) {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$env = EnvironmentVariable::create([
'key' => 'NEXTAUTH_SECRET',
'value' => 'super-secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => $isPreview,
'is_literal' => true,
'is_multiline' => true,
'is_shown_once' => true,
'is_buildtime' => true,
'is_runtime' => true,
]);
$payload = ['key' => 'NEXTAUTH_SECRET', 'is_buildtime' => false];
if ($isPreview) {
$payload['is_preview'] = true;
}
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs", $payload)
->assertStatus(201);
$env->refresh();
expect($env->value)->toBe('super-secret')
->and($env->is_buildtime)->toBeFalse()
->and($env->is_runtime)->toBeTrue()
->and($env->is_literal)->toBeTruthy()
->and($env->is_multiline)->toBeTrue()
->and($env->is_shown_once)->toBeTruthy();
})->with(['production' => false, 'preview' => true]);
test('bulk update keeps value and other flags when only is_buildtime is sent', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$env = EnvironmentVariable::create([
'key' => 'NEXTAUTH_SECRET',
'value' => 'super-secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
'is_literal' => true,
'is_multiline' => true,
'is_shown_once' => true,
'is_buildtime' => true,
]);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [
'data' => [['key' => 'NEXTAUTH_SECRET', 'is_buildtime' => false]],
])->assertStatus(201);
$env->refresh();
expect($env->value)->toBe('super-secret')
->and($env->is_buildtime)->toBeFalse()
->and($env->is_literal)->toBeTruthy()
->and($env->is_multiline)->toBeTrue()
->and($env->is_shown_once)->toBeTruthy();
});
});
describe('partial PATCH /api/v1/services/{uuid}/envs', function () {
test('keeps value when only a flag is sent', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$env = EnvironmentVariable::create([
'key' => 'SECRET',
'value' => 'super-secret',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
'is_preview' => false,
]);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'SECRET',
'is_literal' => true,
])->assertStatus(201);
$env->refresh();
expect($env->value)->toBe('super-secret')
->and($env->is_literal)->toBeTruthy();
});
});
describe('application env PATCH keeps omitted flags', function () {
beforeEach(function () {
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->env = EnvironmentVariable::create([
'key' => 'FLAGGED_KEY',
'value' => 'old-value',
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
'is_preview' => false,
'is_literal' => true,
'is_multiline' => true,
'is_shown_once' => true,
]);
$this->headers = [
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
];
});
test('single update with only key and value keeps flags', function () {
$this->withHeaders($this->headers)
->patchJson("/api/v1/applications/{$this->application->uuid}/envs", [
'key' => 'FLAGGED_KEY',
'value' => 'new-value',
])
->assertStatus(201);
$env = $this->env->fresh();
expect($env->value)->toBe('new-value')
->and((bool) $env->is_literal)->toBeTrue()
->and($env->is_multiline)->toBeTrue()
->and((bool) $env->is_shown_once)->toBeTrue();
});
test('bulk update with only key and value keeps flags', function () {
$this->withHeaders($this->headers)
->patchJson("/api/v1/applications/{$this->application->uuid}/envs/bulk", [
'data' => [['key' => 'FLAGGED_KEY', 'value' => 'new-value']],
])
->assertStatus(201);
$env = $this->env->fresh();
expect($env->value)->toBe('new-value')
->and((bool) $env->is_literal)->toBeTrue()
->and($env->is_multiline)->toBeTrue()
->and((bool) $env->is_shown_once)->toBeTrue();
});
test('explicit flags are still applied', function () {
$this->withHeaders($this->headers)
->patchJson("/api/v1/applications/{$this->application->uuid}/envs", [
'key' => 'FLAGGED_KEY',
'value' => 'new-value',
'is_literal' => false,
'is_multiline' => false,
'is_shown_once' => false,
])
->assertStatus(201);
$env = $this->env->fresh();
expect((bool) $env->is_literal)->toBeFalse()
->and($env->is_multiline)->toBeFalse()
->and((bool) $env->is_shown_once)->toBeFalse();
});
});
describe('environment variable key validation for app and service APIs', function () {
test('rejects invalid service environment variable keys on create update and bulk', function () {
$service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'SAFE_KEY',
'value' => 'old-value',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
'is_preview' => false,
]);
$headers = [
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
];
$this->withHeaders($headers)
->postJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/services/{$service->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/services/{$service->uuid}/envs/bulk", [
'data' => [[
'key' => 'BAD$(id)',
'value' => '1',
]],
])
->assertStatus(422);
});
test('rejects invalid application environment variable keys on create update and bulk', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
EnvironmentVariable::create([
'key' => 'SAFE_KEY',
'value' => 'old-value',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
'is_preview' => false,
]);
$headers = [
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
];
$this->withHeaders($headers)
->postJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/applications/{$application->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [
'data' => [[
'key' => 'BAD$(id)',
'value' => '1',
]],
])
->assertStatus(422);
});
});
describe('bulk env update for services and databases', function () {
test('cannot move an environment variable to a resource of another team', function (string $resourceType) {
$resource = $resourceType === 'services'
? Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
])
: StandalonePostgresql::create([
'name' => 'test-postgres',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
$victim = Application::factory()->create([
'environment_id' => Environment::factory()->create([
'project_id' => Project::factory()->create(['team_id' => $otherTeam->id])->id,
])->id,
'destination_id' => $otherDestination->id,
'destination_type' => $otherDestination->getMorphClass(),
]);
$existing = EnvironmentVariable::create([
'key' => 'EXISTING',
'value' => 'old-value',
'resourceable_type' => $resource->getMorphClass(),
'resourceable_id' => $resource->id,
]);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/{$resourceType}/{$resource->uuid}/envs/bulk", [
'data' => [
['key' => 'EXISTING', 'value' => 'new-value', 'resourceable_type' => Application::class, 'resourceable_id' => $victim->id],
['key' => 'CREATED', 'value' => 'x', 'resourceable_type' => Application::class, 'resourceable_id' => $victim->id],
],
])->assertStatus(201);
$existing->refresh();
expect($existing->resourceable_id)->toBe($resource->id)
->and($existing->resourceable_type)->toBe($resource->getMorphClass())
->and($existing->value)->toBe('new-value')
->and($victim->environment_variables()->whereIn('key', ['EXISTING', 'CREATED'])->exists())->toBeFalse()
->and($resource->environment_variables()->where('key', 'CREATED')->exists())->toBeTrue();
})->with(['services', 'databases']);
});
describe('env PATCH keeps the value when value is omitted', function () {
beforeEach(function () {
$this->headers = [
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
];
});
test('keeps or updates the value', function (string $resourceType, string $endpoint, bool $bulk) {
$resource = match ($resourceType) {
'application' => Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]),
'service' => Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'environment_id' => $this->environment->id,
]),
'database' => StandalonePostgresql::create([
'name' => 'db',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'app',
'image' => 'postgres:16',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]),
};
$env = EnvironmentVariable::create([
'key' => 'KEEP_ME',
'value' => 'secret-value',
'resourceable_type' => $resource->getMorphClass(),
'resourceable_id' => $resource->id,
'is_preview' => false,
'is_literal' => false,
]);
$url = "/api/v1/{$endpoint}/{$resource->uuid}/envs".($bulk ? '/bulk' : '');
$send = fn (array $payload) => $this->withHeaders($this->headers)
->patchJson($url, $bulk ? ['data' => [$payload]] : $payload)
->assertStatus(201);
$send(['key' => 'KEEP_ME', 'is_literal' => true]);
$env->refresh();
expect($env->value)->toBe('secret-value')
->and((bool) $env->is_literal)->toBeTrue();
$send(['key' => 'KEEP_ME', 'value' => 'new-value']);
expect($env->fresh()->value)->toBe('new-value');
})->with([
'application single' => ['application', 'applications', false],
'application bulk' => ['application', 'applications', true],
'service single' => ['service', 'services', false],
'service bulk' => ['service', 'services', true],
'database single' => ['database', 'databases', false],
'database bulk' => ['database', 'databases', true],
]);
});