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

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

No files matched your search

@@ -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);
});
@@ -0,0 +1,103 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
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']);
session(['currentTeam' => $this->team]);
});
describe('POST /api/v1/projects', function () {
test('read-only token cannot create a project', function () {
$token = $this->user->createToken('read-only', ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/projects', [
'name' => 'Test Project',
]);
$response->assertStatus(403);
});
test('write token can create a project', function () {
$token = $this->user->createToken('write-token', ['write']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/projects', [
'name' => 'Test Project',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid']);
});
test('root token can create a project', function () {
$token = $this->user->createToken('root-token', ['root']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/projects', [
'name' => 'Test Project',
]);
$response->assertStatus(201);
$response->assertJsonStructure(['uuid']);
});
});
describe('POST /api/v1/servers', function () {
test('read-only token cannot create a server', function () {
$token = $this->user->createToken('read-only', ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/servers', [
'name' => 'Test Server',
'ip' => '1.2.3.4',
'private_key_uuid' => 'fake-uuid',
]);
$response->assertStatus(403);
});
});
describe('GET /api/v1/servers/{uuid}/validate', function () {
test('read-only token cannot trigger server validation', function () {
$token = $this->user->createToken('read-only', ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson('/api/v1/servers/fake-uuid/validate');
$response->assertStatus(403);
});
});
describe('POST /api/v1/cloud-tokens/{uuid}/validate', function () {
test('read-only token cannot validate cloud provider token', function () {
$token = $this->user->createToken('read-only', ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
'Content-Type' => 'application/json',
])->postJson('/api/v1/cloud-tokens/fake-uuid/validate');
$response->assertStatus(403);
});
});
@@ -0,0 +1,170 @@
<?php
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PersonalAccessToken;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use App\Policies\CloudInitScriptPolicy;
use App\Policies\CloudProviderTokenPolicy;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
DB::table('private_keys')->insert([
'uuid' => (string) Str::uuid(),
'name' => 'Team SSH Key',
'description' => 'Key for testing',
'private_key' => 'test-key-content',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->privateKey = PrivateKey::where('team_id', $this->team->id)->first();
});
// --- Private Key Policy ---
test('admin can create private key', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', PrivateKey::class))->toBeTrue();
});
test('member cannot create private key', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', PrivateKey::class))->toBeFalse();
});
test('admin can view private key', function () {
expect($this->admin->can('view', $this->privateKey))->toBeTrue();
});
test('member can view own team private key', function () {
expect($this->member->can('view', $this->privateKey))->toBeTrue();
});
test('admin can update private key', function () {
expect($this->admin->can('update', $this->privateKey))->toBeTrue();
});
test('member cannot update private key', function () {
expect($this->member->can('update', $this->privateKey))->toBeFalse();
});
test('admin can delete private key', function () {
expect($this->admin->can('delete', $this->privateKey))->toBeTrue();
});
test('member cannot delete private key', function () {
expect($this->member->can('delete', $this->privateKey))->toBeFalse();
});
test('user from different team cannot view private key', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('view', $this->privateKey))->toBeFalse();
});
// --- Cloud Provider Token Policy ---
test('admin can create cloud provider token', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', CloudProviderToken::class))->toBeTrue();
});
test('member cannot create cloud provider token', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', CloudProviderToken::class))->toBeFalse();
});
test('admin can view any cloud provider tokens', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('viewAny', CloudProviderToken::class))->toBeTrue();
});
// --- Cloud Init Script Policy ---
test('admin can create cloud init script', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', CloudInitScript::class))->toBeTrue();
});
test('member cannot create cloud init script', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', CloudInitScript::class))->toBeFalse();
});
test('admin can view any cloud init scripts', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('viewAny', CloudInitScript::class))->toBeTrue();
});
test('cloud provider and cloud init policies are explicitly registered', function () {
expect(Gate::getPolicyFor(CloudProviderToken::class))->toBeInstanceOf(CloudProviderTokenPolicy::class)
->and(Gate::getPolicyFor(CloudInitScript::class))->toBeInstanceOf(CloudInitScriptPolicy::class);
});
// --- Personal Access Token (API Token) Policy ---
test('any user can create personal access token', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', PersonalAccessToken::class))->toBeTrue();
});
test('admin can use root permissions for api tokens', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('useRootPermissions', PersonalAccessToken::class))->toBeTrue();
});
test('member cannot use root permissions for api tokens', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('useRootPermissions', PersonalAccessToken::class))->toBeFalse();
});
test('member cannot use write permissions for api tokens', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('useWritePermissions', PersonalAccessToken::class))->toBeFalse();
});
@@ -0,0 +1,97 @@
<?php
use App\Livewire\Dashboard;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function setupDashboardUser(string $role): array
{
$team = Team::factory()->create();
$user = User::factory()->create();
$user->teams()->attach($team, ['role' => $role]);
return [$user, $team];
}
function createProjectForTeam(Team $team): void
{
Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $team->id,
]);
}
function createServerWithKeyForTeam(Team $team): void
{
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $team->id,
'created_at' => now(),
'updated_at' => now(),
]);
Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $keyId,
]);
}
test('admin sees add project button on dashboard', function () {
[$user, $team] = setupDashboardUser('admin');
$this->actingAs($user);
session(['currentTeam' => $team]);
createProjectForTeam($team);
Livewire::test(Dashboard::class)
->assertSee('New Project');
});
test('member does not see add project button on dashboard', function () {
[$user, $team] = setupDashboardUser('member');
$this->actingAs($user);
session(['currentTeam' => $team]);
createProjectForTeam($team);
Livewire::test(Dashboard::class)
->assertDontSee('New Project');
});
test('admin sees add server button on dashboard', function () {
[$user, $team] = setupDashboardUser('admin');
$this->actingAs($user);
session(['currentTeam' => $team]);
createServerWithKeyForTeam($team);
Livewire::test(Dashboard::class)
->assertSee('New Server');
});
test('member does not see add server button on dashboard', function () {
[$user, $team] = setupDashboardUser('member');
$this->actingAs($user);
session(['currentTeam' => $team]);
createServerWithKeyForTeam($team);
Livewire::test(Dashboard::class)
->assertDontSee('New Server');
});
@@ -0,0 +1,188 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$this->database = StandalonePostgresql::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test DB',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'testdb',
'image' => 'postgres:15',
'status' => 'running',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->databaseUrl = "/project/{$this->project->uuid}/environment/{$this->environment->uuid}/database/{$this->database->uuid}";
});
// --- Database Policy: view ---
test('admin can view database', function () {
expect($this->admin->can('view', $this->database))->toBeTrue();
});
test('member can view database', function () {
expect($this->member->can('view', $this->database))->toBeTrue();
});
// --- Database Policy: update ---
test('admin can update database', function () {
expect($this->admin->can('update', $this->database))->toBeTrue();
});
test('member cannot update database', function () {
expect($this->member->can('update', $this->database))->toBeFalse();
});
// --- Database Policy: delete ---
test('admin can delete database', function () {
expect($this->admin->can('delete', $this->database))->toBeTrue();
});
test('member cannot delete database', function () {
expect($this->member->can('delete', $this->database))->toBeFalse();
});
// --- Database Policy: manage ---
test('admin can manage database', function () {
expect($this->admin->can('manage', $this->database))->toBeTrue();
});
test('member cannot manage database', function () {
expect($this->member->can('manage', $this->database))->toBeFalse();
});
// --- Database Policy: manageBackups ---
test('admin can manage database backups', function () {
expect($this->admin->can('manageBackups', $this->database))->toBeTrue();
});
test('member cannot manage database backups', function () {
expect($this->member->can('manageBackups', $this->database))->toBeFalse();
});
// --- Database Policy: manageEnvironment ---
test('admin can manage database environment variables', function () {
expect($this->admin->can('manageEnvironment', $this->database))->toBeTrue();
});
test('member cannot manage database environment variables', function () {
expect($this->member->can('manageEnvironment', $this->database))->toBeFalse();
});
// --- Database page access ---
test('admin can access database page', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->get($this->databaseUrl)->assertSuccessful();
});
test('member can access database page', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$this->get($this->databaseUrl)->assertSuccessful();
});
// --- Terminal access ---
test('admin can access terminal for database', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('canAccessTerminal'))->toBeTrue();
});
test('member cannot access terminal for database', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('canAccessTerminal'))->toBeFalse();
});
// --- Cross-team isolation ---
test('user from different team cannot view database', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('view', $this->database))->toBeFalse();
});
test('user from different team cannot update database', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('update', $this->database))->toBeFalse();
});
test('user from different team cannot manage database', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('manage', $this->database))->toBeFalse();
});
@@ -0,0 +1,60 @@
<?php
use App\Livewire\Destination\Show;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
// Team A owns the destination
$this->teamA = Team::factory()->create();
$this->userA = User::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'admin']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Key A',
'private_key' => 'test-key-a',
'team_id' => $this->teamA->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->teamA->id,
'private_key_id' => $keyId,
]);
// Use the StandaloneDocker created by the Server factory
$this->destination = $this->server->standaloneDockers()->first();
// Team B is a different team
$this->teamB = Team::factory()->create();
$this->userB = User::factory()->create();
$this->userB->teams()->attach($this->teamB, ['role' => 'admin']);
});
test('team member can view their own destination', function () {
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
Livewire::test(Show::class, ['destination_uuid' => $this->destination->uuid])
->assertSet('name', $this->destination->name);
});
test('cross-team user cannot view destination', function () {
$this->actingAs($this->userB);
session(['currentTeam' => $this->teamB]);
Livewire::test(Show::class, ['destination_uuid' => $this->destination->uuid])
->assertStatus(403);
});
@@ -0,0 +1,274 @@
<?php
use App\Livewire\Project\Shared\EnvironmentVariable\All as EnvironmentVariableAll;
use App\Livewire\Project\Shared\EnvironmentVariable\Show as EnvironmentVariableShow;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$this->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->unlockedEnv = EnvironmentVariable::create([
'key' => 'UNLOCKED_VAR',
'value' => 'secret-unlocked-value',
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
'is_preview' => false,
'is_shown_once' => false,
'is_multiline' => false,
'is_literal' => false,
'is_runtime' => true,
'is_buildtime' => true,
]);
$this->lockedEnv = EnvironmentVariable::create([
'key' => 'LOCKED_VAR',
'value' => 'secret-locked-value',
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
'is_preview' => false,
'is_shown_once' => true,
'is_multiline' => false,
'is_literal' => false,
'is_runtime' => true,
'is_buildtime' => true,
]);
});
// --- Livewire Show component: locked env values ---
test('admin sees unlocked env value in Show component', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableShow::class, [
'env' => $this->unlockedEnv,
'type' => 'application',
]);
expect($component->get('value'))->toBe('secret-unlocked-value');
});
test('admin cannot see locked env value in Show component', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableShow::class, [
'env' => $this->lockedEnv,
'type' => 'application',
]);
expect($component->get('value'))->toBeNull();
expect($component->get('real_value'))->toBeNull();
});
test('member cannot see any env value in Show component', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableShow::class, [
'env' => $this->unlockedEnv,
'type' => 'application',
]);
expect($component->get('value'))->toBeNull();
expect($component->get('real_value'))->toBeNull();
});
test('member has isValueHidden flag set to true', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableShow::class, [
'env' => $this->unlockedEnv,
'type' => 'application',
]);
expect($component->get('isValueHidden'))->toBeTrue();
});
test('admin has isValueHidden flag set to false', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableShow::class, [
'env' => $this->unlockedEnv,
'type' => 'application',
]);
expect($component->get('isValueHidden'))->toBeFalse();
});
// --- Livewire All component: dev view ---
test('admin dev view shows unlocked env value', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableAll::class, [
'resource' => $this->application,
]);
expect($component->get('variables'))->toContain('UNLOCKED_VAR=secret-unlocked-value');
});
test('admin dev view hides locked env value', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableAll::class, [
'resource' => $this->application,
]);
expect($component->get('variables'))->toContain('LOCKED_VAR=(Locked Secret, delete and add again to change)');
expect($component->get('variables'))->not->toContain('secret-locked-value');
});
test('member dev view hides all env values', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$component = Livewire::test(EnvironmentVariableAll::class, [
'resource' => $this->application,
]);
expect($component->get('variables'))->not->toContain('secret-unlocked-value');
expect($component->get('variables'))->not->toContain('secret-locked-value');
expect($component->get('variables'))->toContain('UNLOCKED_VAR=(Hidden');
});
// --- API: locked env values hidden ---
test('API hides locked env value even with read:sensitive token', function () {
session(['currentTeam' => $this->team]);
$token = $this->admin->createToken('admin-sensitive', ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
$response->assertOk();
$envs = collect($response->json());
$locked = $envs->firstWhere('key', 'LOCKED_VAR');
$unlocked = $envs->firstWhere('key', 'UNLOCKED_VAR');
expect($locked)->not->toBeNull();
expect($locked)->not->toHaveKey('value');
expect($locked)->not->toHaveKey('real_value');
expect($unlocked)->not->toBeNull();
expect($unlocked)->toHaveKey('value');
});
test('API hides locked env value with root token', function () {
session(['currentTeam' => $this->team]);
$token = $this->admin->createToken('admin-root', ['root']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
$response->assertOk();
$envs = collect($response->json());
$locked = $envs->firstWhere('key', 'LOCKED_VAR');
expect($locked)->not->toBeNull();
expect($locked)->not->toHaveKey('value');
expect($locked)->not->toHaveKey('real_value');
});
// --- API: member role hides env values ---
test('API hides env values for member even with read:sensitive token', function () {
session(['currentTeam' => $this->team]);
$token = $this->member->createToken('member-sensitive', ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
$response->assertOk();
$envs = collect($response->json());
$unlocked = $envs->firstWhere('key', 'UNLOCKED_VAR');
expect($unlocked)->not->toBeNull();
expect($unlocked)->not->toHaveKey('value');
expect($unlocked)->not->toHaveKey('real_value');
});
test('API shows env values for admin with read:sensitive token', function () {
session(['currentTeam' => $this->team]);
$token = $this->admin->createToken('admin-sensitive-2', ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
$response->assertOk();
$envs = collect($response->json());
$unlocked = $envs->firstWhere('key', 'UNLOCKED_VAR');
expect($unlocked)->not->toBeNull();
expect($unlocked)->toHaveKey('value');
});
@@ -0,0 +1,127 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->member = User::factory()->create();
$this->admin = User::factory()->create();
$this->team->members()->attach($this->member->id, ['role' => 'member']);
$this->team->members()->attach($this->admin->id, ['role' => 'admin']);
session(['currentTeam' => $this->team]);
});
function apiRequest($test, string $token, string $method = 'get', string $url = '/api/v1/version')
{
return $test->withHeaders([
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json',
])->{$method.'Json'}($url);
}
describe('member with legacy elevated token is rejected', function () {
test('member with legacy write token gets 403 with descriptive message', function () {
$token = $this->member->createToken('legacy-write', ['read', 'write']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$response->assertJsonFragment([
'message' => 'This API token has permissions (write) that exceed your current role as a team member. Members are restricted to read-only API access. Please revoke this token and create a new one with only read permissions.',
]);
});
test('member with legacy deploy token gets 403', function () {
$token = $this->member->createToken('legacy-deploy', ['read', 'deploy']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$response->assertSee('deploy');
$response->assertSee('revoke this token');
});
test('member with legacy root token gets 403', function () {
$token = $this->member->createToken('legacy-root', ['root']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$response->assertSee('root');
});
test('member with legacy read:sensitive token gets 403', function () {
$token = $this->member->createToken('legacy-sensitive', ['read', 'read:sensitive']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$response->assertSee('read:sensitive');
});
test('member with legacy write:sensitive token gets 403', function () {
$token = $this->member->createToken('legacy-ws', ['read', 'write:sensitive']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$response->assertSee('write:sensitive');
});
test('member with multiple disallowed abilities lists them all', function () {
$token = $this->member->createToken('legacy-multi', ['read', 'write', 'deploy', 'read:sensitive']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(403);
$json = $response->json();
expect($json['message'])->toContain('write');
expect($json['message'])->toContain('deploy');
expect($json['message'])->toContain('read:sensitive');
});
});
describe('member with read-only token passes through', function () {
test('member with read token can access read endpoints', function () {
$token = $this->member->createToken('read-only', ['read']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(200);
});
});
describe('admin with elevated token passes through', function () {
test('admin with write token is not blocked', function () {
$token = $this->admin->createToken('admin-write', ['read', 'write']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(200);
});
test('admin with root token is not blocked', function () {
$token = $this->admin->createToken('admin-root', ['root']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(200);
});
test('admin with deploy token is not blocked', function () {
$token = $this->admin->createToken('admin-deploy', ['read', 'deploy']);
$response = apiRequest($this, $token->plainTextToken);
$response->assertStatus(200);
});
});
@@ -0,0 +1,46 @@
<?php
use App\Livewire\NavbarDeleteTeam;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->owner = User::factory()->create();
$this->personalTeam = $this->owner->teams()->first();
$this->owner->teams()->updateExistingPivot($this->personalTeam->id, ['role' => 'owner']);
$this->teamToDelete = Team::create(['name' => 'Deletable Team', 'personal_team' => false]);
$this->teamToDelete->members()->attach($this->owner->id, ['role' => 'owner']);
$this->member = User::factory()->create();
$this->teamToDelete->members()->attach($this->member->id, ['role' => 'member']);
});
test('owner can delete team via navbar', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->teamToDelete]);
Livewire::test(NavbarDeleteTeam::class)
->call('delete', 'password')
->assertRedirect(route('team.index'));
expect(Team::find($this->teamToDelete->id))->toBeNull();
});
test('member cannot delete team via navbar', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->teamToDelete]);
Livewire::test(NavbarDeleteTeam::class)
->call('delete', 'password')
->assertDispatched('error');
expect(Team::find($this->teamToDelete->id))->not->toBeNull();
});
@@ -0,0 +1,384 @@
<?php
use App\Livewire\Notifications\Discord as DiscordNotification;
use App\Livewire\Notifications\Email as EmailNotification;
use App\Livewire\Notifications\Pushover as PushoverNotification;
use App\Livewire\Notifications\Slack as SlackNotification;
use App\Livewire\Notifications\Telegram as TelegramNotification;
use App\Livewire\Notifications\Webhook as WebhookNotification;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
});
// --- Discord ---
test('member cannot send test notification on discord', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$settings = $this->team->discordNotificationSettings;
$settings->update([
'discord_enabled' => true,
'discord_webhook_url' => 'https://discord.com/api/webhooks/test',
]);
Livewire::test(DiscordNotification::class)
->call('sendTestNotification')
->assertDispatched('error');
});
test('member cannot update discord notification settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(DiscordNotification::class)
->call('syncData', true)
->assertForbidden();
});
test('admin can update discord notification settings', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$settings = $this->team->discordNotificationSettings;
expect($this->admin->can('update', $settings))->toBeTrue();
});
// --- Slack ---
test('member cannot send test notification on slack', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$settings = $this->team->slackNotificationSettings;
$settings->update([
'slack_enabled' => true,
'slack_webhook_url' => 'https://hooks.slack.com/services/test',
]);
Livewire::test(SlackNotification::class)
->call('sendTestNotification')
->assertDispatched('error');
});
test('member cannot update slack notification settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(SlackNotification::class)
->call('syncData', true)
->assertForbidden();
});
test('admin can update slack notification settings', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$settings = $this->team->slackNotificationSettings;
expect($this->admin->can('update', $settings))->toBeTrue();
});
// --- Telegram ---
test('member cannot send test notification on telegram', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$settings = $this->team->telegramNotificationSettings;
$settings->update([
'telegram_enabled' => true,
'telegram_token' => 'test-token',
'telegram_chat_id' => '123456',
]);
Livewire::test(TelegramNotification::class)
->call('sendTestNotification')
->assertDispatched('error');
});
test('member cannot update telegram notification settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(TelegramNotification::class)
->call('syncData', true)
->assertForbidden();
});
test('admin can update telegram notification settings', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$settings = $this->team->telegramNotificationSettings;
expect($this->admin->can('update', $settings))->toBeTrue();
});
// --- Email ---
test('member cannot send test email notification', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$settings = $this->team->emailNotificationSettings;
$settings->update([
'smtp_enabled' => true,
'smtp_host' => 'localhost',
'smtp_port' => 587,
'smtp_from_address' => 'test@test.com',
'smtp_from_name' => 'Test',
]);
Livewire::test(EmailNotification::class)
->call('sendTestEmail')
->assertDispatched('error');
});
test('member cannot update email notification settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->call('syncData', true)
->assertForbidden();
});
test('member cannot update smtp email transport directly', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->set('smtpFromAddress', 'member@example.com')
->set('smtpFromName', 'Member')
->set('smtpHost', 'smtp.example.com')
->set('smtpPort', '587')
->set('smtpEncryption', 'starttls')
->set('smtpPassword', 'member-smtp-password')
->call('submitSmtp')
->assertForbidden();
});
test('member cannot update resend email transport directly', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->set('smtpFromAddress', 'member@example.com')
->set('smtpFromName', 'Member')
->set('resendApiKey', 'member-resend-api-key')
->call('submitResend')
->assertForbidden();
});
test('member cannot copy instance email settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(EmailNotification::class)
->call('copyFromInstanceSettings')
->assertForbidden();
});
test('admin can update email notification settings', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$settings = $this->team->emailNotificationSettings;
expect($this->admin->can('update', $settings))->toBeTrue();
});
// --- Pushover ---
test('member cannot send test notification on pushover', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$settings = $this->team->pushoverNotificationSettings;
$settings->update([
'pushover_enabled' => true,
'pushover_app_token' => 'test-token',
'pushover_user_key' => 'test-user-key',
]);
Livewire::test(PushoverNotification::class)
->call('sendTestNotification')
->assertDispatched('error');
});
test('member cannot update pushover notification settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(PushoverNotification::class)
->call('syncData', true)
->assertForbidden();
});
test('admin can update pushover notification settings', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$settings = $this->team->pushoverNotificationSettings;
expect($this->admin->can('update', $settings))->toBeTrue();
});
// --- Webhook ---
test('member cannot send test notification on webhook', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$settings = $this->team->webhookNotificationSettings;
$settings->update([
'webhook_enabled' => true,
'webhook_url' => 'https://example.com/webhook',
]);
Livewire::test(WebhookNotification::class)
->call('sendTestNotification')
->assertDispatched('error');
});
test('member cannot update webhook notification settings', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(WebhookNotification::class)
->call('syncData', true)
->assertForbidden();
});
test('admin can update webhook notification settings', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$settings = $this->team->webhookNotificationSettings;
expect($this->admin->can('update', $settings))->toBeTrue();
});
// --- Send test policy checks ---
test('admin can send test on all notification channels', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect($this->admin->can('sendTest', $this->team->discordNotificationSettings))->toBeTrue();
expect($this->admin->can('sendTest', $this->team->slackNotificationSettings))->toBeTrue();
expect($this->admin->can('sendTest', $this->team->telegramNotificationSettings))->toBeTrue();
expect($this->admin->can('sendTest', $this->team->emailNotificationSettings))->toBeTrue();
expect($this->admin->can('sendTest', $this->team->pushoverNotificationSettings))->toBeTrue();
expect($this->admin->can('sendTest', $this->team->webhookNotificationSettings))->toBeTrue();
});
test('member cannot send test on any notification channel', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect($this->member->can('sendTest', $this->team->discordNotificationSettings))->toBeFalse();
expect($this->member->can('sendTest', $this->team->slackNotificationSettings))->toBeFalse();
expect($this->member->can('sendTest', $this->team->telegramNotificationSettings))->toBeFalse();
expect($this->member->can('sendTest', $this->team->emailNotificationSettings))->toBeFalse();
expect($this->member->can('sendTest', $this->team->pushoverNotificationSettings))->toBeFalse();
expect($this->member->can('sendTest', $this->team->webhookNotificationSettings))->toBeFalse();
});
test('member cannot view notification secrets', function (string $component, string $settingsRelation, array $secrets) {
$settings = $this->team->{$settingsRelation};
$settings->update($secrets);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$componentTest = Livewire::test($component);
foreach ($secrets as $column => $value) {
$property = str($column)->camel()->toString();
$componentTest
->assertSet($property, null)
->assertDontSee($value);
}
$componentTest->assertSee('Hidden (only admins can view)');
})->with([
'discord webhook' => [DiscordNotification::class, 'discordNotificationSettings', [
'discord_webhook_url' => 'https://discord.com/api/webhooks/secret-member',
]],
'slack webhook' => [SlackNotification::class, 'slackNotificationSettings', [
'slack_webhook_url' => 'https://hooks.slack.com/services/secret-member',
]],
'telegram token and chat id' => [TelegramNotification::class, 'telegramNotificationSettings', [
'telegram_token' => 'telegram-secret-token',
'telegram_chat_id' => 'telegram-secret-chat',
]],
'pushover credentials' => [PushoverNotification::class, 'pushoverNotificationSettings', [
'pushover_user_key' => 'pushover-secret-user',
'pushover_api_token' => 'pushover-secret-token',
]],
'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [
'webhook_url' => 'https://example.com/secret-webhook',
]],
'email credentials' => [EmailNotification::class, 'emailNotificationSettings', [
'smtp_password' => 'smtp-secret-password',
'resend_api_key' => 'resend-secret-api-key',
]],
]);
test('admin can view notification secrets', function (string $component, string $settingsRelation, array $secrets) {
$settings = $this->team->{$settingsRelation};
$settings->update($secrets);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$componentTest = Livewire::test($component);
foreach ($secrets as $column => $value) {
$property = str($column)->camel()->toString();
$componentTest->assertSet($property, $value);
}
})->with([
'discord webhook' => [DiscordNotification::class, 'discordNotificationSettings', [
'discord_webhook_url' => 'https://discord.com/api/webhooks/secret-admin',
]],
'slack webhook' => [SlackNotification::class, 'slackNotificationSettings', [
'slack_webhook_url' => 'https://hooks.slack.com/services/secret-admin',
]],
'telegram token and chat id' => [TelegramNotification::class, 'telegramNotificationSettings', [
'telegram_token' => 'telegram-admin-token',
'telegram_chat_id' => 'telegram-admin-chat',
]],
'pushover credentials' => [PushoverNotification::class, 'pushoverNotificationSettings', [
'pushover_user_key' => 'pushover-admin-user',
'pushover_api_token' => 'pushover-admin-token',
]],
'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [
'webhook_url' => 'https://example.com/admin-webhook',
]],
'email credentials' => [EmailNotification::class, 'emailNotificationSettings', [
'smtp_password' => 'smtp-admin-password',
'resend_api_key' => 'resend-admin-api-key',
]],
]);
@@ -0,0 +1,55 @@
<?php
use App\Livewire\Project\Show;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
});
test('admin can create environment in project', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(Show::class, ['project_uuid' => $this->project->uuid])
->set('name', 'staging')
->call('submit')
->assertRedirect();
expect(Environment::where('name', 'staging')->exists())->toBeTrue();
});
test('member cannot create environment in project', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Show::class, ['project_uuid' => $this->project->uuid])
->set('name', 'staging')
->call('submit')
->assertDispatched('error');
expect(Environment::where('name', 'staging')->exists())->toBeFalse();
});
@@ -0,0 +1,133 @@
<?php
use App\Http\Middleware\CanCreateResources;
use App\Livewire\Project\New\DockerCompose;
use App\Livewire\Project\New\PublicGitRepository;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.store' => 'array',
'cache.default' => 'array',
]);
InstanceSettings::query()->forceCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
});
test('member cannot pass create resources middleware', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$middleware = new CanCreateResources;
$request = Request::create('/project/new', 'GET');
expect(fn () => $middleware->handle($request, fn () => response('ok')))
->toThrow(HttpException::class, 'You do not have permission to create resources.');
});
test('admin can pass create resources middleware', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$middleware = new CanCreateResources;
$request = Request::create('/project/new', 'GET');
$response = $middleware->handle($request, fn () => response('ok'));
expect($response->getStatusCode())->toBe(200);
});
test('member cannot create docker compose service through livewire action', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(DockerCompose::class)
->set('parameters', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('query', ['destination' => $this->destination->uuid])
->set('dockerComposeRaw', <<<'YAML'
services:
app:
image: alpine
YAML)
->call('submit')
->assertDispatched('error');
expect(Service::query()->count())->toBe(0);
});
test('public git docker compose creates an application in local mode', function () {
config(['app.env' => 'local']);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(PublicGitRepository::class)
->set('parameters', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
])
->set('query', ['destination' => $this->destination->uuid])
->set('repository_url', 'https://github.com/coollabsio/coolify')
->set('git_repository', 'https://github.com/coollabsio/coolify')
->set('git_branch', 'main')
->set('build_pack', 'dockercompose')
->set('new_compose_services', true)
->call('submit');
expect(Application::query()->count())->toBe(1)
->and(Service::query()->count())->toBe(0);
});
@@ -0,0 +1,248 @@
<?php
use App\Livewire\Project\Application\Heading as ApplicationHeading;
use App\Livewire\Project\Service\Heading as ServiceHeading;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$this->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running',
]);
$this->database = StandalonePostgresql::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test DB',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'testdb',
'image' => 'postgres:15',
'status' => 'running',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->service = Service::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Service',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'docker_compose_raw' => 'version: "3"',
]);
$this->serviceParams = [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'service_uuid' => $this->service->uuid,
];
$this->databaseUrl = "/project/{$this->project->uuid}/environment/{$this->environment->uuid}/database/{$this->database->uuid}";
});
// --- Application Heading ---
test('admin sees deploy controls for application', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->assertSee('Redeploy')
->assertSee('Restart')
->assertSee('Stop');
});
test('member cannot call deploy on application', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->call('deploy')
->assertDispatched('error');
});
test('member cannot call restart on application', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->call('restart')
->assertDispatched('error');
});
test('member cannot call stop on application', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->call('stop')
->assertDispatched('error');
});
test('member does not see terminal link for application', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->assertDontSee('Terminal');
});
test('admin sees terminal link for application', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->assertSee('Terminal');
});
// --- Database Heading (via page route for rendering, policy checks for actions) ---
test('admin can access database page', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->get($this->databaseUrl)->assertSuccessful();
});
test('member can access database page', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$this->get($this->databaseUrl)->assertSuccessful();
});
test('admin can manage database', function () {
expect($this->admin->can('manage', $this->database))->toBeTrue();
});
test('member cannot manage database', function () {
expect($this->member->can('manage', $this->database))->toBeFalse();
});
test('admin can access terminal for database', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('canAccessTerminal'))->toBeTrue();
});
test('member cannot access terminal for database', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('canAccessTerminal'))->toBeFalse();
});
// --- Service Heading ---
test('admin sees service deploy button', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->assertSee('Deploy');
});
test('member cannot call stop on service', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->call('stop')
->assertDispatched('error');
});
test('member does not see terminal link for service', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->assertDontSee('Terminal');
});
test('admin sees terminal link for service', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->assertSee('Terminal');
});
@@ -0,0 +1,85 @@
<?php
use App\Livewire\Project\Shared\ResourceOperations;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Livewire\Livewire;
beforeEach(function () {
// Team A (attacker's team)
$this->userA = User::factory()->create();
$this->teamA = Team::factory()->create();
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
$this->serverA = Server::factory()->create(['team_id' => $this->teamA->id]);
$this->destinationA = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
$this->applicationA = Application::factory()->create([
'environment_id' => $this->environmentA->id,
'destination_id' => $this->destinationA->id,
'destination_type' => $this->destinationA->getMorphClass(),
]);
// Team B (victim's team)
$this->teamB = Team::factory()->create();
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id]);
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
$this->actingAs($this->userA);
session(['currentTeam' => $this->teamA]);
});
test('cloneTo rejects destination belonging to another team', function () {
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $this->destinationB->id)
->assertHasErrors('destination_id');
// Ensure no cross-tenant application was created
expect(Application::where('destination_id', $this->destinationB->id)->exists())->toBeFalse();
});
test('cloneTo allows destination belonging to own team', function () {
$secondDestination = StandaloneDocker::factory()->create(['server_id' => $this->serverA->id]);
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('cloneTo', $secondDestination->id)
->assertHasNoErrors('destination_id')
->assertRedirect();
});
test('moveTo rejects environment belonging to another team', function () {
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('moveTo', $this->environmentB->id);
// Resource should still be in original environment
$this->applicationA->refresh();
expect($this->applicationA->environment_id)->toBe($this->environmentA->id);
});
test('moveTo allows environment belonging to own team', function () {
$secondEnvironment = Environment::factory()->create(['project_id' => $this->projectA->id]);
Livewire::test(ResourceOperations::class, ['resource' => $this->applicationA])
->call('moveTo', $secondEnvironment->id)
->assertRedirect();
$this->applicationA->refresh();
expect($this->applicationA->environment_id)->toBe($secondEnvironment->id);
});
test('StandaloneDockerPolicy denies update for cross-team user', function () {
expect($this->userA->can('update', $this->destinationB))->toBeFalse();
});
test('StandaloneDockerPolicy allows update for same-team user', function () {
expect($this->userA->can('update', $this->destinationA))->toBeTrue();
});
@@ -0,0 +1,112 @@
<?php
use App\Livewire\Notifications\Discord as DiscordNotification;
use App\Livewire\Security\PrivateKey\Index as PrivateKeyIndex;
use App\Livewire\Team\Member\Index as TeamMemberIndex;
use App\Models\DiscordNotificationSettings;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
// Create a private key for the team (bypass model validation via DB insert)
DB::table('private_keys')->insert([
'uuid' => (string) Str::uuid(),
'name' => 'Team SSH Key',
'description' => 'Key for testing',
'private_key' => 'test-key-content',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
});
// --- Private Key Index ---
test('admin sees add and cleanup buttons on private key page', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(PrivateKeyIndex::class)
->assertSee('+ Add')
->assertSee('Delete unused SSH Keys');
});
test('member does not see add or cleanup buttons on private key page', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(PrivateKeyIndex::class)
->assertDontSee('+ Add')
->assertDontSee('Delete unused SSH Keys');
});
test('member can view private key names on index page', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
// Member can see key names (view is allowed for same-team members)
// but cannot see create/delete buttons (tested separately above)
Livewire::test(PrivateKeyIndex::class)
->assertSee('Team SSH Key');
});
test('member cannot call cleanup unused keys', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(PrivateKeyIndex::class)
->call('cleanupUnusedKeys')
->assertDispatched('error');
});
// --- Team Member Index (Invitations) ---
test('admin sees invite section on team members page', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(TeamMemberIndex::class)
->assertSee('Invite New Member');
});
test('member does not see invite section on team members page', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(TeamMemberIndex::class)
->assertDontSee('Invite New Member');
});
// --- Notification Settings (Discord as representative) ---
test('member cannot send test notification on discord', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$settings = DiscordNotificationSettings::where('team_id', $this->team->id)->first();
$settings->update([
'discord_enabled' => true,
'discord_webhook_url' => 'https://discord.com/api/webhooks/test',
]);
Livewire::test(DiscordNotification::class)
->call('sendTestNotification')
->assertDispatched('error');
});
@@ -0,0 +1,263 @@
<?php
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Livewire\Server\Navbar as ServerNavbar;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutMiddleware(PreventRequestsDuringMaintenance::class);
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
});
// --- Server Policy: update ---
test('admin can update server', function () {
expect($this->admin->can('update', $this->server))->toBeTrue();
});
test('member cannot update server', function () {
expect($this->member->can('update', $this->server))->toBeFalse();
});
// --- Server Policy: delete ---
test('admin can delete server', function () {
expect($this->admin->can('delete', $this->server))->toBeTrue();
});
test('member cannot delete server', function () {
expect($this->member->can('delete', $this->server))->toBeFalse();
});
// --- Server Policy: view ---
test('admin can view server', function () {
expect($this->admin->can('view', $this->server))->toBeTrue();
});
test('member can view server', function () {
expect($this->member->can('view', $this->server))->toBeTrue();
});
// --- Server Policy: manageProxy ---
test('admin can manage proxy', function () {
expect($this->admin->can('manageProxy', $this->server))->toBeTrue();
});
test('member cannot manage proxy', function () {
expect($this->member->can('manageProxy', $this->server))->toBeFalse();
});
// --- Server Policy: manageSentinel ---
test('admin can manage sentinel', function () {
expect($this->admin->can('manageSentinel', $this->server))->toBeTrue();
});
test('member cannot manage sentinel', function () {
expect($this->member->can('manageSentinel', $this->server))->toBeFalse();
});
// --- Server Policy: manageCaCertificate ---
test('admin can manage CA certificate', function () {
expect($this->admin->can('manageCaCertificate', $this->server))->toBeTrue();
});
test('member cannot manage CA certificate', function () {
expect($this->member->can('manageCaCertificate', $this->server))->toBeFalse();
});
// --- Server Policy: viewSecurity ---
test('admin can view security', function () {
expect($this->admin->can('viewSecurity', $this->server))->toBeTrue();
});
test('member cannot view security', function () {
expect($this->member->can('viewSecurity', $this->server))->toBeFalse();
});
// --- Server Policy: create ---
test('admin can create server', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', Server::class))->toBeTrue();
});
test('member cannot create server', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('create', Server::class))->toBeFalse();
});
// --- Cross-team isolation ---
test('user from different team cannot view server', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('view', $this->server))->toBeFalse();
});
test('user from different team cannot update server', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('update', $this->server))->toBeFalse();
});
// --- Navbar Livewire component actions ---
test('member cannot restart proxy via navbar', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServerNavbar::class, ['server' => $this->server])
->call('restart')
->assertDispatched('error');
});
test('member cannot check proxy via navbar', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServerNavbar::class, ['server' => $this->server])
->call('checkProxy')
->assertDispatched('error');
});
test('member cannot start proxy via navbar', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServerNavbar::class, ['server' => $this->server])
->call('startProxy')
->assertDispatched('error');
});
test('member cannot stop proxy via navbar', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServerNavbar::class, ['server' => $this->server])
->call('stop')
->assertDispatched('error');
});
// --- Terminal access gate ---
test('admin can access terminal', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('canAccessTerminal'))->toBeTrue();
});
test('member cannot access terminal', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('canAccessTerminal'))->toBeFalse();
});
// --- Server page access ---
test('admin can access server page', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->get("/server/{$this->server->uuid}")->assertSuccessful();
});
test('member can access server page', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$this->get("/server/{$this->server->uuid}")->assertSuccessful();
});
test('admin can access sentinel configuration and logs pages', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->get(route('server.sentinel', ['server_uuid' => $this->server->uuid]))
->assertSuccessful();
$this->get(route('server.sentinel.logs', ['server_uuid' => $this->server->uuid]))
->assertSuccessful();
});
test('member cannot access sentinel configuration and logs pages', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$this->get(route('server.sentinel', ['server_uuid' => $this->server->uuid]))
->assertForbidden();
$this->get(route('server.sentinel.logs', ['server_uuid' => $this->server->uuid]))
->assertForbidden();
});
test('sentinel navigation is only visible to team admins', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$this->get("/server/{$this->server->uuid}")
->assertSuccessful()
->assertDontSee(route('server.sentinel', ['server_uuid' => $this->server->uuid]));
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
$this->get("/server/{$this->server->uuid}")
->assertSuccessful()
->assertSee(route('server.sentinel', ['server_uuid' => $this->server->uuid]));
});
test('unauthenticated user cannot access server page', function () {
$this->get("/server/{$this->server->uuid}")->assertRedirect('/login');
});
@@ -0,0 +1,242 @@
<?php
use App\Livewire\Project\Service\Heading as ServiceHeading;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$this->service = Service::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Service',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'docker_compose_raw' => 'version: "3"',
]);
$this->serviceParams = [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
'service_uuid' => $this->service->uuid,
];
});
// --- Service Policy: view ---
test('admin can view service', function () {
expect($this->admin->can('view', $this->service))->toBeTrue();
});
test('member can view service', function () {
expect($this->member->can('view', $this->service))->toBeTrue();
});
// --- Service Policy: update ---
test('admin can update service', function () {
expect($this->admin->can('update', $this->service))->toBeTrue();
});
test('member cannot update service', function () {
expect($this->member->can('update', $this->service))->toBeFalse();
});
// --- Service Policy: delete ---
test('admin can delete service', function () {
expect($this->admin->can('delete', $this->service))->toBeTrue();
});
test('member cannot delete service', function () {
expect($this->member->can('delete', $this->service))->toBeFalse();
});
// --- Service Policy: deploy ---
test('admin can deploy service', function () {
expect($this->admin->can('deploy', $this->service))->toBeTrue();
});
test('member cannot deploy service', function () {
expect($this->member->can('deploy', $this->service))->toBeFalse();
});
// --- Service Policy: stop ---
test('admin can stop service', function () {
expect($this->admin->can('stop', $this->service))->toBeTrue();
});
test('member cannot stop service', function () {
expect($this->member->can('stop', $this->service))->toBeFalse();
});
// --- Service Policy: manageEnvironment ---
test('admin can manage service environment variables', function () {
expect($this->admin->can('manageEnvironment', $this->service))->toBeTrue();
});
test('member cannot manage service environment variables', function () {
expect($this->member->can('manageEnvironment', $this->service))->toBeFalse();
});
// --- Service Heading Livewire actions ---
test('member cannot call start on service heading', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->call('start')
->assertDispatched('error');
});
test('member cannot call stop on service heading', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->call('stop')
->assertDispatched('error');
});
test('member cannot call restart on service heading', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->call('restart')
->assertDispatched('error');
});
test('member cannot call force deploy on service heading', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->call('forceDeploy')
->assertDispatched('error');
});
// --- Service Heading visibility ---
test('member does not see terminal link for service', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->assertDontSee('Terminal');
});
test('admin sees terminal link for service', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->assertSee('Terminal');
});
test('admin sees deploy button for service', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(ServiceHeading::class, [
'service' => $this->service,
'parameters' => $this->serviceParams,
'query' => [],
])
->assertSee('Deploy');
});
// --- Cross-team isolation ---
test('user from different team cannot view service', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('view', $this->service))->toBeFalse();
});
test('user from different team cannot update service', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('update', $this->service))->toBeFalse();
});
@@ -0,0 +1,301 @@
<?php
use App\Livewire\Project\Shared\Danger;
use App\Livewire\Project\Shared\Tags;
use App\Livewire\Project\Shared\Webhooks;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$this->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running',
]);
$this->database = StandalonePostgresql::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test DB',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'testdb',
'image' => 'postgres:15',
'status' => 'running',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->service = Service::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Service',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'docker_compose_raw' => 'version: "3"',
]);
});
// --- Danger Zone: Application ---
test('admin can delete application', function () {
expect($this->admin->can('delete', $this->application))->toBeTrue();
});
test('member cannot delete application via danger zone', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Danger::class, ['resource' => $this->application])
->call('delete', 'password')
->assertDispatched('error');
});
// --- Danger Zone: Database ---
test('admin can delete database', function () {
expect($this->admin->can('delete', $this->database))->toBeTrue();
});
test('member cannot delete database via danger zone', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Danger::class, ['resource' => $this->database])
->call('delete', 'password')
->assertDispatched('error');
});
// --- Danger Zone: Service ---
test('admin can delete service', function () {
expect($this->admin->can('delete', $this->service))->toBeTrue();
});
test('member cannot delete service via danger zone', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Danger::class, ['resource' => $this->service])
->call('delete', 'password')
->assertDispatched('error');
});
// --- Tags: Application ---
test('member cannot add tag to application', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Tags::class, ['resource' => $this->application])
->set('newTags', 'test-tag')
->call('submit')
->assertDispatched('error');
});
test('admin can add tag to application', function () {
expect($this->admin->can('update', $this->application))->toBeTrue();
});
// --- Webhooks: Application ---
test('member cannot update application webhooks', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Webhooks::class, ['resource' => $this->application])
->call('submit')
->assertDispatched('error');
});
test('admin can update application webhooks', function () {
expect($this->admin->can('update', $this->application))->toBeTrue();
});
test('member cannot view application webhook secrets', function () {
$this->application->update([
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
'manual_webhook_secret_github' => 'github-secret-value',
'manual_webhook_secret_gitlab' => 'gitlab-secret-value',
'manual_webhook_secret_bitbucket' => 'bitbucket-secret-value',
'manual_webhook_secret_gitea' => 'gitea-secret-value',
]);
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(Webhooks::class, ['resource' => $this->application->fresh()])
->assertSet('githubManualWebhookSecret', null)
->assertSet('gitlabManualWebhookSecret', null)
->assertSet('bitbucketManualWebhookSecret', null)
->assertSet('giteaManualWebhookSecret', null)
->assertSee('Hidden (only admins can view)')
->assertDontSee('github-secret-value')
->assertDontSee('gitlab-secret-value')
->assertDontSee('bitbucket-secret-value')
->assertDontSee('gitea-secret-value');
});
test('admin can view application webhook secrets', function () {
$this->application->update([
'git_repository' => 'coollabsio/coolify',
'git_branch' => 'main',
'manual_webhook_secret_github' => 'github-secret-value',
'manual_webhook_secret_gitlab' => 'gitlab-secret-value',
'manual_webhook_secret_bitbucket' => 'bitbucket-secret-value',
'manual_webhook_secret_gitea' => 'gitea-secret-value',
]);
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
Livewire::test(Webhooks::class, ['resource' => $this->application->fresh()])
->assertSet('githubManualWebhookSecret', 'github-secret-value')
->assertSet('gitlabManualWebhookSecret', 'gitlab-secret-value')
->assertSet('bitbucketManualWebhookSecret', 'bitbucket-secret-value')
->assertSet('giteaManualWebhookSecret', 'gitea-secret-value');
});
// --- Resource Limits (policy checks, mount requires full resource data) ---
test('member cannot update application resource limits', function () {
expect($this->member->can('update', $this->application))->toBeFalse();
});
test('admin can update application resource limits', function () {
expect($this->admin->can('update', $this->application))->toBeTrue();
});
test('member cannot update database resource limits', function () {
expect($this->member->can('update', $this->database))->toBeFalse();
});
test('member cannot update service resource limits', function () {
expect($this->member->can('update', $this->service))->toBeFalse();
});
// --- Environment Variables (Policy checks) ---
test('admin can manage application environment variables', function () {
expect($this->admin->can('manageEnvironment', $this->application))->toBeTrue();
});
test('member cannot manage application environment variables', function () {
expect($this->member->can('manageEnvironment', $this->application))->toBeFalse();
});
test('admin can manage database environment variables', function () {
expect($this->admin->can('manageEnvironment', $this->database))->toBeTrue();
});
test('member cannot manage database environment variables', function () {
expect($this->member->can('manageEnvironment', $this->database))->toBeFalse();
});
test('admin can manage service environment variables', function () {
expect($this->admin->can('manageEnvironment', $this->service))->toBeTrue();
});
test('member cannot manage service environment variables', function () {
expect($this->member->can('manageEnvironment', $this->service))->toBeFalse();
});
// --- Project Policy ---
test('admin can update project', function () {
expect($this->admin->can('update', $this->project))->toBeTrue();
});
test('member cannot update project', function () {
expect($this->member->can('update', $this->project))->toBeFalse();
});
test('admin can delete project', function () {
expect($this->admin->can('delete', $this->project))->toBeTrue();
});
test('member cannot delete project', function () {
expect($this->member->can('delete', $this->project))->toBeFalse();
});
// --- Environment Policy ---
test('admin can delete environment', function () {
expect($this->admin->can('delete', $this->environment))->toBeTrue();
});
test('member cannot delete environment', function () {
expect($this->member->can('delete', $this->environment))->toBeFalse();
});
// --- Team Policy ---
test('admin can update team', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('update', $this->team))->toBeTrue();
});
test('member cannot update team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('update', $this->team))->toBeFalse();
});
@@ -0,0 +1,327 @@
<?php
use App\Livewire\Team\Index as TeamIndex;
use App\Livewire\Team\Member as TeamMember;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::updateOrCreate(['id' => 0]);
$this->team = Team::factory()->create(['personal_team' => false]);
$this->owner = User::factory()->create();
$this->owner->teams()->attach($this->team, ['role' => 'owner']);
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
});
// --- Team Policy: update ---
test('owner can update team', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('update', $this->team))->toBeTrue();
});
test('admin can update team', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('update', $this->team))->toBeTrue();
});
test('member cannot update team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('update', $this->team))->toBeFalse();
});
// --- Team Policy: delete ---
test('owner can delete team', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('delete', $this->team))->toBeTrue();
});
test('admin can delete team', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('delete', $this->team))->toBeTrue();
});
test('member cannot delete team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('delete', $this->team))->toBeFalse();
});
// --- Team Policy: manageMembers ---
test('owner can manage team members', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageMembers', $this->team))->toBeTrue();
});
test('admin can manage team members', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageMembers', $this->team))->toBeTrue();
});
test('member cannot manage team members', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageMembers', $this->team))->toBeFalse();
});
// --- Team Policy: manageInvitations ---
test('owner can manage invitations', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageInvitations', $this->team))->toBeTrue();
});
test('admin can manage invitations', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageInvitations', $this->team))->toBeTrue();
});
test('member cannot manage invitations', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageInvitations', $this->team))->toBeFalse();
});
// --- Team Index Livewire: update ---
test('member cannot submit team settings via policy', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('update', $this->team))->toBeFalse();
});
// --- Team Index Livewire: delete ---
test('member cannot delete team via index', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(TeamIndex::class)
->call('delete', 'password')
->assertDispatched('error');
expect(Team::find($this->team->id))->not->toBeNull();
});
test('admin can delete team via policy', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('delete', $this->team))->toBeTrue();
});
// --- Team Member Livewire: role changes ---
test('member cannot change roles via team member component', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(TeamMember::class, ['member' => $this->admin])
->call('makeAdmin')
->assertDispatched('error');
});
test('member cannot remove team members', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::test(TeamMember::class, ['member' => $this->admin])
->call('remove')
->assertDispatched('error');
});
// --- Invitations & Invite Link (policy checks — views wrapped in @can render empty for members) ---
test('member cannot manage invitations via policy', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageInvitations', $this->team))->toBeFalse();
});
test('owner can manage invitations via policy', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('manageInvitations', $this->team))->toBeTrue();
});
// --- Cross-team isolation ---
test('user from different team cannot update team', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'owner']);
$this->actingAs($otherUser);
session(['currentTeam' => $otherTeam]);
expect(auth()->user()->can('update', $this->team))->toBeFalse();
});
test('user from different team cannot manage members', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'owner']);
$this->actingAs($otherUser);
session(['currentTeam' => $otherTeam]);
expect(auth()->user()->can('manageMembers', $this->team))->toBeFalse();
});
// --- Team Policy: view ---
test('owner can view team', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('view', $this->team))->toBeTrue();
});
test('admin can view team', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('view', $this->team))->toBeTrue();
});
test('member can view team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('view', $this->team))->toBeTrue();
});
test('non-team member cannot view team', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('view', $this->team))->toBeFalse();
});
// --- Team Policy: viewAdmin ---
test('owner can view admin panel', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('viewAdmin', $this->team))->toBeTrue();
});
test('admin can view admin panel', function () {
$this->actingAs($this->admin);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('viewAdmin', $this->team))->toBeTrue();
});
test('member cannot view admin panel', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(auth()->user()->can('viewAdmin', $this->team))->toBeFalse();
});
test('non-team member cannot view admin panel', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('viewAdmin', $this->team))->toBeFalse();
});
// --- Non-team member isolation ---
test('non-team member cannot update team', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('update', $this->team))->toBeFalse();
});
test('non-team member cannot delete team', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('delete', $this->team))->toBeFalse();
});
test('non-team member cannot manage members', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('manageMembers', $this->team))->toBeFalse();
});
test('non-team member cannot manage invitations', function () {
$outsider = User::factory()->create();
$this->actingAs($outsider);
session(['currentTeam' => $this->team]);
expect($outsider->can('manageInvitations', $this->team))->toBeFalse();
});
// --- Team creation and model-level protection ---
test('member can create a new independent team', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$newTeam = Team::create([
'name' => 'New Team',
'description' => 'Created by member',
'personal_team' => false,
]);
expect($newTeam)->toBeInstanceOf(Team::class)
->and($newTeam->name)->toBe('New Team');
});
test('member cannot update an existing team at model level', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
expect(fn () => $this->team->update(['name' => 'Hacked']))
->toThrow(Exception::class, 'You are not allowed to update this team.');
});
@@ -0,0 +1,194 @@
<?php
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceDatabase;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Once;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withoutVite();
Once::flush();
config(['app.maintenance.driver' => 'file']);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$this->admin = User::factory()->create();
$this->admin->teams()->attach($this->team, ['role' => 'admin']);
$this->member = User::factory()->create();
$this->member->teams()->attach($this->team, ['role' => 'member']);
$keyId = DB::table('private_keys')->insertGetId([
'uuid' => (string) Str::uuid(),
'name' => 'Test Key',
'private_key' => 'test-key',
'team_id' => $this->team->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $keyId,
]);
StandaloneDocker::withoutEvents(function () {
$this->destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
);
});
$this->project = Project::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test Project',
'team_id' => $this->team->id,
]);
$this->environment = $this->project->environments()->first();
$this->database = StandalonePostgresql::create([
'uuid' => (string) Str::uuid(),
'name' => 'Test DB',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'testdb',
'image' => 'postgres:15',
'status' => 'running',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => 'Test App',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running',
]);
$this->service = Service::create([
'uuid' => (string) Str::uuid(),
'name' => 'test-service',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'docker_compose_raw' => 'version: "3"',
]);
$this->serviceDatabase = ServiceDatabase::create([
'uuid' => (string) Str::uuid(),
'name' => 'test-service-db',
'service_id' => $this->service->id,
]);
});
// --- DatabasePolicy::uploadBackup (covers Standalone* databases) ---
test('admin can upload backup for standalone database', function () {
expect($this->admin->can('uploadBackup', $this->database))->toBeTrue();
});
test('member cannot upload backup for standalone database', function () {
expect($this->member->can('uploadBackup', $this->database))->toBeFalse();
});
// --- ApplicationPolicy::uploadBackup ---
test('admin can upload backup for application', function () {
expect($this->admin->can('uploadBackup', $this->application))->toBeTrue();
});
test('member cannot upload backup for application', function () {
expect($this->member->can('uploadBackup', $this->application))->toBeFalse();
});
// --- ServicePolicy::uploadBackup ---
test('admin can upload backup for service', function () {
expect($this->admin->can('uploadBackup', $this->service))->toBeTrue();
});
test('member cannot upload backup for service', function () {
expect($this->member->can('uploadBackup', $this->service))->toBeFalse();
});
// --- ServiceDatabasePolicy::uploadBackup (delegates to ServicePolicy) ---
test('admin can upload backup for service database', function () {
expect($this->admin->can('uploadBackup', $this->serviceDatabase))->toBeTrue();
});
test('member cannot upload backup for service database', function () {
expect($this->member->can('uploadBackup', $this->serviceDatabase))->toBeFalse();
});
// --- Cross-team isolation ---
test('user from different team cannot upload backup', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
expect($otherUser->can('uploadBackup', $this->database))->toBeFalse();
expect($otherUser->can('uploadBackup', $this->application))->toBeFalse();
expect($otherUser->can('uploadBackup', $this->service))->toBeFalse();
expect($otherUser->can('uploadBackup', $this->serviceDatabase))->toBeFalse();
});
// --- HTTP endpoint: POST /upload/backup/{uuid} ---
test('member gets 403 from POST /upload/backup and no file lands on disk', function () {
$uploadDir = storage_path('app/upload/'.$this->database->uuid);
if (File::exists($uploadDir)) {
File::deleteDirectory($uploadDir);
}
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$response = $this->withHeader('Accept', 'application/json')
->post(route('upload.backup', ['databaseUuid' => $this->database->uuid]));
$response->assertForbidden();
expect(File::exists($uploadDir.'/restore'))->toBeFalse();
});
test('user from different team hits null-resource branch with 500', function () {
$otherTeam = Team::factory()->create();
$otherUser = User::factory()->create();
$otherUser->teams()->attach($otherTeam, ['role' => 'admin']);
$this->actingAs($otherUser);
session(['currentTeam' => $otherTeam]);
$response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid]));
$response->assertStatus(500);
});
test('unauthenticated request is redirected to login', function () {
$response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid]));
$response->assertRedirect('/login');
});