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
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create a team with owner
InstanceSettings::updateOrCreate(['id' => 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.']);
});
});

View file

@ -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,
]);
}
});
});

View file

@ -1,58 +1,68 @@
<?php
it('tests login rate limiting with different IPs like the Python script', function () {
// Create a test route that mimics login behavior
// We'll directly test the rate limiter behavior
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\RateLimiter;
$baseUrl = '/login';
$email = 'grumpinout+admin@wearehackerone.com';
uses(RefreshDatabase::class);
// First, get a CSRF token by visiting the login page
$loginPageResponse = $this->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);
});

View file

@ -1,70 +1,65 @@
<?php
use App\Enums\ProcessStatus;
use App\Jobs\CoolifyTask;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('can dispatch CoolifyTask successfully', function () {
// Skip if no servers available
$server = Server::where('ip', '!=', '1.2.3.4')->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');
});