test: refresh api and job feature suites

This commit is contained in:
Andras Bacsai 2026-02-26 08:37:20 +01:00
parent 347af07a79
commit e82942b387
4 changed files with 240 additions and 240 deletions

View file

@ -1,145 +1,167 @@
<?php <?php
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function () { beforeEach(function () {
// Create a team with owner InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create(); $this->team = Team::factory()->create();
$this->user = User::factory()->create(); $this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']); $this->team->members()->attach($this->user->id, ['role' => 'owner']);
// Create an API token for the user session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken; $this->bearerToken = $this->token->plainTextToken;
// Mock a database - we'll use Mockery to avoid needing actual database setup $this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->database = \Mockery::mock(StandalonePostgresql::class);
$this->database->shouldReceive('getAttribute')->with('id')->andReturn(1); StandaloneDocker::withoutEvents(function () {
$this->database->shouldReceive('getAttribute')->with('uuid')->andReturn('test-db-uuid'); $this->destination = StandaloneDocker::firstOrCreate(
$this->database->shouldReceive('getAttribute')->with('postgres_db')->andReturn('testdb'); ['server_id' => $this->server->id, 'network' => 'coolify'],
$this->database->shouldReceive('type')->andReturn('standalone-postgresql'); ['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
$this->database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql'); );
});
$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 () { function backupHeaders(): array
\Mockery::close(); {
}); return [
'Authorization' => 'Bearer '.test()->bearerToken,
'Content-Type' => 'application/json',
];
}
describe('POST /api/v1/databases/{uuid}/backups', function () { describe('POST /api/v1/databases/{uuid}/backups', function () {
test('creates backup configuration with minimal required fields', function () { test('creates backup configuration with valid frequency', function () {
// This is a unit-style test using mocks to avoid database dependency $response = $this->withHeaders(backupHeaders())
// For full integration testing, this should be run inside Docker ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'daily',
]);
$response = $this->withHeaders([ $response->assertStatus(201);
'Authorization' => 'Bearer '.$this->bearerToken, $response->assertJsonStructure(['uuid', 'message']);
'Content-Type' => 'application/json', $response->assertJson(['message' => 'Backup configuration created successfully.']);
])->postJson('/api/v1/databases/test-db-uuid/backups', [ });
'frequency' => 'daily',
]);
// Since we're mocking, this test verifies the endpoint exists and basic validation test('creates backup with valid cron expression', function () {
// Full integration tests should be run in Docker environment $response = $this->withHeaders(backupHeaders())
expect($response->status())->toBeIn([201, 404, 422]); ->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 () { test('validates frequency is required', function () {
$response = $this->withHeaders([ $response = $this->withHeaders(backupHeaders())
'Authorization' => 'Bearer '.$this->bearerToken, ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'Content-Type' => 'application/json', 'enabled' => true,
])->postJson('/api/v1/databases/test-db-uuid/backups', [ ]);
'enabled' => true,
]);
$response->assertStatus(422); $response->assertStatus(422);
$response->assertJsonValidationErrors(['frequency']); $response->assertJsonValidationErrors(['frequency']);
}); });
test('validates s3_storage_uuid required when save_s3 is true', function () { test('rejects invalid frequency format', function () {
$response = $this->withHeaders([ $response = $this->withHeaders(backupHeaders())
'Authorization' => 'Bearer '.$this->bearerToken, ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'Content-Type' => 'application/json', 'frequency' => 'invalid-frequency',
])->postJson('/api/v1/databases/test-db-uuid/backups', [ ]);
'frequency' => 'daily',
'save_s3' => true,
]);
// Should fail validation because s3_storage_uuid is missing $response->assertStatus(422);
expect($response->status())->toBeIn([404, 422]);
}); });
test('rejects invalid frequency format', function () { test('validates s3_storage_uuid required when save_s3 is true', function () {
$response = $this->withHeaders([ $response = $this->withHeaders(backupHeaders())
'Authorization' => 'Bearer '.$this->bearerToken, ->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'Content-Type' => 'application/json', 'frequency' => 'daily',
])->postJson('/api/v1/databases/test-db-uuid/backups', [ 'save_s3' => true,
'frequency' => 'invalid-frequency', ]);
]);
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 () { 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', 'frequency' => 'daily',
]); ]);
$response->assertStatus(401); $response->assertStatus(401);
}); });
test('validates retention fields are integers with minimum 0', function () { test('returns 404 for non-existent database uuid', function () {
$response = $this->withHeaders([ $response = $this->withHeaders(backupHeaders())
'Authorization' => 'Bearer '.$this->bearerToken, ->postJson('/api/v1/databases/non-existent-uuid/backups', [
'Content-Type' => 'application/json', 'frequency' => 'daily',
])->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,
]); ]);
// Will fail with 404 because database doesn't exist, but validates the request format $response->assertStatus(404);
expect($response->status())->toBeIn([201, 404, 422]); $response->assertJson(['message' => 'Database not found.']);
}
});
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]);
}); });
}); });

View file

@ -2,6 +2,7 @@
use App\Enums\ApplicationDeploymentStatus; use App\Enums\ApplicationDeploymentStatus;
use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationDeploymentQueue;
use App\Models\InstanceSettings;
use App\Models\Server; use App\Models\Server;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
@ -10,13 +11,17 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function () { beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
// Create a team with owner // Create a team with owner
$this->team = Team::factory()->create(); $this->team = Team::factory()->create();
$this->user = User::factory()->create(); $this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']); $this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
// Create an API token for the user // 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; $this->bearerToken = $this->token->plainTextToken;
// Create a server for the team // Create a server for the team
@ -76,7 +81,7 @@
])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel");
$response->assertStatus(400); $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 () { test('returns 400 when deployment is already failed', function () {
@ -93,7 +98,7 @@
])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel");
$response->assertStatus(400); $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 () { test('returns 400 when deployment is already cancelled', function () {
@ -110,10 +115,10 @@
])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel");
$response->assertStatus(400); $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 = ApplicationDeploymentQueue::create([
'deployment_uuid' => 'queued-deployment-uuid', 'deployment_uuid' => 'queued-deployment-uuid',
'application_id' => 1, 'application_id' => 1,
@ -121,20 +126,17 @@
'status' => ApplicationDeploymentStatus::QUEUED->value, 'status' => ApplicationDeploymentStatus::QUEUED->value,
]); ]);
$response = $this->withHeaders([ $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken, 'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel");
// Expect success (200) or 500 if server connection fails (which is expected in test environment) // The controller updates status before SSH calls, so DB state is always correct
expect($response->status())->toBeIn([200, 500]);
// Verify deployment status was updated to cancelled
$deployment->refresh(); $deployment->refresh();
expect($deployment->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value); 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 = ApplicationDeploymentQueue::create([
'deployment_uuid' => 'in-progress-deployment-uuid', 'deployment_uuid' => 'in-progress-deployment-uuid',
'application_id' => 1, 'application_id' => 1,
@ -142,42 +144,13 @@
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]); ]);
$response = $this->withHeaders([ $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken, 'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel"); ])->postJson("/api/v1/deployments/{$deployment->deployment_uuid}/cancel");
// Expect success (200) or 500 if server connection fails (which is expected in test environment) // The controller updates status before SSH calls, so DB state is always correct
expect($response->status())->toBeIn([200, 500]);
// Verify deployment status was updated to cancelled
$deployment->refresh(); $deployment->refresh();
expect($deployment->status)->toBe(ApplicationDeploymentStatus::CANCELLED_BY_USER->value); 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,
]);
}
});
}); });

View file

@ -1,58 +1,68 @@
<?php <?php
it('tests login rate limiting with different IPs like the Python script', function () { use App\Models\InstanceSettings;
// Create a test route that mimics login behavior use App\Models\User;
// We'll directly test the rate limiter behavior use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\RateLimiter;
$baseUrl = '/login'; uses(RefreshDatabase::class);
$email = 'grumpinout+admin@wearehackerone.com';
// First, get a CSRF token by visiting the login page beforeEach(function () {
$loginPageResponse = $this->get($baseUrl); InstanceSettings::updateOrCreate(['id' => 0]);
$loginPageResponse->assertSuccessful(); RateLimiter::clear('login');
// Extract CSRF token using regex similar to Python script $this->user = User::factory()->create([
preg_match('/name="_token"\s+value="([^"]+)"/', $loginPageResponse->getContent(), $matches); 'email' => 'test@example.com',
$token = $matches[1] ?? null; 'password' => bcrypt('password'),
]);
expect($token)->not->toBeNull('CSRF token should be found'); });
// Test 14 login attempts with different IPs (like the Python script does 1-14) test('login is rate limited after 5 failed attempts from same IP', function () {
$results = []; $email = 'test@example.com';
for ($i = 1; $i <= 14; $i++) {
$spoofedIp = "198.51.100.{$i}"; // First 5 attempts should be accepted (302 redirect back with error, not 429)
for ($i = 1; $i <= 5; $i++) {
$response = $this->withHeader('X-Forwarded-For', $spoofedIp) $response = $this->post('/login', [
->post($baseUrl, [ 'email' => $email,
'_token' => $token, 'password' => 'wrong-password',
'email' => $email, ]);
'password' => "WrongPass{$i}!",
]); expect($response->status())->toBe(302, "Attempt {$i} should redirect (302), got {$response->status()}");
}
$statusCode = $response->getStatusCode();
$rateLimitLimit = $response->headers->get('X-RateLimit-Limit'); // 6th attempt from same IP should be throttled
$rateLimitRemaining = $response->headers->get('X-RateLimit-Remaining'); $response = $this->post('/login', [
'email' => $email,
$results[$i] = [ 'password' => 'wrong-password',
'ip' => $spoofedIp, ]);
'status' => $statusCode,
'rate_limit' => $rateLimitLimit, expect($response->status())->toBe(429, 'Expected 429 Too Many Requests after exceeding rate limit');
'rate_limit_remaining' => $rateLimitRemaining, });
];
test('rate limit is scoped per email and IP combination', function () {
// Print output similar to Python script // Exhaust rate limit for first email
echo 'Attempt '.str_pad($i, 2, '0', STR_PAD_LEFT).": status=$statusCode, RL=$rateLimitLimit/$rateLimitRemaining\n"; for ($i = 1; $i <= 5; $i++) {
$this->post('/login', [
// Add a small delay like the Python script (0.2 seconds) 'email' => 'test@example.com',
usleep(200000); 'password' => 'wrong-password',
} ]);
}
// Verify results
expect($results)->toHaveCount(14); // Different email from same IP should still work (different composite key)
$response = $this->post('/login', [
// Check that we got responses for all attempts 'email' => 'other@example.com',
foreach ($results as $i => $result) { 'password' => 'wrong-password',
expect($result['status'])->toBeGreaterThanOrEqual(200); ]);
expect($result['ip'])->toBe("198.51.100.{$i}");
} 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);
}); });

View file

@ -1,70 +1,65 @@
<?php <?php
use App\Enums\ProcessStatus;
use App\Jobs\CoolifyTask; use App\Jobs\CoolifyTask;
use App\Models\Server; use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
it('can dispatch CoolifyTask successfully', function () { beforeEach(function () {
// Skip if no servers available $team = Team::factory()->create();
$server = Server::where('ip', '!=', '1.2.3.4')->first(); Server::factory()->create(['team_id' => $team->id]);
if (! $server) { $this->activity = activity()
$this->markTestSkipped('No servers available for testing');
}
Queue::fake();
// Create an activity for the task
$activity = activity()
->withProperties([ ->withProperties([
'server_uuid' => $server->uuid, 'server_uuid' => Server::first()->uuid,
'command' => 'echo "test"', 'command' => 'echo "test"',
'type' => 'inline', 'type' => 'inline',
'status' => ProcessStatus::QUEUED->value,
]) ])
->event('inline') ->event('inline')
->log('[]'); ->log('[]');
// Dispatch the job $this->job = new CoolifyTask(
CoolifyTask::dispatch( activity: $this->activity,
activity: $activity,
ignore_errors: false, ignore_errors: false,
call_event_on_finish: null, 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 () { test('has correct retry configuration', function () {
$server = Server::where('ip', '!=', '1.2.3.4')->first(); expect($this->job->tries)->toBe(3)
->and($this->job->maxExceptions)->toBe(1)
if (! $server) { ->and($this->job->timeout)->toBe(600)
$this->markTestSkipped('No servers available for testing'); ->and($this->job->backoff())->toBe([30, 90, 180]);
} });
$activity = activity() test('is queued on the high priority queue', function () {
->withProperties([ expect($this->job->queue)->toBe('high');
'server_uuid' => $server->uuid, });
'command' => 'echo "test"',
'type' => 'inline', test('marks activity as error on permanent failure', function () {
]) $exception = new \RuntimeException('SSH connection failed');
->event('inline')
->log('[]'); $this->job->failed($exception);
$job = new CoolifyTask( $this->activity->refresh();
activity: $activity, $properties = $this->activity->properties;
ignore_errors: false,
call_event_on_finish: null, expect($properties['status'])->toBe(ProcessStatus::ERROR->value)
call_event_data: null ->and($properties['error'])->toBe('SSH connection failed')
); ->and($properties)->toHaveKey('failed_at');
});
// Assert retry configuration
expect($job->tries)->toBe(3); test('marks activity as error with default message when exception is null', function () {
expect($job->maxExceptions)->toBe(1); $this->job->failed(null);
expect($job->timeout)->toBe(600);
expect($job->backoff())->toBe([30, 90, 180]); $this->activity->refresh();
$properties = $this->activity->properties;
expect($properties['status'])->toBe(ProcessStatus::ERROR->value)
->and($properties['error'])->toBe('Job permanently failed');
}); });