From e82942b3875a25f11c4ee1fd62642dd0f0edac2f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:37:20 +0100 Subject: [PATCH] test: refresh api and job feature suites --- .../Api/DatabaseBackupCreationApiTest.php | 218 ++++++++++-------- .../Api/DeploymentCancellationApiTest.php | 57 ++--- tests/Feature/Auth/LoginRateLimitIPTest.php | 114 ++++----- tests/Feature/Jobs/CoolifyTaskRetryTest.php | 91 ++++---- 4 files changed, 240 insertions(+), 240 deletions(-) diff --git a/tests/Feature/Api/DatabaseBackupCreationApiTest.php b/tests/Feature/Api/DatabaseBackupCreationApiTest.php index 893141de3..70933ccf3 100644 --- a/tests/Feature/Api/DatabaseBackupCreationApiTest.php +++ b/tests/Feature/Api/DatabaseBackupCreationApiTest.php @@ -1,145 +1,167 @@ 0]); + $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); - // Create an API token for the user - $this->token = $this->user->createToken('test-token', ['*'], $this->team->id); + session(['currentTeam' => $this->team]); + + $this->token = $this->user->createToken('test-token', ['*']); $this->bearerToken = $this->token->plainTextToken; - // Mock a database - we'll use Mockery to avoid needing actual database setup - $this->database = \Mockery::mock(StandalonePostgresql::class); - $this->database->shouldReceive('getAttribute')->with('id')->andReturn(1); - $this->database->shouldReceive('getAttribute')->with('uuid')->andReturn('test-db-uuid'); - $this->database->shouldReceive('getAttribute')->with('postgres_db')->andReturn('testdb'); - $this->database->shouldReceive('type')->andReturn('standalone-postgresql'); - $this->database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql'); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $this->team->id, + ]); + + $this->environment = $this->project->environments()->first(); + + $this->database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test DB', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'testdb', + 'image' => 'postgres:15', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); }); -afterEach(function () { - \Mockery::close(); -}); +function backupHeaders(): array +{ + return [ + 'Authorization' => 'Bearer '.test()->bearerToken, + 'Content-Type' => 'application/json', + ]; +} describe('POST /api/v1/databases/{uuid}/backups', function () { - test('creates backup configuration with minimal required fields', function () { - // This is a unit-style test using mocks to avoid database dependency - // For full integration testing, this should be run inside Docker + test('creates backup configuration with valid frequency', function () { + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => 'daily', + ]); - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'frequency' => 'daily', - ]); + $response->assertStatus(201); + $response->assertJsonStructure(['uuid', 'message']); + $response->assertJson(['message' => 'Backup configuration created successfully.']); + }); - // Since we're mocking, this test verifies the endpoint exists and basic validation - // Full integration tests should be run in Docker environment - expect($response->status())->toBeIn([201, 404, 422]); + test('creates backup with valid cron expression', function () { + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => '0 2 * * *', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid', 'message']); + }); + + test('accepts all predefined frequency values', function () { + $frequencies = ['every_minute', 'hourly', 'daily', 'weekly', 'monthly', 'yearly']; + + foreach ($frequencies as $frequency) { + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => $frequency, + ]); + + $response->assertStatus(201, "Expected 201 for frequency '{$frequency}', got {$response->status()}"); + } }); test('validates frequency is required', function () { - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'enabled' => true, - ]); + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'enabled' => true, + ]); $response->assertStatus(422); $response->assertJsonValidationErrors(['frequency']); }); - test('validates s3_storage_uuid required when save_s3 is true', function () { - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'frequency' => 'daily', - 'save_s3' => true, - ]); + test('rejects invalid frequency format', function () { + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => 'invalid-frequency', + ]); - // Should fail validation because s3_storage_uuid is missing - expect($response->status())->toBeIn([404, 422]); + $response->assertStatus(422); }); - test('rejects invalid frequency format', function () { - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'frequency' => 'invalid-frequency', - ]); + test('validates s3_storage_uuid required when save_s3 is true', function () { + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => 'daily', + 'save_s3' => true, + ]); - expect($response->status())->toBeIn([404, 422]); + $response->assertStatus(422); + }); + + test('validates retention fields are integers with minimum 0', function () { + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => 'daily', + 'database_backup_retention_amount_locally' => -1, + ]); + + $response->assertStatus(422); + }); + + test('rejects extra fields not in allowed list', function () { + $response = $this->withHeaders(backupHeaders()) + ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => 'daily', + 'invalid_field' => 'invalid_value', + ]); + + $response->assertStatus(422); }); test('rejects request without authentication', function () { - $response = $this->postJson('/api/v1/databases/test-db-uuid/backups', [ + $response = $this->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ 'frequency' => 'daily', ]); $response->assertStatus(401); }); - test('validates retention fields are integers with minimum 0', function () { - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'frequency' => 'daily', - 'database_backup_retention_amount_locally' => -1, - ]); - - expect($response->status())->toBeIn([404, 422]); - }); - - test('accepts valid cron expressions', function () { - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'frequency' => '0 2 * * *', // Daily at 2 AM - ]); - - // Will fail with 404 because database doesn't exist, but validates the request format - expect($response->status())->toBeIn([201, 404, 422]); - }); - - test('accepts predefined frequency values', function () { - $frequencies = ['every_minute', 'hourly', 'daily', 'weekly', 'monthly', 'yearly']; - - foreach ($frequencies as $frequency) { - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'frequency' => $frequency, + test('returns 404 for non-existent database uuid', function () { + $response = $this->withHeaders(backupHeaders()) + ->postJson('/api/v1/databases/non-existent-uuid/backups', [ + 'frequency' => 'daily', ]); - // Will fail with 404 because database doesn't exist, but validates the request format - expect($response->status())->toBeIn([201, 404, 422]); - } - }); - - test('rejects extra fields not in allowed list', function () { - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson('/api/v1/databases/test-db-uuid/backups', [ - 'frequency' => 'daily', - 'invalid_field' => 'invalid_value', - ]); - - expect($response->status())->toBeIn([404, 422]); + $response->assertStatus(404); + $response->assertJson(['message' => 'Database not found.']); }); }); diff --git a/tests/Feature/Api/DeploymentCancellationApiTest.php b/tests/Feature/Api/DeploymentCancellationApiTest.php index eee689e13..046af059a 100644 --- a/tests/Feature/Api/DeploymentCancellationApiTest.php +++ b/tests/Feature/Api/DeploymentCancellationApiTest.php @@ -2,6 +2,7 @@ use App\Enums\ApplicationDeploymentStatus; use App\Models\ApplicationDeploymentQueue; +use App\Models\InstanceSettings; use App\Models\Server; use App\Models\Team; use App\Models\User; @@ -10,13 +11,17 @@ uses(RefreshDatabase::class); beforeEach(function () { + InstanceSettings::updateOrCreate(['id' => 0]); + // Create a team with owner $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + // Create an API token for the user - $this->token = $this->user->createToken('test-token', ['*'], $this->team->id); + $this->token = $this->user->createToken('test-token', ['*']); $this->bearerToken = $this->token->plainTextToken; // Create a server for the team @@ -76,7 +81,7 @@ ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); $response->assertStatus(400); - $response->assertJsonFragment(['Deployment cannot be cancelled']); + expect($response->json('message'))->toContain('Deployment cannot be cancelled'); }); test('returns 400 when deployment is already failed', function () { @@ -93,7 +98,7 @@ ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); $response->assertStatus(400); - $response->assertJsonFragment(['Deployment cannot be cancelled']); + expect($response->json('message'))->toContain('Deployment cannot be cancelled'); }); test('returns 400 when deployment is already cancelled', function () { @@ -110,10 +115,10 @@ ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); $response->assertStatus(400); - $response->assertJsonFragment(['Deployment cannot be cancelled']); + expect($response->json('message'))->toContain('Deployment cannot be cancelled'); }); - test('successfully cancels queued deployment', function () { + test('cancels queued deployment and updates status in database', function () { $deployment = ApplicationDeploymentQueue::create([ 'deployment_uuid' => 'queued-deployment-uuid', 'application_id' => 1, @@ -121,20 +126,17 @@ 'status' => ApplicationDeploymentStatus::QUEUED->value, ]); - $response = $this->withHeaders([ + $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); - // Expect success (200) or 500 if server connection fails (which is expected in test environment) - expect($response->status())->toBeIn([200, 500]); - - // Verify deployment status was updated to cancelled + // The controller updates status before SSH calls, so DB state is always correct $deployment->refresh(); expect($deployment->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value); }); - test('successfully cancels in-progress deployment', function () { + test('cancels in-progress deployment and updates status in database', function () { $deployment = ApplicationDeploymentQueue::create([ 'deployment_uuid' => 'in-progress-deployment-uuid', 'application_id' => 1, @@ -142,42 +144,13 @@ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, ]); - $response = $this->withHeaders([ + $this->withHeaders([ 'Authorization' => 'Bearer '.$this->bearerToken, 'Content-Type' => 'application/json', ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); - // Expect success (200) or 500 if server connection fails (which is expected in test environment) - expect($response->status())->toBeIn([200, 500]); - - // Verify deployment status was updated to cancelled + // The controller updates status before SSH calls, so DB state is always correct $deployment->refresh(); expect($deployment->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value); }); - - test('returns correct response structure on success', function () { - $deployment = ApplicationDeploymentQueue::create([ - 'deployment_uuid' => 'success-deployment-uuid', - 'application_id' => 1, - 'server_id' => $this->server->id, - 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, - ]); - - $response = $this->withHeaders([ - 'Authorization' => 'Bearer '.$this->bearerToken, - 'Content-Type' => 'application/json', - ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); - - if ($response->status() === 200) { - $response->assertJsonStructure([ - 'message', - 'deployment_uuid', - 'status', - ]); - $response->assertJson([ - 'deployment_uuid' => $deployment->deployment_uuid, - 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, - ]); - } - }); }); diff --git a/tests/Feature/Auth/LoginRateLimitIPTest.php b/tests/Feature/Auth/LoginRateLimitIPTest.php index 6200870a1..e1b85ae5c 100644 --- a/tests/Feature/Auth/LoginRateLimitIPTest.php +++ b/tests/Feature/Auth/LoginRateLimitIPTest.php @@ -1,58 +1,68 @@ get($baseUrl); - $loginPageResponse->assertSuccessful(); +beforeEach(function () { + InstanceSettings::updateOrCreate(['id' => 0]); + RateLimiter::clear('login'); - // Extract CSRF token using regex similar to Python script - preg_match('/name="_token"\s+value="([^"]+)"/', $loginPageResponse->getContent(), $matches); - $token = $matches[1] ?? null; - - expect($token)->not->toBeNull('CSRF token should be found'); - - // Test 14 login attempts with different IPs (like the Python script does 1-14) - $results = []; - for ($i = 1; $i <= 14; $i++) { - $spoofedIp = "198.51.100.{$i}"; - - $response = $this->withHeader('X-Forwarded-For', $spoofedIp) - ->post($baseUrl, [ - '_token' => $token, - 'email' => $email, - 'password' => "WrongPass{$i}!", - ]); - - $statusCode = $response->getStatusCode(); - $rateLimitLimit = $response->headers->get('X-RateLimit-Limit'); - $rateLimitRemaining = $response->headers->get('X-RateLimit-Remaining'); - - $results[$i] = [ - 'ip' => $spoofedIp, - 'status' => $statusCode, - 'rate_limit' => $rateLimitLimit, - 'rate_limit_remaining' => $rateLimitRemaining, - ]; - - // Print output similar to Python script - echo 'Attempt '.str_pad($i, 2, '0', STR_PAD_LEFT).": status=$statusCode, RL=$rateLimitLimit/$rateLimitRemaining\n"; - - // Add a small delay like the Python script (0.2 seconds) - usleep(200000); - } - - // Verify results - expect($results)->toHaveCount(14); - - // Check that we got responses for all attempts - foreach ($results as $i => $result) { - expect($result['status'])->toBeGreaterThanOrEqual(200); - expect($result['ip'])->toBe("198.51.100.{$i}"); - } + $this->user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); +}); + +test('login is rate limited after 5 failed attempts from same IP', function () { + $email = 'test@example.com'; + + // First 5 attempts should be accepted (302 redirect back with error, not 429) + for ($i = 1; $i <= 5; $i++) { + $response = $this->post('/login', [ + 'email' => $email, + 'password' => 'wrong-password', + ]); + + expect($response->status())->toBe(302, "Attempt {$i} should redirect (302), got {$response->status()}"); + } + + // 6th attempt from same IP should be throttled + $response = $this->post('/login', [ + 'email' => $email, + 'password' => 'wrong-password', + ]); + + expect($response->status())->toBe(429, 'Expected 429 Too Many Requests after exceeding rate limit'); +}); + +test('rate limit is scoped per email and IP combination', function () { + // Exhaust rate limit for first email + for ($i = 1; $i <= 5; $i++) { + $this->post('/login', [ + 'email' => 'test@example.com', + 'password' => 'wrong-password', + ]); + } + + // Different email from same IP should still work (different composite key) + $response = $this->post('/login', [ + 'email' => 'other@example.com', + 'password' => 'wrong-password', + ]); + + expect($response->status())->toBe(302, 'Different email should not be rate limited'); +}); + +test('successful login is still possible within rate limit', function () { + $response = $this->post('/login', [ + 'email' => 'test@example.com', + 'password' => 'password', + ]); + + $response->assertRedirect(); + expect($response->status())->not->toBe(429); }); diff --git a/tests/Feature/Jobs/CoolifyTaskRetryTest.php b/tests/Feature/Jobs/CoolifyTaskRetryTest.php index f46ced311..46e652051 100644 --- a/tests/Feature/Jobs/CoolifyTaskRetryTest.php +++ b/tests/Feature/Jobs/CoolifyTaskRetryTest.php @@ -1,70 +1,65 @@ first(); +beforeEach(function () { + $team = Team::factory()->create(); + Server::factory()->create(['team_id' => $team->id]); - if (! $server) { - $this->markTestSkipped('No servers available for testing'); - } - - Queue::fake(); - - // Create an activity for the task - $activity = activity() + $this->activity = activity() ->withProperties([ - 'server_uuid' => $server->uuid, + 'server_uuid' => Server::first()->uuid, 'command' => 'echo "test"', 'type' => 'inline', + 'status' => ProcessStatus::QUEUED->value, ]) ->event('inline') ->log('[]'); - // Dispatch the job - CoolifyTask::dispatch( - activity: $activity, + $this->job = new CoolifyTask( + activity: $this->activity, ignore_errors: false, call_event_on_finish: null, - call_event_data: null + call_event_data: null, ); - - // Assert job was dispatched - Queue::assertPushed(CoolifyTask::class); }); -it('has correct retry configuration on CoolifyTask', function () { - $server = Server::where('ip', '!=', '1.2.3.4')->first(); - - if (! $server) { - $this->markTestSkipped('No servers available for testing'); - } - - $activity = activity() - ->withProperties([ - 'server_uuid' => $server->uuid, - 'command' => 'echo "test"', - 'type' => 'inline', - ]) - ->event('inline') - ->log('[]'); - - $job = new CoolifyTask( - activity: $activity, - ignore_errors: false, - call_event_on_finish: null, - call_event_data: null - ); - - // Assert retry configuration - expect($job->tries)->toBe(3); - expect($job->maxExceptions)->toBe(1); - expect($job->timeout)->toBe(600); - expect($job->backoff())->toBe([30, 90, 180]); +test('has correct retry configuration', function () { + expect($this->job->tries)->toBe(3) + ->and($this->job->maxExceptions)->toBe(1) + ->and($this->job->timeout)->toBe(600) + ->and($this->job->backoff())->toBe([30, 90, 180]); +}); + +test('is queued on the high priority queue', function () { + expect($this->job->queue)->toBe('high'); +}); + +test('marks activity as error on permanent failure', function () { + $exception = new \RuntimeException('SSH connection failed'); + + $this->job->failed($exception); + + $this->activity->refresh(); + $properties = $this->activity->properties; + + expect($properties['status'])->toBe(ProcessStatus::ERROR->value) + ->and($properties['error'])->toBe('SSH connection failed') + ->and($properties)->toHaveKey('failed_at'); +}); + +test('marks activity as error with default message when exception is null', function () { + $this->job->failed(null); + + $this->activity->refresh(); + $properties = $this->activity->properties; + + expect($properties['status'])->toBe(ProcessStatus::ERROR->value) + ->and($properties['error'])->toBe('Job permanently failed'); });