mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 14:16:28 +00:00
Merge remote-tracking branch 'origin/next' into 10633-invitation-link-redirect
This commit is contained in:
commit
eb56287b57
462 files changed
+18688
-4883
No files matched your search
File renamed without changes.
+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);
|
||||
});
|
||||
});
|
||||
File renamed without changes.
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
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;
|
||||
@@ -42,6 +43,7 @@ it('can seed the railpack examples directly on a clean development database', fu
|
||||
expect(Server::query()->find(0))->not->toBeNull();
|
||||
expect(StandaloneDocker::query()->find(0))->not->toBeNull();
|
||||
expect(GithubApp::query()->find(0))->not->toBeNull();
|
||||
expect(GitlabApp::query()->find(1))->not->toBeNull();
|
||||
expect(Project::query()->where('uuid', DevelopmentRailpackExamplesSeeder::PROJECT_UUID)->exists())->toBeTrue();
|
||||
expect(Application::query()->count())->toBe(count(DevelopmentRailpackExamplesSeeder::examples()));
|
||||
});
|
||||
@@ -66,9 +68,10 @@ it('seeds the railpack examples in development mode', function () {
|
||||
|
||||
expect($applications)->toHaveCount(count(DevelopmentRailpackExamplesSeeder::examples()));
|
||||
expect($applications->every(fn (Application $application) => $application->build_pack === 'railpack'))->toBeTrue();
|
||||
expect($applications->every(fn (Application $application) => $application->git_repository === DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY))->toBeTrue();
|
||||
|
||||
$examples = collect(DevelopmentRailpackExamplesSeeder::examples())->keyBy('uuid');
|
||||
expect($applications->every(
|
||||
fn (Application $application) => $application->git_repository === ($examples->get($application->uuid)['git_repository'] ?? DevelopmentRailpackExamplesSeeder::GIT_REPOSITORY)
|
||||
))->toBeTrue();
|
||||
expect($applications->every(
|
||||
fn (Application $application) => $application->git_branch === ($examples->get($application->uuid)['git_branch'] ?? DevelopmentRailpackExamplesSeeder::GIT_BRANCH)
|
||||
))->toBeTrue();
|
||||
@@ -79,6 +82,9 @@ it('seeds the railpack examples in development mode', function () {
|
||||
$pythonFlask = $applications->firstWhere('uuid', 'railpack-python-flask');
|
||||
$goGin = $applications->firstWhere('uuid', 'railpack-go-gin');
|
||||
$rust = $applications->firstWhere('uuid', 'railpack-rust');
|
||||
$githubDeployKey = $applications->firstWhere('uuid', 'railpack-github-deploy-key');
|
||||
$gitlabDeployKey = $applications->firstWhere('uuid', 'railpack-gitlab-deploy-key');
|
||||
$gitlabPublic = $applications->firstWhere('uuid', 'railpack-gitlab-public-example');
|
||||
|
||||
expect($nestjs)
|
||||
->not->toBeNull()
|
||||
@@ -113,6 +119,33 @@ it('seeds the railpack examples in development mode', function () {
|
||||
expect($rust)
|
||||
->not->toBeNull()
|
||||
->and($rust->ports_exposes)->toBe('8000');
|
||||
|
||||
expect($githubDeployKey)
|
||||
->not->toBeNull()
|
||||
->and($githubDeployKey->git_repository)->toBe('git@github.com:coollabsio/coolify-examples-deploy-key.git')
|
||||
->and($githubDeployKey->git_branch)->toBe('main')
|
||||
->and($githubDeployKey->build_pack)->toBe('railpack')
|
||||
->and($githubDeployKey->private_key_id)->toBe(1)
|
||||
->and($githubDeployKey->source_type)->toBe(GithubApp::class)
|
||||
->and($githubDeployKey->source_id)->toBe(0);
|
||||
|
||||
expect($gitlabDeployKey)
|
||||
->not->toBeNull()
|
||||
->and($gitlabDeployKey->git_repository)->toBe('git@gitlab.com:coollabsio/php-example.git')
|
||||
->and($gitlabDeployKey->git_branch)->toBe('main')
|
||||
->and($gitlabDeployKey->build_pack)->toBe('railpack')
|
||||
->and($gitlabDeployKey->private_key_id)->toBe(1)
|
||||
->and($gitlabDeployKey->source_type)->toBe(GitlabApp::class)
|
||||
->and($gitlabDeployKey->source_id)->toBe(1);
|
||||
|
||||
expect($gitlabPublic)
|
||||
->not->toBeNull()
|
||||
->and($gitlabPublic->git_repository)->toBe('https://gitlab.com/andrasbacsai/coolify-examples.git')
|
||||
->and($gitlabPublic->base_directory)->toBe('/astro/static')
|
||||
->and($gitlabPublic->publish_directory)->toBe('/dist')
|
||||
->and($gitlabPublic->build_pack)->toBe('railpack')
|
||||
->and($gitlabPublic->source_type)->toBe(GitlabApp::class)
|
||||
->and($gitlabPublic->settings->is_static)->toBeTrue();
|
||||
});
|
||||
|
||||
it('skips the railpack examples outside development mode', function () {
|
||||
|
||||
File renamed without changes.
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']);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
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;
|
||||
|
||||
+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');
|
||||
|
||||
@@ -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,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'"],
|
||||
],
|
||||
]);
|
||||
@@ -48,19 +48,7 @@ it('cleans up a closed pull request preview when pull request comment cleanup fa
|
||||
'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
|
||||
$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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -92,11 +92,11 @@ it('renders responsive checkbox classes on the application configuration page',
|
||||
$response->assertSee('<optgroup label="Application">', false);
|
||||
$response->assertSee('<optgroup label="Configuration">', false);
|
||||
$response->assertSee('<optgroup label="Links">', false);
|
||||
$response->assertSee('<optgroup label="Actions">', 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('value="action:force-deploy"', false);
|
||||
$response->assertSee('application-mobile-stop-trigger');
|
||||
$response->assertSee('application-mobile-deploy-trigger');
|
||||
$response->assertSee('application-mobile-restart-trigger');
|
||||
|
||||
@@ -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' ");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -87,3 +87,32 @@ it('renders the service templates last updated hint placeholder', function () {
|
||||
$view->assertSee('serviceTemplatesLastUpdated');
|
||||
$view->assertSee('service.templateLastUpdated');
|
||||
});
|
||||
|
||||
it('keeps service template keys for service selection and docs links', function () {
|
||||
$services = collect((new Select)->loadServices()['services']);
|
||||
$denoKv = $services->firstWhere('id', 'denoKV');
|
||||
|
||||
expect($denoKv)
|
||||
->not->toBeNull()
|
||||
->and($denoKv['docsSlug'])->toBe('denokv');
|
||||
|
||||
View::share('errors', new ViewErrorBag);
|
||||
|
||||
$view = $this->view('livewire.project.new.select', [
|
||||
'current_step' => 'type',
|
||||
'environments' => collect(),
|
||||
]);
|
||||
|
||||
$view->assertSee("setType('one-click-service-' + service.id)", false);
|
||||
$view->assertSee('service.docsSlug || this.extractBaseServiceName(service.name)', false);
|
||||
});
|
||||
|
||||
it('preserves one click service key casing when selecting a service template', function () {
|
||||
$component = new Select;
|
||||
$component->servers = collect();
|
||||
$component->allServers = collect();
|
||||
|
||||
$component->setType('one-click-service-denoKV');
|
||||
|
||||
expect($component->type)->toBe('one-click-service-denoKV');
|
||||
});
|
||||
@@ -26,7 +26,7 @@ test('non-admin user is redirected from settings updates page', function () {
|
||||
test('instance admin can access settings updates page', function () {
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
Server::factory()->create(['id' => 0, 'team_id' => $rootTeam->id]);
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
@@ -39,3 +39,24 @@ test('instance admin can access settings updates page', function () {
|
||||
->assertOk()
|
||||
->assertNoRedirect();
|
||||
});
|
||||
|
||||
test('instance admin cannot save an invalid docker registry url', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
$settings = InstanceSettings::forceCreate(['id' => 0]);
|
||||
Once::flush();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$rootTeam->members()->attach($user->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||
|
||||
Livewire::test(Updates::class)
|
||||
->set('docker_registry_url', 'docker.io; touch /tmp/pwned')
|
||||
->call('instantSave')
|
||||
->assertHasErrors(['docker_registry_url' => 'in']);
|
||||
|
||||
expect($settings->fresh()->docker_registry_url)->toBe('docker.io');
|
||||
});
|
||||
@@ -2,18 +2,16 @@
|
||||
|
||||
use App\Livewire\SettingsDropdown;
|
||||
|
||||
it('keeps changelog and the theme switcher in the sidebar without the old preferences trigger', function () {
|
||||
it('keeps changelog in the sidebar without a dedicated theme switcher', function () {
|
||||
$navbarView = file_get_contents(resource_path('views/components/navbar.blade.php'));
|
||||
|
||||
expect($navbarView)
|
||||
->toContain('<livewire:settings-dropdown trigger="changelog-sidebar" />')
|
||||
->not->toContain('<livewire:settings-dropdown />')
|
||||
->toContain('aria-label="Theme switcher"')
|
||||
->toContain('aria-label="Use light theme"')
|
||||
->toContain('aria-label="Use system theme"')
|
||||
->toContain('aria-label="Use dark theme"')
|
||||
->toContain('cycleTheme()')
|
||||
->toContain("const themes = ['light', 'system', 'dark'];")
|
||||
->not->toContain('<span class="text-left menu-item-label">Theme</span>')
|
||||
->not->toContain('Click to change theme.')
|
||||
->not->toContain('cycleTheme()')
|
||||
->toContain('this.queryTheme();')
|
||||
->toContain('pl-2 pr-3 items-start gap-3')
|
||||
->toContain('class="flex min-w-0 flex-1 flex-col"')
|
||||
->toContain('class="min-w-0 flex-1"')
|
||||
@@ -30,6 +28,7 @@ it('keeps changelog and appearance options out of the preferences dropdown', fun
|
||||
->toContain('wire:click="openWhatsNewModal"')
|
||||
->toContain('class="relative text-left menu-item"')
|
||||
->toContain('class="text-left menu-item-label"')
|
||||
->toContain('class="absolute right-2 top-1/2 -translate-y-1/2 bg-error')
|
||||
->toContain("What's New</span>")
|
||||
->toContain('M9.813 15.904 9 18.75')
|
||||
->not->toContain('<span>Changelog</span>')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ServerStorageSaveJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
@@ -7,6 +8,8 @@ use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
@@ -18,8 +21,9 @@ use Illuminate\Support\Str;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.store' => 'array', 'cache.default' => 'array']);
|
||||
Bus::fake();
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
@@ -61,6 +65,24 @@ function createTestDatabase($context): StandalonePostgresql
|
||||
]);
|
||||
}
|
||||
|
||||
function createTestServiceApplication($context): array
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => $context->environment->id,
|
||||
'destination_id' => $context->destination->id,
|
||||
'destination_type' => $context->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$serviceApplication = ServiceApplication::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'test-service-app',
|
||||
'service_id' => $service->id,
|
||||
'image' => 'nginx:alpine',
|
||||
]);
|
||||
|
||||
return [$service, $serviceApplication];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Application Storage Endpoints
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -140,6 +162,56 @@ describe('POST /api/v1/applications/{uuid}/storages', function () {
|
||||
expect($vol)->not->toBeNull();
|
||||
expect($vol->mount_path)->toBe('/app/config.json');
|
||||
expect($vol->is_directory)->toBeFalse();
|
||||
expect($vol->fs_path)->toBe(application_configuration_dir().'/'.$app->uuid.'/app/config.json');
|
||||
});
|
||||
|
||||
test('creates bind only host file storage for application', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'is_host_file' => true,
|
||||
'fs_path' => '/etc/nginx/nginx.conf',
|
||||
'mount_path' => '/etc/nginx/nginx.conf',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$vol = LocalFileVolume::where('resource_id', $app->id)
|
||||
->where('resource_type', get_class($app))
|
||||
->first();
|
||||
|
||||
expect($vol)->not->toBeNull();
|
||||
expect($vol->fs_path)->toBe('/etc/nginx/nginx.conf');
|
||||
expect($vol->mount_path)->toBe('/etc/nginx/nginx.conf');
|
||||
expect($vol->content)->toBeNull();
|
||||
expect($vol->is_host_file)->toBeTrue();
|
||||
expect($vol->is_directory)->toBeFalse();
|
||||
|
||||
Bus::assertNotDispatched(ServerStorageSaveJob::class);
|
||||
});
|
||||
|
||||
test('rejects file storage paths with parent segments', function () {
|
||||
$app = createTestApplication($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/applications/{$app->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'mount_path' => '/../../../../../../etc/example.conf',
|
||||
'content' => 'owned',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonPath('message', 'Validation failed.');
|
||||
|
||||
expect(LocalFileVolume::where('resource_id', $app->id)
|
||||
->where('resource_type', get_class($app))
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('rejects persistent storage without name', function () {
|
||||
@@ -331,6 +403,92 @@ describe('POST /api/v1/databases/{uuid}/storages', function () {
|
||||
expect($vol)->not->toBeNull();
|
||||
expect($vol->mount_path)->toBe('/extra');
|
||||
});
|
||||
|
||||
test('creates a file storage for a database under the database configuration root', function () {
|
||||
$db = createTestDatabase($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'mount_path' => '/postgres/postgresql.conf',
|
||||
'content' => 'listen_addresses = "*"',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$vol = LocalFileVolume::where('resource_id', $db->id)
|
||||
->where('resource_type', get_class($db))
|
||||
->first();
|
||||
|
||||
expect($vol)->not->toBeNull();
|
||||
expect($vol->fs_path)->toBe(database_configuration_dir().'/'.$db->uuid.'/postgres/postgresql.conf');
|
||||
});
|
||||
|
||||
test('rejects file storage paths with parent segments for a database', function () {
|
||||
$db = createTestDatabase($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$db->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'mount_path' => '/postgres/../../../etc/shadow',
|
||||
'content' => 'owned',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
|
||||
expect(LocalFileVolume::where('resource_id', $db->id)
|
||||
->where('resource_type', get_class($db))
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/services/{uuid}/storages', function () {
|
||||
test('creates a file storage for a service resource under the service configuration root', function () {
|
||||
[$service, $serviceApplication] = createTestServiceApplication($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/services/{$service->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'resource_uuid' => $serviceApplication->uuid,
|
||||
'mount_path' => '/etc/nginx/nginx.conf',
|
||||
'content' => 'server {}',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$vol = LocalFileVolume::where('resource_id', $serviceApplication->id)
|
||||
->where('resource_type', get_class($serviceApplication))
|
||||
->first();
|
||||
|
||||
expect($vol)->not->toBeNull();
|
||||
expect($vol->fs_path)->toBe(service_configuration_dir().'/'.$service->uuid.'/etc/nginx/nginx.conf');
|
||||
});
|
||||
|
||||
test('rejects file storage paths with parent segments for a service resource', function () {
|
||||
[$service, $serviceApplication] = createTestServiceApplication($this);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/services/{$service->uuid}/storages", [
|
||||
'type' => 'file',
|
||||
'resource_uuid' => $serviceApplication->uuid,
|
||||
'mount_path' => '/../../../../../../root/.ssh/authorized_keys',
|
||||
'content' => 'owned',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
|
||||
expect(LocalFileVolume::where('resource_id', $serviceApplication->id)
|
||||
->where('resource_type', get_class($serviceApplication))
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/v1/databases/{uuid}/storages', function () {
|
||||
|
||||
@@ -143,7 +143,51 @@ describe('checkout.session.completed', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('customer.subscription.updated clamps quantity to MAX_SERVER_LIMIT', function () {
|
||||
describe('customer.subscription.updated clamps quantity to subscription bounds', function () {
|
||||
test('quantity below MIN is clamped to 2', function () {
|
||||
Queue::fake();
|
||||
|
||||
Subscription::create([
|
||||
'team_id' => $this->team->id,
|
||||
'stripe_subscription_id' => 'sub_existing',
|
||||
'stripe_customer_id' => 'cus_min_clamp_test',
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
|
||||
$event = [
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'customer' => 'cus_min_clamp_test',
|
||||
'id' => 'sub_existing',
|
||||
'status' => 'active',
|
||||
'metadata' => [
|
||||
'team_id' => $this->team->id,
|
||||
'user_id' => $this->user->id,
|
||||
],
|
||||
'items' => [
|
||||
'data' => [[
|
||||
'subscription' => 'sub_existing',
|
||||
'plan' => ['id' => 'price_dynamic_monthly'],
|
||||
'price' => ['lookup_key' => 'dynamic_monthly'],
|
||||
'quantity' => 1,
|
||||
]],
|
||||
],
|
||||
'cancel_at_period_end' => false,
|
||||
'cancellation_details' => ['feedback' => null, 'comment' => null],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$job = new StripeProcessJob($event);
|
||||
$job->handle();
|
||||
|
||||
$this->team->refresh();
|
||||
expect($this->team->custom_server_limit)->toBe(2);
|
||||
|
||||
Queue::assertPushed(ServerLimitCheckJob::class);
|
||||
});
|
||||
|
||||
test('quantity exceeding MAX is clamped to 100', function () {
|
||||
Queue::fake();
|
||||
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Team\Index;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
|
||||
// The owner's personal team (created by factory)
|
||||
$this->personalTeam = $this->owner->teams()->first();
|
||||
$this->owner->teams()->updateExistingPivot($this->personalTeam->id, ['role' => 'owner']);
|
||||
|
||||
// A second team to delete
|
||||
$this->teamToDelete = Team::create(['name' => 'Deletable Team', 'personal_team' => false]);
|
||||
$this->teamToDelete->members()->attach($this->owner->id, ['role' => 'owner']);
|
||||
});
|
||||
|
||||
test('deleting a team switches session to another team without error', function () {
|
||||
$this->actingAs($this->owner);
|
||||
session(['currentTeam' => $this->teamToDelete]);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->call('delete')
|
||||
->assertRedirect(route('team.index'));
|
||||
|
||||
// Team should be deleted from the database
|
||||
expect(Team::find($this->teamToDelete->id))->toBeNull();
|
||||
|
||||
// Session should now have the personal team
|
||||
$sessionTeam = session('currentTeam');
|
||||
expect($sessionTeam)->not->toBeNull()
|
||||
->and($sessionTeam->id)->toBe($this->personalTeam->id);
|
||||
});
|
||||
|
||||
test('refreshSession clears session when no team exists', function () {
|
||||
$user = User::factory()->create();
|
||||
// Detach all teams so user has none
|
||||
$user->teams()->detach();
|
||||
$this->actingAs($user);
|
||||
session(['currentTeam' => null]);
|
||||
|
||||
// Should not throw when no team can be resolved
|
||||
refreshSession(null);
|
||||
|
||||
expect(session('currentTeam'))->toBeNull();
|
||||
});
|
||||
File renamed without changes.
File renamed without changes.
Loaded 100 of 462 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user