mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 04:14:59 +00:00
Merge remote-tracking branch 'origin/next' into api-application-preview-deployments
This commit is contained in:
commit
00a3738ec7
693 files changed
+27662
-7249
No files matched your search
File renamed without changes.
@@ -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');
|
||||
});
|
||||
});
|
||||
+38
@@ -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']);
|
||||
});
|
||||
});
|
||||
+15
-42
@@ -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,
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+4
-4
@@ -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();
|
||||
+92
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () {
|
||||
File renamed without changes.
@@ -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()');
|
||||
}
|
||||
});
|
||||
File renamed without changes.
File renamed without changes.
+224
@@ -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');
|
||||
});
|
||||
});
|
||||
+20
-4
@@ -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);
|
||||
});
|
||||
File renamed without changes.
@@ -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);
|
||||
});
|
||||
File renamed without changes.
@@ -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);
|
||||
});
|
||||
+3
@@ -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');
|
||||
});
|
||||
File renamed without changes.
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
+2
-2
@@ -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'));
|
||||
});
|
||||
File renamed without changes.
+20
-6
@@ -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'",
|
||||
]);
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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',
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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();
|
||||
|
||||
+10
-6
@@ -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}/');
|
||||
});
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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');
|
||||
|
||||
@@ -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">');
|
||||
});
|
||||
@@ -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}");
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+102
-1
@@ -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();
|
||||
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 693 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user