Merge branch 'next' into improve-resource-route-handling

This commit is contained in:
Andras Bacsai committed 2026-07-02 13:25:47 +02:00
commit a121386ab4
570 files changed
+18896 -5156

No files matched your search

@@ -1,14 +1,23 @@
<?php
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->whereKey(0)->delete();
$settings = new InstanceSettings(['is_api_enabled' => true]);
$settings->id = 0;
$settings->save();
Once::flush();
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -410,4 +419,33 @@ describe('POST /api/v1/cloud-tokens/{uuid}/validate', function () {
$response->assertStatus(200);
$response->assertJson(['valid' => true, 'message' => 'Token is valid.']);
});
test('writes an audit log entry when validating a stored token', function () {
$token = CloudProviderToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'hetzner',
'name' => 'Audit Token',
]);
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
]);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->once()
->with('api.cloud_token.validated', Mockery::on(function (array $context) use ($token) {
return $context['cloud_token_uuid'] === $token->uuid
&& $context['provider'] === 'hetzner'
&& $context['valid'] === true;
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate")
->assertOk();
});
});
@@ -0,0 +1,277 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
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 () {
InstanceSettings::updateOrCreate(['id' => 0]);
$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]);
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(),
]);
$this->s3Storage = S3Storage::create([
'name' => 'test-s3',
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $this->team->id,
'is_usable' => true,
]);
});
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 valid frequency', function () {
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'daily',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid', 'message']);
$response->assertJson(['message' => 'Backup configuration created successfully.']);
});
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(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'enabled' => true,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['frequency']);
});
test('rejects invalid frequency format', function () {
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'invalid-frequency',
]);
$response->assertStatus(422);
});
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,
]);
$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/{$this->database->uuid}/backups", [
'frequency' => 'daily',
]);
$response->assertStatus(401);
});
test('returns 404 for non-existent database uuid', function () {
$response = $this->withHeaders(backupHeaders())
->postJson('/api/v1/databases/non-existent-uuid/backups', [
'frequency' => 'daily',
]);
$response->assertStatus(404);
$response->assertJson(['message' => 'Database not found.']);
});
test('creates backup with s3 storage via API token', function () {
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => '0 2 * * 0',
'save_s3' => true,
's3_storage_uuid' => $this->s3Storage->uuid,
'enabled' => true,
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid', 'message']);
$backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->first();
expect($backup)->not->toBeNull();
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
expect($backup->save_s3)->toBeTrue();
expect($backup->team_id)->toBe($this->team->id);
});
test('rejects s3_storage_uuid from another team', function () {
$otherTeam = Team::factory()->create();
$otherS3 = S3Storage::create([
'name' => 'other-s3',
'region' => 'us-east-1',
'key' => 'other-key',
'secret' => 'other-secret',
'bucket' => 'other-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $otherTeam->id,
'is_usable' => true,
]);
$response = $this->withHeaders(backupHeaders())
->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => '0 2 * * 0',
'save_s3' => true,
's3_storage_uuid' => $otherS3->uuid,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['s3_storage_uuid']);
});
});
describe('PATCH /api/v1/databases/{uuid}/backups/{scheduled_backup_uuid}', function () {
test('updates backup to use s3 storage via API token', function () {
$backup = ScheduledDatabaseBackup::create([
'frequency' => 'daily',
'enabled' => true,
'database_id' => $this->database->id,
'database_type' => $this->database->getMorphClass(),
'team_id' => $this->team->id,
]);
$response = $this->withHeaders(backupHeaders())
->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
'save_s3' => true,
's3_storage_uuid' => $this->s3Storage->uuid,
]);
$response->assertStatus(200);
$backup->refresh();
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
expect($backup->save_s3)->toBeTrue();
});
test('rejects s3_storage_uuid from another team on update', function () {
$otherTeam = Team::factory()->create();
$otherS3 = S3Storage::create([
'name' => 'other-s3',
'region' => 'us-east-1',
'key' => 'other-key',
'secret' => 'other-secret',
'bucket' => 'other-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $otherTeam->id,
'is_usable' => true,
]);
$backup = ScheduledDatabaseBackup::create([
'frequency' => 'daily',
'enabled' => true,
'database_id' => $this->database->id,
'database_type' => $this->database->getMorphClass(),
'team_id' => $this->team->id,
]);
$response = $this->withHeaders(backupHeaders())
->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
'save_s3' => true,
's3_storage_uuid' => $otherS3->uuid,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['s3_storage_uuid']);
});
});
@@ -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 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
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 @@ describe('POST /api/v1/deployments/{uuid}/cancel', function () {
])->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 @@ describe('POST /api/v1/deployments/{uuid}/cancel', function () {
])->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 @@ describe('POST /api/v1/deployments/{uuid}/cancel', function () {
])->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 @@ describe('POST /api/v1/deployments/{uuid}/cancel', function () {
'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 @@ describe('POST /api/v1/deployments/{uuid}/cancel', function () {
'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,
]);
}
});
});
@@ -26,7 +26,7 @@ beforeEach(function () {
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function authHeaders(): array
function domainApiAuthHeaders(): array
{
return [
'Authorization' => 'Bearer '.test()->bearerToken,
@@ -41,7 +41,7 @@ test('returns domains for own team application via uuid query param', function (
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders())
$response = $this->withHeaders(domainApiAuthHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$application->uuid}");
$response->assertOk();
@@ -65,7 +65,7 @@ test('returns 404 when application uuid belongs to another team', function () {
'destination_type' => $otherDestination->getMorphClass(),
]);
$response = $this->withHeaders(authHeaders())
$response = $this->withHeaders(domainApiAuthHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid={$otherApplication->uuid}");
$response->assertNotFound();
@@ -73,7 +73,7 @@ test('returns 404 when application uuid belongs to another team', function () {
});
test('returns 404 for nonexistent application uuid', function () {
$response = $this->withHeaders(authHeaders())
$response = $this->withHeaders(domainApiAuthHeaders())
->getJson("/api/v1/servers/{$this->server->uuid}/domains?uuid=nonexistent-uuid");
$response->assertNotFound();
@@ -15,7 +15,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -252,3 +252,94 @@ describe('PATCH /api/v1/applications/{uuid}/envs', function () {
$response->assertJsonFragment(['uuid' => ['This field is not allowed.']]);
});
});
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);
});
});
@@ -1,15 +1,23 @@
<?php
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->whereKey(0)->delete();
$settings = new InstanceSettings(['is_api_enabled' => true]);
$settings->id = 0;
$settings->save();
Once::flush();
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -73,6 +81,27 @@ describe('GET /api/v1/hetzner/locations', function () {
$response->assertStatus(404);
});
test('member read token cannot use a stored cloud provider token', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$memberToken = $member->createToken('member-read', ['read'])->plainTextToken;
Http::fake([
'https://api.hetzner.cloud/v1/locations*' => Http::response([
'locations' => [['id' => 1, 'name' => 'nbg1']],
], 200),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$memberToken,
'Content-Type' => 'application/json',
])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid);
$response->assertForbidden();
Http::assertNothingSent();
});
});
describe('GET /api/v1/hetzner/server-types', function () {
@@ -0,0 +1,68 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\RateLimiter;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
RateLimiter::clear('login');
$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);
});
@@ -0,0 +1,218 @@
<?php
use App\Models\Application;
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\Facades\DB;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
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->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running',
]);
$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(),
]);
// Create real tokens with team_id (createToken reads from session)
session(['currentTeam' => $this->team]);
$this->adminRootToken = $this->admin->createToken('admin-root', ['root']);
$this->adminReadToken = $this->admin->createToken('admin-read', ['read']);
$this->memberRootToken = $this->member->createToken('member-root', ['root']);
});
// --- Unauthenticated Access ---
test('unauthenticated request to api returns 401', function () {
$this->getJson('/api/v1/projects')->assertStatus(401);
});
// --- Admin with root token ---
test('admin with root token can list projects', function () {
$this->withToken($this->adminRootToken->plainTextToken)
->getJson('/api/v1/projects')
->assertSuccessful();
});
test('admin with root token can view project', function () {
$this->withToken($this->adminRootToken->plainTextToken)
->getJson("/api/v1/projects/{$this->project->uuid}")
->assertSuccessful();
});
test('admin with root token can view application', function () {
$this->withToken($this->adminRootToken->plainTextToken)
->getJson("/api/v1/applications/{$this->application->uuid}")
->assertSuccessful();
});
test('admin with root token can view server', function () {
$this->withToken($this->adminRootToken->plainTextToken)
->getJson("/api/v1/servers/{$this->server->uuid}")
->assertSuccessful();
});
test('admin with root token can view database', function () {
$this->withToken($this->adminRootToken->plainTextToken)
->getJson("/api/v1/databases/{$this->database->uuid}")
->assertSuccessful();
});
// --- Member with root token (policy should deny mutations) ---
test('member with root token is blocked by middleware', function () {
$this->withToken($this->memberRootToken->plainTextToken)
->getJson("/api/v1/projects/{$this->project->uuid}")
->assertStatus(403);
});
test('member with root token cannot delete project', function () {
$this->withToken($this->memberRootToken->plainTextToken)
->deleteJson("/api/v1/projects/{$this->project->uuid}")
->assertStatus(403);
});
test('member with root token cannot update application', function () {
$this->withToken($this->memberRootToken->plainTextToken)
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'name' => 'New Name',
])
->assertStatus(403);
});
test('member with root token cannot delete application', function () {
$this->withToken($this->memberRootToken->plainTextToken)
->deleteJson("/api/v1/applications/{$this->application->uuid}")
->assertStatus(403);
});
test('member with root token cannot delete server', function () {
$this->withToken($this->memberRootToken->plainTextToken)
->deleteJson("/api/v1/servers/{$this->server->uuid}")
->assertStatus(403);
});
test('member with root token cannot update server', function () {
$this->withToken($this->memberRootToken->plainTextToken)
->patchJson("/api/v1/servers/{$this->server->uuid}", [
'name' => 'New Server Name',
])
->assertStatus(403);
});
// --- Token ability checks ---
test('read-only token cannot create project', function () {
$this->withToken($this->adminReadToken->plainTextToken)
->postJson('/api/v1/projects', [
'name' => 'New Project',
])
->assertStatus(403);
});
test('read-only token cannot delete application', function () {
$this->withToken($this->adminReadToken->plainTextToken)
->deleteJson("/api/v1/applications/{$this->application->uuid}")
->assertStatus(403);
});
// --- Cross-team isolation ---
test('user from different team cannot view project via api', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
session(['currentTeam' => $otherTeam]);
$otherToken = $otherUser->createToken('other-root', ['root']);
$this->withToken($otherToken->plainTextToken)
->getJson("/api/v1/projects/{$this->project->uuid}")
->assertStatus(404);
});
test('user from different team cannot view application via api', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
session(['currentTeam' => $otherTeam]);
$otherToken = $otherUser->createToken('other-root', ['root']);
$this->withToken($otherToken->plainTextToken)
->getJson("/api/v1/applications/{$this->application->uuid}")
->assertStatus(404);
});
test('user from different team cannot view server via api', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
session(['currentTeam' => $otherTeam]);
$otherToken = $otherUser->createToken('other-root', ['root']);
$this->withToken($otherToken->plainTextToken)
->getJson("/api/v1/servers/{$this->server->uuid}")
->assertStatus(404);
});
@@ -1,5 +1,6 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -7,6 +8,8 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['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']);
@@ -0,0 +1,170 @@
<?php
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PersonalAccessToken;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use App\Policies\CloudInitScriptPolicy;
use App\Policies\CloudProviderTokenPolicy;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
DB::table('private_keys')->insert([
'uuid' => (string) Str::uuid(),
'name' => 'Team SSH Key',
'description' => 'Key for testing',
'private_key' => 'test-key-content',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->privateKey = PrivateKey::where('team_id', $this->team->id)->first();
});
// --- Private Key Policy ---
test('admin can create private key', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', PrivateKey::class))->toBeTrue();
});
test('member cannot create private key', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', PrivateKey::class))->toBeFalse();
});
test('admin can view private key', function () {
expect($this->admin->can('view', $this->privateKey))->toBeTrue();
});
test('member can view own team private key', function () {
expect($this->member->can('view', $this->privateKey))->toBeTrue();
});
test('admin can update private key', function () {
expect($this->admin->can('update', $this->privateKey))->toBeTrue();
});
test('member cannot update private key', function () {
expect($this->member->can('update', $this->privateKey))->toBeFalse();
});
test('admin can delete private key', function () {
expect($this->admin->can('delete', $this->privateKey))->toBeTrue();
});
test('member cannot delete private key', function () {
expect($this->member->can('delete', $this->privateKey))->toBeFalse();
});
test('user from different team cannot view private key', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('view', $this->privateKey))->toBeFalse();
});
// --- Cloud Provider Token Policy ---
test('admin can create cloud provider token', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', CloudProviderToken::class))->toBeTrue();
});
test('member cannot create cloud provider token', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', CloudProviderToken::class))->toBeFalse();
});
test('admin can view any cloud provider tokens', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('viewAny', CloudProviderToken::class))->toBeTrue();
});
// --- Cloud Init Script Policy ---
test('admin can create cloud init script', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', CloudInitScript::class))->toBeTrue();
});
test('member cannot create cloud init script', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', CloudInitScript::class))->toBeFalse();
});
test('admin can view any cloud init scripts', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('viewAny', CloudInitScript::class))->toBeTrue();
});
test('cloud provider and cloud init policies are explicitly registered', function () {
expect(Gate::getPolicyFor(CloudProviderToken::class))->toBeInstanceOf(CloudProviderTokenPolicy::class)
->and(Gate::getPolicyFor(CloudInitScript::class))->toBeInstanceOf(CloudInitScriptPolicy::class);
});
// --- Personal Access Token (API Token) Policy ---
test('any user can create personal access token', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', PersonalAccessToken::class))->toBeTrue();
});
test('admin can use root permissions for api tokens', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('useRootPermissions', PersonalAccessToken::class))->toBeTrue();
});
test('member cannot use root permissions for api tokens', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('useRootPermissions', PersonalAccessToken::class))->toBeFalse();
});
test('member cannot use write permissions for api tokens', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('useWritePermissions', PersonalAccessToken::class))->toBeFalse();
});
@@ -0,0 +1,97 @@
<?php
use App\Livewire\Dashboard;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function setupDashboardUser(string $role): array
{
$team = Team::factory()->create();
$user = User::factory()->create();
$user->teams()->attach($team, ['role' => $role]);
return [$user, $team];
}
function createProjectForTeam(Team $team): void
{
Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $team->id,
]);
}
function createServerWithKeyForTeam(Team $team): void
{
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $team->id,
'created_at' => now(),
'updated_at' => now(),
]);
Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $keyId,
]);
}
test('admin sees add project button on dashboard', function () {
[$user, $team] = setupDashboardUser('admin');
$this->actingAs($user);
session(['currentTeam' => $team]);
createProjectForTeam($team);
Livewire::test(Dashboard::class)
->assertSee('New Project');
});
test('member does not see add project button on dashboard', function () {
[$user, $team] = setupDashboardUser('member');
$this->actingAs($user);
session(['currentTeam' => $team]);
createProjectForTeam($team);
Livewire::test(Dashboard::class)
->assertDontSee('New Project');
});
test('admin sees add server button on dashboard', function () {
[$user, $team] = setupDashboardUser('admin');
$this->actingAs($user);
session(['currentTeam' => $team]);
createServerWithKeyForTeam($team);
Livewire::test(Dashboard::class)
->assertSee('New Server');
});
test('member does not see add server button on dashboard', function () {
[$user, $team] = setupDashboardUser('member');
$this->actingAs($user);
session(['currentTeam' => $team]);
createServerWithKeyForTeam($team);
Livewire::test(Dashboard::class)
->assertDontSee('New Server');
});
@@ -0,0 +1,188 @@
<?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\Facades\DB;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
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(),
]);
$this->databaseUrl = "/project/{$this->project->uuid}/environment/{$this->environment->uuid}/database/{$this->database->uuid}";
});
// --- Database Policy: view ---
test('admin can view database', function () {
expect($this->admin->can('view', $this->database))->toBeTrue();
});
test('member can view database', function () {
expect($this->member->can('view', $this->database))->toBeTrue();
});
// --- Database Policy: update ---
test('admin can update database', function () {
expect($this->admin->can('update', $this->database))->toBeTrue();
});
test('member cannot update database', function () {
expect($this->member->can('update', $this->database))->toBeFalse();
});
// --- Database Policy: delete ---
test('admin can delete database', function () {
expect($this->admin->can('delete', $this->database))->toBeTrue();
});
test('member cannot delete database', function () {
expect($this->member->can('delete', $this->database))->toBeFalse();
});
// --- Database Policy: manage ---
test('admin can manage database', function () {
expect($this->admin->can('manage', $this->database))->toBeTrue();
});
test('member cannot manage database', function () {
expect($this->member->can('manage', $this->database))->toBeFalse();
});
// --- Database Policy: manageBackups ---
test('admin can manage database backups', function () {
expect($this->admin->can('manageBackups', $this->database))->toBeTrue();
});
test('member cannot manage database backups', function () {
expect($this->member->can('manageBackups', $this->database))->toBeFalse();
});
// --- Database Policy: manageEnvironment ---
test('admin can manage database environment variables', function () {
expect($this->admin->can('manageEnvironment', $this->database))->toBeTrue();
});
test('member cannot manage database environment variables', function () {
expect($this->member->can('manageEnvironment', $this->database))->toBeFalse();
});
// --- Database page access ---
test('admin can access database page', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->get($this->databaseUrl)->assertSuccessful();
});
test('member can access database page', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$this->get($this->databaseUrl)->assertSuccessful();
});
// --- Terminal access ---
test('admin can access terminal for database', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('canAccessTerminal'))->toBeTrue();
});
test('member cannot access terminal for database', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('canAccessTerminal'))->toBeFalse();
});
// --- Cross-team isolation ---
test('user from different team cannot view database', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('view', $this->database))->toBeFalse();
});
test('user from different team cannot update database', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('update', $this->database))->toBeFalse();
});
test('user from different team cannot manage database', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('manage', $this->database))->toBeFalse();
});
@@ -0,0 +1,60 @@
<?php
use App\Livewire\Destination\Show;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
// Team A owns the destination
$this->teamA = Team::factory()->create();
$this->userA = User::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'admin']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Key A',
'private_key' => 'test-key-a',
'team_id' => $this->teamA->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->teamA->id,
'private_key_id' => $keyId,
]);
// Use the StandaloneDocker created by the Server factory
$this->destination = $this->server->standaloneDockers()->first();
// Team B is a different team
$this->teamB = Team::factory()->create();
$this->userB = User::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'admin']);
});
test('team member can view their own destination', function () {
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
Livewire::test(Show::class, ['destination_uuid' => $this->destination->uuid])
->assertSet('name', $this->destination->name);
});
test('cross-team user cannot view destination', function () {
$this->actingAs($this->userB);
session(['currentTeam' => $this->teamB]);
Livewire::test(Show::class, ['destination_uuid' => $this->destination->uuid])
->assertStatus(403);
});
@@ -0,0 +1,274 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All as EnvironmentVariableAll;
use App\Livewire\Project\Shared\EnvironmentVariable\Show as EnvironmentVariableShow;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
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->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->unlockedEnv = EnvironmentVariable::create([
'key' => 'UNLOCKED_VAR',
'value' => 'secret-unlocked-value',
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
'is_preview' => false,
'is_shown_once' => false,
'is_multiline' => false,
'is_literal' => false,
'is_runtime' => true,
'is_buildtime' => true,
]);
$this->lockedEnv = EnvironmentVariable::create([
'key' => 'LOCKED_VAR',
'value' => 'secret-locked-value',
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
'is_preview' => false,
'is_shown_once' => true,
'is_multiline' => false,
'is_literal' => false,
'is_runtime' => true,
'is_buildtime' => true,
]);
});
// --- Livewire Show component: locked env values ---
test('admin sees unlocked env value in Show component', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableShow::class, [
'env' => $this->unlockedEnv,
'type' => 'application',
]);
expect($component->get('value'))->toBe('secret-unlocked-value');
});
test('admin cannot see locked env value in Show component', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableShow::class, [
'env' => $this->lockedEnv,
'type' => 'application',
]);
expect($component->get('value'))->toBeNull();
expect($component->get('real_value'))->toBeNull();
});
test('member cannot see any env value in Show component', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableShow::class, [
'env' => $this->unlockedEnv,
'type' => 'application',
]);
expect($component->get('value'))->toBeNull();
expect($component->get('real_value'))->toBeNull();
});
test('member has isValueHidden flag set to true', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableShow::class, [
'env' => $this->unlockedEnv,
'type' => 'application',
]);
expect($component->get('isValueHidden'))->toBeTrue();
});
test('admin has isValueHidden flag set to false', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableShow::class, [
'env' => $this->unlockedEnv,
'type' => 'application',
]);
expect($component->get('isValueHidden'))->toBeFalse();
});
// --- Livewire All component: dev view ---
test('admin dev view shows unlocked env value', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableAll::class, [
'resource' => $this->application,
]);
expect($component->get('variables'))->toContain('UNLOCKED_VAR=secret-unlocked-value');
});
test('admin dev view hides locked env value', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableAll::class, [
'resource' => $this->application,
]);
expect($component->get('variables'))->toContain('LOCKED_VAR=(Locked Secret, delete and add again to change)');
expect($component->get('variables'))->not->toContain('secret-locked-value');
});
test('member dev view hides all env values', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableAll::class, [
'resource' => $this->application,
]);
expect($component->get('variables'))->not->toContain('secret-unlocked-value');
expect($component->get('variables'))->not->toContain('secret-locked-value');
expect($component->get('variables'))->toContain('UNLOCKED_VAR=(Hidden');
});
// --- API: locked env values hidden ---
test('API hides locked env value even with read:sensitive token', function () {
session(['currentTeam' => $this->team]);
$token = $this->admin->createToken('admin-sensitive', ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
$response->assertOk();
$envs = collect($response->json());
$locked = $envs->firstWhere('key', 'LOCKED_VAR');
$unlocked = $envs->firstWhere('key', 'UNLOCKED_VAR');
expect($locked)->not->toBeNull();
expect($locked)->not->toHaveKey('value');
expect($locked)->not->toHaveKey('real_value');
expect($unlocked)->not->toBeNull();
expect($unlocked)->toHaveKey('value');
});
test('API hides locked env value with root token', function () {
session(['currentTeam' => $this->team]);
$token = $this->admin->createToken('admin-root', ['root']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
$response->assertOk();
$envs = collect($response->json());
$locked = $envs->firstWhere('key', 'LOCKED_VAR');
expect($locked)->not->toBeNull();
expect($locked)->not->toHaveKey('value');
expect($locked)->not->toHaveKey('real_value');
});
// --- API: member role hides env values ---
test('API hides env values for member even with read:sensitive token', function () {
session(['currentTeam' => $this->team]);
$token = $this->member->createToken('member-sensitive', ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
$response->assertOk();
$envs = collect($response->json());
$unlocked = $envs->firstWhere('key', 'UNLOCKED_VAR');
expect($unlocked)->not->toBeNull();
expect($unlocked)->not->toHaveKey('value');
expect($unlocked)->not->toHaveKey('real_value');
});
test('API shows env values for admin with read:sensitive token', function () {
session(['currentTeam' => $this->team]);
$token = $this->admin->createToken('admin-sensitive-2', ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
$response->assertOk();
$envs = collect($response->json());
$unlocked = $envs->firstWhere('key', 'UNLOCKED_VAR');
expect($unlocked)->not->toBeNull();
expect($unlocked)->toHaveKey('value');
});
@@ -0,0 +1,127 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->member = User::factory()->create();
$this->admin = User::factory()->create();
$this->team->members()->attach($this->member->id, ['role' => 'member']);
$this->team->members()->attach($this->admin->id, ['role' => 'admin']);
session(['currentTeam' => $this->team]);
});
function apiRequest($test, string $token, string $method = 'get', string $url = '/api/v1/version')
{
return $test->withHeaders([
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json',
])->{$method.'Json'}($url);
}
describe('member with legacy elevated token is rejected', function () {
test('member with legacy write token gets 403 with descriptive message', function () {
$token = $this->member->createToken('legacy-write', ['read', 'write']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$response->assertJsonFragment([
'message' => 'This API token has permissions (write) that exceed your current role as a team member. Members are restricted to read-only API access. Please revoke this token and create a new one with only read permissions.',
]);
});
test('member with legacy deploy token gets 403', function () {
$token = $this->member->createToken('legacy-deploy', ['read', 'deploy']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$response->assertSee('deploy');
$response->assertSee('revoke this token');
});
test('member with legacy root token gets 403', function () {
$token = $this->member->createToken('legacy-root', ['root']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$response->assertSee('root');
});
test('member with legacy read:sensitive token gets 403', function () {
$token = $this->member->createToken('legacy-sensitive', ['read', 'read:sensitive']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$response->assertSee('read:sensitive');
});
test('member with legacy write:sensitive token gets 403', function () {
$token = $this->member->createToken('legacy-ws', ['read', 'write:sensitive']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$response->assertSee('write:sensitive');
});
test('member with multiple disallowed abilities lists them all', function () {
$token = $this->member->createToken('legacy-multi', ['read', 'write', 'deploy', 'read:sensitive']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$json = $response->json();
expect($json['message'])->toContain('write');
expect($json['message'])->toContain('deploy');
expect($json['message'])->toContain('read:sensitive');
});
});
describe('member with read-only token passes through', function () {
test('member with read token can access read endpoints', function () {
$token = $this->member->createToken('read-only', ['read']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(200);
});
});
describe('admin with elevated token passes through', function () {
test('admin with write token is not blocked', function () {
$token = $this->admin->createToken('admin-write', ['read', 'write']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(200);
});
test('admin with root token is not blocked', function () {
$token = $this->admin->createToken('admin-root', ['root']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(200);
});
test('admin with deploy token is not blocked', function () {
$token = $this->admin->createToken('admin-deploy', ['read', 'deploy']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(200);
});
});
@@ -0,0 +1,46 @@
<?php
use App\Livewire\NavbarDeleteTeam;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->owner = User::factory()->create();
$this->personalTeam = $this->owner->teams()->first();
$this->owner->teams()->updateExistingPivot($this->personalTeam->id, ['role' => 'owner']);
$this->teamToDelete = Team::create(['name' => 'Deletable Team', 'personal_team' => false]);
$this->teamToDelete->members()->attach($this->owner->id, ['role' => 'owner']);
$this->member = User::factory()->create();
$this->teamToDelete->members()->attach($this->member->id, ['role' => 'member']);
});
test('owner can delete team via navbar', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->teamToDelete]);
Livewire::test(NavbarDeleteTeam::class)
->call('delete', 'password')
->assertRedirect(route('team.index'));
expect(Team::find($this->teamToDelete->id))->toBeNull();
});
test('member cannot delete team via navbar', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->teamToDelete]);
Livewire::test(NavbarDeleteTeam::class)
->call('delete', 'password')
->assertDispatched('error');
expect(Team::find($this->teamToDelete->id))->not->toBeNull();
});
@@ -0,0 +1,384 @@
<?php
use App\Livewire\Notifications\Discord as DiscordNotification;
use App\Livewire\Notifications\Email as EmailNotification;
use App\Livewire\Notifications\Pushover as PushoverNotification;
use App\Livewire\Notifications\Slack as SlackNotification;
use App\Livewire\Notifications\Telegram as TelegramNotification;
use App\Livewire\Notifications\Webhook as WebhookNotification;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
});
// --- Discord ---
test('member cannot send test notification on discord', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$settings = $this->team->discordNotificationSettings;
$settings->update([
'discord_enabled' => true,
'discord_webhook_url' => 'https://discord.com/api/webhooks/test',
]);
Livewire::test(DiscordNotification::class)
->call('sendTestNotification')
->assertDispatched('error');
});
test('member cannot update discord notification settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(DiscordNotification::class)
->call('syncData', true)
->assertForbidden();
});
test('admin can update discord notification settings', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$settings = $this->team->discordNotificationSettings;
expect($this->admin->can('update', $settings))->toBeTrue();
});
// --- Slack ---
test('member cannot send test notification on slack', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$settings = $this->team->slackNotificationSettings;
$settings->update([
'slack_enabled' => true,
'slack_webhook_url' => 'https://hooks.slack.com/services/test',
]);
Livewire::test(SlackNotification::class)
->call('sendTestNotification')
->assertDispatched('error');
});
test('member cannot update slack notification settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(SlackNotification::class)
->call('syncData', true)
->assertForbidden();
});
test('admin can update slack notification settings', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$settings = $this->team->slackNotificationSettings;
expect($this->admin->can('update', $settings))->toBeTrue();
});
// --- Telegram ---
test('member cannot send test notification on telegram', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$settings = $this->team->telegramNotificationSettings;
$settings->update([
'telegram_enabled' => true,
'telegram_token' => 'test-token',
'telegram_chat_id' => '123456',
]);
Livewire::test(TelegramNotification::class)
->call('sendTestNotification')
->assertDispatched('error');
});
test('member cannot update telegram notification settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(TelegramNotification::class)
->call('syncData', true)
->assertForbidden();
});
test('admin can update telegram notification settings', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$settings = $this->team->telegramNotificationSettings;
expect($this->admin->can('update', $settings))->toBeTrue();
});
// --- Email ---
test('member cannot send test email notification', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$settings = $this->team->emailNotificationSettings;
$settings->update([
'smtp_enabled' => true,
'smtp_host' => 'localhost',
'smtp_port' => 587,
'smtp_from_address' => 'test@test.com',
'smtp_from_name' => 'Test',
]);
Livewire::test(EmailNotification::class)
->call('sendTestEmail')
->assertDispatched('error');
});
test('member cannot update email notification settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->call('syncData', true)
->assertForbidden();
});
test('member cannot update smtp email transport directly', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->set('smtpFromAddress', 'member@example.com')
->set('smtpFromName', 'Member')
->set('smtpHost', 'smtp.example.com')
->set('smtpPort', '587')
->set('smtpEncryption', 'starttls')
->set('smtpPassword', 'member-smtp-password')
->call('submitSmtp')
->assertForbidden();
});
test('member cannot update resend email transport directly', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->set('smtpFromAddress', 'member@example.com')
->set('smtpFromName', 'Member')
->set('resendApiKey', 'member-resend-api-key')
->call('submitResend')
->assertForbidden();
});
test('member cannot copy instance email settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->call('copyFromInstanceSettings')
->assertForbidden();
});
test('admin can update email notification settings', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$settings = $this->team->emailNotificationSettings;
expect($this->admin->can('update', $settings))->toBeTrue();
});
// --- Pushover ---
test('member cannot send test notification on pushover', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$settings = $this->team->pushoverNotificationSettings;
$settings->update([
'pushover_enabled' => true,
'pushover_app_token' => 'test-token',
'pushover_user_key' => 'test-user-key',
]);
Livewire::test(PushoverNotification::class)
->call('sendTestNotification')
->assertDispatched('error');
});
test('member cannot update pushover notification settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(PushoverNotification::class)
->call('syncData', true)
->assertForbidden();
});
test('admin can update pushover notification settings', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$settings = $this->team->pushoverNotificationSettings;
expect($this->admin->can('update', $settings))->toBeTrue();
});
// --- Webhook ---
test('member cannot send test notification on webhook', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$settings = $this->team->webhookNotificationSettings;
$settings->update([
'webhook_enabled' => true,
'webhook_url' => 'https://example.com/webhook',
]);
Livewire::test(WebhookNotification::class)
->call('sendTestNotification')
->assertDispatched('error');
});
test('member cannot update webhook notification settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(WebhookNotification::class)
->call('syncData', true)
->assertForbidden();
});
test('admin can update webhook notification settings', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$settings = $this->team->webhookNotificationSettings;
expect($this->admin->can('update', $settings))->toBeTrue();
});
// --- Send test policy checks ---
test('admin can send test on all notification channels', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect($this->admin->can('sendTest', $this->team->discordNotificationSettings))->toBeTrue();
expect($this->admin->can('sendTest', $this->team->slackNotificationSettings))->toBeTrue();
expect($this->admin->can('sendTest', $this->team->telegramNotificationSettings))->toBeTrue();
expect($this->admin->can('sendTest', $this->team->emailNotificationSettings))->toBeTrue();
expect($this->admin->can('sendTest', $this->team->pushoverNotificationSettings))->toBeTrue();
expect($this->admin->can('sendTest', $this->team->webhookNotificationSettings))->toBeTrue();
});
test('member cannot send test on any notification channel', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect($this->member->can('sendTest', $this->team->discordNotificationSettings))->toBeFalse();
expect($this->member->can('sendTest', $this->team->slackNotificationSettings))->toBeFalse();
expect($this->member->can('sendTest', $this->team->telegramNotificationSettings))->toBeFalse();
expect($this->member->can('sendTest', $this->team->emailNotificationSettings))->toBeFalse();
expect($this->member->can('sendTest', $this->team->pushoverNotificationSettings))->toBeFalse();
expect($this->member->can('sendTest', $this->team->webhookNotificationSettings))->toBeFalse();
});
test('member cannot view notification secrets', function (string $component, string $settingsRelation, array $secrets) {
$settings = $this->team->{$settingsRelation};
$settings->update($secrets);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$componentTest = Livewire::test($component);
foreach ($secrets as $column => $value) {
$property = str($column)->camel()->toString();
$componentTest
->assertSet($property, null)
->assertDontSee($value);
}
$componentTest->assertSee('Hidden (only admins can view)');
})->with([
'discord webhook' => [DiscordNotification::class, 'discordNotificationSettings', [
'discord_webhook_url' => 'https://discord.com/api/webhooks/secret-member',
]],
'slack webhook' => [SlackNotification::class, 'slackNotificationSettings', [
'slack_webhook_url' => 'https://hooks.slack.com/services/secret-member',
]],
'telegram token and chat id' => [TelegramNotification::class, 'telegramNotificationSettings', [
'telegram_token' => 'telegram-secret-token',
'telegram_chat_id' => 'telegram-secret-chat',
]],
'pushover credentials' => [PushoverNotification::class, 'pushoverNotificationSettings', [
'pushover_user_key' => 'pushover-secret-user',
'pushover_api_token' => 'pushover-secret-token',
]],
'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [
'webhook_url' => 'https://example.com/secret-webhook',
]],
'email credentials' => [EmailNotification::class, 'emailNotificationSettings', [
'smtp_password' => 'smtp-secret-password',
'resend_api_key' => 'resend-secret-api-key',
]],
]);
test('admin can view notification secrets', function (string $component, string $settingsRelation, array $secrets) {
$settings = $this->team->{$settingsRelation};
$settings->update($secrets);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$componentTest = Livewire::test($component);
foreach ($secrets as $column => $value) {
$property = str($column)->camel()->toString();
$componentTest->assertSet($property, $value);
}
})->with([
'discord webhook' => [DiscordNotification::class, 'discordNotificationSettings', [
'discord_webhook_url' => 'https://discord.com/api/webhooks/secret-admin',
]],
'slack webhook' => [SlackNotification::class, 'slackNotificationSettings', [
'slack_webhook_url' => 'https://hooks.slack.com/services/secret-admin',
]],
'telegram token and chat id' => [TelegramNotification::class, 'telegramNotificationSettings', [
'telegram_token' => 'telegram-admin-token',
'telegram_chat_id' => 'telegram-admin-chat',
]],
'pushover credentials' => [PushoverNotification::class, 'pushoverNotificationSettings', [
'pushover_user_key' => 'pushover-admin-user',
'pushover_api_token' => 'pushover-admin-token',
]],
'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [
'webhook_url' => 'https://example.com/admin-webhook',
]],
'email credentials' => [EmailNotification::class, 'emailNotificationSettings', [
'smtp_password' => 'smtp-admin-password',
'resend_api_key' => 'resend-admin-api-key',
]],
]);
@@ -0,0 +1,55 @@
<?php
use App\Livewire\Project\Show;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
});
test('admin can create environment in project', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(Show::class, ['project_uuid' => $this->project->uuid])
->set('name', 'staging')
->call('submit')
->assertRedirect();
expect(Environment::where('name', 'staging')->exists())->toBeTrue();
});
test('member cannot create environment in project', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Show::class, ['project_uuid' => $this->project->uuid])
->set('name', 'staging')
->call('submit')
->assertDispatched('error');
expect(Environment::where('name', 'staging')->exists())->toBeFalse();
});
@@ -0,0 +1,133 @@
<?php
use App\Http\Middleware\CanCreateResources;
use App\Livewire\Project\New\DockerCompose;
use App\Livewire\Project\New\PublicGitRepository;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.store' => 'array',
'cache.default' => 'array',
]);
InstanceSettings::query()->forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
});
test('member cannot pass create resources middleware', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$middleware = new CanCreateResources;
$request = Request::create('/project/new', 'GET');
expect(fn () => $middleware->handle($request, fn () => response('ok')))
->toThrow(HttpException::class, 'You do not have permission to create resources.');
});
test('admin can pass create resources middleware', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$middleware = new CanCreateResources;
$request = Request::create('/project/new', 'GET');
$response = $middleware->handle($request, fn () => response('ok'));
expect($response->getStatusCode())->toBe(200);
});
test('member cannot create docker compose service through livewire action', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(DockerCompose::class)
->set('parameters', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('query', ['destination' => $this->destination->uuid])
->set('dockerComposeRaw', <<<'YAML'
services:
app:
image: alpine
YAML)
->call('submit')
->assertDispatched('error');
expect(Service::query()->count())->toBe(0);
});
test('public git docker compose creates an application in local mode', function () {
config(['app.env' => 'local']);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(PublicGitRepository::class)
->set('parameters', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('query', ['destination' => $this->destination->uuid])
->set('repository_url', 'https://github.com/coollabsio/coolify')
->set('git_repository', 'https://github.com/coollabsio/coolify')
->set('git_branch', 'main')
->set('build_pack', 'dockercompose')
->set('new_compose_services', true)
->call('submit');
expect(Application::query()->count())->toBe(1)
->and(Service::query()->count())->toBe(0);
});
@@ -0,0 +1,248 @@
<?php
use App\Livewire\Project\Application\Heading as ApplicationHeading;
use App\Livewire\Project\Service\Heading as ServiceHeading;
use App\Models\Application;
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;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
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->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running',
]);
$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(),
]);
$this->service = Service::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Service',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'docker_compose_raw' => 'version: "3"',
]);
$this->serviceParams = [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'service_uuid' => $this->service->uuid,
];
$this->databaseUrl = "/project/{$this->project->uuid}/environment/{$this->environment->uuid}/database/{$this->database->uuid}";
});
// --- Application Heading ---
test('admin sees deploy controls for application', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->assertSee('Redeploy')
->assertSee('Restart')
->assertSee('Stop');
});
test('member cannot call deploy on application', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->call('deploy')
->assertDispatched('error');
});
test('member cannot call restart on application', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->call('restart')
->assertDispatched('error');
});
test('member cannot call stop on application', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->call('stop')
->assertDispatched('error');
});
test('member does not see terminal link for application', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->assertDontSee('Terminal');
});
test('admin sees terminal link for application', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->assertSee('Terminal');
});
// --- Database Heading (via page route for rendering, policy checks for actions) ---
test('admin can access database page', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->get($this->databaseUrl)->assertSuccessful();
});
test('member can access database page', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$this->get($this->databaseUrl)->assertSuccessful();
});
test('admin can manage database', function () {
expect($this->admin->can('manage', $this->database))->toBeTrue();
});
test('member cannot manage database', function () {
expect($this->member->can('manage', $this->database))->toBeFalse();
});
test('admin can access terminal for database', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('canAccessTerminal'))->toBeTrue();
});
test('member cannot access terminal for database', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('canAccessTerminal'))->toBeFalse();
});
// --- Service Heading ---
test('admin sees service deploy button', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->assertSee('Deploy');
});
test('member cannot call stop on service', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->call('stop')
->assertDispatched('error');
});
test('member does not see terminal link for service', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->assertDontSee('Terminal');
});
test('admin sees terminal link for service', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->assertSee('Terminal');
});
@@ -0,0 +1,112 @@
<?php
use App\Livewire\Notifications\Discord as DiscordNotification;
use App\Livewire\Security\PrivateKey\Index as PrivateKeyIndex;
use App\Livewire\Team\Member\Index as TeamMemberIndex;
use App\Models\DiscordNotificationSettings;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
// Create a private key for the team (bypass model validation via DB insert)
DB::table('private_keys')->insert([
'uuid' => (string) Str::uuid(),
'name' => 'Team SSH Key',
'description' => 'Key for testing',
'private_key' => 'test-key-content',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
});
// --- Private Key Index ---
test('admin sees add and cleanup buttons on private key page', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(PrivateKeyIndex::class)
->assertSee('+ Add')
->assertSee('Delete unused SSH Keys');
});
test('member does not see add or cleanup buttons on private key page', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(PrivateKeyIndex::class)
->assertDontSee('+ Add')
->assertDontSee('Delete unused SSH Keys');
});
test('member can view private key names on index page', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
// Member can see key names (view is allowed for same-team members)
// but cannot see create/delete buttons (tested separately above)
Livewire::test(PrivateKeyIndex::class)
->assertSee('Team SSH Key');
});
test('member cannot call cleanup unused keys', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(PrivateKeyIndex::class)
->call('cleanupUnusedKeys')
->assertDispatched('error');
});
// --- Team Member Index (Invitations) ---
test('admin sees invite section on team members page', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(TeamMemberIndex::class)
->assertSee('Invite New Member');
});
test('member does not see invite section on team members page', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(TeamMemberIndex::class)
->assertDontSee('Invite New Member');
});
// --- Notification Settings (Discord as representative) ---
test('member cannot send test notification on discord', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$settings = DiscordNotificationSettings::where('team_id', $this->team->id)->first();
$settings->update([
'discord_enabled' => true,
'discord_webhook_url' => 'https://discord.com/api/webhooks/test',
]);
Livewire::test(DiscordNotification::class)
->call('sendTestNotification')
->assertDispatched('error');
});
@@ -0,0 +1,263 @@
<?php
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Livewire\Server\Navbar as ServerNavbar;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutMiddleware(PreventRequestsDuringMaintenance::class);
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
});
// --- Server Policy: update ---
test('admin can update server', function () {
expect($this->admin->can('update', $this->server))->toBeTrue();
});
test('member cannot update server', function () {
expect($this->member->can('update', $this->server))->toBeFalse();
});
// --- Server Policy: delete ---
test('admin can delete server', function () {
expect($this->admin->can('delete', $this->server))->toBeTrue();
});
test('member cannot delete server', function () {
expect($this->member->can('delete', $this->server))->toBeFalse();
});
// --- Server Policy: view ---
test('admin can view server', function () {
expect($this->admin->can('view', $this->server))->toBeTrue();
});
test('member can view server', function () {
expect($this->member->can('view', $this->server))->toBeTrue();
});
// --- Server Policy: manageProxy ---
test('admin can manage proxy', function () {
expect($this->admin->can('manageProxy', $this->server))->toBeTrue();
});
test('member cannot manage proxy', function () {
expect($this->member->can('manageProxy', $this->server))->toBeFalse();
});
// --- Server Policy: manageSentinel ---
test('admin can manage sentinel', function () {
expect($this->admin->can('manageSentinel', $this->server))->toBeTrue();
});
test('member cannot manage sentinel', function () {
expect($this->member->can('manageSentinel', $this->server))->toBeFalse();
});
// --- Server Policy: manageCaCertificate ---
test('admin can manage CA certificate', function () {
expect($this->admin->can('manageCaCertificate', $this->server))->toBeTrue();
});
test('member cannot manage CA certificate', function () {
expect($this->member->can('manageCaCertificate', $this->server))->toBeFalse();
});
// --- Server Policy: viewSecurity ---
test('admin can view security', function () {
expect($this->admin->can('viewSecurity', $this->server))->toBeTrue();
});
test('member cannot view security', function () {
expect($this->member->can('viewSecurity', $this->server))->toBeFalse();
});
// --- Server Policy: create ---
test('admin can create server', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', Server::class))->toBeTrue();
});
test('member cannot create server', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', Server::class))->toBeFalse();
});
// --- Cross-team isolation ---
test('user from different team cannot view server', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('view', $this->server))->toBeFalse();
});
test('user from different team cannot update server', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('update', $this->server))->toBeFalse();
});
// --- Navbar Livewire component actions ---
test('member cannot restart proxy via navbar', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServerNavbar::class, ['server' => $this->server])
->call('restart')
->assertDispatched('error');
});
test('member cannot check proxy via navbar', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServerNavbar::class, ['server' => $this->server])
->call('checkProxy')
->assertDispatched('error');
});
test('member cannot start proxy via navbar', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServerNavbar::class, ['server' => $this->server])
->call('startProxy')
->assertDispatched('error');
});
test('member cannot stop proxy via navbar', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServerNavbar::class, ['server' => $this->server])
->call('stop')
->assertDispatched('error');
});
// --- Terminal access gate ---
test('admin can access terminal', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('canAccessTerminal'))->toBeTrue();
});
test('member cannot access terminal', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('canAccessTerminal'))->toBeFalse();
});
// --- Server page access ---
test('admin can access server page', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->get("/server/{$this->server->uuid}")->assertSuccessful();
});
test('member can access server page', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$this->get("/server/{$this->server->uuid}")->assertSuccessful();
});
test('admin can access sentinel configuration and logs pages', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->get(route('server.sentinel', ['server_uuid' => $this->server->uuid]))
->assertSuccessful();
$this->get(route('server.sentinel.logs', ['server_uuid' => $this->server->uuid]))
->assertSuccessful();
});
test('member cannot access sentinel configuration and logs pages', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$this->get(route('server.sentinel', ['server_uuid' => $this->server->uuid]))
->assertForbidden();
$this->get(route('server.sentinel.logs', ['server_uuid' => $this->server->uuid]))
->assertForbidden();
});
test('sentinel navigation is only visible to team admins', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$this->get("/server/{$this->server->uuid}")
->assertSuccessful()
->assertDontSee(route('server.sentinel', ['server_uuid' => $this->server->uuid]));
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->get("/server/{$this->server->uuid}")
->assertSuccessful()
->assertSee(route('server.sentinel', ['server_uuid' => $this->server->uuid]));
});
test('unauthenticated user cannot access server page', function () {
$this->get("/server/{$this->server->uuid}")->assertRedirect('/login');
});
@@ -0,0 +1,242 @@
<?php
use App\Livewire\Project\Service\Heading as ServiceHeading;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
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->service = Service::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Service',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'docker_compose_raw' => 'version: "3"',
]);
$this->serviceParams = [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'service_uuid' => $this->service->uuid,
];
});
// --- Service Policy: view ---
test('admin can view service', function () {
expect($this->admin->can('view', $this->service))->toBeTrue();
});
test('member can view service', function () {
expect($this->member->can('view', $this->service))->toBeTrue();
});
// --- Service Policy: update ---
test('admin can update service', function () {
expect($this->admin->can('update', $this->service))->toBeTrue();
});
test('member cannot update service', function () {
expect($this->member->can('update', $this->service))->toBeFalse();
});
// --- Service Policy: delete ---
test('admin can delete service', function () {
expect($this->admin->can('delete', $this->service))->toBeTrue();
});
test('member cannot delete service', function () {
expect($this->member->can('delete', $this->service))->toBeFalse();
});
// --- Service Policy: deploy ---
test('admin can deploy service', function () {
expect($this->admin->can('deploy', $this->service))->toBeTrue();
});
test('member cannot deploy service', function () {
expect($this->member->can('deploy', $this->service))->toBeFalse();
});
// --- Service Policy: stop ---
test('admin can stop service', function () {
expect($this->admin->can('stop', $this->service))->toBeTrue();
});
test('member cannot stop service', function () {
expect($this->member->can('stop', $this->service))->toBeFalse();
});
// --- Service Policy: manageEnvironment ---
test('admin can manage service environment variables', function () {
expect($this->admin->can('manageEnvironment', $this->service))->toBeTrue();
});
test('member cannot manage service environment variables', function () {
expect($this->member->can('manageEnvironment', $this->service))->toBeFalse();
});
// --- Service Heading Livewire actions ---
test('member cannot call start on service heading', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->call('start')
->assertDispatched('error');
});
test('member cannot call stop on service heading', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->call('stop')
->assertDispatched('error');
});
test('member cannot call restart on service heading', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->call('restart')
->assertDispatched('error');
});
test('member cannot call force deploy on service heading', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->call('forceDeploy')
->assertDispatched('error');
});
// --- Service Heading visibility ---
test('member does not see terminal link for service', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->assertDontSee('Terminal');
});
test('admin sees terminal link for service', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->assertSee('Terminal');
});
test('admin sees deploy button for service', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->assertSee('Deploy');
});
// --- Cross-team isolation ---
test('user from different team cannot view service', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('view', $this->service))->toBeFalse();
});
test('user from different team cannot update service', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('update', $this->service))->toBeFalse();
});
@@ -0,0 +1,301 @@
<?php
use App\Livewire\Project\Shared\Danger;
use App\Livewire\Project\Shared\Tags;
use App\Livewire\Project\Shared\Webhooks;
use App\Models\Application;
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;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
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->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running',
]);
$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(),
]);
$this->service = Service::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Service',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'docker_compose_raw' => 'version: "3"',
]);
});
// --- Danger Zone: Application ---
test('admin can delete application', function () {
expect($this->admin->can('delete', $this->application))->toBeTrue();
});
test('member cannot delete application via danger zone', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Danger::class, ['resource' => $this->application])
->call('delete', 'password')
->assertDispatched('error');
});
// --- Danger Zone: Database ---
test('admin can delete database', function () {
expect($this->admin->can('delete', $this->database))->toBeTrue();
});
test('member cannot delete database via danger zone', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Danger::class, ['resource' => $this->database])
->call('delete', 'password')
->assertDispatched('error');
});
// --- Danger Zone: Service ---
test('admin can delete service', function () {
expect($this->admin->can('delete', $this->service))->toBeTrue();
});
test('member cannot delete service via danger zone', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Danger::class, ['resource' => $this->service])
->call('delete', 'password')
->assertDispatched('error');
});
// --- Tags: Application ---
test('member cannot add tag to application', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Tags::class, ['resource' => $this->application])
->set('newTags', 'test-tag')
->call('submit')
->assertDispatched('error');
});
test('admin can add tag to application', function () {
expect($this->admin->can('update', $this->application))->toBeTrue();
});
// --- Webhooks: Application ---
test('member cannot update application webhooks', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Webhooks::class, ['resource' => $this->application])
->call('submit')
->assertDispatched('error');
});
test('admin can update application webhooks', function () {
expect($this->admin->can('update', $this->application))->toBeTrue();
});
test('member cannot view application webhook secrets', function () {
$this->application->update([
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
'manual_webhook_secret_github' => 'github-secret-value',
'manual_webhook_secret_gitlab' => 'gitlab-secret-value',
'manual_webhook_secret_bitbucket' => 'bitbucket-secret-value',
'manual_webhook_secret_gitea' => 'gitea-secret-value',
]);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Webhooks::class, ['resource' => $this->application->fresh()])
->assertSet('githubManualWebhookSecret', null)
->assertSet('gitlabManualWebhookSecret', null)
->assertSet('bitbucketManualWebhookSecret', null)
->assertSet('giteaManualWebhookSecret', null)
->assertSee('Hidden (only admins can view)')
->assertDontSee('github-secret-value')
->assertDontSee('gitlab-secret-value')
->assertDontSee('bitbucket-secret-value')
->assertDontSee('gitea-secret-value');
});
test('admin can view application webhook secrets', function () {
$this->application->update([
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
'manual_webhook_secret_github' => 'github-secret-value',
'manual_webhook_secret_gitlab' => 'gitlab-secret-value',
'manual_webhook_secret_bitbucket' => 'bitbucket-secret-value',
'manual_webhook_secret_gitea' => 'gitea-secret-value',
]);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(Webhooks::class, ['resource' => $this->application->fresh()])
->assertSet('githubManualWebhookSecret', 'github-secret-value')
->assertSet('gitlabManualWebhookSecret', 'gitlab-secret-value')
->assertSet('bitbucketManualWebhookSecret', 'bitbucket-secret-value')
->assertSet('giteaManualWebhookSecret', 'gitea-secret-value');
});
// --- Resource Limits (policy checks, mount requires full resource data) ---
test('member cannot update application resource limits', function () {
expect($this->member->can('update', $this->application))->toBeFalse();
});
test('admin can update application resource limits', function () {
expect($this->admin->can('update', $this->application))->toBeTrue();
});
test('member cannot update database resource limits', function () {
expect($this->member->can('update', $this->database))->toBeFalse();
});
test('member cannot update service resource limits', function () {
expect($this->member->can('update', $this->service))->toBeFalse();
});
// --- Environment Variables (Policy checks) ---
test('admin can manage application environment variables', function () {
expect($this->admin->can('manageEnvironment', $this->application))->toBeTrue();
});
test('member cannot manage application environment variables', function () {
expect($this->member->can('manageEnvironment', $this->application))->toBeFalse();
});
test('admin can manage database environment variables', function () {
expect($this->admin->can('manageEnvironment', $this->database))->toBeTrue();
});
test('member cannot manage database environment variables', function () {
expect($this->member->can('manageEnvironment', $this->database))->toBeFalse();
});
test('admin can manage service environment variables', function () {
expect($this->admin->can('manageEnvironment', $this->service))->toBeTrue();
});
test('member cannot manage service environment variables', function () {
expect($this->member->can('manageEnvironment', $this->service))->toBeFalse();
});
// --- Project Policy ---
test('admin can update project', function () {
expect($this->admin->can('update', $this->project))->toBeTrue();
});
test('member cannot update project', function () {
expect($this->member->can('update', $this->project))->toBeFalse();
});
test('admin can delete project', function () {
expect($this->admin->can('delete', $this->project))->toBeTrue();
});
test('member cannot delete project', function () {
expect($this->member->can('delete', $this->project))->toBeFalse();
});
// --- Environment Policy ---
test('admin can delete environment', function () {
expect($this->admin->can('delete', $this->environment))->toBeTrue();
});
test('member cannot delete environment', function () {
expect($this->member->can('delete', $this->environment))->toBeFalse();
});
// --- Team Policy ---
test('admin can update team', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('update', $this->team))->toBeTrue();
});
test('member cannot update team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('update', $this->team))->toBeFalse();
});
@@ -0,0 +1,327 @@
<?php
use App\Livewire\Team\Index as TeamIndex;
use App\Livewire\Team\Member as TeamMember;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create(['personal_team' => false]);
$this->owner = User::factory()->create();
$this->owner->teams()->attach($this->team, ['role' => 'owner']);
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
});
// --- Team Policy: update ---
test('owner can update team', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('update', $this->team))->toBeTrue();
});
test('admin can update team', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('update', $this->team))->toBeTrue();
});
test('member cannot update team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('update', $this->team))->toBeFalse();
});
// --- Team Policy: delete ---
test('owner can delete team', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('delete', $this->team))->toBeTrue();
});
test('admin can delete team', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('delete', $this->team))->toBeTrue();
});
test('member cannot delete team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('delete', $this->team))->toBeFalse();
});
// --- Team Policy: manageMembers ---
test('owner can manage team members', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageMembers', $this->team))->toBeTrue();
});
test('admin can manage team members', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageMembers', $this->team))->toBeTrue();
});
test('member cannot manage team members', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageMembers', $this->team))->toBeFalse();
});
// --- Team Policy: manageInvitations ---
test('owner can manage invitations', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageInvitations', $this->team))->toBeTrue();
});
test('admin can manage invitations', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageInvitations', $this->team))->toBeTrue();
});
test('member cannot manage invitations', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageInvitations', $this->team))->toBeFalse();
});
// --- Team Index Livewire: update ---
test('member cannot submit team settings via policy', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('update', $this->team))->toBeFalse();
});
// --- Team Index Livewire: delete ---
test('member cannot delete team via index', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(TeamIndex::class)
->call('delete', 'password')
->assertDispatched('error');
expect(Team::find($this->team->id))->not->toBeNull();
});
test('admin can delete team via policy', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('delete', $this->team))->toBeTrue();
});
// --- Team Member Livewire: role changes ---
test('member cannot change roles via team member component', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(TeamMember::class, ['member' => $this->admin])
->call('makeAdmin')
->assertDispatched('error');
});
test('member cannot remove team members', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(TeamMember::class, ['member' => $this->admin])
->call('remove')
->assertDispatched('error');
});
// --- Invitations & Invite Link (policy checks — views wrapped in @can render empty for members) ---
test('member cannot manage invitations via policy', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageInvitations', $this->team))->toBeFalse();
});
test('owner can manage invitations via policy', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageInvitations', $this->team))->toBeTrue();
});
// --- Cross-team isolation ---
test('user from different team cannot update team', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'owner']);
$this->actingAs($otherUser);
session(['currentTeam' => $otherTeam]);
expect(auth()->user()->can('update', $this->team))->toBeFalse();
});
test('user from different team cannot manage members', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'owner']);
$this->actingAs($otherUser);
session(['currentTeam' => $otherTeam]);
expect(auth()->user()->can('manageMembers', $this->team))->toBeFalse();
});
// --- Team Policy: view ---
test('owner can view team', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('view', $this->team))->toBeTrue();
});
test('admin can view team', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('view', $this->team))->toBeTrue();
});
test('member can view team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('view', $this->team))->toBeTrue();
});
test('non-team member cannot view team', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('view', $this->team))->toBeFalse();
});
// --- Team Policy: viewAdmin ---
test('owner can view admin panel', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('viewAdmin', $this->team))->toBeTrue();
});
test('admin can view admin panel', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('viewAdmin', $this->team))->toBeTrue();
});
test('member cannot view admin panel', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('viewAdmin', $this->team))->toBeFalse();
});
test('non-team member cannot view admin panel', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('viewAdmin', $this->team))->toBeFalse();
});
// --- Non-team member isolation ---
test('non-team member cannot update team', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('update', $this->team))->toBeFalse();
});
test('non-team member cannot delete team', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('delete', $this->team))->toBeFalse();
});
test('non-team member cannot manage members', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('manageMembers', $this->team))->toBeFalse();
});
test('non-team member cannot manage invitations', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('manageInvitations', $this->team))->toBeFalse();
});
// --- Team creation and model-level protection ---
test('member can create a new independent team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$newTeam = Team::create([
'name' => 'New Team',
'description' => 'Created by member',
'personal_team' => false,
]);
expect($newTeam)->toBeInstanceOf(Team::class)
->and($newTeam->name)->toBe('New Team');
});
test('member cannot update an existing team at model level', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(fn () => $this->team->update(['name' => 'Hacked']))
->toThrow(Exception::class, 'You are not allowed to update this team.');
});
@@ -0,0 +1,194 @@
<?php
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Once;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
Once::flush();
config(['app.maintenance.driver' => 'file']);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
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(),
]);
$this->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running',
]);
$this->service = Service::create([
'uuid' => (string) Str::uuid(),
'name' => 'test-service',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'docker_compose_raw' => 'version: "3"',
]);
$this->serviceDatabase = ServiceDatabase::create([
'uuid' => (string) Str::uuid(),
'name' => 'test-service-db',
'service_id' => $this->service->id,
]);
});
// --- DatabasePolicy::uploadBackup (covers Standalone* databases) ---
test('admin can upload backup for standalone database', function () {
expect($this->admin->can('uploadBackup', $this->database))->toBeTrue();
});
test('member cannot upload backup for standalone database', function () {
expect($this->member->can('uploadBackup', $this->database))->toBeFalse();
});
// --- ApplicationPolicy::uploadBackup ---
test('admin can upload backup for application', function () {
expect($this->admin->can('uploadBackup', $this->application))->toBeTrue();
});
test('member cannot upload backup for application', function () {
expect($this->member->can('uploadBackup', $this->application))->toBeFalse();
});
// --- ServicePolicy::uploadBackup ---
test('admin can upload backup for service', function () {
expect($this->admin->can('uploadBackup', $this->service))->toBeTrue();
});
test('member cannot upload backup for service', function () {
expect($this->member->can('uploadBackup', $this->service))->toBeFalse();
});
// --- ServiceDatabasePolicy::uploadBackup (delegates to ServicePolicy) ---
test('admin can upload backup for service database', function () {
expect($this->admin->can('uploadBackup', $this->serviceDatabase))->toBeTrue();
});
test('member cannot upload backup for service database', function () {
expect($this->member->can('uploadBackup', $this->serviceDatabase))->toBeFalse();
});
// --- Cross-team isolation ---
test('user from different team cannot upload backup', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('uploadBackup', $this->database))->toBeFalse();
expect($otherUser->can('uploadBackup', $this->application))->toBeFalse();
expect($otherUser->can('uploadBackup', $this->service))->toBeFalse();
expect($otherUser->can('uploadBackup', $this->serviceDatabase))->toBeFalse();
});
// --- HTTP endpoint: POST /upload/backup/{uuid} ---
test('member gets 403 from POST /upload/backup and no file lands on disk', function () {
$uploadDir = storage_path('app/upload/'.$this->database->uuid);
if (File::exists($uploadDir)) {
File::deleteDirectory($uploadDir);
}
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$response = $this->withHeader('Accept', 'application/json')
->post(route('upload.backup', ['databaseUuid' => $this->database->uuid]));
$response->assertForbidden();
expect(File::exists($uploadDir.'/restore'))->toBeFalse();
});
test('user from different team hits null-resource branch with 500', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
$this->actingAs($otherUser);
session(['currentTeam' => $otherTeam]);
$response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid]));
$response->assertStatus(500);
});
test('unauthenticated request is redirected to login', function () {
$response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid]));
$response->assertRedirect('/login');
});
+124 -2
View File
@@ -4,6 +4,7 @@ use App\Livewire\Project\Database\BackupEdit;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Server;
use App\Models\StandaloneDocker;
@@ -43,6 +44,20 @@ function createBackupForEditValidationTest(Team $team, array $overrides = []): S
], $overrides));
}
function createS3StorageForBackupEditValidationTest(Team|int $team, string $name = 'Backup Edit S3'): S3Storage
{
return S3Storage::create([
'name' => $name,
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'is_usable' => true,
'team_id' => $team instanceof Team ? $team->id : $team,
]);
}
beforeEach(function () {
if (InstanceSettings::find(0) === null) {
$settings = new InstanceSettings;
@@ -60,7 +75,7 @@ beforeEach(function () {
it('disables S3 backup when saved without a selected S3 storage', function () {
$backup = createBackupForEditValidationTest($this->team);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->call('submit')
->assertDispatched('success');
@@ -74,7 +89,7 @@ it('cascades to disabling local backup deletion when S3 is force-disabled', func
'disable_local_backup' => true,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 's3s' => $this->team->s3s])
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->call('submit')
->assertDispatched('success');
@@ -83,3 +98,110 @@ it('cascades to disabling local backup deletion when S3 is force-disabled', func
expect($backup->s3_storage_id)->toBeNull();
expect($backup->disable_local_backup)->toBeFalsy();
});
it('keeps S3 enabled by selecting the only available team storage when none is selected yet', function () {
createS3StorageForBackupEditValidationTest(Team::factory()->create());
$s3 = createS3StorageForBackupEditValidationTest($this->team);
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->set('saveS3', true)
->call('instantSave')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($s3->id);
});
it('defaults to the first available storage when multiple storages are available', function () {
$firstS3 = createS3StorageForBackupEditValidationTest($this->team, 'First S3');
createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSet('s3StorageId', $firstS3->id)
->set('saveS3', true)
->call('instantSave')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($firstS3->id);
});
it('accepts the S3 storage scope passed to the component', function () {
$s3 = createS3StorageForBackupEditValidationTest(0);
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect([$s3])])
->set('saveS3', true)
->set('s3StorageId', $s3->id)
->call('instantSave')
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeTruthy();
expect($backup->s3_storage_id)->toBe($s3->id);
});
it('shows available S3 storages even when S3 backup is disabled', function () {
createS3StorageForBackupEditValidationTest($this->team, 'First S3');
createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('First S3')
->assertSee('Second S3');
});
it('shows disabled S3 storage dropdown when no storages are available', function () {
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('No S3 storage available');
});
it('shows when S3 backups are currently disabled', function () {
createS3StorageForBackupEditValidationTest($this->team);
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->assertSee('S3 Storage')
->assertSee('(currently disabled)');
});
it('saves selected S3 storage immediately when it changes', function () {
createS3StorageForBackupEditValidationTest($this->team, 'First S3');
$secondS3 = createS3StorageForBackupEditValidationTest($this->team, 'Second S3');
$backup = createBackupForEditValidationTest($this->team, [
'save_s3' => false,
's3_storage_id' => null,
]);
Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s])
->set('s3StorageId', $secondS3->id)
->assertDispatched('success');
$backup->refresh();
expect($backup->save_s3)->toBeFalsy();
expect($backup->s3_storage_id)->toBe($secondS3->id);
});
-70
View File
@@ -1,70 +0,0 @@
<?php
use App\Jobs\CoolifyTask;
use App\Models\Server;
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();
if (! $server) {
$this->markTestSkipped('No servers available for testing');
}
Queue::fake();
// Create an activity for the task
$activity = activity()
->withProperties([
'server_uuid' => $server->uuid,
'command' => 'echo "test"',
'type' => 'inline',
])
->event('inline')
->log('[]');
// Dispatch the job
CoolifyTask::dispatch(
activity: $activity,
ignore_errors: false,
call_event_on_finish: 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]);
});
@@ -1,207 +0,0 @@
<?php
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
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;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$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]);
$this->database = StandalonePostgresql::create([
'name' => 'test-postgres',
'image' => 'postgres:15-alpine',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'testdb',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->s3Storage = S3Storage::create([
'name' => 'test-s3',
'region' => 'us-east-1',
'key' => 'test-key',
'secret' => 'test-secret',
'bucket' => 'test-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $this->team->id,
'is_usable' => true,
]);
});
describe('POST /api/v1/databases/{uuid}/backups', function () {
test('creates backup with s3 storage via API token', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => '0 2 * * 0',
'save_s3' => true,
's3_storage_uuid' => $this->s3Storage->uuid,
'enabled' => true,
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid', 'message']);
$backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->first();
expect($backup)->not->toBeNull();
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
expect($backup->save_s3)->toBeTrue();
expect($backup->team_id)->toBe($this->team->id);
});
test('creates backup without s3 storage', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'daily',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid', 'message']);
});
test('rejects s3_storage_uuid from another team', function () {
$otherTeam = Team::factory()->create();
$otherS3 = S3Storage::create([
'name' => 'other-s3',
'region' => 'us-east-1',
'key' => 'other-key',
'secret' => 'other-secret',
'bucket' => 'other-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $otherTeam->id,
'is_usable' => true,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => '0 2 * * 0',
'save_s3' => true,
's3_storage_uuid' => $otherS3->uuid,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['s3_storage_uuid']);
});
test('validates frequency is required', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->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/{$this->database->uuid}/backups", [
'frequency' => 'daily',
'save_s3' => true,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['s3_storage_uuid']);
});
test('rejects request without authentication', function () {
$response = $this->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
'frequency' => 'daily',
]);
$response->assertStatus(401);
});
});
describe('PATCH /api/v1/databases/{uuid}/backups/{scheduled_backup_uuid}', function () {
test('updates backup to use s3 storage via API token', function () {
$backup = ScheduledDatabaseBackup::create([
'frequency' => 'daily',
'enabled' => true,
'database_id' => $this->database->id,
'database_type' => $this->database->getMorphClass(),
'team_id' => $this->team->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
'save_s3' => true,
's3_storage_uuid' => $this->s3Storage->uuid,
]);
$response->assertStatus(200);
$backup->refresh();
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
expect($backup->save_s3)->toBeTrue();
});
test('rejects s3_storage_uuid from another team on update', function () {
$otherTeam = Team::factory()->create();
$otherS3 = S3Storage::create([
'name' => 'other-s3',
'region' => 'us-east-1',
'key' => 'other-key',
'secret' => 'other-secret',
'bucket' => 'other-bucket',
'endpoint' => 'https://s3.example.com',
'team_id' => $otherTeam->id,
'is_usable' => true,
]);
$backup = ScheduledDatabaseBackup::create([
'frequency' => 'daily',
'enabled' => true,
'database_id' => $this->database->id,
'database_type' => $this->database->getMorphClass(),
'team_id' => $this->team->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
'save_s3' => true,
's3_storage_uuid' => $otherS3->uuid,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['s3_storage_uuid']);
});
});
@@ -1,6 +1,28 @@
<?php
use App\Http\Controllers\UploadController;
use App\Livewire\Project\Database\ImportForm;
use App\Models\StandalonePostgresql;
use App\Support\DatabaseBackupFileValidator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Process;
function writeScanPayload(string $content, bool $gzip = false): string
{
$path = tempnam(sys_get_temp_dir(), 'coolify-scan-payload-');
file_put_contents($path, $gzip ? gzencode($content) : $content);
return $path;
}
/**
* Execute the real shell scanner snippet (as it runs inside the container)
* against a local payload file and return true when the restore is blocked.
*/
function scannerBlocks(string $script): bool
{
return Process::run(['sh', '-c', $script])->exitCode() === 1;
}
function invokeHasAllowedExtension(string $name): bool
{
@@ -10,6 +32,28 @@ function invokeHasAllowedExtension(string $name): bool
return $method->invoke(null, $name);
}
function backupValidationImportFormWithResource(string $modelClass): ImportForm
{
$component = new class extends ImportForm
{
public $resource;
};
$database = Mockery::mock($modelClass);
$database->shouldReceive('getMorphClass')->andReturn($modelClass);
$component->resource = $database;
return $component;
}
function makeTemporaryUpload(string $name, string $content): UploadedFile
{
$path = tempnam(sys_get_temp_dir(), 'coolify-upload-test-');
file_put_contents($path, $content);
return new UploadedFile($path, $name, null, null, true);
}
test('hasAllowedExtension accepts supported extensions', function (string $name) {
expect(invokeHasAllowedExtension($name))->toBeTrue();
})->with([
@@ -46,6 +90,140 @@ test('hasAllowedExtension rejects unsupported or empty stems', function (string
'misleading double ext' => ['shell.php.sql-evil'],
]);
test('hasAllowedExtension rejects dangerous double extensions', function (string $name) {
expect(invokeHasAllowedExtension($name))->toBeFalse();
})->with([
'php sql' => ['evil.php.sql'],
'php gzip' => ['evil.php.gz'],
'shell tar' => ['evil.sh.tar'],
'php tar gzip' => ['shell.php.tar.gz'],
'exe zip' => ['cmd.exe.zip'],
'jsp sql' => ['evil.jsp.sql'],
]);
test('backup validator rejects content that does not match the backup extension', function () {
$file = makeTemporaryUpload('payload.sql.gz', 'not actually gzip');
expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeFalse();
});
test('backup validator accepts valid plain sql and gzip backup content', function () {
$plainSql = makeTemporaryUpload('backup.sql', "CREATE TABLE users (id integer);\n");
$gzipSql = makeTemporaryUpload('backup.sql.gz', gzencode("CREATE TABLE users (id integer);\n"));
expect(DatabaseBackupFileValidator::isUploadAllowed($plainSql, 10 * 1024 * 1024))->toBeTrue()
->and(DatabaseBackupFileValidator::isUploadAllowed($gzipSql, 10 * 1024 * 1024))->toBeTrue();
});
test('postgresql backup safety scanner detects program execution payloads', function (string $payload) {
expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($payload))->toBeTrue();
})->with([
'copy from program' => ["COPY pwned FROM PROGRAM 'id';"],
'copy to program' => ["COPY pwned TO PROGRAM 'cat > /tmp/out';"],
'copy with block comment' => ["COPY pwned FROM/**/PROGRAM 'id';"],
'psql shell command' => ["\\! id\n"],
'psql copy program' => ["\\copy pwned from program 'id'\n"],
]);
test('postgresql backup safety scanner allows ordinary sql dumps', function () {
$dump = <<<'SQL'
-- PostgreSQL database dump
CREATE TABLE users (id integer, name text);
COPY users (id, name) FROM stdin;
1 Taylor
\.
SQL;
expect(DatabaseBackupFileValidator::containsPostgresqlProgramExecution($dump))->toBeFalse();
});
test('postgresql restore commands include a safety check before execution', function () {
$component = new class extends ImportForm
{
public function __get($property)
{
if ($property === 'resource') {
return new class
{
public function getMorphClass(): string
{
return StandalonePostgresql::class;
}
};
}
return parent::__get($property);
}
};
$component->container = 'postgres-test';
$command = $component->buildRestoreSafetyCheckCommand('/tmp/restore_test');
expect($command)
->toContain('docker exec postgres-test')
->toContain('COPY ... PROGRAM')
->toContain('/tmp/restore_test')
->toContain('grep -Eiq');
});
test('non postgresql restore commands do not include a safety check', function () {
$component = backupValidationImportFormWithResource('App\Models\StandaloneMysql');
$component->container = 'mysql-test';
expect($component->buildRestoreSafetyCheckCommand('/tmp/restore_test'))->toBeNull();
});
test('file scanner detects program execution payloads inside gzipped backups', function () {
$gzPayload = writeScanPayload("CREATE TABLE x();\nCOPY x FROM/**/PROGRAM 'id';\n", gzip: true);
expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzPayload))->toBeTrue();
});
test('file scanner allows ordinary gzipped dumps', function () {
$gzClean = writeScanPayload("CREATE TABLE x();\nCOPY x FROM stdin;\n1\\.\n", gzip: true);
expect(DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($gzClean))->toBeFalse();
});
test('backup validator rejects plaintext .dump containing program execution', function () {
$file = makeTemporaryUpload('evil.dump', "COPY x FROM PROGRAM 'id';\n");
expect(DatabaseBackupFileValidator::isUploadAllowed($file, 10 * 1024 * 1024))->toBeFalse();
});
test('remote postgresql scanner blocks bypass payloads', function (string $content, bool $gzip) {
$component = backupValidationImportFormWithResource(StandalonePostgresql::class);
$component->container = 'postgres-test';
$payload = writeScanPayload($content, $gzip);
$script = $component->buildPostgresRestoreScanScript($payload);
expect(scannerBlocks($script))->toBeTrue();
})->with([
'psql shell escape' => ["\\! id\n", false],
'copy from program' => ["COPY x FROM PROGRAM 'id';\n", false],
'copy with block comment' => ["COPY x FROM/**/PROGRAM 'id';\n", false],
'copy split across lines' => ["COPY x FROM\nPROGRAM 'id';\n", false],
'copy to program' => ["COPY x TO PROGRAM 'cat > /tmp/x';\n", false],
'psql pipe redirect' => ["\\o | id\n", false],
'gzipped comment bypass' => ["COPY x FROM/**/PROGRAM 'id';\n", true],
]);
test('remote postgresql scanner allows legitimate restores', function (string $content, bool $gzip) {
$component = backupValidationImportFormWithResource(StandalonePostgresql::class);
$component->container = 'postgres-test';
$payload = writeScanPayload($content, $gzip);
$script = $component->buildPostgresRestoreScanScript($payload);
expect(scannerBlocks($script))->toBeFalse();
})->with([
'commented out payload' => ["-- COPY x FROM PROGRAM 'id'\nSELECT 1;\n", false],
'copy from stdin' => ["COPY users FROM stdin;\n1\tTaylor\n\\.\n", false],
'plain select' => ["SELECT * FROM users;\n", false],
'gzipped clean dump' => ["CREATE TABLE users (id int);\n", true],
]);
test('MAX_BYTES constant is 10 GiB', function () {
$constant = (new ReflectionClass(UploadController::class))->getConstant('MAX_BYTES');
expect($constant)->toBe(10 * 1024 * 1024 * 1024);
@@ -14,7 +14,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
@@ -344,3 +344,44 @@ describe('DELETE /api/v1/databases/{uuid}/envs/{env_uuid}', function () {
$response->assertStatus(404);
});
});
describe('environment variable key validation for database APIs', function () {
test('rejects invalid database environment variable keys on create update and bulk', function () {
$database = createDatabase($this);
EnvironmentVariable::create([
'key' => 'SAFE_KEY',
'value' => 'old-value',
'resourceable_type' => StandalonePostgresql::class,
'resourceable_id' => $database->id,
]);
$headers = [
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
];
$this->withHeaders($headers)
->postJson("/api/v1/databases/{$database->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/databases/{$database->uuid}/envs", [
'key' => 'BAD$(id)',
'value' => '1',
])
->assertStatus(422);
$this->withHeaders($headers)
->patchJson("/api/v1/databases/{$database->uuid}/envs/bulk", [
'data' => [[
'key' => 'BAD$(id)',
'value' => '1',
]],
])
->assertStatus(422);
});
});
@@ -2,6 +2,7 @@
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
@@ -42,6 +43,7 @@ it('can seed the railpack examples directly on a clean development database', fu
expect(Server::query()->find(0))->not->toBeNull();
expect(StandaloneDocker::query()->find(0))->not->toBeNull();
expect(GithubApp::query()->find(0))->not->toBeNull();
expect(GitlabApp::query()->find(1))->not->toBeNull();
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue();
expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
});
@@ -66,9 +68,10 @@ it('seeds the railpack examples in development mode', function () {
expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue();
expect($applications->every(fn (Application $application) => $application->git_repository === DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY))->toBeTrue();
$examples = collect(DevelopmentRailpackExamplesSeeder::examples())->keyBy('uuid');
expect($applications->every(
fn (Application $application) => $application->git_repository === ($examples->get($application->uuid)['git_repository'] ?? DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY)
))->toBeTrue();
expect($applications->every(
fn (Application $application) => $application->git_branch === ($examples->get($application->uuid)['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
))->toBeTrue();
@@ -79,6 +82,9 @@ it('seeds the railpack examples in development mode', function () {
$pythonFlask = $applications->firstWhere('uuid', 'railpack-python-flask');
$goGin = $applications->firstWhere('uuid', 'railpack-go-gin');
$rust = $applications->firstWhere('uuid', 'railpack-rust');
$githubDeployKey = $applications->firstWhere('uuid', 'railpack-github-deploy-key');
$gitlabDeployKey = $applications->firstWhere('uuid', 'railpack-gitlab-deploy-key');
$gitlabPublic = $applications->firstWhere('uuid', 'railpack-gitlab-public-example');
expect($nestjs)
->not->toBeNull()
@@ -113,6 +119,33 @@ it('seeds the railpack examples in development mode', function () {
expect($rust)
->not->toBeNull()
->and($rust->ports_exposes)->toBe('8000');
expect($githubDeployKey)
->not->toBeNull()
->and($githubDeployKey->git_repository)->toBe('git@github.com:coollabsio/coolify-examples-deploy-key.git')
->and($githubDeployKey->git_branch)->toBe('main')
->and($githubDeployKey->build_pack)->toBe('railpack')
->and($githubDeployKey->private_key_id)->toBe(1)
->and($githubDeployKey->source_type)->toBe(GithubApp::class)
->and($githubDeployKey->source_id)->toBe(0);
expect($gitlabDeployKey)
->not->toBeNull()
->and($gitlabDeployKey->git_repository)->toBe('git@gitlab.com:coollabsio/php-example.git')
->and($gitlabDeployKey->git_branch)->toBe('main')
->and($gitlabDeployKey->build_pack)->toBe('railpack')
->and($gitlabDeployKey->private_key_id)->toBe(1)
->and($gitlabDeployKey->source_type)->toBe(GitlabApp::class)
->and($gitlabDeployKey->source_id)->toBe(1);
expect($gitlabPublic)
->not->toBeNull()
->and($gitlabPublic->git_repository)->toBe('https://gitlab.com/andrasbacsai/coolify-examples.git')
->and($gitlabPublic->base_directory)->toBe('/astro/static')
->and($gitlabPublic->publish_directory)->toBe('/dist')
->and($gitlabPublic->build_pack)->toBe('railpack')
->and($gitlabPublic->source_type)->toBe(GitlabApp::class)
->and($gitlabPublic->settings->is_static)->toBeTrue();
});
it('skips the railpack examples outside development mode', function () {
@@ -9,8 +9,8 @@ test('generateDockerBuildArgs returns only keys without values', function () {
$buildArgs = generateDockerBuildArgs($variables);
// Docker gets values from the environment, so only keys should be in build args
expect($buildArgs->first())->toBe('--build-arg SSH_PRIVATE_KEY');
expect($buildArgs->last())->toBe('--build-arg REGULAR_VAR');
expect($buildArgs->first())->toBe("--build-arg 'SSH_PRIVATE_KEY'");
expect($buildArgs->last())->toBe("--build-arg 'REGULAR_VAR'");
});
test('generateDockerBuildArgs works with collection of objects', function () {
@@ -22,8 +22,8 @@ test('generateDockerBuildArgs works with collection of objects', function () {
$buildArgs = generateDockerBuildArgs($variables);
expect($buildArgs)->toHaveCount(2);
expect($buildArgs->values()->toArray())->toBe([
'--build-arg VAR1',
'--build-arg VAR2',
"--build-arg 'VAR1'",
"--build-arg 'VAR2'",
]);
});
@@ -38,7 +38,7 @@ test('generateDockerBuildArgs collection can be imploded into valid command stri
// The collection must be imploded to a string for command interpolation
// This was the bug: Collection was interpolated as JSON instead of a space-separated string
$argsString = $buildArgs->implode(' ');
expect($argsString)->toBe('--build-arg COOLIFY_URL --build-arg COOLIFY_BRANCH');
expect($argsString)->toBe("--build-arg 'COOLIFY_URL' --build-arg 'COOLIFY_BRANCH'");
// Verify it does NOT produce JSON when cast to string
expect($argsString)->not->toContain('{');
@@ -53,7 +53,7 @@ test('generateDockerBuildArgs handles variables without is_multiline', function
$buildArgs = generateDockerBuildArgs($variables);
$arg = $buildArgs->first();
expect($arg)->toBe('--build-arg NO_FLAG_VAR');
expect($arg)->toBe("--build-arg 'NO_FLAG_VAR'");
});
test('generateDockerEnvFlags produces correct format', function () {
@@ -81,3 +81,17 @@ test('generateDockerEnvFlags works with collection input', function () {
expect($envFlags)->toContain('-e VAR1=');
expect($envFlags)->toContain('-e VAR2="');
});
test('generateDockerBuildArgs escapes legacy keys', function () {
$variables = [
['key' => 'BAD$(id)', 'value' => '1'],
['key' => "BAD'KEY", 'value' => '1'],
];
$buildArgs = generateDockerBuildArgs($variables);
expect($buildArgs->values()->toArray())->toBe([
"--build-arg 'BAD$(id)'",
"--build-arg 'BAD'\''KEY'",
]);
});
@@ -1,57 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\Application;
use App\Models\Server;
use App\Models\User;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
use Tests\Traits\HandlesTestDatabase;
class ExecuteContainerCommandTest extends TestCase
{
use HandlesTestDatabase;
private $user;
private $team;
private $server;
private $application;
protected function setUp(): void
{
parent::setUp();
// Only set up database for tests that need it
if ($this->shouldSetUpDatabase()) {
$this->setUpTestDatabase();
}
// Create test data
$this->user = User::factory()->create();
$this->team = $this->user->teams()->first();
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->application = Application::factory()->create();
// Login the user
$this->actingAs($this->user);
}
protected function tearDown(): void
{
if ($this->shouldSetUpDatabase()) {
$this->tearDownTestDatabase();
}
parent::tearDown();
}
private function shouldSetUpDatabase(): bool
{
return in_array($this->name(), [
'it_allows_valid_container_access',
'it_prevents_cross_server_container_access',
]);
}
}
@@ -1,6 +1,6 @@
<?php
namespace Tests\Feature;
namespace Tests\Feature\Helpers;
use Tests\TestCase;
@@ -1,11 +1,15 @@
<?php
use App\Jobs\CheckTraefikVersionForServerJob;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\Server\TraefikVersionOutdated;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Schema;
uses(RefreshDatabase::class);
@@ -14,7 +18,7 @@ beforeEach(function () {
});
it('detects servers table has detected_traefik_version column', function () {
expect(\Illuminate\Support\Facades\Schema::hasColumn('servers', 'detected_traefik_version'))->toBeTrue();
expect(Schema::hasColumn('servers', 'detected_traefik_version'))->toBeTrue();
});
it('server model casts detected_traefik_version as string', function () {
@@ -137,7 +141,7 @@ it('notification channels can be retrieved', function () {
});
it('traefik version check command exists', function () {
$commands = \Illuminate\Support\Facades\Artisan::all();
$commands = Artisan::all();
expect($commands)->toHaveKey('traefik:check-version');
});
@@ -181,16 +185,16 @@ it('groups servers by team correctly', function () {
});
it('server check job exists and has correct structure', function () {
expect(class_exists(\App\Jobs\CheckTraefikVersionForServerJob::class))->toBeTrue();
expect(class_exists(CheckTraefikVersionForServerJob::class))->toBeTrue();
// Verify CheckTraefikVersionForServerJob has required properties
$reflection = new \ReflectionClass(\App\Jobs\CheckTraefikVersionForServerJob::class);
$reflection = new ReflectionClass(CheckTraefikVersionForServerJob::class);
expect($reflection->hasProperty('tries'))->toBeTrue();
expect($reflection->hasProperty('timeout'))->toBeTrue();
// Verify it implements ShouldQueue
$interfaces = class_implements(\App\Jobs\CheckTraefikVersionForServerJob::class);
expect($interfaces)->toContain(\Illuminate\Contracts\Queue\ShouldQueue::class);
$interfaces = class_implements(CheckTraefikVersionForServerJob::class);
expect($interfaces)->toContain(ShouldQueue::class);
});
it('sends immediate notifications when outdated traefik is detected', function () {
File renamed without changes.
@@ -0,0 +1,65 @@
<?php
use App\Enums\ProcessStatus;
use App\Jobs\CoolifyTask;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$team = Team::factory()->create();
Server::factory()->create(['team_id' => $team->id]);
$this->activity = activity()
->withProperties([
'server_uuid' => Server::first()->uuid,
'command' => 'echo "test"',
'type' => 'inline',
'status' => ProcessStatus::QUEUED->value,
])
->event('inline')
->log('[]');
$this->job = new CoolifyTask(
activity: $this->activity,
ignore_errors: false,
call_event_on_finish: null,
call_event_data: null,
);
});
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');
});
+16 -1
View File
@@ -36,10 +36,25 @@ it('uses geist mono for shared logs and terminal rendering', function () {
->toContain('class="flex flex-col font-logs"')
->toContain('class="font-logs text-neutral-400 mb-2"')
->and($activityMonitorView)
->toContain('<pre class="font-logs whitespace-pre-wrap"')
->toContain('<pre class="font-logs min-w-0 max-w-full whitespace-pre-wrap wrap-anywhere"')
->and($dockerCleanupView)
->toContain('class="flex-1 text-sm font-logs text-gray-700 dark:text-gray-300"')
->toContain('class="font-logs text-sm text-gray-600 dark:text-gray-300 whitespace-pre-wrap"')
->and($terminalClient)
->toContain('"Geist Mono"');
});
it('constrains activity monitor logs inside the available viewport', function () {
$activityMonitorView = file_get_contents(resource_path('views/livewire/activity-monitor.blade.php'));
expect($activityMonitorView)
->toContain('flex flex-col w-full min-w-0 max-w-full')
->toContain('<pre class="font-logs min-w-0 max-w-full whitespace-pre-wrap wrap-anywhere"');
});
it('bounds database startup activity monitor to the slide over height', function () {
$databaseHeadingView = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
expect($databaseHeadingView)
->toContain('<div wire:ignore class="h-full min-h-0 min-w-0 max-w-full">');
});
-58
View File
@@ -1,58 +0,0 @@
<?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
$baseUrl = '/login';
$email = 'grumpinout+admin@wearehackerone.com';
// First, get a CSRF token by visiting the login page
$loginPageResponse = $this->get($baseUrl);
$loginPageResponse->assertSuccessful();
// 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}");
}
});
+60
View File
@@ -6,6 +6,7 @@ use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
@@ -64,6 +65,23 @@ function mcpToolJson($response): array
return json_decode($response->json('result.content.0.text'), true);
}
function expectMcpAuditLog(array $expected): void
{
$auditChannel = Mockery::mock();
Log::shouldReceive('channel')
->with('audit')
->once()
->andReturn($auditChannel);
$auditChannel
->shouldReceive('info')
->once()
->with('mcp.tool.called', Mockery::on(fn (array $context) => collect($expected)->every(
fn ($value, $key) => data_get($context, $key) === $value,
)));
}
test('MCP endpoint returns 404 when the instance setting is disabled', function () {
InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => false]);
Once::flush();
@@ -193,6 +211,48 @@ test('tool calls fail when the token lacks the read ability', function () {
expect($response->json('result.content.0.text'))->toContain('Missing required permissions');
});
test('MCP tools audit successful execution with the actual tool name', function () {
Project::create(['name' => 'Mine', 'team_id' => $this->team->id]);
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
expectMcpAuditLog([
'tool' => 'list_projects',
'team_id' => $this->team->id,
'outcome' => 'success',
]);
mcpCallTool($token, 'list_projects')->assertOk();
});
test('MCP tools audit denied execution after ability checks', function () {
$token = $this->user->createToken('mcp-no-abilities', [])->plainTextToken;
expectMcpAuditLog([
'tool' => 'list_projects',
'team_id' => $this->team->id,
'outcome' => 'denied',
]);
$response = mcpCallTool($token, 'list_projects');
$response->assertOk();
expect($response->json('result.isError'))->toBeTrue();
});
test('MCP tools audit execution errors after tool handling', function () {
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
expectMcpAuditLog([
'tool' => 'get_server',
'team_id' => $this->team->id,
'outcome' => 'error',
'resource_uuid' => 'missing-server',
]);
$response = mcpCallTool($token, 'get_server', ['uuid' => 'missing-server']);
$response->assertOk();
expect($response->json('result.isError'))->toBeTrue();
});
test('MCP rejects token when user no longer belongs to token team', function () {
Project::create(['name' => 'Hidden', 'team_id' => $this->team->id]);
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
+146
View File
@@ -0,0 +1,146 @@
<?php
it('uses native mobile menus for databases and services', function () {
$applicationHeading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
$serviceHeading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
$applicationMobileActions = mobileActionsMarkup($applicationHeading, 'application-mobile-actions', 'application-mobile-section');
$databaseMobileActions = mobileActionsMarkup($databaseHeading, 'database-mobile-actions', 'database-mobile-section');
$serviceMobileActions = mobileActionsMarkup($serviceHeading, 'service-mobile-actions', 'service-mobile-section');
expect(mobileActionsAreBeforeSelect($applicationHeading, 'application-mobile-actions', 'application-mobile-section'))->toBeTrue();
expect(mobileActionsAreBeforeSelect($databaseHeading, 'database-mobile-actions', 'database-mobile-section'))->toBeTrue();
expect(mobileActionsAreBeforeSelect($serviceHeading, 'service-mobile-actions', 'service-mobile-section'))->toBeTrue();
expect($applicationHeading)
->toContain('application-mobile-actions')
->toContain("'route' => 'project.application.command'")
->toContain("'navigate' => false")
->toContain("value.startsWith('location|')")
->toContain('window.location.href = url')
->toContain('application-mobile-stop-trigger')
->toContain('application-mobile-deploy-trigger')
->toContain('application-mobile-restart-trigger')
->toContain('application-mobile-force-deploy-trigger')
->not->toContain('<optgroup label="Actions">');
expect($applicationMobileActions)
->toContain('mb-3')
->toContain('Actions')
->toContain('<x-forms.button isError class="shrink-0"')
->not->toContain('button type="button" class="button shrink-0 text-error"')
->toContain('M7 4v16l13 -8z')
->toContain('M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747')
->toContain('M6 5m0 1a1 1 0 0 1 1 -1h2');
expect($applicationHeading)
->toContain('application-mobile-section-label')
->toContain('Section');
expect($databaseHeading)
->toContain('database-mobile-section')
->toContain('database-mobile-actions')
->toContain('<optgroup label="Database">')
->toContain('<optgroup label="Configuration">')
->toContain("'route' => 'project.database.command'")
->toContain("'navigate' => false")
->toContain("value.startsWith('location|')")
->toContain('window.location.href = url')
->toContain('window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url')
->toContain("window.Livewire?.hook?.('morphed'")
->toContain('x-model="selected"')
->toContain('database-restart-trigger')
->toContain('database-stop-trigger')
->toContain('database-start-trigger')
->toContain('scrollbar hidden min-h-10')
->not->toContain('<optgroup label="Links">')
->not->toContain('<optgroup label="Actions">')
->not->toContain('@selected');
expect($databaseMobileActions)
->toContain('mb-3')
->toContain('Actions')
->toContain('<x-forms.button isError class="shrink-0"')
->not->toContain('button type="button" class="button shrink-0 text-error"')
->toContain('M7 4v16l13 -8z')
->toContain('M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747')
->toContain('M6 5m0 1a1 1 0 0 1 1 -1h2');
expect($databaseHeading)
->toContain('database-mobile-section-label')
->toContain('Section');
expect($serviceHeading)
->toContain('service-mobile-section')
->toContain('service-mobile-actions')
->toContain('<optgroup label="Service">')
->toContain('<optgroup label="Configuration">')
->toContain('<optgroup label="Resource">')
->toContain('<optgroup label="Links">')
->toContain("'route' => 'project.service.command'")
->toContain("'navigate' => false")
->toContain("value.startsWith('location|')")
->toContain('window.location.href = url')
->toContain('window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url')
->toContain("window.Livewire?.hook?.('morphed'")
->toContain('x-model="selected"')
->toContain('service-restart-trigger')
->toContain('service-stop-trigger')
->toContain('service-forceDeploy-trigger')
->toContain('service-pullAndRestart-trigger')
->toContain('scrollbar hidden min-h-10')
->toContain('mb-4 w-full md:mb-0 md:hidden')
->toContain('hidden flex-wrap items-center gap-2 md:flex')
->toContain('flex flex-nowrap')
->toContain('overflow-x-auto')
->not->toContain('<optgroup label="Actions">')
->not->toContain('order-first flex flex-wrap items-center gap-2 sm:order-last')
->not->toContain('@selected');
expect($serviceMobileActions)
->toContain('mb-3')
->toContain('Actions')
->toContain('<x-forms.button isError class="shrink-0"')
->not->toContain('button type="button" class="button shrink-0 text-error"')
->toContain('M7 4v16l13 -8z')
->toContain('M19.933 13.041a8 8 0 1 1-9.925-8.788c3.899-1 7.935 1.007 9.425 4.747')
->toContain('M6 5m0 1a1 1 0 0 1 1 -1h2');
expect($serviceHeading)
->toContain('service-mobile-section-label')
->toContain('Section');
});
function mobileActionsMarkup(string $heading, string $actionsId, string $selectId): string
{
$actionsPosition = strpos($heading, 'id="'.$actionsId.'"');
$selectPosition = strpos($heading, 'id="'.$selectId.'"');
if ($actionsPosition === false || $selectPosition === false || $actionsPosition > $selectPosition) {
return '';
}
return substr($heading, $actionsPosition, $selectPosition - $actionsPosition);
}
function mobileActionsAreBeforeSelect(string $heading, string $actionsId, string $selectId): bool
{
$actionsPosition = strpos($heading, 'id="'.$actionsId.'"');
$selectPosition = strpos($heading, 'id="'.$selectId.'"');
return $actionsPosition !== false && $selectPosition !== false && $actionsPosition < $selectPosition;
}
it('keeps configuration sidebars hidden until desktop breakpoint', function () {
expect(file_get_contents(resource_path('views/livewire/project/database/configuration.blade.php')))
->toContain('sub-menu-wrapper hidden md:flex');
expect(file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php')))
->toContain('sub-menu-wrapper hidden md:flex');
expect(file_get_contents(resource_path('views/livewire/project/service/index.blade.php')))
->toContain('sub-menu-wrapper hidden md:flex');
expect(file_get_contents(resource_path('views/components/service-database/sidebar.blade.php')))
->toContain('sub-menu-wrapper hidden md:flex');
});
@@ -0,0 +1,30 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('keeps mutable Livewire components behind authorization checks', function (string $path, array $requiredNeedles) {
$source = file_get_contents(base_path($path));
foreach ($requiredNeedles as $needle) {
expect($source)->toContain($needle);
}
})->with([
'storage resources' => [
'app/Livewire/Storage/Resources.php',
['AuthorizesRequests', "authorize('update'", "authorize('view'"],
],
'postgres init script editor' => [
'app/Livewire/Project/Database/InitScript.php',
['AuthorizesRequests', "authorize('update'"],
],
'execute container command' => [
'app/Livewire/Project/Shared/ExecuteContainerCommand.php',
['AuthorizesRequests', "authorize('view'", "authorize('canAccessTerminal'"],
],
'terminal' => [
'app/Livewire/Project/Shared/Terminal.php',
['AuthorizesRequests', "authorize('view'", "authorize('canAccessTerminal'"],
],
]);
@@ -0,0 +1,60 @@
<?php
use App\Actions\Application\CleanupPreviewDeployment;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$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]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
it('cleans up a closed pull request preview when pull request comment cleanup fails', function () {
$preview = ApplicationPreview::create([
'application_id' => $this->application->id,
'pull_request_id' => 42,
'pull_request_html_url' => 'https://github.com/example/repo/pull/42',
]);
CleanupPreviewDeployment::shouldRun()
->once()
->withArgs(fn (Application $application, int $pullRequestId, ApplicationPreview $applicationPreview): bool => $application->is($this->application)
&& $pullRequestId === 42
&& $applicationPreview->is($preview))
->andReturn([
'cancelled_deployments' => 0,
'killed_containers' => 0,
'status' => 'success',
]);
$job = new class(applicationId: $this->application->id, githubAppId: null, action: 'closed', pullRequestId: 42, pullRequestHtmlUrl: 'https://github.com/example/repo/pull/42', pullRequestTitle: null, beforeSha: null, afterSha: null, commitSha: 'HEAD', authorAssociation: 'OWNER', fullName: 'example/repo') extends ProcessGithubPullRequestWebhook
{
protected function dispatchPullRequestClosedUpdate(Application $application, ApplicationPreview $preview): void
{
throw new RuntimeException('GitHub comment cleanup failed.');
}
};
$job->handle();
});
@@ -0,0 +1,38 @@
<?php
it('shows an empty state when there are no projects', function () {
$this->view('livewire.project.index', [
'projects' => collect(),
])
->assertSee('No projects found.')
->assertSee('onboarding');
});
it('does not show the empty state when projects exist', function () {
$project = new class
{
public string $name = 'Test Project';
public string $description = 'A project description';
public string $uuid = 'test-project-uuid';
public $environments;
public function __construct()
{
$this->environments = collect();
}
public function navigateTo(): string
{
return '#';
}
};
$this->view('livewire.project.index', [
'projects' => collect([$project]),
])
->assertSee('Test Project')
->assertDontSee('No projects found.');
});
+79 -127
View File
@@ -1,139 +1,91 @@
<?php
namespace Tests\Feature\Proxy;
use App\Jobs\RestartProxyJob;
use App\Enums\ProxyTypes;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
use Tests\TestCase;
class RestartProxyTest extends TestCase
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
});
function setupProxyUser(string $role): array
{
use RefreshDatabase;
$team = Team::factory()->create();
$user = User::factory()->create();
$user->teams()->attach($team, ['role' => $role]);
protected User $user;
$server = Server::factory()->create([
'team_id' => $team->id,
'name' => 'Test Server',
'ip' => '192.168.1.100',
]);
protected Team $team;
protected Server $server;
protected function setUp(): void
{
parent::setUp();
// Create test user and team
$this->user = User::factory()->create();
$this->team = Team::factory()->create(['name' => 'Test Team']);
$this->user->teams()->attach($this->team);
// Create test server
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'name' => 'Test Server',
'ip' => '192.168.1.100',
]);
// Authenticate user
$this->actingAs($this->user);
}
public function test_restart_dispatches_job_for_all_servers()
{
Queue::fake();
Livewire::test('server.navbar', ['server' => $this->server])
->call('restart');
// Assert job was dispatched
Queue::assertPushed(RestartProxyJob::class, function ($job) {
return $job->server->id === $this->server->id;
});
}
public function test_restart_dispatches_job_for_localhost_server()
{
Queue::fake();
// Create localhost server (id = 0)
$localhostServer = Server::factory()->create([
'id' => 0,
'team_id' => $this->team->id,
'name' => 'Localhost',
'ip' => 'host.docker.internal',
]);
Livewire::test('server.navbar', ['server' => $localhostServer])
->call('restart');
// Assert job was dispatched
Queue::assertPushed(RestartProxyJob::class, function ($job) use ($localhostServer) {
return $job->server->id === $localhostServer->id;
});
}
public function test_restart_shows_info_message()
{
Queue::fake();
Livewire::test('server.navbar', ['server' => $this->server])
->call('restart')
->assertDispatched('info', 'Proxy restart initiated. Monitor progress in activity logs.');
}
public function test_unauthorized_user_cannot_restart_proxy()
{
Queue::fake();
// Create another user without access
$unauthorizedUser = User::factory()->create();
$this->actingAs($unauthorizedUser);
Livewire::test('server.navbar', ['server' => $this->server])
->call('restart')
->assertForbidden();
// Assert job was NOT dispatched
Queue::assertNotPushed(RestartProxyJob::class);
}
public function test_restart_prevents_concurrent_jobs_via_without_overlapping()
{
Queue::fake();
// Dispatch job twice
Livewire::test('server.navbar', ['server' => $this->server])
->call('restart');
Livewire::test('server.navbar', ['server' => $this->server])
->call('restart');
// Assert job was pushed twice (WithoutOverlapping middleware will handle deduplication)
Queue::assertPushed(RestartProxyJob::class, 2);
// Get the jobs
$jobs = Queue::pushed(RestartProxyJob::class);
// Verify both jobs have WithoutOverlapping middleware
foreach ($jobs as $job) {
$middleware = $job['job']->middleware();
$this->assertCount(1, $middleware);
$this->assertInstanceOf(\Illuminate\Queue\Middleware\WithoutOverlapping::class, $middleware[0]);
}
}
public function test_restart_uses_server_team_id()
{
Queue::fake();
Livewire::test('server.navbar', ['server' => $this->server])
->call('restart');
Queue::assertPushed(RestartProxyJob::class, function ($job) {
return $job->server->team_id === $this->team->id;
});
}
return [$user, $team, $server];
}
function makeServerProxyRunning(Server $server): void
{
$server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
$server->proxy->status = 'running';
$server->proxy->type = ProxyTypes::TRAEFIK->value;
$server->save();
$server->refresh();
}
test('member cannot see proxy restart and stop buttons', function () {
[$user, $team, $server] = setupProxyUser('member');
makeServerProxyRunning($server);
// Mock proxySet to bypass SQLite boolean casting issue with force_disabled
$mock = Mockery::mock($server)->makePartial();
$mock->shouldReceive('proxySet')->andReturn(true);
$this->actingAs($user);
session(['currentTeam' => $team]);
Livewire::test('server.navbar', ['server' => $mock])
->assertDontSee('Restart Proxy')
->assertDontSee('Stop Proxy');
});
test('admin can see proxy restart and stop buttons', function () {
[$user, $team, $server] = setupProxyUser('admin');
makeServerProxyRunning($server);
$mock = Mockery::mock($server)->makePartial();
$mock->shouldReceive('proxySet')->andReturn(true);
$this->actingAs($user);
session(['currentTeam' => $team]);
Livewire::test('server.navbar', ['server' => $mock])
->assertSee('Restart Proxy')
->assertSee('Stop Proxy');
});
test('member cannot see start proxy button', function () {
[$user, $team, $server] = setupProxyUser('member');
$server->proxy->status = 'exited';
$server->proxy->type = ProxyTypes::TRAEFIK->value;
$server->save();
$server->refresh();
$mock = Mockery::mock($server)->makePartial();
$mock->shouldReceive('proxySet')->andReturn(true);
$this->actingAs($user);
session(['currentTeam' => $team]);
Livewire::test('server.navbar', ['server' => $mock])
->assertDontSee('Start Proxy');
});
@@ -0,0 +1,46 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\MessageBag;
use Illuminate\Support\ViewErrorBag;
beforeEach(function () {
$errors = new ViewErrorBag;
$errors->put('default', new MessageBag);
view()->share('errors', $errors);
});
it('keeps the resource details helper text visible below the modal header', function () {
$html = view('livewire.project.shared.resource-details', [
'resource' => (object) [
'name' => 'Crash Loop Example',
'uuid' => 'crashloop',
],
'environment_uuid' => null,
'environment_name' => null,
'project_uuid' => null,
'project_name' => null,
'server_uuid' => null,
'server_name' => null,
'stack_applications' => [],
'stack_databases' => [],
])->render();
expect($html)
->toContain('Identifiers for this resource. Read-only')
->toContain('pt-1')
->not->toContain('-mt-4');
});
it('renders copy fields as visible readonly controls with an accessible copy action', function () {
$html = Blade::render('<x-forms.copy-button label="UUID" text="crashloop" />');
expect($html)
->toContain('label class="flex gap-1 items-center mb-1 text-sm font-medium text-black dark:text-white"')
->toContain('readonly')
->toContain('class="input pr-11 bg-white dark:bg-coolgray-100 dark:read-only:bg-coolgray-100 dark:read-only:text-white"')
->toContain('aria-label="Copy to clipboard"')
->toContain('title="Copy to clipboard"')
->toContain('rounded-sm p-1.5 text-neutral-500 transition-colors hover:text-neutral-700 focus-visible:ring-2 focus-visible:ring-coollabs focus-visible:ring-offset-2 dark:text-neutral-400 dark:hover:text-white dark:focus-visible:ring-warning dark:focus-visible:ring-offset-base')
->toContain('class="w-5 h-5 text-green-500"');
});
@@ -0,0 +1,82 @@
<?php
use App\Livewire\Project\Shared\ResourceOperations;
use App\Livewire\Project\Shared\Tags;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function resourcePermissionCalloutApplicationFor(string $role): Application
{
$user = User::factory()->create();
$team = Team::factory()->create();
$user->teams()->attach($team, ['role' => $role]);
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = $server->standaloneDockers()->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
test()->actingAs($user);
session(['currentTeam' => $team]);
return Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
}
it('shows one red insufficient permissions callout for resource operations when update is denied', function () {
$application = resourcePermissionCalloutApplicationFor('member');
$component = Livewire::test(ResourceOperations::class, ['resource' => $application])
->assertSee('Insufficient Permissions')
->assertSee('permission to modify this resource')
->assertSee('team administrator for access')
->assertDontSee('Access Restricted')
->assertDontSee("You don't have permission to clone resources")
->assertDontSee("You don't have permission to move resources");
expect(substr_count($component->html(), 'Insufficient Permissions'))->toBe(1)
->and($component->html())->toContain('bg-red-50')
->and($component->html())->not->toContain('bg-warning-50');
});
it('shows the red insufficient permissions callout for tags when update is denied', function () {
$application = resourcePermissionCalloutApplicationFor('member');
$component = Livewire::test(Tags::class, ['resource' => $application])
->assertSee('Insufficient Permissions')
->assertSee('permission to manage this resource')
->assertSee('team administrator for access')
->assertDontSee('Access Restricted')
->assertDontSee("You don't have permission to manage tags");
expect(substr_count($component->html(), 'Insufficient Permissions'))->toBe(1)
->and($component->html())->toContain('bg-red-50')
->and($component->html())->not->toContain('bg-warning-50');
});
it('does not use yellow permission callouts in blade views', function () {
$offendingFiles = collect(new RecursiveIteratorIterator(new RecursiveDirectoryIterator(resource_path('views'))))
->filter(fn (SplFileInfo $file) => $file->isFile() && $file->getExtension() === 'php')
->filter(function (SplFileInfo $file) {
$contents = file_get_contents($file->getPathname());
return str_contains($contents, 'type="warning" title="Permission Required"')
|| str_contains($contents, 'title="Access Restricted"');
})
->map(fn (SplFileInfo $file) => str_replace(base_path().'/', '', $file->getPathname()))
->values()
->all();
expect($offendingFiles)->toBeEmpty();
});
@@ -87,6 +87,35 @@ it('renders responsive checkbox classes on the application configuration page',
$response->assertSuccessful();
$response->assertSee('Use a Build Server?');
$response->assertSee('application-mobile-section');
$response->assertSee('Application menu');
$response->assertSee('<optgroup label="Application">', false);
$response->assertSee('<optgroup label="Configuration">', false);
$response->assertSee('<optgroup label="Links">', false);
$response->assertSee('application-mobile-actions');
$response->assertDontSee('<optgroup label="Actions">', false);
$response->assertSee('value="navigate|application|', false);
$response->assertSee('value="navigate|configuration|', false);
$response->assertSee('window.Livewire?.navigate ? window.Livewire.navigate(url) : window.location.href = url', false);
$response->assertSee('application-mobile-stop-trigger');
$response->assertSee('application-mobile-deploy-trigger');
$response->assertSee('application-mobile-restart-trigger');
$response->assertSee('application-mobile-force-deploy-trigger');
$response->assertSee('Confirm Application Deployment?');
$response->assertSee('Confirm Application Restart?');
$response->assertSee('Confirm Application Force Deployment?');
$response->assertSee('sub-menu-wrapper hidden md:flex', false);
$response->assertSee('scrollbar hidden min-h-10', false);
$response->assertSee(route('project.application.deployment.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $this->application->uuid,
]));
$response->assertSee(route('project.application.environment-variables', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'application_uuid' => $this->application->uuid,
]));
$response->assertSee('form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2', false);
$response->assertSee('label flex w-full max-w-full min-w-0 items-center gap-4 px-0', false);
$response->assertSee('flex min-w-0 grow gap-2 break-words', false);
@@ -0,0 +1,54 @@
<?php
use App\Models\S3Storage;
use App\Models\Team;
use App\Models\User;
use App\Policies\S3StoragePolicy;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
uses(RefreshDatabase::class);
test('s3 storage model resolves its registered policy through the gate', function () {
expect(Gate::getPolicyFor(S3Storage::class))->toBeInstanceOf(S3StoragePolicy::class);
});
test('s3 storage create ability is enforced through the registered policy', function () {
$team = Team::factory()->create();
$admin = User::factory()->create();
$admin->teams()->attach($team, ['role' => 'admin']);
$member = User::factory()->create();
$member->teams()->attach($team, ['role' => 'member']);
$this->actingAs($admin);
session(['currentTeam' => $team]);
expect($admin->can('create', S3Storage::class))->toBeTrue()
->and($member->can('create', S3Storage::class))->toBeFalse();
});
test('s3 storage validate connection ability is enforced through the registered policy', function () {
$team = Team::factory()->create();
$admin = User::factory()->create();
$admin->teams()->attach($team, ['role' => 'admin']);
$member = User::factory()->create();
$member->teams()->attach($team, ['role' => 'member']);
$storage = S3Storage::create([
'team_id' => $team->id,
'name' => 'Backups',
'description' => 'Team backup storage',
'region' => 'us-east-1',
'key' => 'access-key',
'secret' => 'secret-key',
'bucket' => 'coolify-backups',
'endpoint' => 'https://s3.us-east-1.amazonaws.com',
]);
expect($admin->can('validateConnection', $storage))->toBeTrue()
->and($member->can('validateConnection', $storage))->toBeFalse();
});
@@ -0,0 +1,166 @@
<?php
use App\Livewire\Security\ApiTokens;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->owner->teams()->attach($this->team, ['role' => 'owner']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
});
describe('Livewire ApiTokens — member cannot create elevated tokens', function () {
test('member cannot create token with root permissions', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'my-root-token')
->set('permissions', ['root'])
->call('addNewToken')
->assertDispatched('error');
expect($this->member->tokens()->count())->toBe(0);
});
test('member cannot create token with write permissions', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'my-write-token')
->set('permissions', ['write'])
->call('addNewToken')
->assertDispatched('error');
expect($this->member->tokens()->count())->toBe(0);
});
test('member cannot create token with deploy permissions', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'my-deploy-token')
->set('permissions', ['deploy'])
->call('addNewToken')
->assertDispatched('error');
expect($this->member->tokens()->count())->toBe(0);
});
test('member cannot create token with read:sensitive permissions', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'my-sensitive-token')
->set('permissions', ['read', 'read:sensitive'])
->call('addNewToken')
->assertDispatched('error');
expect($this->member->tokens()->count())->toBe(0);
});
test('member cannot bypass by setting canUseRootPermissions property', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
// Simulate snapshot replay: force the boolean to true
Livewire::test(ApiTokens::class)
->set('canUseRootPermissions', true)
->set('description', 'sneaky-root-token')
->set('permissions', ['root'])
->call('addNewToken')
->assertDispatched('error');
expect($this->member->tokens()->count())->toBe(0);
});
test('member can create token with read permissions', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'my-read-token')
->set('permissions', ['read'])
->call('addNewToken')
->assertNotDispatched('error');
expect($this->member->tokens()->count())->toBe(1);
expect($this->member->tokens()->first()->abilities)->toBe(['read']);
});
test('owner can create token with root permissions', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test(ApiTokens::class)
->set('description', 'my-root-token')
->set('permissions', ['root'])
->call('addNewToken')
->assertNotDispatched('error');
expect($this->owner->tokens()->count())->toBe(1);
expect($this->owner->tokens()->first()->abilities)->toBe(['root']);
});
});
describe('ApiAbility middleware — member with elevated token blocked', function () {
test('member root token is blocked on team_id=0 (root team)', function () {
// Create root team with id=0
$rootTeam = Team::factory()->create(['id' => 0]);
$member = User::factory()->create();
$rootTeam->members()->attach($member->id, ['role' => 'member']);
session(['currentTeam' => $rootTeam]);
$token = $member->createToken('root-token', ['root']);
$this->withToken($token->plainTextToken)
->getJson('/api/v1/projects')
->assertStatus(403);
});
test('admin root token passes on team_id=0 (root team)', function () {
$rootTeam = Team::factory()->create(['id' => 0]);
$admin = User::factory()->create();
$rootTeam->members()->attach($admin->id, ['role' => 'admin']);
session(['currentTeam' => $rootTeam]);
$token = $admin->createToken('root-token', ['root']);
$this->withToken($token->plainTextToken)
->getJson('/api/v1/projects')
->assertSuccessful();
});
test('member root token is blocked on non-zero team', function () {
session(['currentTeam' => $this->team]);
$token = $this->member->createToken('root-token', ['root']);
$this->withToken($token->plainTextToken)
->getJson('/api/v1/projects')
->assertStatus(403);
});
test('member read token passes on non-zero team', function () {
session(['currentTeam' => $this->team]);
$token = $this->member->createToken('read-token', ['read']);
$this->withToken($token->plainTextToken)
->getJson('/api/v1/projects')
->assertSuccessful();
});
});
+100
View File
@@ -1,19 +1,36 @@
<?php
use App\Livewire\Security\CloudInitScriptForm;
use App\Livewire\Security\CloudInitScripts;
use App\Livewire\Security\CloudProviderTokenForm;
use App\Livewire\Security\CloudProviderTokens;
use App\Models\Application;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function makeAuditTeamUser(): array
{
if (! InstanceSettings::query()->whereKey(0)->exists()) {
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
}
Once::flush();
$team = Team::factory()->create();
$user = User::factory()->create();
$team->members()->attach($user->id, ['role' => 'owner']);
@@ -122,6 +139,89 @@ describe('audit channel helper', function () {
});
});
describe('security UI audit logging', function () {
test('creating a cloud provider token from Livewire writes an audit entry', function () {
[$team] = makeAuditTeamUser();
Http::fake([
'https://api.hetzner.cloud/v1/servers' => Http::response([], 200),
]);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->once()
->with('ui.cloud_token.created', Mockery::on(function (array $context) use ($team) {
return $context['team_id'] === $team->id
&& $context['provider'] === 'hetzner'
&& $context['cloud_token_name'] === 'UI Token';
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Livewire::test(CloudProviderTokenForm::class)
->set('provider', 'hetzner')
->set('token', 'secret-token')
->set('name', 'UI Token')
->call('addToken');
});
test('deleting a cloud provider token from Livewire writes an audit entry', function () {
[$team] = makeAuditTeamUser();
$token = CloudProviderToken::factory()->create([
'team_id' => $team->id,
'provider' => 'hetzner',
'name' => 'Delete Me',
]);
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->once()
->with('ui.cloud_token.deleted', Mockery::on(function (array $context) use ($token) {
return $context['cloud_token_uuid'] === $token->uuid
&& $context['cloud_token_name'] === 'Delete Me';
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Livewire::test(CloudProviderTokens::class)
->call('deleteToken', $token->id);
});
test('creating and deleting cloud-init scripts from Livewire write audit entries', function () {
[$team] = makeAuditTeamUser();
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->once()
->with('ui.cloud_init_script.created', Mockery::on(function (array $context) use ($team) {
return $context['team_id'] === $team->id
&& $context['cloud_init_script_name'] === 'Bootstrap';
}));
$auditChannel->shouldReceive('info')
->once()
->with('ui.cloud_init_script.deleted', Mockery::on(function (array $context) {
return $context['cloud_init_script_name'] === 'Bootstrap';
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
Livewire::test(CloudInitScriptForm::class)
->set('name', 'Bootstrap')
->set('script', "#cloud-config\npackages: []")
->call('save');
$script = CloudInitScript::where('team_id', $team->id)->firstOrFail();
Livewire::test(CloudInitScripts::class)
->call('deleteScript', $script->id);
});
test('cloud provider token form does not contain debug ray calls', function () {
expect(file_get_contents(app_path('Livewire/Security/CloudProviderTokenForm.php')))
->not->toContain('ray(');
});
});
describe('webhook signature failure logging', function () {
test('GitHub manual webhook with bad signature logs to audit channel', function () {
$app = makeAuditApplication();
@@ -1,5 +1,7 @@
<?php
use App\Rules\ValidIpOrCidr;
test('IP allowlist with single IPs', function () {
$testCases = [
['ip' => '192.168.1.100', 'allowlist' => ['192.168.1.100'], 'expected' => true],
@@ -200,7 +202,7 @@ test('IP allowlist comma-separated string input', function () {
});
test('ValidIpOrCidr validation rule', function () {
$rule = new \App\Rules\ValidIpOrCidr;
$rule = new ValidIpOrCidr;
// Helper function to test validation
$validate = function ($value) use ($rule) {
@@ -248,7 +250,7 @@ test('ValidIpOrCidr validation rule', function () {
});
test('ValidIpOrCidr validation rule error messages', function () {
$rule = new \App\Rules\ValidIpOrCidr;
$rule = new ValidIpOrCidr;
// Helper function to get error message
$getError = function ($value) use ($rule) {
@@ -0,0 +1,361 @@
<?php
use App\Http\Middleware\TrustHosts;
use App\Models\InstanceSettings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
uses(RefreshDatabase::class);
beforeEach(function () {
// Clear cache before each test to ensure isolation
Cache::forget('instance_settings_fqdn_host');
});
it('trusts the configured FQDN from InstanceSettings', function () {
// Create instance settings with FQDN
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('coolify.example.com');
});
it('rejects password reset request with malicious host header', function () {
// Set up instance settings with legitimate FQDN
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
// The malicious host should NOT be in the trusted hosts
expect($hosts)->not->toContain('coolify.example.com.evil.com');
expect($hosts)->toContain('coolify.example.com');
});
it('handles missing FQDN gracefully', function () {
// Create instance settings without FQDN
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null]
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
// Should still return APP_URL pattern without throwing
expect($hosts)->not->toBeEmpty();
});
it('filters out null and empty values from trusted hosts', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => '']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
// Should not contain empty strings or null
foreach ($hosts as $host) {
if ($host !== null) {
expect($host)->not->toBeEmpty();
}
}
});
it('extracts host from FQDN with protocol and port', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com:8443']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('coolify.example.com');
});
it('handles exception during InstanceSettings fetch', function () {
// Drop the instance_settings table to simulate installation
Schema::dropIfExists('instance_settings');
$middleware = new TrustHosts($this->app);
// Should not throw an exception
$hosts = $middleware->hosts();
expect($hosts)->not->toBeEmpty();
});
it('trusts IP addresses with port', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'http://65.21.3.91:8000']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('65.21.3.91');
});
it('trusts IP addresses without port', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'http://192.168.1.100']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('192.168.1.100');
});
it('rejects malicious host when using IP address', function () {
// Simulate an instance using IP address
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'http://65.21.3.91:8000']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
// The malicious host attempting to mimic the IP should NOT be trusted
expect($hosts)->not->toContain('65.21.3.91.evil.com');
expect($hosts)->not->toContain('evil.com');
expect($hosts)->toContain('65.21.3.91');
});
it('trusts IPv6 addresses', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'http://[2001:db8::1]:8000']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
// IPv6 addresses are enclosed in brackets, getHost() should handle this
expect($hosts)->toContain('[2001:db8::1]');
});
it('invalidates cache when FQDN is updated', function () {
// Set initial FQDN
$settings = InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://old-domain.com']
);
// First call should cache it
$middleware = new TrustHosts($this->app);
$hosts1 = $middleware->hosts();
expect($hosts1)->toContain('old-domain.com');
// Verify cache exists
expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue();
// Update FQDN - should trigger cache invalidation
$settings->fqdn = 'https://new-domain.com';
$settings->save();
// Cache should be cleared
expect(Cache::has('instance_settings_fqdn_host'))->toBeFalse();
// New call should return updated host
$middleware2 = new TrustHosts($this->app);
$hosts2 = $middleware2->hosts();
expect($hosts2)->toContain('new-domain.com');
expect($hosts2)->not->toContain('old-domain.com');
});
it('caches trusted hosts to avoid database queries on every request', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
// Clear cache first
Cache::forget('instance_settings_fqdn_host');
// First call - should query database and cache result
$middleware1 = new TrustHosts($this->app);
$hosts1 = $middleware1->hosts();
// Verify result is cached
expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue();
expect(Cache::get('instance_settings_fqdn_host'))->toBe('coolify.example.com');
// Subsequent calls should use cache (no DB query)
$middleware2 = new TrustHosts($this->app);
$hosts2 = $middleware2->hosts();
expect($hosts1)->toBe($hosts2);
expect($hosts2)->toContain('coolify.example.com');
});
it('caches negative results when no FQDN is configured', function () {
// Create instance settings without FQDN
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => null]
);
// Clear cache first
Cache::forget('instance_settings_fqdn_host');
// First call - should query database and cache empty string sentinel
$middleware1 = new TrustHosts($this->app);
$hosts1 = $middleware1->hosts();
// Verify empty string sentinel is cached (not null, which wouldn't be cached)
expect(Cache::has('instance_settings_fqdn_host'))->toBeTrue();
expect(Cache::get('instance_settings_fqdn_host'))->toBe('');
// Subsequent calls should use cached sentinel value
$middleware2 = new TrustHosts($this->app);
$hosts2 = $middleware2->hosts();
expect($hosts1)->toBe($hosts2);
// Should only contain APP_URL pattern, not any FQDN
expect($hosts2)->not->toBeEmpty();
});
it('skips host validation for terminal auth routes', function () {
// These routes should be accessible with any Host header (for internal container communication)
$response = $this->postJson('/terminal/auth', [], [
'Host' => 'coolify:8080', // Internal Docker host
]);
// Should not get 400 Bad Host (might get 401 Unauthorized instead)
expect($response->status())->not->toBe(400);
});
it('skips host validation for terminal auth ips route', function () {
// These routes should be accessible with any Host header (for internal container communication)
$response = $this->postJson('/terminal/auth/ips', [], [
'Host' => 'soketi:6002', // Another internal Docker host
]);
// Should not get 400 Bad Host (might get 401 Unauthorized instead)
expect($response->status())->not->toBe(400);
});
it('still enforces host validation for non-terminal routes', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
// Regular routes should still validate Host header
$response = $this->get('/', [
'Host' => 'evil.com',
]);
// Should get 400 Bad Host for untrusted host
expect($response->status())->toBe(400);
});
it('skips host validation for API routes', function () {
// All API routes use token-based auth (Sanctum), not host validation
// They should be accessible from any host (mobile apps, CLI tools, scripts)
// Test health check endpoint
$response = $this->get('/api/health', [
'Host' => 'internal-lb.local',
]);
expect($response->status())->not->toBe(400);
// Test v1 health check
$response = $this->get('/api/v1/health', [
'Host' => '10.0.0.5',
]);
expect($response->status())->not->toBe(400);
// Test feedback endpoint
$response = $this->post('/api/feedback', [], [
'Host' => 'mobile-app.local',
]);
expect($response->status())->not->toBe(400);
});
it('trusts localhost when FQDN is configured', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('localhost');
});
it('trusts 127.0.0.1 when FQDN is configured', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('127.0.0.1');
});
it('trusts IPv6 loopback when FQDN is configured', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
$middleware = new TrustHosts($this->app);
$hosts = $middleware->hosts();
expect($hosts)->toContain('[::1]');
});
it('allows local access via localhost when FQDN is configured and request uses localhost host header', function () {
InstanceSettings::updateOrCreate(
['id' => 0],
['fqdn' => 'https://coolify.example.com']
);
$response = $this->get('/', [
'Host' => 'localhost',
]);
// Should NOT be rejected as untrusted host (would be 400)
expect($response->status())->not->toBe(400);
});
it('skips host validation for webhook endpoints', function () {
// All webhook routes are under /webhooks/* prefix (see RouteServiceProvider)
// and use cryptographic signature validation instead of host validation
// Test GitHub webhook
$response = $this->post('/webhooks/source/github/events', [], [
'Host' => 'github-webhook-proxy.local',
]);
expect($response->status())->not->toBe(400);
// Test GitLab webhook
$response = $this->post('/webhooks/source/gitlab/events/manual', [], [
'Host' => 'gitlab.example.com',
]);
expect($response->status())->not->toBe(400);
// Test Stripe webhook
$response = $this->post('/webhooks/payments/stripe/events', [], [
'Host' => 'stripe-webhook-forwarder.local',
]);
expect($response->status())->not->toBe(400);
});
@@ -8,6 +8,7 @@ use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
@@ -81,6 +82,23 @@ it('skips the job when the second push is identical', function () use ($running)
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('only audits sentinel pushes that dispatch a state update', function () use ($running) {
$auditChannel = Mockery::mock();
$auditChannel->shouldReceive('info')
->once()
->with('sentinel.metrics_pushed', Mockery::on(function (array $context) {
return $context['server_uuid'] === $this->server->uuid
&& $context['team_id'] === $this->team->id;
}));
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
Queue::assertPushed(PushServerUpdateJob::class, 1);
});
it('updates the heartbeat even when the job is skipped', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
@@ -0,0 +1,27 @@
<?php
it('uses left aligned single color status badges in the server header', function () {
$navbarView = file_get_contents(resource_path('views/livewire/server/navbar.blade.php'));
$badgeView = file_get_contents(resource_path('views/components/status-badge.blade.php'));
expect($navbarView)
->toContain('data-testid="server-status-summary"')
->toContain('<x-status-badge')
->toContain('label="Proxy"')
->toContain('label="Sentinel"')
->toContain('as="button"')
->toContain("wire:click='checkProxyStatus'")
->toContain('status="Refresh"')
->toContain('border-transparent')
->toContain('wire:loading.attr="disabled"')
->not->toContain('status="Refreshing..."')
->not->toContain('justify-between')
->not->toContain('<x-status.stopped status="Proxy Exited" noLoading />')
->not->toContain('<x-status.running status="Sentinel In Sync" noLoading />');
expect($badgeView)
->not->toContain('text-neutral-500')
->not->toContain('dark:text-neutral-400')
->toContain('<button')
->toContain("merge(['type' => 'button'])");
});
+47
View File
@@ -0,0 +1,47 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function serviceExtraFieldsTestServiceWithApplicationImage(string $image): Service
{
$team = Team::factory()->create();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$server = Server::factory()->create();
$destination = StandaloneDocker::factory()->create(['server_id' => $server->id]);
$service = Service::factory()->create([
'environment_id' => $environment->id,
'server_id' => $server->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
]);
$service->applications()->create([
'name' => 'app',
'image' => $image,
]);
return $service;
}
it('only adds Grafana extra fields for Grafana server images', function (string $image, bool $shouldHaveGrafanaFields) {
$fields = serviceExtraFieldsTestServiceWithApplicationImage($image)->extraFields();
expect($fields->has('Grafana'))->toBe($shouldHaveGrafanaFields);
})->with([
'grafana oss' => ['grafana/grafana-oss:latest', true],
'grafana enterprise' => ['grafana/grafana-enterprise:latest', true],
'grafana default' => ['grafana/grafana:latest', true],
'loki' => ['grafana/loki:latest', false],
'promtail' => ['grafana/promtail:latest', false],
'tempo' => ['grafana/tempo:latest', false],
]);
@@ -0,0 +1,29 @@
<?php
use App\Console\Commands\Generate\Services;
use Illuminate\Console\OutputStyle;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
it('adds the last git commit timestamp to generated service template payloads', function () {
$command = new Services;
$command->setOutput(new OutputStyle(new ArrayInput([]), new NullOutput));
$method = new ReflectionMethod($command, 'processFile');
$payload = $method->invoke($command, 'activepieces.yaml');
$expectedTimestamp = Process::run([
'git',
'log',
'-1',
'--format=%cI',
'--',
'templates/compose/activepieces.yaml',
])->throw()->output();
expect($payload)
->toHaveKey('template_last_updated_at')
->and($payload['template_last_updated_at'])
->toBe(trim($expectedTimestamp));
});
@@ -3,6 +3,7 @@
use App\Livewire\Project\New\Select;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ViewErrorBag;
@@ -22,38 +23,56 @@ it('returns the service templates bundle last updated timestamp', function () {
->toBe(CarbonImmutable::createFromTimestamp(filemtime($templatePath))->timezone(config('app.timezone'))->format('M j, Y H:i'));
});
it('returns each service template last updated timestamp', function () {
it('returns each service template last updated timestamp from the generated bundle', function () {
$component = new Select;
$templatePath = base_path('templates/compose/activepieces.yaml');
$templates = json_decode(file_get_contents(base_path('templates/'.config('constants.services.file_name'))), true);
$templateTimestamp = $templates['activepieces']['template_last_updated_at'];
$resources = $component->loadServices();
expect($resources['services']['activepieces'])
->toHaveKey('templateLastUpdated')
->and($resources['services']['activepieces']['templateLastUpdated'])
->toBe(CarbonImmutable::createFromTimestamp(filemtime($templatePath))->timezone(config('app.timezone'))->format('M j, Y H:i'));
->toBe(CarbonImmutable::parse($templateTimestamp)->timezone(config('app.timezone'))->format('M j, Y H:i'));
});
it('uses a service template timestamp cache keyed by bundle mtime', function () {
$bundleMtime = filemtime(base_path('templates/'.config('constants.services.file_name')));
Cache::put("service-template-last-updated-map:{$bundleMtime}", [
'activepieces' => 'Cached timestamp',
], now()->addDay());
it('prefers embedded service template git timestamps from the templates bundle', function () {
File::shouldReceive('get')
->with(base_path('templates/'.config('constants.services.file_name')))
->andReturn(json_encode([
'activepieces' => [
'documentation' => 'https://coolify.io/docs',
'slogan' => 'Open source no-code business automation.',
'compose' => '',
'tags' => null,
'category' => 'automation',
'logo' => 'images/default.webp',
'minversion' => '0.0.0',
'template_last_updated_at' => '2026-05-31T12:34:56+00:00',
],
]));
$resources = (new Select)->loadServices();
expect($resources['services']['activepieces']['templateLastUpdated'])->toBe('Cached timestamp');
expect($resources['services']['activepieces']['templateLastUpdated'])->toBe('May 31, 2026 12:34');
});
it('does not use stale service template timestamp cache entries from another bundle mtime', function () {
$bundleMtime = filemtime(base_path('templates/'.config('constants.services.file_name')));
Cache::put('service-template-last-updated-map:'.($bundleMtime - 1), [
'activepieces' => 'Stale cached timestamp',
], now()->addDay());
it('caches parsed local service templates by bundle mtime', function () {
Cache::flush();
$resources = (new Select)->loadServices();
$path = base_path('templates/'.config('constants.services.file_name'));
$json = file_get_contents($path);
expect($resources['services']['activepieces']['templateLastUpdated'])->not->toBe('Stale cached timestamp');
File::partialMock()
->shouldReceive('get')
->once()
->with($path)
->andReturn($json);
$first = get_service_templates();
$second = get_service_templates();
expect($first->keys()->all())->toBe($second->keys()->all());
});
it('renders the service templates last updated hint placeholder', function () {
@@ -2,18 +2,16 @@
use App\Livewire\SettingsDropdown;
it('keeps changelog and the theme switcher in the sidebar without the old preferences trigger', function () {
it('keeps changelog in the sidebar without a dedicated theme switcher', function () {
$navbarView = file_get_contents(resource_path('views/components/navbar.blade.php'));
expect($navbarView)
->toContain('<livewire:settings-dropdown trigger="changelog-sidebar" />')
->not->toContain('<livewire:settings-dropdown />')
->toContain('aria-label="Theme switcher"')
->toContain('aria-label="Use light theme"')
->toContain('aria-label="Use system theme"')
->toContain('aria-label="Use dark theme"')
->toContain('cycleTheme()')
->toContain("const themes = ['light', 'system', 'dark'];")
->not->toContain('<span class="text-left menu-item-label">Theme</span>')
->not->toContain('Click to change theme.')
->not->toContain('cycleTheme()')
->toContain('this.queryTheme();')
->toContain('pl-2 pr-3 items-start gap-3')
->toContain('class="flex min-w-0 flex-1 flex-col"')
->toContain('class="min-w-0 flex-1"')
@@ -30,6 +28,7 @@ it('keeps changelog and appearance options out of the preferences dropdown', fun
->toContain('wire:click="openWhatsNewModal"')
->toContain('class="relative text-left menu-item"')
->toContain('class="text-left menu-item-label"')
->toContain('class="absolute right-2 top-1/2 -translate-y-1/2 bg-error')
->toContain("What's New</span>")
->toContain('M9.813 15.904 9 18.75')
->not->toContain('<span>Changelog</span>')
+114
View File
@@ -0,0 +1,114 @@
<?php
it('renders application and database statuses as shared status badges', function (string $status, string $expectedText) {
$resource = new class($status)
{
public function __construct(public string $status) {}
};
$html = view('components.status.index', [
'resource' => $resource,
'showRefreshButton' => false,
])->render();
expect($html)
->toContain('inline-flex h-5 max-w-full items-center gap-1 rounded-sm border')
->toContain($expectedText)
->not->toContain('badge-success')
->not->toContain('badge-warning')
->not->toContain('badge-error');
})->with([
'running healthy' => ['running:healthy', 'Running (healthy)'],
'starting unknown' => ['starting:unknown', 'Starting (unknown)'],
'degraded unhealthy' => ['degraded:unhealthy', 'Degraded (unhealthy)'],
'exited unhealthy' => ['exited:unhealthy', 'Exited'],
]);
it('renders service container statuses as shared status badges', function () {
$html = view('components.status.services', [
'complexStatus' => 'running:healthy',
'showRefreshButton' => false,
])->render();
expect($html)
->toContain('inline-flex h-5 max-w-full items-center gap-1 rounded-sm border')
->toContain('Running (healthy)')
->not->toContain('badge-success');
});
it('uses a shared refresh badge for resource status refresh actions', function () {
$statusIndex = file_get_contents(resource_path('views/components/status/index.blade.php'));
$serviceStatus = file_get_contents(resource_path('views/components/status/services.blade.php'));
expect($statusIndex)
->toContain('<x-status-badge as="button"')
->toContain("wire:click='manualCheckStatus'")
->toContain('wire:loading.attr="disabled"')
->not->toContain('status="Refreshing..."')
->toContain('wire:loading.attr="disabled"')
->not->toContain('status="Refreshing..."')
->not->toContain('<svg');
expect(file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php')))
->toContain('<x-status-badge')
->toContain('formatContainerStatus($resource->status)');
expect($serviceStatus)
->toContain('<x-status-badge as="button"')
->toContain("wire:click='manualCheckStatus'")
->not->toContain('<svg');
});
it('renders health warning helpers as badges instead of warning icons', function () {
$html = view('components.status.running', [
'status' => 'running:unknown',
])->render();
$runningStatus = file_get_contents(resource_path('views/components/status/running.blade.php'));
expect($html)
->toContain('No health check')
->toContain('inline-flex h-5 max-w-full items-center gap-1 rounded-sm border')
->not->toContain('<svg');
expect($runningStatus)
->toContain('<x-status-badge')
->toContain('class="flex items-center gap-1"')
->not->toContain('class="px-2"')
->not->toContain('viewBox="0 0 256 256"');
});
it('renders restart counts as warning badges', function () {
$resource = new class
{
public string $status = 'running:unknown';
public int $restart_count = 9;
public ?int $max_restart_count = null;
public ?string $last_restart_type = null;
public $last_restart_at;
public function __construct()
{
$this->last_restart_at = now();
}
};
$html = view('components.status.index', [
'resource' => $resource,
'showRefreshButton' => false,
])->render();
$statusIndex = file_get_contents(resource_path('views/components/status/index.blade.php'));
expect($html)
->toContain('9x restarts')
->toContain('border-yellow-300 bg-yellow-50')
->not->toContain('(9x restarts)');
expect($statusIndex)
->toContain('<x-status-badge')
->toContain('restart_count')
->not->toContain('class="text-xs dark:text-warning"');
});
@@ -5,6 +5,7 @@ use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Exception\InvalidRequestException;
use Stripe\Service\InvoiceService;
use Stripe\Service\RefundService;
use Stripe\Service\SubscriptionService;
@@ -85,6 +86,28 @@ describe('checkEligibility', function () {
expect($result['current_period_end'])->toBe($periodEnd);
});
test('returns eligible when subscription is set to cancel at period end', function () {
$this->subscription->update(['stripe_cancel_at_period_end' => true]);
$periodEnd = now()->addDays(20)->timestamp;
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
'current_period_end' => $periodEnd,
];
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_123')
->andReturn($stripeSubscription);
$action = new RefundSubscription($this->mockStripe);
$result = $action->checkEligibility($this->team);
expect($result['eligible'])->toBeTrue();
expect($result['days_remaining'])->toBe(20);
});
test('returns ineligible when subscription is not active', function () {
$periodEnd = now()->addDays(25)->timestamp;
$stripeSubscription = (object) [
@@ -141,7 +164,7 @@ describe('checkEligibility', function () {
$this->mockSubscriptions
->shouldReceive('retrieve')
->with('sub_test_123')
->andThrow(new \Stripe\Exception\InvalidRequestException('No such subscription'));
->andThrow(new InvalidRequestException('No such subscription'));
$action = new RefundSubscription($this->mockStripe);
$result = $action->checkEligibility($this->team);
@@ -269,6 +292,7 @@ describe('execute', function () {
$stripeSubscription = (object) [
'status' => 'active',
'start_date' => now()->subDays(10)->timestamp,
'current_period_end' => now()->addDays(20)->timestamp,
];
$this->mockSubscriptions
@@ -298,7 +322,7 @@ describe('execute', function () {
$this->mockSubscriptions
->shouldReceive('cancel')
->with('sub_test_123')
->andThrow(new \Exception('Stripe cancel API error'));
->andThrow(new Exception('Stripe cancel API error'));
$action = new RefundSubscription($this->mockStripe);
$result = $action->execute($this->team);
@@ -143,7 +143,51 @@ describe('checkout.session.completed', function () {
});
});
describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () {
describe('customer.subscription.updated clamps quantity to subscription bounds', function () {
test('quantity below MIN is clamped to 2', function () {
Queue::fake();
Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_existing',
'stripe_customer_id' => 'cus_min_clamp_test',
'stripe_invoice_paid' => true,
]);
$event = [
'type' => 'customer.subscription.updated',
'data' => [
'object' => [
'customer' => 'cus_min_clamp_test',
'id' => 'sub_existing',
'status' => 'active',
'metadata' => [
'team_id' => $this->team->id,
'user_id' => $this->user->id,
],
'items' => [
'data' => [[
'subscription' => 'sub_existing',
'plan' => ['id' => 'price_dynamic_monthly'],
'price' => ['lookup_key' => 'dynamic_monthly'],
'quantity' => 1,
]],
],
'cancel_at_period_end' => false,
'cancellation_details' => ['feedback' => null, 'comment' => null],
],
],
];
$job = new StripeProcessJob($event);
$job->handle();
$this->team->refresh();
expect($this->team->custom_server_limit)->toBe(2);
Queue::assertPushed(ServerLimitCheckJob::class);
});
test('quantity exceeding MAX is clamped to 100', function () {
Queue::fake();
@@ -0,0 +1,70 @@
<?php
use App\Actions\Stripe\RefundSubscription;
use App\Livewire\Subscription\Actions;
use App\Models\InstanceSettings;
use App\Models\Subscription;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('constants.coolify.self_hosted', false);
config()->set('subscription.provider', 'stripe');
config()->set('subscription.stripe_api_key', 'sk_test_fake');
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
Subscription::create([
'team_id' => $this->team->id,
'stripe_subscription_id' => 'sub_test_123',
'stripe_customer_id' => 'cus_test_123',
'stripe_invoice_paid' => true,
'stripe_plan_id' => 'price_test_123',
'stripe_cancel_at_period_end' => false,
'stripe_past_due' => false,
]);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
describe('cancelImmediately with refund option', function () {
test('refunds and cancels via RefundSubscription when refund checkbox is selected', function () {
$mock = Mockery::mock(RefundSubscription::class);
$mock->shouldReceive('execute')->once()->andReturn(['success' => true, 'error' => null]);
$this->instance(RefundSubscription::class, $mock);
Livewire::test(Actions::class)
->call('cancelImmediately', 'password', ['refundLatestPayment'])
->assertDispatched('success')
->assertRedirect(route('subscription.index'));
});
test('dispatches error when refund fails', function () {
$mock = Mockery::mock(RefundSubscription::class);
$mock->shouldReceive('execute')->once()->andReturn(['success' => false, 'error' => 'No paid invoice found to refund.']);
$this->instance(RefundSubscription::class, $mock);
Livewire::test(Actions::class)
->call('cancelImmediately', 'password', ['refundLatestPayment'])
->assertDispatched('error');
});
test('rejects invalid password before refunding', function () {
$mock = Mockery::mock(RefundSubscription::class);
$mock->shouldNotReceive('execute');
$this->instance(RefundSubscription::class, $mock);
Livewire::test(Actions::class)
->call('cancelImmediately', 'wrong-password', ['refundLatestPayment'])
->assertReturned('Invalid password.');
});
});
+54
View File
@@ -0,0 +1,54 @@
<?php
use App\Livewire\Team\Index;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->owner = User::factory()->create();
// The owner's personal team (created by factory)
$this->personalTeam = $this->owner->teams()->first();
$this->owner->teams()->updateExistingPivot($this->personalTeam->id, ['role' => 'owner']);
// A second team to delete
$this->teamToDelete = Team::create(['name' => 'Deletable Team', 'personal_team' => false]);
$this->teamToDelete->members()->attach($this->owner->id, ['role' => 'owner']);
});
test('deleting a team switches session to another team without error', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->teamToDelete]);
Livewire::test(Index::class)
->call('delete')
->assertRedirect(route('team.index'));
// Team should be deleted from the database
expect(Team::find($this->teamToDelete->id))->toBeNull();
// Session should now have the personal team
$sessionTeam = session('currentTeam');
expect($sessionTeam)->not->toBeNull()
->and($sessionTeam->id)->toBe($this->personalTeam->id);
});
test('refreshSession clears session when no team exists', function () {
$user = User::factory()->create();
// Detach all teams so user has none
$user->teams()->detach();
$this->actingAs($user);
session(['currentTeam' => null]);
// Should not throw when no team can be resolved
refreshSession(null);
expect(session('currentTeam'))->toBeNull();
});
Loaded 100 of 570 files, more files were not shown because too many files have changed in this diff. Show more