Merge remote-tracking branch 'origin/next' into api-application-preview-deployments

This commit is contained in:
Andras Bacsai committed 2026-07-07 12:38:37 +02:00
commit 00a3738ec7
693 files changed
+27662 -7249

No files matched your search

@@ -0,0 +1,81 @@
<?php
use App\Models\Application;
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 App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['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]);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'source-commit-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$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->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
function sourceCommitSettingApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
describe('PATCH /api/v1/applications/{uuid} include_source_commit_in_build', function () {
test('updates the application setting through the API', function () {
expect((bool) $this->application->settings->include_source_commit_in_build)->toBeFalse();
$this->withHeaders(sourceCommitSettingApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'include_source_commit_in_build' => true,
])
->assertOk();
expect((bool) $this->application->fresh()->settings->include_source_commit_in_build)->toBeTrue();
$this->withHeaders(sourceCommitSettingApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'include_source_commit_in_build' => false,
])
->assertOk();
expect((bool) $this->application->fresh()->settings->include_source_commit_in_build)->toBeFalse();
});
test('rejects non boolean values', function () {
$this->withHeaders(sourceCommitSettingApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'include_source_commit_in_build' => 'not-a-boolean',
])
->assertUnprocessable()
->assertJsonValidationErrors('include_source_commit_in_build');
});
});
@@ -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,
]);
}
});
});
+282
View File
@@ -0,0 +1,282 @@
<?php
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
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 () {
config([
'cache.default' => 'array',
'session.driver' => 'array',
'queue.default' => 'sync',
'app.maintenance.driver' => 'file',
]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(
['id' => 0],
['is_api_enabled' => true],
));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->bearerToken = destinationsApiToken($this->user, $this->team, ['*']);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
});
function destinationsApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
function destinationsApiToken(User $user, Team $team, array $abilities): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'destinations-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
describe('GET /api/v1/destinations', function () {
test('lists only destinations owned by the token team', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->getJson('/api/v1/destinations');
$response->assertOk();
$uuids = collect($response->json())->pluck('uuid');
expect($response->json('0'))->not->toHaveKey('id')
->and($uuids)->toContain($this->destination->uuid)
->not->toContain($otherDestination->uuid);
});
});
describe('GET /api/v1/destinations/{uuid}', function () {
test('does not expose another team destination', function () {
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->first();
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->getJson("/api/v1/destinations/{$otherDestination->uuid}");
$response->assertNotFound();
});
});
describe('GET /api/v1/servers/{server_uuid}/destinations', function () {
test('lists destinations for a team server', function () {
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->getJson("/api/v1/servers/{$this->server->uuid}/destinations");
$response->assertOk();
expect($response->json())->toHaveCount(1)
->and($response->json('0.uuid'))->toBe($this->destination->uuid);
});
});
describe('POST /api/v1/servers/{server_uuid}/destinations', function () {
test('requires a write token', function () {
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders(destinationsApiHeaders($readOnlyToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => 'new-network',
]);
$response->assertForbidden();
});
test('rejects create requests from non-admin team members', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$memberToken = destinationsApiToken($member, $this->team, ['*']);
$response = $this->withHeaders(destinationsApiHeaders($memberToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => 'member-network',
]);
$response->assertForbidden();
expect(StandaloneDocker::where('server_id', $this->server->id)->where('network', 'member-network')->exists())->toBeFalse();
});
test('rejects non-json requests before creating a destination', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->post("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => 'api-swarm-network',
'type' => 'swarm',
]);
$response->assertStatus(400);
expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'api-swarm-network')->exists())->toBeFalse();
});
test('rejects unknown fields', function () {
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => 'new-network',
'unexpected' => 'value',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['unexpected']);
});
test('rejects unsafe docker network names', function () {
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => 'bad;network',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['network']);
});
test('rejects a destination type that does not match the server mode', function () {
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => 'wrong-type-network',
'type' => 'swarm',
]);
$response->assertUnprocessable();
expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'wrong-type-network')->exists())->toBeFalse();
});
test('creates a swarm destination on a swarm server', function () {
$this->server->settings()->update(['is_swarm_manager' => true]);
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'name' => 'API Swarm',
'network' => 'api-swarm-network',
'type' => 'swarm',
]);
$response->assertCreated();
$response->assertJsonStructure(['uuid']);
expect(SwarmDocker::where('server_id', $this->server->id)->where('network', 'api-swarm-network')->exists())->toBeTrue();
});
test('rejects duplicate networks on the same server and type', function () {
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => $this->destination->network,
]);
$response->assertStatus(409);
});
test('returns conflict when the database unique constraint wins a create race', function () {
$network = 'raced-network';
StandaloneDocker::creating(function (StandaloneDocker $destination) use ($network) {
if ($destination->network !== $network) {
return;
}
DB::table('standalone_dockers')->insert([
'name' => 'Concurrent destination',
'uuid' => (string) Str::uuid(),
'network' => $network,
'server_id' => $destination->server_id,
'created_at' => now(),
'updated_at' => now(),
]);
});
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
'network' => $network,
]);
$response->assertStatus(409)
->assertJson(['message' => 'A destination with this network already exists on the server.']);
expect(StandaloneDocker::where('server_id', $this->server->id)->where('network', $network)->count())->toBe(1);
});
});
describe('DELETE /api/v1/destinations/{uuid}', function () {
test('requires a write token', function () {
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders(destinationsApiHeaders($readOnlyToken))
->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
$response->assertForbidden();
$this->assertModelExists($this->destination);
});
test('rejects delete requests from non-admin team members', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$memberToken = destinationsApiToken($member, $this->team, ['*']);
$response = $this->withHeaders(destinationsApiHeaders($memberToken))
->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
$response->assertForbidden();
$this->assertModelExists($this->destination);
});
test('deletes standalone destinations after removing the docker network', function () {
$cleanup = Mockery::mock(RemoveStandaloneDockerNetwork::class);
$cleanup->shouldReceive('handle')
->once()
->with(Mockery::on(fn (StandaloneDocker $destination) => $destination->is($this->destination)));
$this->app->instance(RemoveStandaloneDockerNetwork::class, $cleanup);
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
$response->assertOk();
$this->assertModelMissing($this->destination);
});
test('blocks deleting a destination with an attached service', function () {
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = $project->environments()->first();
Service::factory()->create([
'environment_id' => $environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
->deleteJson("/api/v1/destinations/{$this->destination->uuid}");
$response->assertStatus(409);
$this->assertModelExists($this->destination);
});
});
@@ -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);
});
});
+424
View File
@@ -0,0 +1,424 @@
<?php
use App\Models\GithubApp;
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;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
// 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->bearerToken = $this->token->plainTextToken;
// Create a private key for the team
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => validGithubAppsApiPrivateKey(),
'team_id' => $this->team->id,
]);
});
/**
* Generate a temporary 2048-bit RSA private key for GitHub Apps API tests.
*
* Generated in-process so tests do not depend on external files or secrets;
* the key is used only as a signing fixture and is never persisted.
*
* @return string PEM-encoded RSA private key
*/
function validGithubAppsApiPrivateKey(): string
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $privateKey);
return $privateKey;
}
describe('GET /api/v1/github-apps', function () {
test('returns 401 when not authenticated', function () {
$response = $this->getJson('/api/v1/github-apps');
$response->assertStatus(401);
});
test('returns empty array when no github apps exist', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$response->assertJson([]);
});
test('returns team github apps', function () {
// Create a GitHub app for the team
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment([
'name' => 'Test GitHub App',
'app_id' => 12345,
]);
});
test('does not return sensitive data', function () {
// Create a GitHub app
GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'secret-should-be-hidden',
'webhook_secret' => 'webhook-secret-should-be-hidden',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$json = $response->json();
// Ensure sensitive data is not present
expect($json[0])->not->toHaveKey('client_secret');
expect($json[0])->not->toHaveKey('webhook_secret');
});
test('returns system-wide github apps', function () {
// Create a system-wide GitHub app
$systemApp = GithubApp::create([
'name' => 'System GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 11111,
'installation_id' => 22222,
'client_id' => 'system-client-id',
'client_secret' => 'system-secret',
'webhook_secret' => 'system-webhook',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => true,
]);
// Create another team and user
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
session(['currentTeam' => $otherTeam]);
$otherToken = $otherUser->createToken('other-token');
// System-wide apps should be visible to other teams
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$otherToken->plainTextToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$response->assertJsonFragment([
'name' => 'System GitHub App',
'is_system_wide' => true,
]);
});
test('does not return other teams github apps', function () {
// Create a GitHub app for this team
GithubApp::create([
'name' => 'Team 1 App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 11111,
'installation_id' => 22222,
'client_id' => 'team1-client-id',
'client_secret' => 'team1-secret',
'webhook_secret' => 'team1-webhook',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
// Create another team with a GitHub app
$otherTeam = Team::factory()->create();
$otherPrivateKey = PrivateKey::create([
'name' => 'Other Key',
'private_key' => validGithubAppsApiPrivateKey(),
'team_id' => $otherTeam->id,
]);
GithubApp::create([
'name' => 'Team 2 App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 33333,
'installation_id' => 44444,
'client_id' => 'team2-client-id',
'client_secret' => 'team2-secret',
'webhook_secret' => 'team2-webhook',
'private_key_id' => $otherPrivateKey->id,
'team_id' => $otherTeam->id,
'is_system_wide' => false,
]);
// Request from first team should only see their app
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'Team 1 App']);
$response->assertJsonMissing(['name' => 'Team 2 App']);
});
test('returns correct response structure', function () {
GithubApp::create([
'name' => 'Test App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'client-id',
'client_secret' => 'secret',
'webhook_secret' => 'webhook',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$response->assertJsonStructure([
[
'id',
'uuid',
'name',
'api_url',
'html_url',
'custom_user',
'custom_port',
'app_id',
'installation_id',
'client_id',
'private_key_id',
'team_id',
'type',
],
]);
});
});
describe('GitHub app API url normalization', function () {
test('normalizes ghe dot com api url when creating github apps', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/github-apps', [
'name' => 'GHE App',
'organization' => '/octocorp/',
'html_url' => 'https://github.ghe.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertCreated()
->assertJsonFragment([
'organization' => 'octocorp',
'api_url' => 'https://api.github.ghe.com',
'html_url' => 'https://github.ghe.com',
]);
});
test('preserves provided api url when creating github apps', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/github-apps', [
'name' => 'GHE App',
'organization' => '/octocorp/',
'api_url' => 'https://github.com/api/v3',
'html_url' => 'https://github.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertCreated()
->assertJsonFragment([
'organization' => 'octocorp',
'api_url' => 'https://github.com/api/v3',
'html_url' => 'https://github.com',
]);
});
test('preserves provided api url when updating github apps', function () {
$githubApp = GithubApp::create([
'name' => 'GHE App',
'api_url' => 'https://github.company.internal/api/v3',
'html_url' => 'https://github.company.internal',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/github-apps/{$githubApp->id}", [
'html_url' => 'https://github.com',
'api_url' => 'https://github.com/api/v3',
]);
$response->assertSuccessful()
->assertJsonPath('data.api_url', 'https://github.com/api/v3');
});
test('preserves provided api url when updating api url only', function () {
$githubApp = GithubApp::create([
'name' => 'GHE App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/github-apps/{$githubApp->id}", [
'api_url' => 'https://github.com/api/v3',
]);
$response->assertSuccessful()
->assertJsonPath('data.api_url', 'https://github.com/api/v3');
});
test('rejects invalid organization when creating github apps', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/github-apps', [
'name' => 'GHE App',
'organization' => 'octo/corp',
'api_url' => 'https://api.octocorp.ghe.com',
'html_url' => 'https://octocorp.ghe.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['organization']);
});
test('loads repositories and branches through normalized ghe dot com api url', function () {
$this->privateKey->update([
'private_key' => validGithubAppsApiPrivateKey(),
]);
$githubApp = GithubApp::create([
'name' => 'GHE App',
'api_url' => 'https://api.octocorp.ghe.com',
'html_url' => 'https://octocorp.ghe.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
Http::preventStrayRequests();
Http::fake([
'https://api.octocorp.ghe.com/zen' => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.octocorp.ghe.com/app/installations/67890/access_tokens' => Http::response([
'token' => 'installation-token',
]),
'https://api.octocorp.ghe.com/installation/repositories*' => Http::response([
'repositories' => [
['name' => 'repo', 'full_name' => 'octocorp/repo'],
],
]),
'https://api.octocorp.ghe.com/repos/octocorp/repo/branches' => Http::response([
['name' => 'main'],
]),
]);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/github-apps/{$githubApp->id}/repositories")
->assertSuccessful()
->assertJsonPath('repositories.0.name', 'repo');
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/github-apps/{$githubApp->id}/repositories/octocorp/repo/branches")
->assertSuccessful()
->assertJsonPath('branches.0.name', 'main');
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/installation/repositories?per_page=100&page=1');
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/repos/octocorp/repo/branches');
});
});
@@ -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,108 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
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 App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Testing\Fluent\AssertableJson;
uses(RefreshDatabase::class);
beforeEach(function () {
Bus::fake([ApplicationDeploymentJob::class]);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->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']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('deployment-actions-test', ['deploy'])->plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function deploymentActionHeaders(string $token): array
{
return [
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json',
];
}
function makeDeploymentActionApplication(Environment $environment, StandaloneDocker $destination): Application
{
return Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'git_repository' => 'https://github.com/coollabsio/coolify',
'git_branch' => 'main',
'git_commit_sha' => 'HEAD',
]);
}
test('application start API returns queued deployment uuid as a string', function () {
$application = makeDeploymentActionApplication($this->environment, $this->destination);
$response = $this->withHeaders(deploymentActionHeaders($this->token))
->postJson("/api/v1/applications/{$application->uuid}/start");
$response->assertSuccessful()
->assertJson(fn (AssertableJson $json) => $json
->where('message', 'Deployment request queued.')
->whereType('deployment_uuid', 'string')
);
expect(ApplicationDeploymentQueue::query()->where('deployment_uuid', $response->json('deployment_uuid'))->exists())->toBeTrue();
});
test('application restart API returns queued deployment uuid as a string', function () {
$application = makeDeploymentActionApplication($this->environment, $this->destination);
$response = $this->withHeaders(deploymentActionHeaders($this->token))
->postJson("/api/v1/applications/{$application->uuid}/restart");
$response->assertSuccessful()
->assertJson(fn (AssertableJson $json) => $json
->where('message', 'Restart request queued.')
->whereType('deployment_uuid', 'string')
);
$deployment = ApplicationDeploymentQueue::query()
->where('deployment_uuid', $response->json('deployment_uuid'))
->first();
expect($deployment)->not->toBeNull()
->and($deployment->restart_only)->toBeTruthy();
});
test('deployment uuid strings are not converted as objects in API and webhook controllers', function () {
$files = [
app_path('Http/Controllers/Api/DeployController.php'),
app_path('Http/Controllers/Webhook/Bitbucket.php'),
app_path('Http/Controllers/Webhook/Gitea.php'),
app_path('Http/Controllers/Webhook/Gitlab.php'),
];
foreach ($files as $file) {
expect(file_get_contents($file))
->not->toContain('$deployment_uuid->toString()')
->not->toContain('$deployment_uuid?->toString()');
}
});
@@ -147,6 +147,140 @@ describe('GitHub Source Change Component', function () {
]);
});
test('ghe.com installation path uses github cloud owner scoped route', function () {
$githubApp = new GithubApp;
$githubApp->forceFill([
'id' => 123,
'name' => 'provided-github-app',
'organization' => 'acme-enterprise',
'html_url' => 'https://octocorp.ghe.com',
'team_id' => 456,
]);
$installationUrl = getInstallationPath($githubApp);
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
$installState = $query['state'] ?? null;
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/provided-github-app/installations/new?')
->and($installState)->not->toBeEmpty()
->and(Cache::get('github-app-setup-state:'.hash('sha256', $installState)))
->toMatchArray([
'action' => 'install',
'github_app_id' => 123,
'team_id' => 456,
]);
});
test('installation path is pure and never calls github or mutates the app', function () {
Http::fake();
$privateKey = PrivateKey::create([
'name' => 'github-app-local-name',
'private_key' => validPrivateKey(),
'team_id' => $this->team->id,
'is_git_related' => true,
]);
$githubApp = GithubApp::create([
'name' => 'Local Display Name',
'organization' => 'acme-enterprise',
'api_url' => 'https://api.github.com',
'html_url' => 'https://octocorp.ghe.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
$installationUrl = getInstallationPath($githubApp);
Http::assertNothingSent();
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/local-display-name/installations/new?')
->and($githubApp->refresh()->name)->toBe('Local Display Name')
->and($privateKey->refresh()->name)->toBe('github-app-local-name');
});
test('syncGithubAppName persists the github slug and renames the private key', function () {
Http::fake([
'*/app' => Http::response(['slug' => 'actual-github-slug']),
'*/zen' => Http::response('Keep it logically awesome.'),
]);
$privateKey = PrivateKey::create([
'name' => 'github-app-local-name',
'private_key' => validPrivateKey(),
'team_id' => $this->team->id,
'is_git_related' => true,
]);
$githubApp = GithubApp::create([
'name' => 'Local Display Name',
'organization' => 'acme-enterprise',
'api_url' => 'https://api.github.com',
'html_url' => 'https://octocorp.ghe.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
$appSlug = syncGithubAppName($githubApp, true);
expect($appSlug)->toBe('actual-github-slug')
->and($githubApp->refresh()->name)->toBe('actual-github-slug')
->and($privateKey->refresh()->name)->toBe('github-app-actual-github-slug');
});
test('ghe.com installation path encodes the organization segment', function () {
$githubApp = new GithubApp;
$githubApp->forceFill([
'id' => 123,
'name' => 'provided-github-app',
'organization' => '/acme enterprise/',
'html_url' => 'https://octocorp.ghe.com',
'team_id' => 456,
]);
$installationUrl = getInstallationPath($githubApp);
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme%20enterprise/provided-github-app/installations/new?');
});
test('ghe.com installation path encodes plus signs in the organization segment', function () {
$githubApp = new GithubApp;
$githubApp->forceFill([
'id' => 123,
'name' => 'Provided GitHub App',
'organization' => 'octo+corp',
'html_url' => 'https://octocorp.ghe.com',
'team_id' => 456,
]);
$installationUrl = getInstallationPath($githubApp);
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/octo%2Bcorp/provided-git-hub-app/installations/new?');
});
test('ghe.com installation path keeps app scoped fallback when organization is blank', function () {
$githubApp = new GithubApp;
$githubApp->forceFill([
'id' => 123,
'name' => 'provided-github-app',
'organization' => null,
'html_url' => 'https://octocorp.ghe.com',
'team_id' => 456,
]);
$installationUrl = getInstallationPath($githubApp);
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/provided-github-app/installations/new?');
});
test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
config(['app.url' => 'http://localhost:8000']);
@@ -207,6 +341,10 @@ describe('GitHub Source Change Component', function () {
});
test('can mount with fully configured github app', function () {
Http::fake([
'https://api.github.com/app' => Http::response(['slug' => 'test-github-app']),
]);
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => validPrivateKey(),
@@ -241,6 +379,10 @@ describe('GitHub Source Change Component', function () {
});
test('can update github app from null to valid values', function () {
Http::fake([
'https://api.github.com/app' => Http::response(['slug' => 'test-github-app']),
]);
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => validPrivateKey(),
@@ -277,6 +419,49 @@ describe('GitHub Source Change Component', function () {
expect($githubApp->private_key_id)->toBe($privateKey->id);
});
test('preserves custom ghe api url when saving github app settings', function () {
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://github.ghe.com/api/v3',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->set('htmlUrl', 'https://github.ghe.com')
->set('apiUrl', 'https://github.ghe.com/api/v3')
->call('submit')
->assertDispatched('success')
->assertSet('apiUrl', 'https://github.ghe.com/api/v3');
$githubApp->refresh();
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
});
test('rejects invalid github organization values', function () {
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->set('organization', 'octo/corp')
->call('submit')
->assertHasErrors(['organization']);
});
test('validation allows nullable values for app configuration', function () {
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
@@ -429,4 +614,43 @@ describe('GitHub Source Change Component', function () {
->and($githubApp->metadata)->toBe('read')
->and($githubApp->pull_requests)->toBe('write');
});
test('sync name uses normalized resolvable ghe dot com api url', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => validPrivateKey(),
'team_id' => $this->team->id,
]);
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.ghe.com',
'html_url' => 'https://github.ghe.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Http::preventStrayRequests();
Http::fake([
'https://api.github.ghe.com/zen' => Http::response('Keep it logically awesome.', 200, [
'date' => now()->toRfc7231String(),
]),
'https://api.github.ghe.com/app' => Http::response([
'slug' => 'octocorp-app',
]),
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->call('updateGithubAppName')
->assertDispatched('success');
Http::assertSent(fn ($request) => $request->url() === 'https://api.github.ghe.com/app');
});
});
@@ -72,8 +72,8 @@ describe('GitHub Source Create Component', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', 'enterprise-app')
->set('api_url', 'https://github.enterprise.com/api/v3')
->set('html_url', 'https://github.enterprise.com')
->set('api_url', 'https://github.ghe.com/api/v3')
->set('html_url', 'https://github.ghe.com')
->set('custom_user', 'git-custom')
->set('custom_port', 2222)
->call('createGitHubApp')
@@ -82,12 +82,28 @@ describe('GitHub Source Create Component', function () {
$githubApp = GithubApp::where('name', 'enterprise-app')->first();
expect($githubApp)->not->toBeNull();
expect($githubApp->api_url)->toBe('https://github.enterprise.com/api/v3');
expect($githubApp->html_url)->toBe('https://github.enterprise.com');
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
expect($githubApp->html_url)->toBe('https://github.ghe.com');
expect($githubApp->custom_user)->toBe('git-custom');
expect($githubApp->custom_port)->toBe(2222);
});
test('preserves custom github enterprise api url when creating github app', function () {
Livewire::test(Create::class)
->assertSuccessful()
->set('name', 'ghe-custom-api-app')
->set('api_url', 'https://github.ghe.com/api/v3')
->set('html_url', 'https://github.ghe.com')
->call('createGitHubApp')
->assertRedirect();
$githubApp = GithubApp::where('name', 'ghe-custom-api-app')->first();
expect($githubApp)->not->toBeNull();
expect($githubApp->api_url)->toBe('https://github.ghe.com/api/v3');
expect($githubApp->html_url)->toBe('https://github.ghe.com');
});
test('validates required fields', function () {
Livewire::test(Create::class)
->assertSuccessful()
@@ -0,0 +1,289 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use phpseclib3\Crypt\EC;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
$ecKey = EC::createKey('Ed25519');
$privateKeyContent = $ecKey->toString('OpenSSH');
$privateKey = PrivateKey::create([
'name' => 'test-key',
'private_key' => $privateKeyContent,
'team_id' => $this->team->id,
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $privateKey->id,
]);
ServerSetting::create([
'server_id' => $this->server->id,
'wildcard_domain' => 'http://127.0.0.1.sslip.io',
]);
$this->destination = StandaloneDocker::factory()->create([
'server_id' => $this->server->id,
'network' => 'test-network-'.fake()->uuid(),
]);
});
test('applicationParser populates docker_compose_domains for KEY-based SERVICE_FQDN variables', function () {
$dockerCompose = <<<'YAML'
services:
backend:
image: myapp/backend:latest
environment:
- SERVICE_FQDN_BACKEND_8000=${BACKEND_URL}
frontend:
image: myapp/frontend:latest
environment:
- SERVICE_FQDN_FRONTEND=${FRONTEND_URL}
YAML;
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'docker_compose_raw' => $dockerCompose,
'fqdn' => null,
'docker_compose_domains' => null,
]);
applicationParser($application);
$application->refresh();
$domains = json_decode($application->docker_compose_domains, true);
expect($domains)->not->toBeNull()
->and($domains)->toBeArray()
->and($domains)->toHaveKey('backend')
->and($domains['backend'])->toHaveKey('domain')
->and($domains['backend']['domain'])->toStartWith('http://')
->and($domains['backend']['domain'])->not->toBeEmpty();
});
test('applicationParser populates docker_compose_domains for KEY-based SERVICE_FQDN without port', function () {
$dockerCompose = <<<'YAML'
services:
frontend:
image: myapp/frontend:latest
environment:
- SERVICE_FQDN_FRONTEND=${FRONTEND_URL}
YAML;
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'docker_compose_raw' => $dockerCompose,
'fqdn' => null,
'docker_compose_domains' => null,
]);
applicationParser($application);
$application->refresh();
$domains = json_decode($application->docker_compose_domains, true);
expect($domains)->not->toBeNull()
->and($domains)->toHaveKey('frontend')
->and($domains['frontend'])->toHaveKey('domain');
});
test('applicationParser does not populate docker_compose_domains for non-dockercompose build_pack', function () {
$dockerCompose = <<<'YAML'
services:
backend:
image: myapp/backend:latest
environment:
- SERVICE_FQDN_BACKEND_8000=${BACKEND_URL}
YAML;
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockerfile',
'docker_compose_raw' => $dockerCompose,
'fqdn' => null,
'docker_compose_domains' => null,
]);
applicationParser($application);
$application->refresh();
// For non-dockercompose, docker_compose_domains should remain empty
$domains = json_decode($application->docker_compose_domains, true);
expect($domains)->toBeNull();
});
test('applicationParser populates docker_compose_domains for KEY-based SERVICE_URL variables', function () {
$dockerCompose = <<<'YAML'
services:
frontend:
image: myapp/frontend:latest
environment:
- SERVICE_URL_FRONTEND=/ui
YAML;
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'docker_compose_raw' => $dockerCompose,
'fqdn' => null,
'docker_compose_domains' => null,
]);
applicationParser($application);
$application->refresh();
$domains = json_decode($application->docker_compose_domains, true);
expect($domains)->not->toBeNull()
->and($domains)->toHaveKey('frontend')
->and($domains['frontend']['domain'])->toStartWith('http://')
->and($domains['frontend']['domain'])->toEndWith('/ui');
});
test('applicationParser preserves existing docker_compose_domains entries', function () {
$dockerCompose = <<<'YAML'
services:
backend:
image: myapp/backend:latest
environment:
- SERVICE_FQDN_BACKEND_8000=${BACKEND_URL}
frontend:
image: myapp/frontend:latest
environment:
- SERVICE_URL_FRONTEND=/ui
YAML;
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'docker_compose_raw' => $dockerCompose,
'fqdn' => null,
'docker_compose_domains' => json_encode([
'frontend' => ['domain' => 'https://existing.example.com'],
]),
]);
applicationParser($application);
$application->refresh();
$domains = json_decode($application->docker_compose_domains, true);
expect($domains['frontend']['domain'])->toBe('https://existing.example.com')
->and($domains)->toHaveKey('backend')
->and($domains['backend']['domain'])->toStartWith('http://');
});
test('applicationParser handles other docker compose domain shapes without regressions', function () {
$createApplication = function (string $dockerCompose, ?string $dockerComposeDomains = null): Application {
return Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'docker_compose_raw' => $dockerCompose,
'fqdn' => null,
'docker_compose_domains' => $dockerComposeDomains,
]);
};
$valueBasedCompose = <<<'YAML'
services:
backend:
image: myapp/backend:latest
frontend:
image: myapp/frontend:latest
environment:
API_URL: ${SERVICE_URL_BACKEND}
YAML;
$valueBasedApplication = $createApplication($valueBasedCompose);
applicationParser($valueBasedApplication);
$valueBasedApplication->refresh();
$valueBasedDomains = json_decode($valueBasedApplication->docker_compose_domains, true);
expect($valueBasedDomains)->toHaveKey('backend')
->and($valueBasedDomains['backend']['domain'])->toStartWith('http://')
->and($valueBasedDomains)->not->toHaveKey('frontend');
$mapStyleCompose = <<<'YAML'
services:
frontend:
image: myapp/frontend:latest
environment:
SERVICE_URL_FRONTEND: /ui
YAML;
$mapStyleApplication = $createApplication($mapStyleCompose);
applicationParser($mapStyleApplication);
$mapStyleApplication->refresh();
$mapStyleDomains = json_decode($mapStyleApplication->docker_compose_domains, true);
expect($mapStyleDomains)->toHaveKey('frontend')
->and($mapStyleDomains['frontend']['domain'])->toEndWith('/ui');
$missingServiceCompose = <<<'YAML'
services:
worker:
image: myapp/worker:latest
environment:
SERVICE_URL_API: /api
YAML;
$missingServiceApplication = $createApplication($missingServiceCompose);
applicationParser($missingServiceApplication);
$missingServiceApplication->refresh();
expect(json_decode($missingServiceApplication->docker_compose_domains, true))->toBeNull();
$plainCompose = <<<'YAML'
services:
worker:
image: myapp/worker:latest
environment:
FOO: bar
YAML;
$plainApplication = $createApplication($plainCompose);
applicationParser($plainApplication);
$plainApplication->refresh();
expect(json_decode($plainApplication->docker_compose_domains, true))->toBeNull();
});
@@ -0,0 +1,88 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
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\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['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]);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'public-git-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$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]);
});
function createPublicGitApplication(array $overrides = []): Application
{
$response = test()->withHeaders([
'Authorization' => 'Bearer '.test()->bearerToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/applications/public', array_merge([
'project_uuid' => test()->project->uuid,
'environment_uuid' => test()->environment->uuid,
'server_uuid' => test()->server->uuid,
'git_repository' => 'https://gitlab.com/coolify/test-static-app',
'git_branch' => 'main',
'build_pack' => 'static',
'ports_exposes' => '80',
'autogenerate_domain' => false,
], $overrides));
$response->assertCreated();
return Application::where('uuid', $response->json('uuid'))->firstOrFail();
}
test('public non github repositories keep their full git repository url', function () {
$application = createPublicGitApplication([
'git_repository' => 'https://cnb.cool/matridx/frp_webui',
]);
expect($application->git_repository)->toBe('https://cnb.cool/matridx/frp_webui')
->and($application->source_type)->toBeNull()
->and($application->source_id)->toBeNull();
});
test('public github repositories are stored as owner and repository with the public github source', function () {
GithubApp::unguarded(fn () => GithubApp::create([
'id' => 0,
'name' => 'Public GitHub',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'is_public' => true,
'team_id' => 0,
]));
$application = createPublicGitApplication([
'git_repository' => 'https://github.com/coollabsio/coolify-examples',
]);
expect($application->git_repository)->toBe('coollabsio/coolify-examples')
->and($application->source_type)->toBe(GithubApp::class)
->and($application->source_id)->toBe(0);
});
@@ -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,245 @@
<?php
use App\Livewire\Project\Application\Advanced as ApplicationAdvanced;
use App\Livewire\Project\Application\Heading as ApplicationHeading;
use App\Livewire\Project\Application\Rollback as ApplicationRollback;
use App\Models\Application;
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 () {
$this->withoutVite();
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,
]);
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',
]);
});
// --- Application Policy: view ---
test('admin can view application', function () {
expect($this->admin->can('view', $this->application))->toBeTrue();
});
test('member can view application', function () {
expect($this->member->can('view', $this->application))->toBeTrue();
});
// --- Application Policy: update ---
test('admin can update application', function () {
expect($this->admin->can('update', $this->application))->toBeTrue();
});
test('member cannot update application', function () {
expect($this->member->can('update', $this->application))->toBeFalse();
});
// --- Application Policy: deploy ---
test('admin can deploy application', function () {
expect($this->admin->can('deploy', $this->application))->toBeTrue();
});
test('member cannot deploy application', function () {
expect($this->member->can('deploy', $this->application))->toBeFalse();
});
// --- Application Policy: delete ---
test('admin can delete application', function () {
expect($this->admin->can('delete', $this->application))->toBeTrue();
});
test('member cannot delete application', function () {
expect($this->member->can('delete', $this->application))->toBeFalse();
});
// --- Application Policy: manageEnvironment ---
test('admin can manage application environment', function () {
expect($this->admin->can('manageEnvironment', $this->application))->toBeTrue();
});
test('member cannot manage application environment', function () {
expect($this->member->can('manageEnvironment', $this->application))->toBeFalse();
});
// --- Application Heading Livewire actions ---
test('member cannot call deploy on application heading', 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 heading', 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 heading', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->call('stop')
->assertDispatched('error');
});
test('member cannot call force deploy on application heading', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->call('force_deploy_without_cache')
->assertDispatched('error');
});
// --- Application General policy (Livewire mount requires full app data) ---
test('member cannot update application general settings', function () {
expect($this->member->can('update', $this->application))->toBeFalse();
});
// --- Application Advanced Livewire actions ---
test('member cannot save application advanced settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationAdvanced::class, ['application' => $this->application])
->call('instantSave')
->assertDispatched('error');
});
test('member cannot submit application advanced settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationAdvanced::class, ['application' => $this->application])
->call('submit')
->assertDispatched('error');
});
// --- Application Rollback Livewire actions ---
test('member cannot save rollback settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationRollback::class, ['application' => $this->application])
->call('saveSettings')
->assertDispatched('error');
});
test('member cannot rollback image', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationRollback::class, ['application' => $this->application])
->call('rollbackImage', 'test-image:latest')
->assertForbidden();
});
// --- Application Heading visibility ---
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');
});
// --- Cross-team isolation ---
test('user from different team cannot view application', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('view', $this->application))->toBeFalse();
});
test('user from different team cannot update application', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('update', $this->application))->toBeFalse();
});
@@ -0,0 +1,89 @@
<?php
use App\Http\Middleware\CanUpdateResource;
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\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
uses(RefreshDatabase::class);
function requestWithCanUpdateResourceRouteParameter(string $parameter, ?string $value): Request
{
$parameters = [
'application_uuid' => null,
'database_uuid' => null,
'stack_service_uuid' => null,
'service_uuid' => null,
'server_uuid' => null,
'environment_uuid' => null,
'project_uuid' => null,
$parameter => $value,
];
$request = Mockery::mock(Request::class)->makePartial();
$request->shouldReceive('route')->andReturnUsing(fn (string $key): ?string => $parameters[$key] ?? null);
return $request;
}
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$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']);
});
it('blocks members from update-only project routes before the page renders', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
(new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('project_uuid', $this->project->uuid),
fn () => response('ok')
);
})->throws(HttpException::class, 'You do not have permission to update this resource.');
it('allows admins through update-only project routes', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$response = (new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('project_uuid', $this->project->uuid),
fn () => response('ok')
);
expect($response->getContent())->toBe('ok');
});
it('blocks members from update-only server routes before the page renders', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
(new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('server_uuid', $this->server->uuid),
fn () => response('ok')
);
})->throws(HttpException::class, 'You do not have permission to update this resource.');
it('returns not found when an update-only route references an unknown resource', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
(new CanUpdateResource)->handle(
requestWithCanUpdateResourceRouteParameter('project_uuid', 'not-a-project'),
fn () => response('ok')
);
})->throws(NotFoundHttpException::class, 'Resource not found.');
@@ -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,339 @@
<?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();
});
test('owner can update team MCP setting', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test(TeamIndex::class)
->set('is_mcp_server_enabled', false)
->call('submit')
->assertDispatched('success');
expect($this->team->fresh()->is_mcp_server_enabled)->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;
@@ -16,6 +17,7 @@ use Database\Seeders\StandaloneDockerSeeder;
use Database\Seeders\TeamSeeder;
use Database\Seeders\UserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Collection;
uses(RefreshDatabase::class);
@@ -32,6 +34,11 @@ function seedRailpackExamplePrerequisites(): void
]);
}
function limaServers(): Collection
{
return collect(DevelopmentRailpackExamplesSeeder::LIMA_SERVERS);
}
it('can seed the railpack examples directly on a clean development database', function () {
config()->set('app.env', 'local');
@@ -42,14 +49,52 @@ 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(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue();
expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
expect(GitlabApp::query()->find(1))->not->toBeNull();
expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()) * limaServers()->count());
$project = Project::query()
->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)
->first();
expect($project)
->not->toBeNull()
->and($project->environments)->toHaveCount(limaServers()->count());
foreach (limaServers() as $limaServer) {
$server = Server::query()->where('uuid', $limaServer['server_uuid'])->first();
expect($server)->not->toBeNull();
expect($server->settings->sentinel_custom_url)->toBe('http://host.lima.internal:8000');
expect(StandaloneDocker::query()->whereRelation('server', 'uuid', $limaServer['server_uuid'])->exists())->toBeTrue();
expect($project->environments()->where('uuid', $limaServer['environment_uuid'])->exists())->toBeTrue();
}
});
it('seeds the railpack examples in development mode', function () {
config()->set('app.env', 'local');
seedRailpackExamplePrerequisites();
$legacyProject = Project::query()->create([
'uuid' => 'railpack-examples-lima-ubuntu-2404',
'name' => 'Railpack Examples - lima-ubuntu-2404',
'description' => 'Legacy generated Railpack examples project',
'team_id' => 0,
]);
Application::query()->create([
'uuid' => 'lima-ubuntu-2404-railpack-nextjs-ssr',
'name' => 'Legacy Railpack Next.js SSR Example',
'repository_project_id' => DevelopmentRailpackExamplesSeeder::REPOSITORY_PROJECT_ID,
'git_repository' => DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY,
'git_branch' => DevelopmentRailpackExamplesSeeder::GIT_BRANCH,
'build_pack' => 'railpack',
'ports_exposes' => '3000',
'environment_id' => $legacyProject->environments()->first()->id,
'destination_id' => 0,
'destination_type' => StandaloneDocker::class,
'source_id' => 0,
'source_type' => GithubApp::class,
]);
$this->seed(DevelopmentRailpackExamplesSeeder::class);
$project = Project::query()
@@ -59,26 +104,58 @@ it('seeds the railpack examples in development mode', function () {
expect($project)
->not->toBeNull()
->and($project->name)->toBe('Railpack Examples')
->and($project->environments)->toHaveCount(1)
->and($project->environments->first()->uuid)->toBe(DevelopmentRailpackExamplesSeeder::ENVIRONMENT_UUID);
->and($project->environments)->toHaveCount(limaServers()->count());
expect(Project::query()->pluck('uuid')->sort()->values()->all())->toBe([
'project',
DevelopmentRailpackExamplesSeeder::PROJECT_UUID,
]);
expect(Application::query()->where('uuid', 'lima-ubuntu-2404-railpack-nextjs-ssr')->exists())->toBeFalse();
$applications = $project->applications()->with('settings')->orderBy('uuid')->get();
expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()) * limaServers()->count());
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_branch === ($examples->get($application->uuid)['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
fn (Application $application) => $application->git_repository === ($examples->get(str($application->uuid)->after('-')->value())['git_repository'] ?? DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY)
))->toBeTrue();
expect($applications->every(
fn (Application $application) => $application->git_branch === ($examples->get(str($application->uuid)->after('-')->value())['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
))->toBeTrue();
$nestjs = $applications->firstWhere('uuid', 'railpack-nestjs');
$angularStatic = $applications->firstWhere('uuid', 'railpack-angular-static');
$eleventyStatic = $applications->firstWhere('uuid', 'railpack-eleventy-static');
$pythonFlask = $applications->firstWhere('uuid', 'railpack-python-flask');
$goGin = $applications->firstWhere('uuid', 'railpack-go-gin');
$rust = $applications->firstWhere('uuid', 'railpack-rust');
foreach (limaServers() as $limaServer) {
$limaEnvironment = $project->environments()
->where('uuid', $limaServer['environment_uuid'])
->first();
expect($limaEnvironment)
->not->toBeNull()
->and($limaEnvironment->name)->toBe($limaServer['environment_name']);
$limaApplications = $limaEnvironment->applications()->with('settings', 'destination.server')->orderBy('uuid')->get();
expect($limaApplications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
expect($limaApplications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue();
expect($limaApplications->every(fn (Application $application) => str($application->uuid)->startsWith($limaServer['uuid_prefix'])))->toBeTrue();
expect($limaApplications->every(fn (Application $application) => $application->destination->server->uuid === $limaServer['server_uuid']))->toBeTrue();
expect($limaApplications->every(
fn (Application $application) => $application->git_repository === ($examples->get(str($application->uuid)->after($limaServer['uuid_prefix'])->value())['git_repository'] ?? DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY)
))->toBeTrue();
expect($limaApplications->every(
fn (Application $application) => $application->git_branch === ($examples->get(str($application->uuid)->after($limaServer['uuid_prefix'])->value())['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
))->toBeTrue();
}
$nestjs = $applications->firstWhere('uuid', 'ubuntu24-railpack-nestjs');
$angularStatic = $applications->firstWhere('uuid', 'ubuntu24-railpack-angular-static');
$eleventyStatic = $applications->firstWhere('uuid', 'ubuntu24-railpack-eleventy-static');
$pythonFlask = $applications->firstWhere('uuid', 'ubuntu24-railpack-python-flask');
$goGin = $applications->firstWhere('uuid', 'ubuntu24-railpack-go-gin');
$rust = $applications->firstWhere('uuid', 'ubuntu24-railpack-rust');
$githubDeployKey = $applications->firstWhere('uuid', 'ubuntu24-railpack-github-deploy-key');
$gitlabDeployKey = $applications->firstWhere('uuid', 'ubuntu24-railpack-gitlab-deploy-key');
$gitlabPublic = $applications->firstWhere('uuid', 'ubuntu24-railpack-gitlab-public-example');
expect($nestjs)
->not->toBeNull()
@@ -113,6 +190,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 () {
@@ -123,6 +227,12 @@ it('skips the railpack examples outside development mode', function () {
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeFalse();
expect(Application::query()->where('uuid', 'railpack-nextjs-ssr')->exists())->toBeFalse();
foreach (limaServers() as $limaServer) {
expect(Project::query()->where('uuid', 'railpack-examples-lima-ubuntu-2404')->exists())->toBeFalse();
expect(Project::query()->where('uuid', 'railpack-examples-lima-ubuntu-2604')->exists())->toBeFalse();
expect(Application::query()->where('uuid', $limaServer['uuid_prefix'].'railpack-nextjs-ssr')->exists())->toBeFalse();
}
});
it('is idempotent when run multiple times', function () {
@@ -137,5 +247,14 @@ it('is idempotent when run multiple times', function () {
->first();
expect($project)->not->toBeNull();
expect($project->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
expect($project->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()) * limaServers()->count());
foreach (limaServers() as $limaServer) {
$limaEnvironment = $project->environments()
->where('uuid', $limaServer['environment_uuid'])
->first();
expect($limaEnvironment)->not->toBeNull();
expect($limaEnvironment->applications()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
}
});
@@ -180,7 +180,7 @@ test('convertContainerEnvsToArray', function () {
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"RAY_ENABLED=true=123",
"FEATURE_FLAG_WITH_EQUALS=true=123",
"REGISTRY_URL=docker.io",
"SUBSCRIPTION_PROVIDER=stripe",
"TELESCOPE_ENABLED=false",
@@ -295,5 +295,5 @@ test('convertContainerEnvsToArray', function () {
}
]';
$envs = format_docker_envs_to_json($data);
$this->assertEquals('true=123', $envs->get('RAY_ENABLED'));
$this->assertEquals('true=123', $envs->get('FEATURE_FLAG_WITH_EQUALS'));
});
@@ -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'",
]);
});
+37 -17
View File
@@ -56,7 +56,7 @@ it('filters production environment variables by key case-insensitively', functio
->toBe(['API_KEY']);
});
it('treats production environment variable search wildcards literally', function () {
it('treats production environment variable search underscore wildcards literally', function () {
$application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
@@ -75,23 +75,11 @@ it('treats production environment variable search wildcards literally', function
'resourceable_id' => $application->id,
]);
EnvironmentVariable::create([
'key' => 'PERCENT%KEY',
'value' => 'percent-secret',
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
$component = Livewire::test(All::class, ['resource' => $application])
->set('search', 'api_key');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['API_KEY']);
$component->set('search', '%KEY');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['PERCENT%KEY']);
});
it('filters preview environment variables by key case-insensitively', function () {
@@ -142,6 +130,34 @@ YAML,
->toBe(['API_TOKEN']);
});
it('searches service environment variables without requiring preview variables', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
]);
EnvironmentVariable::create([
'key' => 'API_KEY',
'value' => 'secret',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
]);
EnvironmentVariable::create([
'key' => 'DATABASE_URL',
'value' => 'postgres://example',
'resourceable_type' => Service::class,
'resourceable_id' => $service->id,
]);
$component = Livewire::test(All::class, ['resource' => $service])
->set('search', 'api')
->assertSee('Production Environment Variables')
->assertDontSee('Preview Deployments Environment Variables');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['API_KEY']);
});
it('does not show the empty production message when search only matches hardcoded variables', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
@@ -155,11 +171,13 @@ services:
YAML,
]);
Livewire::test(All::class, ['resource' => $service])
$component = Livewire::test(All::class, ['resource' => $service])
->set('search', 'api')
->assertSee('Production Environment Variables')
->assertSee('API_TOKEN')
->assertDontSee('No environment variables found.');
expect($component->instance()->hardcodedEnvironmentVariables->pluck('key')->all())
->toBe(['API_TOKEN']);
});
it('keeps developer view unfiltered after searching', function () {
@@ -242,10 +260,12 @@ it('hides the preview section when search filters out all preview variables', fu
'resourceable_id' => $application->id,
]);
Livewire::test(All::class, ['resource' => $application])
$component = Livewire::test(All::class, ['resource' => $application])
->set('search', 'api')
->assertSee('Production Environment Variables')
->assertSee('API_KEY')
->assertDontSee('Preview Deployments Environment Variables')
->assertDontSee('PREVIEW_TOKEN');
expect($component->instance()->environmentVariables->pluck('key')->all())
->toBe(['API_KEY']);
});
@@ -0,0 +1,45 @@
<?php
use App\Models\Application;
use App\Models\EnvironmentVariable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('stores null environment variable values as database null', function () {
$env = EnvironmentVariable::create([
'key' => 'NULL_VALUE',
'value' => null,
'resourceable_type' => Application::class,
'resourceable_id' => 1,
]);
$rawValue = DB::table('environment_variables')
->where('id', $env->id)
->value('value');
expect($rawValue)->toBeNull();
$env->refresh();
expect($env->value)->toBeNull();
});
it('preserves intentional empty string environment variable values', function () {
$env = EnvironmentVariable::create([
'key' => 'EMPTY_STRING_VALUE',
'value' => '',
'resourceable_type' => Application::class,
'resourceable_id' => 1,
]);
$rawValue = DB::table('environment_variables')
->where('id', $env->id)
->value('value');
expect($rawValue)->not->toBeNull()
->and(decrypt($rawValue))->toBe('');
$env->refresh();
expect($env->value)->toBe('');
});
@@ -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',
]);
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
use App\Jobs\ServerStorageSaveJob;
use App\Livewire\Project\Service\Storage;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalFileVolume;
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\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
Bus::fake();
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']);
$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()
?? Environment::factory()->create(['project_id' => $this->project->id]);
$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->actingAs($this->admin);
session(['currentTeam' => $this->team]);
});
test('livewire file storage rejects parent segments and does not create a local file volume', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('file_storage_path', '/../../../../../../etc/example.conf')
->set('file_storage_content', 'owned')
->call('submitFileStorage')
->assertDispatched('error');
expect(LocalFileVolume::query()->count())->toBe(0);
});
test('file mount modal shows the calculated host file path above the destination input', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->assertSeeText('This file will be created on the host, then mounted into the container.')
->assertSeeText('Host file path')
->assertSeeText($this->application->workdir().'/')
->set('file_storage_path', '/etc/nginx/nginx.conf')
->assertSeeText($this->application->workdir().'/etc/nginx/nginx.conf')
->assertDontSeeText('Actual file mounted from the host system to the container.');
});
test('livewire file storage stores safe file mounts under the application configuration root', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('file_storage_path', '/etc/nginx/nginx.conf')
->set('file_storage_content', 'server {}')
->call('submitFileStorage')
->assertDispatched('success');
$volume = LocalFileVolume::query()->sole();
expect($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
->and($volume->fs_path)->toBe(application_configuration_dir().'/'.$this->application->uuid.'/etc/nginx/nginx.conf')
->and($volume->is_directory)->toBeFalse();
});
test('livewire host file storage stores an existing host file path without managed content', function () {
Livewire::test(Storage::class, ['resource' => $this->application])
->set('host_file_storage_source', '/etc/nginx/nginx.conf')
->set('host_file_storage_destination', '/etc/nginx/nginx.conf')
->call('submitHostFileStorage')
->assertDispatched('success');
$volume = LocalFileVolume::query()->sole();
expect($volume->fs_path)->toBe('/etc/nginx/nginx.conf')
->and($volume->mount_path)->toBe('/etc/nginx/nginx.conf')
->and($volume->content)->toBeNull()
->and($volume->is_host_file)->toBeTrue()
->and($volume->is_directory)->toBeFalse();
Bus::assertNotDispatched(ServerStorageSaveJob::class);
});
@@ -0,0 +1,171 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\LocalFileVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
uses(RefreshDatabase::class);
const TWO_FILE_COMPOSE = <<<'YAML'
services:
app:
image: nginx:latest
volumes:
- ./wg_config.conf:/app/ps/wg0.conf
- ./override_trays.json:/app/ps/override_tray.json
YAML;
const DATA_DIR_COMPOSE = <<<'YAML'
services:
app:
image: nginx:latest
volumes:
- ./data:/app/data
YAML;
beforeEach(function () {
Bus::fake();
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = StandaloneDocker::where('server_id', $server->id)->first();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->destination = $destination;
$this->environment = $environment;
});
function makeComposeApplication(string $dockerComposeRaw): Application
{
return Application::factory()->create([
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
'build_pack' => 'dockercompose',
'docker_compose_raw' => $dockerComposeRaw,
]);
}
/**
* @return array{0: Service, 1: ServiceApplication}
*/
function makeComposeService(string $dockerComposeRaw): array
{
$service = Service::factory()->create([
'environment_id' => test()->environment->id,
'server_id' => test()->destination->server_id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
'docker_compose_raw' => $dockerComposeRaw,
]);
$serviceApplication = ServiceApplication::create([
'name' => 'app',
'service_id' => $service->id,
]);
return [$service, $serviceApplication];
}
function seedFileVolume($resource, string $baseDir, string $fileName, string $mountPath, string $content): void
{
LocalFileVolume::create([
'fs_path' => "{$baseDir}/{$fileName}",
'mount_path' => $mountPath,
'content' => $content,
'is_directory' => false,
'resource_id' => $resource->id,
'resource_type' => $resource->getMorphClass(),
]);
}
it('preserves existing application file volume content when reparsing compose bind mounts', function () {
$application = makeComposeApplication(TWO_FILE_COMPOSE);
$baseDir = application_configuration_dir()."/{$application->uuid}";
seedFileVolume($application, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf');
seedFileVolume($application, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', '0');
applicationParser($application);
$fileVolume = $application->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first();
expect($fileVolume->content)->toBe('0')
->and($fileVolume->is_directory)->toBeFalse();
});
it('keeps existing application file volumes as files when content is empty', function () {
$application = makeComposeApplication(TWO_FILE_COMPOSE);
$baseDir = application_configuration_dir()."/{$application->uuid}";
seedFileVolume($application, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf');
seedFileVolume($application, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', '');
applicationParser($application);
$fileVolume = $application->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first();
expect($fileVolume->content)->toBe('')
->and($fileVolume->is_directory)->toBeFalse();
});
it('defaults new application bind mounts to directories', function () {
$application = makeComposeApplication(DATA_DIR_COMPOSE);
applicationParser($application);
$fileVolume = $application->fileStorages()->where('mount_path', '/app/data')->first();
expect($fileVolume->content)->toBeNull()
->and($fileVolume->is_directory)->toBeTrue();
});
it('preserves existing service file volume content when reparsing compose bind mounts', function () {
[$service, $serviceApplication] = makeComposeService(TWO_FILE_COMPOSE);
$baseDir = service_configuration_dir()."/{$service->uuid}";
seedFileVolume($serviceApplication, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf');
seedFileVolume($serviceApplication, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', '0');
serviceParser($service);
$fileVolume = $serviceApplication->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first();
expect($fileVolume->content)->toBe('0')
->and($fileVolume->is_directory)->toBeFalse();
});
it('keeps existing service file volumes as files when content is empty', function () {
[$service, $serviceApplication] = makeComposeService(TWO_FILE_COMPOSE);
$baseDir = service_configuration_dir()."/{$service->uuid}";
seedFileVolume($serviceApplication, $baseDir, 'wg_config.conf', '/app/ps/wg0.conf', 'test-conf');
seedFileVolume($serviceApplication, $baseDir, 'override_trays.json', '/app/ps/override_tray.json', '');
serviceParser($service);
$fileVolume = $serviceApplication->fileStorages()->where('mount_path', '/app/ps/override_tray.json')->first();
expect($fileVolume->content)->toBe('')
->and($fileVolume->is_directory)->toBeFalse();
});
it('defaults new service bind mounts to directories', function () {
[$service, $serviceApplication] = makeComposeService(DATA_DIR_COMPOSE);
serviceParser($service);
$fileVolume = $serviceApplication->fileStorages()->where('mount_path', '/app/data')->first();
expect($fileVolume->content)->toBeNull()
->and($fileVolume->is_directory)->toBeTrue();
});
-222
View File
@@ -1,222 +0,0 @@
<?php
use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
// Create a team with owner
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
// Create an API token for the user
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
$this->bearerToken = $this->token->plainTextToken;
// Create a private key for the team
$this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => 'test-private-key-content',
'team_id' => $this->team->id,
]);
});
describe('GET /api/v1/github-apps', function () {
test('returns 401 when not authenticated', function () {
$response = $this->getJson('/api/v1/github-apps');
$response->assertStatus(401);
});
test('returns empty array when no github apps exist', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$response->assertJson([]);
});
test('returns team github apps', function () {
// Create a GitHub app for the team
$githubApp = GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment([
'name' => 'Test GitHub App',
'app_id' => 12345,
]);
});
test('does not return sensitive data', function () {
// Create a GitHub app
GithubApp::create([
'name' => 'Test GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'secret-should-be-hidden',
'webhook_secret' => 'webhook-secret-should-be-hidden',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$json = $response->json();
// Ensure sensitive data is not present
expect($json[0])->not->toHaveKey('client_secret');
expect($json[0])->not->toHaveKey('webhook_secret');
});
test('returns system-wide github apps', function () {
// Create a system-wide GitHub app
$systemApp = GithubApp::create([
'name' => 'System GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 11111,
'installation_id' => 22222,
'client_id' => 'system-client-id',
'client_secret' => 'system-secret',
'webhook_secret' => 'system-webhook',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => true,
]);
// Create another team and user
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherTeam->members()->attach($otherUser->id, ['role' => 'owner']);
$otherToken = $otherUser->createToken('other-token', ['*'], $otherTeam->id);
// System-wide apps should be visible to other teams
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$otherToken->plainTextToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$response->assertJsonFragment([
'name' => 'System GitHub App',
'is_system_wide' => true,
]);
});
test('does not return other teams github apps', function () {
// Create a GitHub app for this team
GithubApp::create([
'name' => 'Team 1 App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 11111,
'installation_id' => 22222,
'client_id' => 'team1-client-id',
'client_secret' => 'team1-secret',
'webhook_secret' => 'team1-webhook',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
// Create another team with a GitHub app
$otherTeam = Team::factory()->create();
$otherPrivateKey = PrivateKey::create([
'name' => 'Other Key',
'private_key' => 'other-key',
'team_id' => $otherTeam->id,
]);
GithubApp::create([
'name' => 'Team 2 App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 33333,
'installation_id' => 44444,
'client_id' => 'team2-client-id',
'client_secret' => 'team2-secret',
'webhook_secret' => 'team2-webhook',
'private_key_id' => $otherPrivateKey->id,
'team_id' => $otherTeam->id,
'is_system_wide' => false,
]);
// Request from first team should only see their app
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$response->assertJsonCount(1);
$response->assertJsonFragment(['name' => 'Team 1 App']);
$response->assertJsonMissing(['name' => 'Team 2 App']);
});
test('returns correct response structure', function () {
GithubApp::create([
'name' => 'Test App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'client-id',
'client_secret' => 'secret',
'webhook_secret' => 'webhook',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/github-apps');
$response->assertStatus(200);
$response->assertJsonStructure([
[
'id',
'uuid',
'name',
'api_url',
'html_url',
'custom_user',
'custom_port',
'app_id',
'installation_id',
'client_id',
'private_key_id',
'team_id',
'type',
],
]);
});
});
@@ -1,6 +1,6 @@
<?php
namespace Tests\Feature;
namespace Tests\Feature\Helpers;
use Tests\TestCase;
@@ -9,6 +9,7 @@ use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Once;
use Visus\Cuid2\Cuid2;
@@ -77,6 +78,55 @@ it('accepts a valid magic link invitation only once and rotates the temporary pa
$this->assertGuest();
});
it('accepts a magic link when opened from a different public origin', function () {
[$team, $user, $password, $token] = createInvitationLinkFixture();
$this->get('https://coolify.example.com/auth/link?token='.urlencode($token))
->assertRedirect(route('dashboard'));
$this->assertAuthenticatedAs($user);
$this->assertDatabaseMissing('team_invitations', ['email' => $user->email]);
expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue();
$user->refresh();
expect(Hash::check($password, $user->password))->toBeFalse();
});
it('keeps the invited user authenticated after rotating the temporary password with database sessions', function () {
$this->withMiddleware([CheckForcePasswordReset::class, DecideWhatToDoWithUser::class]);
Config::set('session.driver', 'database');
[$team, $user, $password, $token] = createInvitationLinkFixture();
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('dashboard'));
expect(DB::table('sessions')->where('user_id', $user->id)->exists())->toBeTrue();
$this->get(route('dashboard'))
->assertRedirect(route('auth.force-password-reset'));
$this->assertAuthenticatedAs($user);
expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue();
$user->refresh();
expect(Hash::check($password, $user->password))->toBeFalse();
});
it('rejects a magic link when the stored invitation token differs', function () {
[, $user, , $token, $invitation] = createInvitationLinkFixture();
$differentToken = Crypt::encryptString("{$user->email}@@@{$invitation->uuid}@@@different-password");
$invitation->forceFill([
'link' => route('auth.link', ['token' => $differentToken]),
])->save();
$this->get(route('auth.link', ['token' => $token]))
->assertRedirect(route('login'));
$this->assertGuest();
});
it('rejects a magic link when the invitation was revoked', function () {
[, $user, , $token, $invitation] = createInvitationLinkFixture();
$invitation->delete();
@@ -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');
});
@@ -262,3 +262,11 @@ test('s3 storage has scheduled backups relationship', function () {
expect($s3->scheduledBackups()->count())->toBe(1);
});
test('database backup job escapes the S3 copy destination argument', function () {
$source = file_get_contents(app_path('Jobs/DatabaseBackupJob.php'));
expect($source)->toContain('$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");')
->and($source)->toContain('mc cp {$escapedBackupLocation} {$escapedS3Destination}')
->and($source)->not->toContain('mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/');
});
@@ -2,6 +2,40 @@
use App\Livewire\Project\Application\General;
it('uses safe domain validation rules in the application general form', function () {
$component = new General;
$method = new ReflectionMethod($component, 'rules');
$rules = $method->invoke($component);
$validator = validator([
'fqdn' => 'http://$(whoami).example.com',
], [
'fqdn' => $rules['fqdn'],
]);
expect($validator->fails())->toBeTrue()
->and($validator->errors()->has('fqdn'))->toBeTrue();
});
it('uses safe docker compose service domain validation rules in the application general form', function () {
$component = new General;
$method = new ReflectionMethod($component, 'rules');
$rules = $method->invoke($component);
$validator = validator([
'parsedServiceDomains' => [
'app' => [
'domain' => 'http://$(whoami).example.com',
],
],
], [
'parsedServiceDomains.*.domain' => $rules['parsedServiceDomains.*.domain'],
]);
expect($validator->fails())->toBeTrue()
->and($validator->errors()->has('parsedServiceDomains.app.domain'))->toBeTrue();
});
it('uses safe docker registry image validation rules in the application general form', function () {
$component = new General;
$method = new ReflectionMethod($component, 'rules');
+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}");
}
});
+70
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();
@@ -72,6 +90,16 @@ test('MCP endpoint returns 404 when the instance setting is disabled', function
$response->assertStatus(404);
});
test('MCP endpoint returns 403 when the token team has MCP disabled', function () {
$this->team->update(['is_mcp_server_enabled' => false]);
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
$response = mcpListTools($token);
$response->assertForbidden();
$response->assertJson(['message' => 'MCP server is disabled for this team.']);
});
test('MCP endpoint rejects unauthenticated requests', function () {
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
$response->assertStatus(401);
@@ -193,6 +221,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.');
});
+31
View File
@@ -0,0 +1,31 @@
<?php
use App\Models\Project;
use Database\Seeders\PrivateKeySeeder;
use Database\Seeders\ProjectSeeder;
use Database\Seeders\TeamSeeder;
use Database\Seeders\UserSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('seeds the first project with lima environments', function () {
$this->seed([
UserSeeder::class,
TeamSeeder::class,
PrivateKeySeeder::class,
ProjectSeeder::class,
]);
$project = Project::query()
->where('uuid', 'project')
->first();
expect($project)
->not->toBeNull()
->and($project->name)->toBe('My first project')
->and($project->environments()->pluck('uuid', 'name')->all())->toBe([
'ubuntu24' => 'ubuntu24',
'ubuntu26' => 'ubuntu26',
]);
});
+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();
@@ -130,6 +130,69 @@ describe('deployment job path field validation', function () {
});
describe('API validation rules for path fields', function () {
test('domains validation rejects command injection payloads', function (string $payload) {
$rules = sharedDataApplications();
$validator = validator(
['domains' => $payload],
['domains' => $rules['domains']]
);
expect($validator->fails())->toBeTrue();
})->with([
'host command substitution' => 'http://$(whoami).example.com',
'path command substitution' => 'http://example.com/$(whoami)',
'query command substitution' => 'http://example.com/path?next=$(id)',
'host backtick substitution' => 'http://`whoami`.example.com',
'path backtick substitution' => 'http://example.com/`whoami`',
'semicolon command separator' => 'http://example.com/path;id',
'newline injection' => "http://example.com\nwhoami",
'carriage return injection' => "http://example.com\rwhoami",
'pipe injection' => 'http://example.com/path|id',
]);
test('domains validation rejects non http schemes', function () {
$rules = sharedDataApplications();
$validator = validator(
['domains' => 'ftp://example.com'],
['domains' => $rules['domains']]
);
expect($validator->fails())->toBeTrue();
});
test('domains validation allows comma separated http and https urls', function () {
$rules = sharedDataApplications();
$validator = validator(
['domains' => 'https://app.example.com,http://api.example.com/path'],
['domains' => $rules['domains']]
);
expect($validator->fails())->toBeFalse();
});
test('docker compose service domains validation rejects command injection payloads', function () {
$rules = [
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
];
$validator = validator(
[
'docker_compose_domains' => [
['name' => 'app', 'domain' => 'https://app.example.com/$(whoami)'],
],
],
$rules
);
expect($validator->fails())->toBeTrue();
});
test('git_branch validation rejects shell metacharacters', function (string $branch) {
$rules = sharedDataApplications();
@@ -276,7 +339,45 @@ describe('deployment git command escaping', function () {
expect($coolifyVariables->getValue($instance))
->toContain("COOLIFY_BRANCH='main`id`' ")
->toContain('COOLIFY_RESOURCE_UUID=app-uuid ');
->toContain("COOLIFY_RESOURCE_UUID='app-uuid' ");
});
test('coolify url and fqdn shell assignments are quoted', function () {
$job = new ReflectionClass(ApplicationDeploymentJob::class);
$instance = $job->newInstanceWithoutConstructor();
$application = new Application;
$application->uuid = 'app-uuid';
$application->git_branch = 'main';
$application->fqdn = 'https://app.example.com/path';
$application->compose_parsing_version = '3';
$settings = new ApplicationSetting;
$settings->include_source_commit_in_build = true;
$application->setRelation('settings', $settings);
foreach ([
'application' => $application,
'commit' => 'HEAD$(id)',
'pull_request_id' => 0,
] as $property => $value) {
$reflectionProperty = $job->getProperty($property);
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($instance, $value);
}
$method = $job->getMethod('set_coolify_variables');
$method->setAccessible(true);
$method->invoke($instance);
$coolifyVariables = $job->getProperty('coolify_variables');
$coolifyVariables->setAccessible(true);
expect($coolifyVariables->getValue($instance))
->toContain("SOURCE_COMMIT='HEAD$(id)' ")
->toContain("COOLIFY_URL='https://app.example.com/path' ")
->toContain("COOLIFY_FQDN='app.example.com' ")
->toContain("COOLIFY_RESOURCE_UUID='app-uuid' ");
});
});
@@ -44,7 +44,7 @@ function authenticateGithubSetupCallbackTest(object $test): void
session(['currentTeam' => $test->team]);
}
function fakeGithubManifestConversion(): void
function fakeGithubManifestConversion(string $apiUrl = 'https://api.github.com'): void
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
@@ -54,7 +54,7 @@ function fakeGithubManifestConversion(): void
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/app-manifests/*/conversions' => Http::response([
"{$apiUrl}/app-manifests/*/conversions" => Http::response([
'id' => 987654,
'slug' => 'attacker-controlled-app',
'client_id' => 'new-client-id',
@@ -86,14 +86,14 @@ function configureGithubAppCredentials(GithubApp $githubApp): void
])->save();
}
function fakeGithubInstallationVerification(int $appId): void
function fakeGithubInstallationVerification(int $appId, string $apiUrl = 'https://api.github.com'): void
{
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
"{$apiUrl}/zen" => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.github.com/app/installations/*' => Http::response([
"{$apiUrl}/app/installations/*" => Http::response([
'id' => 555,
'app_id' => $appId,
], 200),
@@ -183,6 +183,21 @@ it('configures an unbound github app with a valid one-time manifest state', func
->and($this->githubApp->private_key_id)->not->toBeNull();
});
it('converts ghe dot com app manifests through the data residency api host', function () {
authenticateGithubSetupCallbackTest($this);
$this->githubApp->forceFill([
'api_url' => 'https://api.octocorp.ghe.com',
'html_url' => 'https://octocorp.ghe.com',
])->save();
fakeGithubManifestConversion('https://api.octocorp.ghe.com');
cacheGithubAppSetupState('valid-state', 'manifest', $this->githubApp);
$this->get('/webhooks/source/github/redirect?state=valid-state&code=real-code')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app-manifests/real-code/conversions');
});
it('rejects replayed github app manifest states', function () {
authenticateGithubSetupCallbackTest($this);
fakeGithubManifestConversion();
@@ -333,6 +348,23 @@ it('sets installation id when github confirms it belongs to the app', function (
expect($this->githubApp->installation_id)->toBe(123456);
});
it('verifies ghe dot com installations through the data residency api host', function () {
authenticateGithubSetupCallbackTest($this);
$this->githubApp->forceFill([
'api_url' => 'https://api.octocorp.ghe.com',
'html_url' => 'https://octocorp.ghe.com',
])->save();
configureGithubAppCredentials($this->githubApp);
fakeGithubInstallationVerification($this->githubApp->app_id, 'https://api.octocorp.ghe.com');
cacheGithubAppSetupState('valid-install-state', 'install', $this->githubApp);
$this->get('/webhooks/source/github/install?state=valid-install-state&setup_action=install&installation_id=123456')
->assertRedirect(route('source.github.show', ['github_app_uuid' => $this->githubApp->uuid]));
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/zen');
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app/installations/123456');
});
it('rejects replayed github app install states', function () {
authenticateGithubSetupCallbackTest($this);
configureGithubAppCredentials($this->githubApp);
@@ -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();
Loaded 100 of 693 files, more files were not shown because too many files have changed in this diff. Show more