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, but a locked variable stays locked', 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)->toBeTrue(); }); }); 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], ]); }); describe('env API cannot unlock a locked variable', function () { test('is_shown_once false keeps the lock and the value can still be replaced', 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' => 'LOCKED_KEY', 'value' => 'locked-secret', 'is_shown_once' => true, 'resourceable_type' => $resource->getMorphClass(), 'resourceable_id' => $resource->id, 'is_preview' => false, ]); $payload = ['key' => 'LOCKED_KEY', 'value' => 'rotated-secret', 'is_shown_once' => false]; $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json']) ->patchJson("/api/v1/{$endpoint}/{$resource->uuid}/envs".($bulk ? '/bulk' : ''), $bulk ? ['data' => [$payload]] : $payload) ->assertStatus(201) ->assertDontSee('rotated-secret'); $env->refresh(); expect((bool) $env->is_shown_once)->toBeTrue() ->and($env->value)->toBe('rotated-secret'); })->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], ]); }); describe('env API keeps multiline values exact', function () { test('create and bulk update keep the trailing newline of a private key', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, 'destination_id' => $this->destination->id, 'destination_type' => $this->destination->getMorphClass(), ]); $key = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----\n"; $headers = ['Authorization' => 'Bearer '.$this->bearerToken]; $this->withHeaders($headers)->postJson("/api/v1/applications/{$application->uuid}/envs", [ 'key' => 'SSH_KEY', 'value' => $key, 'is_multiline' => true, ])->assertStatus(201); $this->withHeaders($headers)->patchJson("/api/v1/applications/{$application->uuid}/envs/bulk", [ 'data' => [ ['key' => 'OTHER_KEY', 'value' => $key, 'is_multiline' => true], ['key' => 'PLAIN', 'value' => " plain \n"], ], ])->assertStatus(201); $values = $application->environment_variables()->get()->pluck('value', 'key'); expect($values['SSH_KEY'])->toBe($key) ->and($values['OTHER_KEY'])->toBe($key) ->and($values['PLAIN'])->toBe('plain'); }); });