mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 12:16:45 +00:00
Merge remote-tracking branch 'origin/next' into fix/oauth-email-normalization
This commit is contained in:
commit
b4e139929e
250 files changed
+7092
-1484
No files matched your search
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Admin\Index as AdminIndex;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -70,9 +71,9 @@ test('submitSearch requires admin authorization', function () {
|
||||
test('switchUser requires root user id 0', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
$rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
$rootTeam->members()->attach($rootUser->id, ['role' => 'admin']);
|
||||
$rootTeam = Team::find(0);
|
||||
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = Team::factory()->create();
|
||||
@@ -84,7 +85,47 @@ test('switchUser requires root user id 0', function () {
|
||||
Livewire::test(AdminIndex::class)
|
||||
->assertOk()
|
||||
->call('switchUser', $targetUser->id)
|
||||
->assertRedirect();
|
||||
->assertRedirect(route('dashboard'));
|
||||
});
|
||||
|
||||
test('back() redirects impersonator to admin index and clears session', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
$rootTeam = Team::find(0);
|
||||
|
||||
$this->actingAs($rootUser);
|
||||
session([
|
||||
'currentTeam' => ['id' => $rootTeam->id],
|
||||
'impersonating' => true,
|
||||
]);
|
||||
|
||||
Livewire::test(AdminIndex::class)
|
||||
->call('back')
|
||||
->assertRedirect(route('admin.index'));
|
||||
|
||||
expect(session('impersonating'))->toBeNull();
|
||||
});
|
||||
|
||||
test('switchUser ignores Referer header and uses dashboard route', function () {
|
||||
config()->set('constants.coolify.self_hosted', false);
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
$rootUser = User::factory()->create(['id' => 0]);
|
||||
$rootTeam = Team::find(0);
|
||||
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = Team::factory()->create();
|
||||
$targetTeam->members()->attach($targetUser->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($rootUser);
|
||||
session(['currentTeam' => ['id' => $rootTeam->id]]);
|
||||
|
||||
Livewire::withHeaders(['Referer' => 'https://example.com/elsewhere'])
|
||||
->test(AdminIndex::class)
|
||||
->call('switchUser', $targetUser->id)
|
||||
->assertRedirect(route('dashboard'));
|
||||
});
|
||||
|
||||
test('switchUser rejects non-root user', function () {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\ApiTokens;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
describe('token creation with expiration', function () {
|
||||
test('livewire component stores expires_at when expiresInDays set', function () {
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'test-token')
|
||||
->set('expiresInDays', 7)
|
||||
->set('permissions', ['read'])
|
||||
->call('addNewToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token = $this->user->tokens()->latest()->first();
|
||||
|
||||
expect($token)->not->toBeNull()
|
||||
->and($token->expires_at)->not->toBeNull()
|
||||
->and($token->expires_at->diffInDays(now()))->toBeGreaterThanOrEqual(6)
|
||||
->and($token->expires_at->diffInDays(now()))->toBeLessThanOrEqual(7);
|
||||
});
|
||||
|
||||
test('livewire component stores null expires_at when expiresInDays null (Never)', function () {
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'never-token')
|
||||
->set('expiresInDays', null)
|
||||
->set('permissions', ['read'])
|
||||
->call('addNewToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token = $this->user->tokens()->latest()->first();
|
||||
|
||||
expect($token)->not->toBeNull()
|
||||
->and($token->expires_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('livewire component rejects invalid expiresInDays value', function () {
|
||||
Livewire::test(ApiTokens::class)
|
||||
->set('description', 'bad-token')
|
||||
->set('expiresInDays', 42)
|
||||
->set('permissions', ['read'])
|
||||
->call('addNewToken')
|
||||
->assertHasErrors('expiresInDays');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expired token rejected on API', function () {
|
||||
test('request with expired token returns 401', function () {
|
||||
$token = $this->user->createToken('expired', ['read'], now()->subDay());
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
])->getJson('/api/v1/projects');
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('request with non-expired token works', function () {
|
||||
$token = $this->user->createToken('valid', ['read'], now()->addDay());
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
])->getJson('/api/v1/projects');
|
||||
|
||||
$response->assertStatus(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApiTokenExpirationWarningJob;
|
||||
use App\Models\PersonalAccessToken;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Notifications\ApiTokenExpiringNotification;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->team->emailNotificationSettings()->update(['use_instance_email_settings' => true]);
|
||||
$this->team->discordNotificationSettings()->update([
|
||||
'discord_enabled' => true,
|
||||
'discord_webhook_url' => 'https://discord.com/api/webhooks/fake/fake',
|
||||
]);
|
||||
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->actingAs($this->user);
|
||||
|
||||
Cache::flush();
|
||||
Notification::fake();
|
||||
});
|
||||
|
||||
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): PersonalAccessToken
|
||||
{
|
||||
$plain = $user->createToken('t-'.uniqid(), ['read'], $expiresAt);
|
||||
$token = $plain->accessToken;
|
||||
$token->team_id = $team->id;
|
||||
$token->save();
|
||||
|
||||
return $token->fresh();
|
||||
}
|
||||
|
||||
describe('ApiTokenExpirationWarningJob', function () {
|
||||
test('notifies team when token expires within 24h', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(23));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class);
|
||||
});
|
||||
|
||||
test('rate limiter prevents duplicate warnings on repeat runs', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addHours(12));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
|
||||
});
|
||||
|
||||
test('skips tokens expiring more than 24h out', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->addDays(3));
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
test('skips already-expired tokens', function () {
|
||||
createTokenExpiring($this->user, $this->team, now()->subHour());
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
test('skips tokens with null expires_at', function () {
|
||||
createTokenExpiring($this->user, $this->team, null);
|
||||
|
||||
(new ApiTokenExpirationWarningJob)->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Bus::fake();
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->bearerToken = createTeamApiToken($this->user, $this->team, ['*']);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
CleanupPreviewDeployment::shouldRun()->andReturn([
|
||||
'cancelled_deployments' => 0,
|
||||
'killed_containers' => 0,
|
||||
'status' => 'success',
|
||||
]);
|
||||
});
|
||||
|
||||
function previewAuthHeaders(string $bearerToken): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
function createTeamApiToken(User $user, Team $team, array $abilities): string
|
||||
{
|
||||
$plainTextToken = Str::random(40);
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'test-token-'.Str::random(6),
|
||||
'token' => hash('sha256', $plainTextToken),
|
||||
'abilities' => $abilities,
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
return $token->getKey().'|'.$plainTextToken;
|
||||
}
|
||||
|
||||
function createPreview(Application $application, int $pullRequestId): ApplicationPreview
|
||||
{
|
||||
return ApplicationPreview::create([
|
||||
'uuid' => (string) new Cuid2,
|
||||
'application_id' => $application->id,
|
||||
'pull_request_id' => $pullRequestId,
|
||||
'pull_request_html_url' => "https://github.com/example/repo/pull/{$pullRequestId}",
|
||||
'fqdn' => "pr-{$pullRequestId}.example.com",
|
||||
]);
|
||||
}
|
||||
|
||||
describe('DELETE /api/v1/applications/{uuid}/previews/{pull_request_id}', function () {
|
||||
test('returns 401 when no bearer token provided', function () {
|
||||
$response = $this->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42");
|
||||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('returns 404 when application uuid does not exist', function () {
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson('/api/v1/applications/nonexistent-uuid/previews/42');
|
||||
|
||||
$response->assertNotFound()
|
||||
->assertJson(['message' => 'Application not found.']);
|
||||
});
|
||||
|
||||
test('returns 404 when preview does not exist for the application', function () {
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/9999");
|
||||
|
||||
$response->assertNotFound()
|
||||
->assertJson(['message' => 'Preview not found.']);
|
||||
});
|
||||
|
||||
test('returns 422 when pull_request_id is not a positive integer', function () {
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/0");
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJson(['message' => 'Invalid pull_request_id.']);
|
||||
});
|
||||
|
||||
test('soft-deletes the preview and returns 200 on success', function () {
|
||||
$preview = createPreview($this->application, 42);
|
||||
|
||||
$response = $this->withHeaders(previewAuthHeaders($this->bearerToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/42");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['message' => 'Preview deletion request queued.']);
|
||||
|
||||
expect($preview->fresh()->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns 403 when token lacks write ability', function () {
|
||||
$readOnlyToken = createTeamApiToken($this->user, $this->team, ['read']);
|
||||
createPreview($this->application, 7);
|
||||
|
||||
$response = $this->withHeaders(previewAuthHeaders($readOnlyToken))
|
||||
->deleteJson("/api/v1/applications/{$this->application->uuid}/previews/7");
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'name' => 'Pure Dockerfile Example',
|
||||
'status' => 'running',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides the breadcrumb trail on mobile while keeping the current status visible', function () {
|
||||
$response = $this->get(route('project.application.configuration', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('flex min-w-0 flex-col gap-1 md:hidden', false);
|
||||
$response->assertSee('flex min-w-0 items-center text-xs text-neutral-400', false);
|
||||
$response->assertSee('hidden flex-wrap items-center gap-y-1 md:flex', false);
|
||||
$response->assertSee('flex flex-wrap items-center gap-1', false);
|
||||
$response->assertSee(
|
||||
'scrollbar flex min-h-10 w-full flex-nowrap items-center gap-6 overflow-x-scroll overflow-y-hidden pb-1 whitespace-nowrap md:w-auto md:overflow-visible',
|
||||
false,
|
||||
);
|
||||
$response->assertSee('shrink-0', false);
|
||||
$response->assertSee('Actions');
|
||||
$response->assertSee('dropdown-item-touch', false);
|
||||
$response->assertSee('hidden flex-wrap items-center gap-2 md:flex', false);
|
||||
$response->assertSee('window.innerWidth >= 768', false);
|
||||
$response->assertSee(':style="panelStyles"', false);
|
||||
$response->assertSee('absolute top-full z-50 mt-1 min-w-max max-w-[calc(100vw-1rem)] md:top-0 md:mt-6', false);
|
||||
$response->assertSee('Pure Dockerfile Example');
|
||||
$response->assertSee('Running');
|
||||
$response->assertSee('pt-2 pb-4 md:pb-10', false);
|
||||
|
||||
expect($response->getContent())->not->toContain('hidden pt-2 pb-10 md:flex');
|
||||
});
|
||||
@@ -30,7 +30,7 @@ it('does not clean up servers with unreachable_count less than 3', function () {
|
||||
'updated_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$originalIp = $server->ip;
|
||||
$originalIp = (string) $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
@@ -47,7 +47,7 @@ it('does not clean up servers updated within 7 days', function () {
|
||||
'updated_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$originalIp = $server->ip;
|
||||
$originalIp = (string) $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
@@ -64,7 +64,7 @@ it('does not clean up servers without notification sent', function () {
|
||||
'updated_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$originalIp = $server->ip;
|
||||
$originalIp = (string) $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('sets unreachable fields on servers when subscription ends', function () {
|
||||
$team = Team::factory()->create();
|
||||
Subscription::create([
|
||||
'team_id' => $team->id,
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 0,
|
||||
'unreachable_notification_sent' => false,
|
||||
]);
|
||||
|
||||
$team->subscriptionEnded();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->unreachable_count)->toBe(3);
|
||||
expect($server->unreachable_notification_sent)->toBeTrue();
|
||||
});
|
||||
|
||||
it('cleans up unsubscribed server IP after 7 days via cleanup command', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 3,
|
||||
'unreachable_notification_sent' => true,
|
||||
'updated_at' => now()->subDays(8),
|
||||
]);
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect($server->ip)->toBe('1.2.3.4');
|
||||
});
|
||||
|
||||
it('does not clean up unsubscribed server IP within 7 day grace period', function () {
|
||||
$team = Team::factory()->create();
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 3,
|
||||
'unreachable_notification_sent' => true,
|
||||
'updated_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$originalIp = (string) $server->ip;
|
||||
|
||||
$this->artisan('cleanup:unreachable-servers')->assertSuccessful();
|
||||
|
||||
$server->refresh();
|
||||
expect((string) $server->ip)->toBe($originalIp);
|
||||
});
|
||||
|
||||
it('does not affect servers with active subscriptions', function () {
|
||||
$team = Team::factory()->create();
|
||||
Subscription::create([
|
||||
'team_id' => $team->id,
|
||||
'stripe_invoice_paid' => true,
|
||||
]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $team->id,
|
||||
'unreachable_count' => 0,
|
||||
'unreachable_notification_sent' => false,
|
||||
]);
|
||||
|
||||
$originalCount = $server->unreachable_count;
|
||||
$originalNotification = $server->unreachable_notification_sent;
|
||||
|
||||
expect($originalCount)->toBe(0);
|
||||
expect($originalNotification)->toBeFalse();
|
||||
});
|
||||
@@ -414,7 +414,7 @@ describe('docker_compose_custom_command validation', function () {
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects single quotes in docker_compose_custom_start_command', function () {
|
||||
test('allows single-quoted arguments in docker_compose_custom_start_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
@@ -422,7 +422,7 @@ describe('docker_compose_custom_command validation', function () {
|
||||
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
test('allows double quotes in docker_compose_custom_start_command', function () {
|
||||
@@ -474,6 +474,127 @@ describe('docker_compose_custom_command validation', function () {
|
||||
expect($method->invoke($instance, 'docker compose up -d --build', 'docker_compose_custom_start_command'))
|
||||
->toBe('docker compose up -d --build');
|
||||
});
|
||||
|
||||
test('rejects bare ampersand PoC payload (GHSA-chg4-63hm-xv9x)', function () {
|
||||
$rules = sharedDataApplications();
|
||||
$payload = 'true & docker run --rm -v /:/h alpine sh -c "cp /h/etc/shadow /h/tmp/leak"';
|
||||
|
||||
$validator = validator(
|
||||
['docker_compose_custom_start_command' => $payload],
|
||||
['docker_compose_custom_start_command' => $rules['docker_compose_custom_start_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
test('rejects bare ampersand across every shell-safe field', function ($field) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
[$field => 'cmd1 & cmd2'],
|
||||
[$field => $rules[$field]]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with([
|
||||
'install_command',
|
||||
'build_command',
|
||||
'start_command',
|
||||
'docker_compose_custom_build_command',
|
||||
'docker_compose_custom_start_command',
|
||||
'custom_docker_run_options',
|
||||
]);
|
||||
|
||||
test('rejects command substitution inside double quotes', function ($payload) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => "echo $payload"],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with(['"$(whoami)"', '"`whoami`"']);
|
||||
|
||||
test('rejects unbalanced quotes', function ($payload) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $payload],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with(['echo "unterminated', "echo 'unterminated"]);
|
||||
|
||||
test('rejects backslash anywhere', function ($payload) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $payload],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with(['echo \\;', 'echo \\$HOME']);
|
||||
|
||||
test('runtime validateShellSafeCommand rejects bare ampersand payload', function () {
|
||||
$job = new ReflectionClass(ApplicationDeploymentJob::class);
|
||||
$method = $job->getMethod('validateShellSafeCommand');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$instance = $job->newInstanceWithoutConstructor();
|
||||
|
||||
expect(fn () => $method->invoke($instance, 'true & whoami', 'docker_compose_custom_start_command'))
|
||||
->toThrow(RuntimeException::class, 'contains forbidden shell characters');
|
||||
});
|
||||
|
||||
test('allows logical OR chaining', function ($cmd) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $cmd],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'make build || make clean',
|
||||
'npm run build || npm run fallback',
|
||||
'cmd-a || cmd-b && cmd-c',
|
||||
]);
|
||||
|
||||
test('allows glob and bang tokens', function ($cmd) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $cmd],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
})->with([
|
||||
'rm *.tmp',
|
||||
'cp src/?.js dist/',
|
||||
'! grep -q foo && echo missing',
|
||||
'docker build --tag app-v1!',
|
||||
]);
|
||||
|
||||
test('rejects bare pipe even though || is allowed', function ($cmd) {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
$validator = validator(
|
||||
['build_command' => $cmd],
|
||||
['build_command' => $rules['build_command']]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
})->with([
|
||||
'cmd | cat',
|
||||
'cmd|cat',
|
||||
'a |b',
|
||||
'a| b',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('custom_docker_run_options validation', function () {
|
||||
@@ -676,7 +797,7 @@ describe('API route middleware for deploy actions', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('install/build/start command validation (GHSA-9pp4-wcmj-rq73)', function () {
|
||||
describe('install/build/start command validation', function () {
|
||||
test('rejects semicolon injection in install_command', function () {
|
||||
$rules = sharedDataApplications();
|
||||
|
||||
|
||||
@@ -60,3 +60,47 @@ test('convertGitUrlsForSourceAndSshUrlWithCustomPort', function () {
|
||||
'port' => '766',
|
||||
]);
|
||||
});
|
||||
|
||||
test('convertGitUrlsForSourceAndSshUrlSchemeWithCustomPort', function () {
|
||||
$result = convertGitUrl('ssh://git@192.168.56.11:22222/User/Repo.git', 'source', null);
|
||||
expect($result)->toBe([
|
||||
'repository' => 'ssh://git@192.168.56.11:22222/User/Repo.git',
|
||||
'port' => '22222',
|
||||
]);
|
||||
});
|
||||
|
||||
test('convertGitUrlsForSourceAndSshUrlSchemeWithCustomPortAndIpv6Host', function () {
|
||||
$result = convertGitUrl('ssh://git@[2001:db8::10]:22222/group/project.git', 'source', null);
|
||||
expect($result)->toBe([
|
||||
'repository' => 'ssh://git@[2001:db8::10]:22222/group/project.git',
|
||||
'port' => '22222',
|
||||
]);
|
||||
});
|
||||
|
||||
test('convertGitUrlsForDeployKeyAndGithubAppWithCustomPort', function () {
|
||||
$githubApp = new GithubApp([
|
||||
'html_url' => 'https://github.example.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22222,
|
||||
]);
|
||||
|
||||
$result = convertGitUrl('andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp);
|
||||
expect($result)->toBe([
|
||||
'repository' => 'ssh://git@github.example.com:22222/andrasbacsai/coolify-examples.git',
|
||||
'port' => '22222',
|
||||
]);
|
||||
});
|
||||
|
||||
test('convertGitUrlsForDeployKeyAndGithubAppWithCustomPortAndIpv6Host', function () {
|
||||
$githubApp = new GithubApp([
|
||||
'html_url' => 'https://[2001:db8::10]',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22222,
|
||||
]);
|
||||
|
||||
$result = convertGitUrl('andrasbacsai/coolify-examples.git', 'deploy_key', $githubApp);
|
||||
expect($result)->toBe([
|
||||
'repository' => 'ssh://git@[2001:db8::10]:22222/andrasbacsai/coolify-examples.git',
|
||||
'port' => '22222',
|
||||
]);
|
||||
});
|
||||
@@ -1,15 +1,19 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Livewire\Boarding\Index as BoardingIndex;
|
||||
use App\Livewire\GlobalSearch;
|
||||
use App\Livewire\Project\CloneMe;
|
||||
use App\Livewire\Project\DeleteProject;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
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 Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@@ -39,7 +43,7 @@ beforeEach(function () {
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
describe('Boarding Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
describe('Boarding Server IDOR', function () {
|
||||
test('boarding mount cannot load server from another team via selectedExistingServer', function () {
|
||||
$component = Livewire::test(BoardingIndex::class, [
|
||||
'selectedServerType' => 'remote',
|
||||
@@ -62,7 +66,7 @@ describe('Boarding Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Boarding Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
describe('Boarding Project IDOR', function () {
|
||||
test('boarding mount cannot load project from another team via selectedProject', function () {
|
||||
$component = Livewire::test(BoardingIndex::class, [
|
||||
'selectedProject' => $this->projectB->id,
|
||||
@@ -91,7 +95,7 @@ describe('Boarding Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GlobalSearch Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
describe('GlobalSearch Server IDOR', function () {
|
||||
test('loadDestinations cannot access server from another team', function () {
|
||||
$component = Livewire::test(GlobalSearch::class)
|
||||
->set('selectedServerId', $this->serverB->id)
|
||||
@@ -102,7 +106,7 @@ describe('GlobalSearch Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GlobalSearch Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
describe('GlobalSearch Project IDOR', function () {
|
||||
test('loadEnvironments cannot access project from another team', function () {
|
||||
$component = Livewire::test(GlobalSearch::class)
|
||||
->set('selectedProjectUuid', $this->projectB->uuid)
|
||||
@@ -113,11 +117,11 @@ describe('GlobalSearch Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeleteProject IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
describe('DeleteProject IDOR', function () {
|
||||
test('cannot mount DeleteProject with project from another team', function () {
|
||||
// Should throw ModelNotFoundException (404) because team-scoped query won't find it
|
||||
Livewire::test(DeleteProject::class, ['project_id' => $this->projectB->id]);
|
||||
})->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
|
||||
})->throws(ModelNotFoundException::class);
|
||||
|
||||
test('can mount DeleteProject with own team project', function () {
|
||||
$component = Livewire::test(DeleteProject::class, ['project_id' => $this->projectA->id]);
|
||||
@@ -126,14 +130,14 @@ describe('DeleteProject IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CloneMe Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
describe('CloneMe Project IDOR', function () {
|
||||
test('cannot mount CloneMe with project UUID from another team', function () {
|
||||
// Should throw ModelNotFoundException because team-scoped query won't find it
|
||||
Livewire::test(CloneMe::class, [
|
||||
'project_uuid' => $this->projectB->uuid,
|
||||
'environment_uuid' => $this->environmentB->uuid,
|
||||
]);
|
||||
})->throws(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
|
||||
})->throws(ModelNotFoundException::class);
|
||||
|
||||
test('can mount CloneMe with own team project UUID', function () {
|
||||
$component = Livewire::test(CloneMe::class, [
|
||||
@@ -145,27 +149,27 @@ describe('CloneMe Project IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeployController API Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
describe('DeployController API Server IDOR', function () {
|
||||
test('deploy cancel API cannot access build server from another team', function () {
|
||||
// Create a deployment queue entry that references Team B's server as build_server
|
||||
$application = \App\Models\Application::factory()->create([
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environmentA->id,
|
||||
'destination_id' => StandaloneDocker::factory()->create(['server_id' => $this->serverA->id])->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
]);
|
||||
|
||||
$deployment = \App\Models\ApplicationDeploymentQueue::create([
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => $application->id,
|
||||
'deployment_uuid' => 'test-deploy-' . fake()->uuid(),
|
||||
'deployment_uuid' => 'test-deploy-'.fake()->uuid(),
|
||||
'server_id' => $this->serverA->id,
|
||||
'build_server_id' => $this->serverB->id, // Cross-team build server
|
||||
'status' => \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||
]);
|
||||
|
||||
$token = $this->userA->createToken('test-token', ['*']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $token->plainTextToken,
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
])->deleteJson("/api/v1/deployments/{$deployment->deployment_uuid}");
|
||||
|
||||
// The cancellation should proceed but the build_server should NOT be found
|
||||
@@ -176,7 +180,7 @@ describe('DeployController API Server IDOR (GHSA-qfcc-2fm3-9q42)', function () {
|
||||
// Verify the deployment was cancelled
|
||||
$deployment->refresh();
|
||||
expect($deployment->status)->toBe(
|
||||
\App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value
|
||||
ApplicationDeploymentStatus::CANCELLED_BY_USER->value
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
@@ -8,50 +15,110 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Create a team with owner
|
||||
InstanceSettings::updateOrCreate(['id' => 0]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
// Create an API token for the user
|
||||
$this->token = $this->user->createToken('test-token', ['*'], $this->team->id);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->token = $this->user->createToken('test-token', ['*']);
|
||||
$this->bearerToken = $this->token->plainTextToken;
|
||||
|
||||
// Mock a database - we'll use Mockery to avoid needing actual database setup
|
||||
$this->database = \Mockery::mock(StandalonePostgresql::class);
|
||||
$this->database->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||
$this->database->shouldReceive('getAttribute')->with('uuid')->andReturn('test-db-uuid');
|
||||
$this->database->shouldReceive('getAttribute')->with('postgres_db')->andReturn('testdb');
|
||||
$this->database->shouldReceive('type')->andReturn('standalone-postgresql');
|
||||
$this->database->shouldReceive('getMorphClass')->andReturn('App\Models\StandalonePostgresql');
|
||||
});
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
afterEach(function () {
|
||||
\Mockery::close();
|
||||
$this->database = StandalonePostgresql::create([
|
||||
'name' => 'test-postgres',
|
||||
'image' => 'postgres:15-alpine',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'testdb',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->s3Storage = S3Storage::create([
|
||||
'name' => 'test-s3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'test-key',
|
||||
'secret' => 'test-secret',
|
||||
'bucket' => 'test-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $this->team->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('POST /api/v1/databases/{uuid}/backups', function () {
|
||||
test('creates backup configuration with minimal required fields', function () {
|
||||
// This is a unit-style test using mocks to avoid database dependency
|
||||
// For full integration testing, this should be run inside Docker
|
||||
test('creates backup with s3 storage via API token', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => '0 2 * * 0',
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $this->s3Storage->uuid,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid', 'message']);
|
||||
|
||||
$backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->first();
|
||||
expect($backup)->not->toBeNull();
|
||||
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
|
||||
expect($backup->save_s3)->toBeTrue();
|
||||
expect($backup->team_id)->toBe($this->team->id);
|
||||
});
|
||||
|
||||
test('creates backup without s3 storage', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['uuid', 'message']);
|
||||
});
|
||||
|
||||
test('rejects s3_storage_uuid from another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherS3 = S3Storage::create([
|
||||
'name' => 'other-s3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'other-key',
|
||||
'secret' => 'other-secret',
|
||||
'bucket' => 'other-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $otherTeam->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => 'daily',
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => '0 2 * * 0',
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $otherS3->uuid,
|
||||
]);
|
||||
|
||||
// Since we're mocking, this test verifies the endpoint exists and basic validation
|
||||
// Full integration tests should be run in Docker environment
|
||||
expect($response->status())->toBeIn([201, 404, 422]);
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['s3_storage_uuid']);
|
||||
});
|
||||
|
||||
test('validates frequency is required', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
@@ -63,83 +130,78 @@ describe('POST /api/v1/databases/{uuid}/backups', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
])->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
'save_s3' => true,
|
||||
]);
|
||||
|
||||
// Should fail validation because s3_storage_uuid is missing
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
});
|
||||
|
||||
test('rejects invalid frequency format', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => 'invalid-frequency',
|
||||
]);
|
||||
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['s3_storage_uuid']);
|
||||
});
|
||||
|
||||
test('rejects request without authentication', function () {
|
||||
$response = $this->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
$response = $this->postJson("/api/v1/databases/{$this->database->uuid}/backups", [
|
||||
'frequency' => 'daily',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
});
|
||||
|
||||
test('validates retention fields are integers with minimum 0', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
describe('PATCH /api/v1/databases/{uuid}/backups/{scheduled_backup_uuid}', function () {
|
||||
test('updates backup to use s3 storage via API token', function () {
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => 'daily',
|
||||
'database_backup_retention_amount_locally' => -1,
|
||||
'enabled' => true,
|
||||
'database_id' => $this->database->id,
|
||||
'database_type' => $this->database->getMorphClass(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
});
|
||||
|
||||
test('accepts valid cron expressions', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => '0 2 * * *', // Daily at 2 AM
|
||||
])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $this->s3Storage->uuid,
|
||||
]);
|
||||
|
||||
// Will fail with 404 because database doesn't exist, but validates the request format
|
||||
expect($response->status())->toBeIn([201, 404, 422]);
|
||||
$response->assertStatus(200);
|
||||
$backup->refresh();
|
||||
expect($backup->s3_storage_id)->toBe($this->s3Storage->id);
|
||||
expect($backup->save_s3)->toBeTrue();
|
||||
});
|
||||
|
||||
test('accepts predefined frequency values', function () {
|
||||
$frequencies = ['every_minute', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'];
|
||||
test('rejects s3_storage_uuid from another team on update', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
$otherS3 = S3Storage::create([
|
||||
'name' => 'other-s3',
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'other-key',
|
||||
'secret' => 'other-secret',
|
||||
'bucket' => 'other-bucket',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $otherTeam->id,
|
||||
'is_usable' => true,
|
||||
]);
|
||||
|
||||
foreach ($frequencies as $frequency) {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
'frequency' => $frequency,
|
||||
]);
|
||||
|
||||
// Will fail with 404 because database doesn't exist, but validates the request format
|
||||
expect($response->status())->toBeIn([201, 404, 422]);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects extra fields not in allowed list', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/databases/test-db-uuid/backups', [
|
||||
$backup = ScheduledDatabaseBackup::create([
|
||||
'frequency' => 'daily',
|
||||
'invalid_field' => 'invalid_value',
|
||||
'enabled' => true,
|
||||
'database_id' => $this->database->id,
|
||||
'database_type' => $this->database->getMorphClass(),
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
expect($response->status())->toBeIn([404, 422]);
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [
|
||||
'save_s3' => true,
|
||||
's3_storage_uuid' => $otherS3->uuid,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['s3_storage_uuid']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\UploadController;
|
||||
|
||||
function invokeHasAllowedExtension(string $name): bool
|
||||
{
|
||||
$method = new ReflectionMethod(UploadController::class, 'hasAllowedExtension');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke(null, $name);
|
||||
}
|
||||
|
||||
test('hasAllowedExtension accepts supported extensions', function (string $name) {
|
||||
expect(invokeHasAllowedExtension($name))->toBeTrue();
|
||||
})->with([
|
||||
'plain sql' => ['backup.sql'],
|
||||
'uppercase sql' => ['BACKUP.SQL'],
|
||||
'compound sql.gz' => ['backup.sql.gz'],
|
||||
'compound tar.gz' => ['backup.tar.gz'],
|
||||
'tgz' => ['archive.tgz'],
|
||||
'zip' => ['dump.zip'],
|
||||
'tar' => ['dump.tar'],
|
||||
'gz' => ['data.gz'],
|
||||
'dump' => ['data.dump'],
|
||||
'bak' => ['data.bak'],
|
||||
'bson' => ['data.bson'],
|
||||
'bson.gz' => ['data.bson.gz'],
|
||||
'archive' => ['data.archive'],
|
||||
'archive.gz' => ['data.archive.gz'],
|
||||
'bz2' => ['data.bz2'],
|
||||
'xz' => ['data.xz'],
|
||||
]);
|
||||
|
||||
test('hasAllowedExtension rejects unsupported or empty stems', function (string $name) {
|
||||
expect(invokeHasAllowedExtension($name))->toBeFalse();
|
||||
})->with([
|
||||
'php' => ['shell.php'],
|
||||
'phtml' => ['shell.phtml'],
|
||||
'sh' => ['run.sh'],
|
||||
'exe' => ['malware.exe'],
|
||||
'elf binary no ext' => ['payload'],
|
||||
'html' => ['index.html'],
|
||||
'bare compound without stem' => ['.sql.gz'],
|
||||
'bare extension' => ['.sql'],
|
||||
'empty string' => [''],
|
||||
'misleading double ext' => ['shell.php.sql-evil'],
|
||||
]);
|
||||
|
||||
test('MAX_BYTES constant is 10 GiB', function () {
|
||||
$constant = (new ReflectionClass(UploadController::class))->getConstant('MAX_BYTES');
|
||||
expect($constant)->toBe(10 * 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('ALLOWED_EXTENSIONS does not include executable formats', function () {
|
||||
$constant = (new ReflectionClass(UploadController::class))->getConstant('ALLOWED_EXTENSIONS');
|
||||
expect($constant)->toBeArray();
|
||||
|
||||
$forbidden = ['php', 'phtml', 'php5', 'sh', 'bash', 'exe', 'js', 'html', 'htm', 'pl', 'py'];
|
||||
foreach ($forbidden as $bad) {
|
||||
expect($constant)->not->toContain($bad);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ApplicationDeploymentStatus;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'status' => 'running',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders deployment logs in a full-height layout', function () {
|
||||
$deployment = ApplicationDeploymentQueue::create([
|
||||
'application_id' => $this->application->id,
|
||||
'deployment_uuid' => 'deploy-layout-test',
|
||||
'server_id' => $this->server->id,
|
||||
'status' => ApplicationDeploymentStatus::FINISHED->value,
|
||||
'logs' => json_encode([
|
||||
[
|
||||
'command' => null,
|
||||
'output' => 'rolling update started',
|
||||
'type' => 'stdout',
|
||||
'timestamp' => now()->toISOString(),
|
||||
'hidden' => false,
|
||||
'batch' => 1,
|
||||
'order' => 1,
|
||||
],
|
||||
], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
$response = $this->get(route('project.application.deployment.show', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
'deployment_uuid' => $deployment->deployment_uuid,
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('rolling update started');
|
||||
$response->assertSee('flex h-[calc(100vh-10rem)] min-h-40 flex-col overflow-hidden', false);
|
||||
$response->assertSee('flex flex-1 min-h-0 flex-col overflow-hidden', false);
|
||||
$response->assertSee('mt-4 flex flex-1 min-h-0 flex-col overflow-hidden', false);
|
||||
$response->assertSee('flex min-h-0 flex-col w-full overflow-hidden bg-white', false);
|
||||
$response->assertSee('flex min-h-40 flex-1 flex-col overflow-y-auto p-2 px-4 scrollbar', false);
|
||||
|
||||
expect($response->getContent())->not->toContain('max-h-[30rem]');
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Settings\Index;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Model::unguarded(function () {
|
||||
$this->rootTeam = Team::find(0) ?? Team::create(['id' => 0, 'name' => 'Root Team', 'personal_team' => false]);
|
||||
if (! Server::find(0)) {
|
||||
Server::factory()->create(['id' => 0, 'team_id' => $this->rootTeam->id]);
|
||||
}
|
||||
if (! InstanceSettings::find(0)) {
|
||||
InstanceSettings::create(['id' => 0]);
|
||||
}
|
||||
});
|
||||
Once::flush();
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->rootTeam->members()->attach($this->user->id, ['role' => 'admin']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => $this->rootTeam->id]]);
|
||||
});
|
||||
|
||||
test('dev_helper_version rejects values outside Docker tag grammar on save', function () {
|
||||
$invalid = [
|
||||
'latest with spaces',
|
||||
'a$b',
|
||||
'a`b',
|
||||
'a|b',
|
||||
'a;b',
|
||||
'a&b',
|
||||
'a>b',
|
||||
'a<b',
|
||||
"a\nb",
|
||||
'.bad',
|
||||
'-rm',
|
||||
];
|
||||
|
||||
foreach ($invalid as $payload) {
|
||||
Livewire::test(Index::class)
|
||||
->set('dev_helper_version', $payload)
|
||||
->call('instantSave')
|
||||
->assertHasErrors(['dev_helper_version']);
|
||||
}
|
||||
|
||||
expect(InstanceSettings::find(0)->dev_helper_version)->toBeNull();
|
||||
});
|
||||
|
||||
test('dev_helper_version accepts valid docker tag formats', function () {
|
||||
$valid = ['1.0.12', 'latest', 'dev', 'dev-branch_2', 'v1.2.3-rc1', '1_0_0'];
|
||||
|
||||
foreach ($valid as $tag) {
|
||||
Livewire::test(Index::class)
|
||||
->set('dev_helper_version', $tag)
|
||||
->call('instantSave')
|
||||
->assertHasNoErrors(['dev_helper_version']);
|
||||
|
||||
expect(InstanceSettings::find(0)->fresh()->dev_helper_version)->toBe($tag);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildHelperImage refuses when non-dev environment', function () {
|
||||
config(['app.env' => 'production']);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->set('dev_helper_version', 'latest')
|
||||
->call('buildHelperImage')
|
||||
->assertDispatched('error');
|
||||
});
|
||||
|
||||
test('buildHelperImage refuses previously stored invalid version', function () {
|
||||
config(['app.env' => 'local']);
|
||||
|
||||
$settings = InstanceSettings::find(0);
|
||||
$settings->forceFill(['dev_helper_version' => 'bad value'])->saveQuietly();
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->call('buildHelperImage')
|
||||
->assertDispatched('error');
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
describe('email verification hash', function () {
|
||||
test('sha256 hash is accepted and marks the user verified', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'verify-me@example.com',
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
|
||||
'id' => $user->getKey(),
|
||||
'hash' => hash('sha256', $user->getEmailForVerification()),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->get($url)->assertRedirect();
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email_verified_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('legacy sha1 hash is rejected', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'legacy-sha1@example.com',
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
|
||||
'id' => $user->getKey(),
|
||||
'hash' => sha1($user->getEmailForVerification()),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->get($url)->assertStatus(403);
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email_verified_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('tampered signature is rejected', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'tampered@example.com',
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
|
||||
$url = URL::temporarySignedRoute('verify.verify', now()->addHour(), [
|
||||
'id' => $user->getKey(),
|
||||
'hash' => hash('sha256', $user->getEmailForVerification()),
|
||||
]);
|
||||
|
||||
$tampered = $url.'x';
|
||||
|
||||
$this->actingAs($user)->get($tampered)->assertStatus(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
Http::fake([
|
||||
'discord.com/*' => Http::response([], 204),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects feedback with missing content', function () {
|
||||
$response = $this->postJson('/api/feedback', []);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('rejects feedback with content too short', function () {
|
||||
$response = $this->postJson('/api/feedback', ['content' => 'short']);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('rejects feedback with content too long', function () {
|
||||
$response = $this->postJson('/api/feedback', ['content' => str_repeat('a', 2001)]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('rejects feedback with non-string content', function () {
|
||||
$response = $this->postJson('/api/feedback', ['content' => ['array', 'value']]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors('content');
|
||||
});
|
||||
|
||||
it('accepts valid feedback and forwards to discord with mentions disabled', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test');
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'This is a valid feedback message for testing purposes.',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['message' => 'Feedback sent.']);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://discord.com/api/webhooks/test'
|
||||
&& $request['content'] === 'This is a valid feedback message for testing purposes.'
|
||||
&& $request['allowed_mentions'] === ['parse' => []];
|
||||
});
|
||||
});
|
||||
|
||||
it('does not forward to discord when webhook url is not configured', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', null);
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'This is a valid feedback message for testing purposes.',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('throttles feedback endpoint after 3 requests per minute', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', null);
|
||||
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => "Valid feedback message number {$i} for testing.",
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'This fourth request should be throttled.',
|
||||
]);
|
||||
$response->assertStatus(429);
|
||||
});
|
||||
|
||||
it('disables discord mention parsing regardless of content', function () {
|
||||
config()->set('constants.webhooks.feedback_discord_webhook', 'https://discord.com/api/webhooks/test');
|
||||
|
||||
$response = $this->postJson('/api/feedback', [
|
||||
'content' => 'User feedback includes an @everyone style phrase and a link https://example.com for reference.',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request['allowed_mentions'] === ['parse' => []];
|
||||
});
|
||||
});
|
||||
@@ -446,3 +446,74 @@ describe('POST /api/v1/servers/hetzner', function () {
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error responses do not leak exception details', function () {
|
||||
test('locations endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/locations*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc /var/secret/path'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner locations.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
expect($response->getContent())->not->toContain('/var/secret/path');
|
||||
});
|
||||
|
||||
test('server-types endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/server_types*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner server types.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('images endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/images*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/images?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner images.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
|
||||
test('ssh-keys endpoint returns generic 500 message on upstream failure', function () {
|
||||
Http::fake([
|
||||
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||
'error' => ['message' => 'INTERNAL_LEAK_TOKEN_abc'],
|
||||
], 500),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->bearerToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->getJson('/api/v1/hetzner/ssh-keys?cloud_provider_token_id='.$this->hetznerToken->uuid);
|
||||
|
||||
$response->assertStatus(500);
|
||||
$response->assertExactJson(['message' => 'Failed to fetch Hetzner SSH keys.']);
|
||||
expect($response->getContent())->not->toContain('INTERNAL_LEAK_TOKEN_abc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
describe('invitation link login', function () {
|
||||
test('does not auto-verify the email address', function () {
|
||||
$team = Team::factory()->create();
|
||||
$password = 'test-password-123';
|
||||
$user = User::factory()->create([
|
||||
'email' => 'invitee@example.com',
|
||||
'password' => Hash::make($password),
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
$user->teams()->attach($team->id, ['role' => 'member']);
|
||||
|
||||
$token = Crypt::encryptString("{$user->email}@@@{$password}");
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]));
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email_verified_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('still logs the user in', function () {
|
||||
$team = Team::factory()->create();
|
||||
$password = 'test-password-123';
|
||||
$user = User::factory()->create([
|
||||
'email' => 'invitee2@example.com',
|
||||
'password' => Hash::make($password),
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
$user->teams()->attach($team->id, ['role' => 'member']);
|
||||
|
||||
$token = Crypt::encryptString("{$user->email}@@@{$password}");
|
||||
|
||||
$this->get(route('auth.link', ['token' => $token]));
|
||||
|
||||
expect(auth()->id())->toBe($user->id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ProxyStatus;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
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;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::unguarded(function () {
|
||||
InstanceSettings::query()->create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->user = User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$this->team = Team::factory()->create([
|
||||
'show_boarding' => false,
|
||||
]);
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->privateKey = PrivateKey::create([
|
||||
'team_id' => $this->team->id,
|
||||
'name' => 'Test Key',
|
||||
'description' => 'Test SSH key',
|
||||
'private_key' => '-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
|
||||
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
|
||||
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
|
||||
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
|
||||
-----END OPENSSH PRIVATE KEY-----',
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'private_key_id' => $this->privateKey->id,
|
||||
'proxy' => [
|
||||
'type' => ProxyTypes::TRAEFIK->value,
|
||||
'status' => ProxyStatus::EXITED->value,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->destination = StandaloneDocker::query()
|
||||
->where('server_id', $this->server->id)
|
||||
->firstOrFail();
|
||||
|
||||
$this->project = Project::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$this->environment = Environment::factory()->create([
|
||||
'project_id' => $this->project->id,
|
||||
]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
'status' => 'running',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders responsive checkbox classes on the application configuration page', function () {
|
||||
$response = $this->get(route('project.application.configuration', [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Use a Build Server?');
|
||||
$response->assertSee('form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2', false);
|
||||
$response->assertSee('label flex w-full max-w-full min-w-0 items-center gap-4 px-0', false);
|
||||
$response->assertSee('flex min-w-0 grow gap-2 break-words', false);
|
||||
$response->assertSee('shrink-0', false);
|
||||
$response->assertSee('pt-2 w-full sm:w-96', false);
|
||||
|
||||
expect($response->getContent())->not->toContain('min-w-fit');
|
||||
});
|
||||
|
||||
it('renders responsive checkbox classes on the server page', function () {
|
||||
$response = $this->get(route('server.show', [
|
||||
'server_uuid' => $this->server->uuid,
|
||||
]));
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Use it as a build server?');
|
||||
$response->assertSee('form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2', false);
|
||||
$response->assertSee('label flex w-full max-w-full min-w-0 items-center gap-4 px-0', false);
|
||||
$response->assertSee('flex min-w-0 grow gap-2 break-words', false);
|
||||
$response->assertSee('shrink-0', false);
|
||||
$response->assertSee('w-full sm:w-96', false);
|
||||
|
||||
expect($response->getContent())->not->toContain('min-w-fit');
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use App\Console\Commands\ViewScheduledLogs;
|
||||
use App\Http\Middleware\CheckForcePasswordReset;
|
||||
use App\Http\Middleware\DecideWhatToDoWithUser;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Once;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutMiddleware([DecideWhatToDoWithUser::class, CheckForcePasswordReset::class]);
|
||||
Once::flush();
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->saveQuietly();
|
||||
}
|
||||
});
|
||||
|
||||
describe('logs:scheduled --date option', function () {
|
||||
test('rejects a malformed date and exits before touching the shell', function () {
|
||||
$this->artisan('logs:scheduled', ['--date' => '2025-01-01; touch /tmp/pwn'])
|
||||
->expectsOutputToContain('Invalid date format')
|
||||
->assertExitCode(ViewScheduledLogs::INVALID);
|
||||
|
||||
expect(file_exists('/tmp/pwn'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('accepts a well-formed date', function () {
|
||||
$this->artisan('logs:scheduled', ['--date' => '2025-01-01'])
|
||||
->assertExitCode(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Storage\Resources as StorageResources;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
$this->userA = User::factory()->create();
|
||||
$this->teamA = Team::factory()->create();
|
||||
$this->userA->teams()->attach($this->teamA, ['role' => 'owner']);
|
||||
|
||||
$this->userB = User::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
|
||||
$this->storageA = S3Storage::unguarded(fn () => S3Storage::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'storage-a-'.fake()->unique()->word(),
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'key-a',
|
||||
'secret' => 'secret-a',
|
||||
'bucket' => 'bucket-a',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $this->teamA->id,
|
||||
]));
|
||||
|
||||
$this->storageB = S3Storage::unguarded(fn () => S3Storage::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'storage-b-'.fake()->unique()->word(),
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'key-b',
|
||||
'secret' => 'secret-b',
|
||||
'bucket' => 'bucket-b',
|
||||
'endpoint' => 'https://s3.example.com',
|
||||
'team_id' => $this->teamB->id,
|
||||
]));
|
||||
|
||||
$this->backupA = ScheduledDatabaseBackup::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'team_id' => $this->teamA->id,
|
||||
'enabled' => true,
|
||||
'save_s3' => true,
|
||||
'frequency' => '0 0 * * *',
|
||||
'database_type' => 'App\\Models\\StandalonePostgresql',
|
||||
'database_id' => 1,
|
||||
's3_storage_id' => $this->storageA->id,
|
||||
]);
|
||||
|
||||
$this->backupB = ScheduledDatabaseBackup::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'team_id' => $this->teamB->id,
|
||||
'enabled' => true,
|
||||
'save_s3' => true,
|
||||
'frequency' => '0 0 * * *',
|
||||
'database_type' => 'App\\Models\\StandalonePostgresql',
|
||||
'database_id' => 2,
|
||||
's3_storage_id' => $this->storageB->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
describe('Storage/Resources team-scoped backup access', function () {
|
||||
test('disableS3 on other team backup throws and leaves row unchanged', function () {
|
||||
expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA])
|
||||
->call('disableS3', $this->backupB->id))
|
||||
->toThrow(ModelNotFoundException::class);
|
||||
|
||||
$this->backupB->refresh();
|
||||
expect((bool) $this->backupB->save_s3)->toBeTrue();
|
||||
expect($this->backupB->s3_storage_id)->toBe($this->storageB->id);
|
||||
});
|
||||
|
||||
test('moveBackup on other team backup throws and leaves row unchanged', function () {
|
||||
expect(fn () => Livewire::test(StorageResources::class, ['storage' => $this->storageA])
|
||||
->set('selectedStorages', [$this->backupB->id => $this->storageA->id])
|
||||
->call('moveBackup', $this->backupB->id))
|
||||
->toThrow(ModelNotFoundException::class);
|
||||
|
||||
$this->backupB->refresh();
|
||||
expect($this->backupB->s3_storage_id)->toBe($this->storageB->id);
|
||||
});
|
||||
|
||||
test('disableS3 on own backup succeeds', function () {
|
||||
Livewire::test(StorageResources::class, ['storage' => $this->storageA])
|
||||
->call('disableS3', $this->backupA->id);
|
||||
|
||||
$this->backupA->refresh();
|
||||
expect((bool) $this->backupA->save_s3)->toBeFalse();
|
||||
expect($this->backupA->s3_storage_id)->toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Destination\Show as DestinationShow;
|
||||
use App\Livewire\Project\New\DockerCompose;
|
||||
use App\Livewire\Project\New\DockerImage;
|
||||
use App\Livewire\Project\New\GithubPrivateRepository;
|
||||
use App\Livewire\Project\New\GithubPrivateRepositoryDeployKey;
|
||||
use App\Livewire\Project\New\PublicGitRepository;
|
||||
use App\Livewire\Project\New\SimpleDockerfile;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
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\SwarmDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
|
||||
|
||||
$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->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
$this->destinationA = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->serverA->id,
|
||||
'name' => 'dest-a-'.fake()->unique()->word(),
|
||||
'network' => 'coolify-a-'.fake()->unique()->word(),
|
||||
]);
|
||||
|
||||
$this->userB = User::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
|
||||
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
|
||||
$this->destinationB = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->serverB->id,
|
||||
'name' => 'dest-b-'.fake()->unique()->word(),
|
||||
'network' => 'coolify-b-'.fake()->unique()->word(),
|
||||
]);
|
||||
$this->swarmDestinationB = SwarmDocker::create([
|
||||
'uuid' => fake()->uuid(),
|
||||
'name' => 'swarm-b-'.fake()->unique()->word(),
|
||||
'network' => 'swarm-b-'.fake()->unique()->word(),
|
||||
'server_id' => $this->serverB->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
describe('find_destination_for_current_team helper', function () {
|
||||
test('returns null for other team destination UUID', function () {
|
||||
expect(find_destination_for_current_team($this->destinationB->uuid))->toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for other team swarm destination UUID', function () {
|
||||
expect(find_destination_for_current_team($this->swarmDestinationB->uuid))->toBeNull();
|
||||
});
|
||||
|
||||
test('returns own team destination', function () {
|
||||
$found = find_destination_for_current_team($this->destinationA->uuid);
|
||||
expect($found)->not->toBeNull();
|
||||
expect($found->id)->toBe($this->destinationA->id);
|
||||
});
|
||||
|
||||
test('returns null for blank uuid', function () {
|
||||
expect(find_destination_for_current_team(null))->toBeNull();
|
||||
expect(find_destination_for_current_team(''))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SimpleDockerfile destination team scope', function () {
|
||||
test('submit with other team destination throws and creates no application', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
request()->headers->set('referer', route('project.resource.create', $routeParams).'?destination='.$this->destinationB->uuid);
|
||||
|
||||
$before = Application::count();
|
||||
|
||||
expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
|
||||
->test(SimpleDockerfile::class, $routeParams)
|
||||
->set('dockerfile', "FROM nginx\nCMD [\"nginx\"]\n")
|
||||
->call('submit'))
|
||||
->toThrow(Exception::class, 'Destination not found.');
|
||||
|
||||
expect(Application::count())->toBe($before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerImage destination team scope', function () {
|
||||
test('submit with other team destination throws and creates no application', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
$before = Application::count();
|
||||
|
||||
expect(fn () => Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
|
||||
->test(DockerImage::class, $routeParams)
|
||||
->set('imageName', 'nginx')
|
||||
->set('imageTag', 'latest')
|
||||
->call('submit'))
|
||||
->toThrow(Exception::class, 'Destination not found.');
|
||||
|
||||
expect(Application::count())->toBe($before);
|
||||
});
|
||||
|
||||
test('submit with other team swarm destination throws', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
expect(fn () => Livewire::withUrlParams(['destination' => $this->swarmDestinationB->uuid])
|
||||
->test(DockerImage::class, $routeParams)
|
||||
->set('imageName', 'nginx')
|
||||
->set('imageTag', 'latest')
|
||||
->call('submit'))
|
||||
->toThrow(Exception::class, 'Destination not found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerCompose destination + server_id team scope', function () {
|
||||
test('submit with other team destination throws and creates no service', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
$before = Service::count();
|
||||
|
||||
Livewire::withUrlParams([
|
||||
'destination' => $this->destinationB->uuid,
|
||||
'server_id' => $this->serverB->id,
|
||||
])
|
||||
->test(DockerCompose::class, $routeParams)
|
||||
->set('dockerComposeRaw', "services:\n app:\n image: nginx\n")
|
||||
->call('submit');
|
||||
|
||||
expect(Service::count())->toBe($before);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('PublicGitRepository destination team scope', function () {
|
||||
test('submit with other team destination creates no application', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
$before = Application::count();
|
||||
|
||||
try {
|
||||
Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
|
||||
->test(PublicGitRepository::class, $routeParams)
|
||||
->set('repository_url', 'https://github.com/coollabsio/coolify')
|
||||
->set('git_repository', 'coollabsio/coolify')
|
||||
->set('git_branch', 'main')
|
||||
->set('port', 3000)
|
||||
->set('build_pack', 'nixpacks')
|
||||
->set('git_source', 'other')
|
||||
->call('submit');
|
||||
} catch (Throwable $e) {
|
||||
// submit wraps errors via handleError; count assertion below is source of truth
|
||||
}
|
||||
|
||||
expect(Application::count())->toBe($before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GithubPrivateRepository destination team scope', function () {
|
||||
test('submit with other team destination throws and creates no application', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
$before = Application::count();
|
||||
|
||||
try {
|
||||
Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
|
||||
->test(GithubPrivateRepository::class, $routeParams)
|
||||
->call('submit');
|
||||
} catch (Throwable $e) {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(Application::count())->toBe($before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GithubPrivateRepositoryDeployKey destination team scope', function () {
|
||||
test('submit with other team destination throws and creates no application', function () {
|
||||
$routeParams = [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
];
|
||||
|
||||
$before = Application::count();
|
||||
|
||||
try {
|
||||
Livewire::withUrlParams(['destination' => $this->destinationB->uuid])
|
||||
->test(GithubPrivateRepositoryDeployKey::class, $routeParams)
|
||||
->call('submit');
|
||||
} catch (Throwable $e) {
|
||||
// expected
|
||||
}
|
||||
|
||||
expect(Application::count())->toBe($before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource/Create database destination team scope', function () {
|
||||
test('mount with other team destination does not create database', function () {
|
||||
$before = StandalonePostgresql::count();
|
||||
|
||||
$url = route('project.resource.create', [
|
||||
'project_uuid' => $this->projectA->uuid,
|
||||
'environment_uuid' => $this->environmentA->uuid,
|
||||
]).'?type=postgresql&destination='.$this->destinationB->uuid.'&server_id='.$this->serverB->id.'&database_image=postgres:16-alpine';
|
||||
|
||||
$this->get($url);
|
||||
|
||||
expect(StandalonePostgresql::count())->toBe($before);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('StandaloneDocker/SwarmDocker ownedByCurrentTeam scope', function () {
|
||||
test('StandaloneDocker::ownedByCurrentTeam excludes other team destinations', function () {
|
||||
expect(StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationB->uuid)->first())->toBeNull();
|
||||
});
|
||||
|
||||
test('SwarmDocker::ownedByCurrentTeam excludes other team destinations', function () {
|
||||
expect(SwarmDocker::ownedByCurrentTeam()->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull();
|
||||
});
|
||||
|
||||
test('StandaloneDocker::ownedByCurrentTeam returns own destination', function () {
|
||||
$found = StandaloneDocker::ownedByCurrentTeam()->where('uuid', $this->destinationA->uuid)->first();
|
||||
expect($found)->not->toBeNull();
|
||||
expect($found->id)->toBe($this->destinationA->id);
|
||||
});
|
||||
|
||||
test('StandaloneDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () {
|
||||
expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->destinationB->uuid)->first())->toBeNull();
|
||||
expect(StandaloneDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->destinationB->uuid)->first()?->id)->toBe($this->destinationB->id);
|
||||
});
|
||||
|
||||
test('SwarmDocker::ownedByCurrentTeamAPI scopes by explicit team id', function () {
|
||||
expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamA->id)->where('uuid', $this->swarmDestinationB->uuid)->first())->toBeNull();
|
||||
expect(SwarmDocker::ownedByCurrentTeamAPI($this->teamB->id)->where('uuid', $this->swarmDestinationB->uuid)->first()?->id)->toBe($this->swarmDestinationB->id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Destination/Show team scope', function () {
|
||||
test('mount with other team destination UUID redirects to index', function () {
|
||||
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationB->uuid]);
|
||||
|
||||
expect($component->get('destination'))->toBeNull();
|
||||
$component->assertRedirect(route('destination.index'));
|
||||
});
|
||||
|
||||
test('mount with own destination UUID loads it', function () {
|
||||
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->destinationA->uuid]);
|
||||
|
||||
expect($component->get('destination'))->not->toBeNull();
|
||||
expect($component->get('destination')->id)->toBe($this->destinationA->id);
|
||||
});
|
||||
|
||||
test('mount with other team swarm destination UUID redirects to index', function () {
|
||||
$component = Livewire::test(DestinationShow::class, ['destination_uuid' => $this->swarmDestinationB->uuid]);
|
||||
|
||||
expect($component->get('destination'))->toBeNull();
|
||||
$component->assertRedirect(route('destination.index'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
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;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Team A (current actor)
|
||||
$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, 'network' => 'net-a-'.fake()->uuid()]);
|
||||
$this->projectA = Project::factory()->create(['team_id' => $this->teamA->id]);
|
||||
$this->environmentA = Environment::factory()->create(['project_id' => $this->projectA->id]);
|
||||
|
||||
// Team B (other team)
|
||||
$this->userB = User::factory()->create();
|
||||
$this->teamB = Team::factory()->create();
|
||||
$this->userB->teams()->attach($this->teamB, ['role' => 'owner']);
|
||||
$this->serverB = Server::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->destinationB = StandaloneDocker::factory()->create(['server_id' => $this->serverB->id, 'network' => 'net-b-'.fake()->uuid()]);
|
||||
$this->projectB = Project::factory()->create(['team_id' => $this->teamB->id]);
|
||||
$this->environmentB = Environment::factory()->create(['project_id' => $this->projectB->id]);
|
||||
|
||||
// Authenticate as Team A
|
||||
$this->actingAs($this->userA);
|
||||
session(['currentTeam' => $this->teamA]);
|
||||
});
|
||||
|
||||
test('unscoped Project lookup returns another teams project', function () {
|
||||
$project = Project::where('uuid', $this->projectB->uuid)->first();
|
||||
|
||||
expect($project)->not->toBeNull()
|
||||
->and($project->team_id)->toBe($this->teamB->id)
|
||||
->and($project->team_id)->not->toBe($this->teamA->id);
|
||||
});
|
||||
|
||||
test('unscoped StandaloneDocker lookup returns another teams destination', function () {
|
||||
$dest = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first();
|
||||
|
||||
expect($dest)->not->toBeNull()
|
||||
->and($dest->server->team_id)->toBe($this->teamB->id);
|
||||
});
|
||||
|
||||
test('ownedByCurrentTeam scope blocks other-team Project access', function () {
|
||||
expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectB->uuid)->first())->toBeNull();
|
||||
});
|
||||
|
||||
test('ownedByCurrentTeam scope allows own Project access', function () {
|
||||
expect(Project::ownedByCurrentTeam()->where('uuid', $this->projectA->uuid)->first())->not->toBeNull();
|
||||
});
|
||||
|
||||
test('Team A can create Application in Team B environment via unscoped lookups', function () {
|
||||
$destination = StandaloneDocker::where('uuid', $this->destinationB->uuid)->first();
|
||||
$project = Project::where('uuid', $this->projectB->uuid)->first();
|
||||
$environment = $project->load(['environments'])->environments->where('uuid', $this->environmentB->uuid)->first();
|
||||
|
||||
$application = Application::create([
|
||||
'name' => 'team-scope-test-canary',
|
||||
'repository_project_id' => 0,
|
||||
'git_repository' => 'coollabsio/coolify',
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'dockerfile',
|
||||
'dockerfile' => "FROM alpine\nCMD echo hello",
|
||||
'ports_exposes' => 80,
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'health_check_enabled' => false,
|
||||
'source_id' => 0,
|
||||
'source_type' => GithubApp::class,
|
||||
]);
|
||||
|
||||
expect($application->environment_id)->toBe($this->environmentB->id)
|
||||
->and($application->destination_id)->toBe($this->destinationB->id)
|
||||
->and($application->environment->project->team->id)->toBe($this->teamB->id)
|
||||
->and($application->environment->project->team->id)->not->toBe($this->teamA->id);
|
||||
});
|
||||
|
||||
test('resource creation page loads with another teams project UUID', function () {
|
||||
$response = $this->get(route('project.resource.create', [
|
||||
'project_uuid' => $this->projectB->uuid,
|
||||
'environment_uuid' => $this->environmentB->uuid,
|
||||
]));
|
||||
|
||||
expect($response->status())->not->toBe(403);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Upgrade;
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('initializes latest version during mount from cached versions data', function () {
|
||||
config(['constants.coolify.version' => '4.0.0-beta.998']);
|
||||
InstanceSettings::create([
|
||||
'id' => 0,
|
||||
'new_version_available' => true,
|
||||
]);
|
||||
|
||||
Cache::shouldReceive('remember')
|
||||
->once()
|
||||
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
|
||||
->andReturn([
|
||||
'coolify' => [
|
||||
'v4' => [
|
||||
'version' => '4.0.0-beta.999',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Upgrade::class)
|
||||
->assertSet('currentVersion', '4.0.0-beta.998')
|
||||
->assertSet('latestVersion', '4.0.0-beta.999')
|
||||
->assertSet('isUpgradeAvailable', true)
|
||||
->assertSee('4.0.0-beta.998')
|
||||
->assertSee('4.0.0-beta.999');
|
||||
});
|
||||
|
||||
it('falls back to 0.0.0 during mount when cached versions data is unavailable', function () {
|
||||
InstanceSettings::create([
|
||||
'id' => 0,
|
||||
'new_version_available' => false,
|
||||
]);
|
||||
|
||||
Cache::shouldReceive('remember')
|
||||
->once()
|
||||
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
|
||||
->andReturn(null);
|
||||
|
||||
Livewire::test(Upgrade::class)
|
||||
->assertSet('latestVersion', '0.0.0');
|
||||
});
|
||||
|
||||
it('clears stale upgrade availability when current version already matches latest version', function () {
|
||||
config(['constants.coolify.version' => '4.0.0-beta.999']);
|
||||
InstanceSettings::create([
|
||||
'id' => 0,
|
||||
'new_version_available' => true,
|
||||
]);
|
||||
|
||||
Cache::shouldReceive('remember')
|
||||
->once()
|
||||
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
|
||||
->andReturn([
|
||||
'coolify' => [
|
||||
'v4' => [
|
||||
'version' => '4.0.0-beta.999',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Upgrade::class)
|
||||
->assertSet('latestVersion', '4.0.0-beta.999')
|
||||
->assertSet('isUpgradeAvailable', false);
|
||||
|
||||
expect(InstanceSettings::findOrFail(0)->new_version_available)->toBeFalse();
|
||||
});
|
||||
|
||||
it('clears stale upgrade availability when current version is newer than cached latest version', function () {
|
||||
config(['constants.coolify.version' => '4.0.0-beta.1000']);
|
||||
InstanceSettings::create([
|
||||
'id' => 0,
|
||||
'new_version_available' => true,
|
||||
]);
|
||||
|
||||
Cache::shouldReceive('remember')
|
||||
->once()
|
||||
->with('coolify:versions:all', 3600, Mockery::type(\Closure::class))
|
||||
->andReturn([
|
||||
'coolify' => [
|
||||
'v4' => [
|
||||
'version' => '4.0.0-beta.999',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
Livewire::test(Upgrade::class)
|
||||
->assertSet('latestVersion', '4.0.0-beta.999')
|
||||
->assertSet('isUpgradeAvailable', false);
|
||||
|
||||
expect(InstanceSettings::findOrFail(0)->new_version_available)->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\PrivateKey;
|
||||
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;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Create root team and admin user (instance admin)
|
||||
$this->rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']);
|
||||
$this->adminUser = User::factory()->create();
|
||||
$this->rootTeam->members()->attach($this->adminUser->id, ['role' => 'owner']);
|
||||
$this->actingAs($this->adminUser);
|
||||
session(['currentTeam' => $this->rootTeam]);
|
||||
});
|
||||
|
||||
it('deletes a user whose team has a github app with applications', function () {
|
||||
// Create the user to be deleted with their own team
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = $targetUser->teams()->first(); // created by User::created event
|
||||
|
||||
// Create a private key for the team
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $targetTeam->id]);
|
||||
|
||||
// Create a server and destination for the team
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $targetTeam->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
$destination = StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
|
||||
// Create a project and environment
|
||||
$project = Project::factory()->create(['team_id' => $targetTeam->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
// Create a GitHub App owned by the target team
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'Test GitHub App',
|
||||
'team_id' => $targetTeam->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
// Create an application that uses the GitHub App as its source
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'source_id' => $githubApp->id,
|
||||
'source_type' => GithubApp::class,
|
||||
]);
|
||||
|
||||
// Delete the user — this should NOT throw a GithubApp exception
|
||||
$targetUser->delete();
|
||||
|
||||
// Assert user is deleted
|
||||
expect(User::find($targetUser->id))->toBeNull();
|
||||
|
||||
// Assert the GitHub App is deleted
|
||||
expect(GithubApp::find($githubApp->id))->toBeNull();
|
||||
|
||||
// Assert the application is deleted
|
||||
expect(Application::find($application->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('does not delete system-wide github apps when deleting a different team', function () {
|
||||
// Create a system-wide GitHub App owned by the root team
|
||||
$rootPrivateKey = PrivateKey::factory()->create(['team_id' => $this->rootTeam->id]);
|
||||
$systemGithubApp = GithubApp::create([
|
||||
'name' => 'System GitHub App',
|
||||
'team_id' => $this->rootTeam->id,
|
||||
'private_key_id' => $rootPrivateKey->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
'is_system_wide' => true,
|
||||
]);
|
||||
|
||||
// Create a target user with their own team
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = $targetUser->teams()->first();
|
||||
|
||||
// Create an application on the target team that uses the system-wide GitHub App
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $targetTeam->id]);
|
||||
$server = Server::factory()->create([
|
||||
'team_id' => $targetTeam->id,
|
||||
'private_key_id' => $privateKey->id,
|
||||
]);
|
||||
$destination = StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
||||
$project = Project::factory()->create(['team_id' => $targetTeam->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'source_id' => $systemGithubApp->id,
|
||||
'source_type' => GithubApp::class,
|
||||
]);
|
||||
|
||||
// Delete the target user — should NOT throw or delete the system-wide GitHub App
|
||||
$targetUser->delete();
|
||||
|
||||
// Assert user is deleted
|
||||
expect(User::find($targetUser->id))->toBeNull();
|
||||
|
||||
// Assert the system-wide GitHub App still exists
|
||||
expect(GithubApp::find($systemGithubApp->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('transfers instance-wide github app to root team when owning user is deleted', function () {
|
||||
// Create a user whose team owns an instance-wide GitHub App
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = $targetUser->teams()->first();
|
||||
|
||||
$targetPrivateKey = PrivateKey::factory()->create(['team_id' => $targetTeam->id]);
|
||||
$instanceWideApp = GithubApp::create([
|
||||
'name' => 'Instance-Wide GitHub App',
|
||||
'team_id' => $targetTeam->id,
|
||||
'private_key_id' => $targetPrivateKey->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
'is_system_wide' => true,
|
||||
]);
|
||||
|
||||
// Create an application on the ROOT team that uses this instance-wide GitHub App
|
||||
$rootPrivateKey = PrivateKey::factory()->create(['team_id' => $this->rootTeam->id]);
|
||||
$rootServer = Server::factory()->create([
|
||||
'team_id' => $this->rootTeam->id,
|
||||
'private_key_id' => $rootPrivateKey->id,
|
||||
]);
|
||||
$rootDestination = StandaloneDocker::factory()->create(['server_id' => $rootServer->id]);
|
||||
$rootProject = Project::factory()->create(['team_id' => $this->rootTeam->id]);
|
||||
$rootEnvironment = Environment::factory()->create(['project_id' => $rootProject->id]);
|
||||
|
||||
$otherTeamApp = Application::factory()->create([
|
||||
'environment_id' => $rootEnvironment->id,
|
||||
'destination_id' => $rootDestination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'source_id' => $instanceWideApp->id,
|
||||
'source_type' => GithubApp::class,
|
||||
]);
|
||||
|
||||
// Delete the user — should succeed and transfer the instance-wide app to root team
|
||||
$targetUser->delete();
|
||||
|
||||
// Assert user is deleted
|
||||
expect(User::find($targetUser->id))->toBeNull();
|
||||
|
||||
// Assert the instance-wide GitHub App is preserved and transferred to root team
|
||||
$instanceWideApp->refresh();
|
||||
expect($instanceWideApp)->not->toBeNull();
|
||||
expect($instanceWideApp->team_id)->toBe($this->rootTeam->id);
|
||||
|
||||
// Assert the other team's application still has its source intact
|
||||
$otherTeamApp->refresh();
|
||||
expect($otherTeamApp->source_id)->toBe($instanceWideApp->id);
|
||||
expect($otherTeamApp->source_type)->toBe(GithubApp::class);
|
||||
});
|
||||
|
||||
it('transfers instance-wide github app to root team when team is deleted directly', function () {
|
||||
// Create a team that owns an instance-wide GitHub App
|
||||
$targetUser = User::factory()->create();
|
||||
$targetTeam = $targetUser->teams()->first();
|
||||
|
||||
$targetPrivateKey = PrivateKey::factory()->create(['team_id' => $targetTeam->id]);
|
||||
$instanceWideApp = GithubApp::create([
|
||||
'name' => 'Instance-Wide GitHub App',
|
||||
'team_id' => $targetTeam->id,
|
||||
'private_key_id' => $targetPrivateKey->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
'is_system_wide' => true,
|
||||
]);
|
||||
|
||||
// Create an application on the ROOT team that uses this instance-wide GitHub App
|
||||
$rootPrivateKey = PrivateKey::factory()->create(['team_id' => $this->rootTeam->id]);
|
||||
$rootServer = Server::factory()->create([
|
||||
'team_id' => $this->rootTeam->id,
|
||||
'private_key_id' => $rootPrivateKey->id,
|
||||
]);
|
||||
$rootDestination = StandaloneDocker::factory()->create(['server_id' => $rootServer->id]);
|
||||
$rootProject = Project::factory()->create(['team_id' => $this->rootTeam->id]);
|
||||
$rootEnvironment = Environment::factory()->create(['project_id' => $rootProject->id]);
|
||||
|
||||
$otherTeamApp = Application::factory()->create([
|
||||
'environment_id' => $rootEnvironment->id,
|
||||
'destination_id' => $rootDestination->id,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
'source_id' => $instanceWideApp->id,
|
||||
'source_type' => GithubApp::class,
|
||||
]);
|
||||
|
||||
// Delete the team directly — should transfer instance-wide app to root team
|
||||
$targetTeam->delete();
|
||||
|
||||
// Assert the instance-wide GitHub App is preserved and transferred to root team
|
||||
$instanceWideApp->refresh();
|
||||
expect($instanceWideApp)->not->toBeNull();
|
||||
expect($instanceWideApp->team_id)->toBe($this->rootTeam->id);
|
||||
|
||||
// Assert the other team's application still has its source intact
|
||||
$otherTeamApp->refresh();
|
||||
expect($otherTeamApp->source_id)->toBe($instanceWideApp->id);
|
||||
expect($otherTeamApp->source_type)->toBe(GithubApp::class);
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createApplicationWithWebhook(string $repo = 'test-org/test-repo', string $branch = 'main', array $overrides = []): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
|
||||
return Application::create(array_merge([
|
||||
'name' => 'webhook-test-app',
|
||||
'git_repository' => "https://github.com/{$repo}",
|
||||
'git_branch' => $branch,
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
describe('GitHub Manual Webhook HMAC', function () {
|
||||
test('rejects push when secret is empty', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
DB::table('applications')->where('id', $app->id)->update([
|
||||
'manual_webhook_secret_github' => null,
|
||||
]);
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Webhook secret not configured');
|
||||
});
|
||||
|
||||
test('rejects push with forged hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('accepts push with valid hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$secret = $app->manual_webhook_secret_github;
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$hmac = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => "sha256={$hmac}",
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->not->toContain('Invalid signature');
|
||||
expect($content)->not->toContain('Webhook secret not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitLab Manual Webhook HMAC', function () {
|
||||
test('rejects push when secret is empty', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
DB::table('applications')->where('id', $app->id)->update([
|
||||
'manual_webhook_secret_gitlab' => null,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => 'attacker-supplied-token',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Webhook secret not configured');
|
||||
});
|
||||
|
||||
test('rejects push with wrong token', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => 'wrong-token',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('accepts push with valid token', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$secret = $app->manual_webhook_secret_gitlab;
|
||||
|
||||
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => $secret,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->not->toContain('Invalid signature');
|
||||
expect($content)->not->toContain('Webhook secret not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bitbucket Manual Webhook HMAC', function () {
|
||||
test('rejects push when secret is empty', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
DB::table('applications')->where('id', $app->id)->update([
|
||||
'manual_webhook_secret_bitbucket' => null,
|
||||
]);
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
||||
'HTTP_X-Event-Key' => 'repo:push',
|
||||
'HTTP_X-Hub-Signature' => 'sha256='.hash_hmac('sha256', $payload, ''),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Webhook secret not configured');
|
||||
});
|
||||
|
||||
test('rejects push with non-sha256 algorithm', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$secret = $app->manual_webhook_secret_bitbucket;
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
||||
'HTTP_X-Event-Key' => 'repo:push',
|
||||
'HTTP_X-Hub-Signature' => 'sha1='.hash_hmac('sha1', $payload, $secret),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('rejects push with forged hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
||||
'HTTP_X-Event-Key' => 'repo:push',
|
||||
'HTTP_X-Hub-Signature' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('accepts push with valid sha256 hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$secret = $app->manual_webhook_secret_bitbucket;
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
$hmac = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
||||
'HTTP_X-Event-Key' => 'repo:push',
|
||||
'HTTP_X-Hub-Signature' => "sha256={$hmac}",
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->not->toContain('Invalid signature');
|
||||
expect($content)->not->toContain('Webhook secret not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gitea Manual Webhook HMAC', function () {
|
||||
test('rejects push when secret is empty', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
DB::table('applications')->where('id', $app->id)->update([
|
||||
'manual_webhook_secret_gitea' => null,
|
||||
]);
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
|
||||
'HTTP_X-Gitea-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $payload, ''),
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Webhook secret not configured');
|
||||
});
|
||||
|
||||
test('rejects push with forged hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
|
||||
'HTTP_X-Gitea-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('accepts push with valid hash', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$secret = $app->manual_webhook_secret_gitea;
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$hmac = hash_hmac('sha256', $payload, $secret);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
|
||||
'HTTP_X-Gitea-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => "sha256={$hmac}",
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
$content = $response->getContent();
|
||||
expect($content)->not->toContain('Invalid signature');
|
||||
expect($content)->not->toContain('Webhook secret not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhook Secret Auto-Generation', function () {
|
||||
test('auto-generates webhook secrets on application creation', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
|
||||
expect($app->manual_webhook_secret_github)->not->toBeEmpty();
|
||||
expect($app->manual_webhook_secret_gitlab)->not->toBeEmpty();
|
||||
expect($app->manual_webhook_secret_bitbucket)->not->toBeEmpty();
|
||||
expect($app->manual_webhook_secret_gitea)->not->toBeEmpty();
|
||||
expect(strlen($app->manual_webhook_secret_github))->toBe(40);
|
||||
expect(strlen($app->manual_webhook_secret_gitlab))->toBe(40);
|
||||
expect(strlen($app->manual_webhook_secret_bitbucket))->toBe(40);
|
||||
expect(strlen($app->manual_webhook_secret_gitea))->toBe(40);
|
||||
});
|
||||
|
||||
test('encrypts webhook secrets at rest', function () {
|
||||
$app = createApplicationWithWebhook();
|
||||
$plaintext = $app->manual_webhook_secret_github;
|
||||
|
||||
$raw = DB::table('applications')->where('id', $app->id)->first();
|
||||
|
||||
expect($raw->manual_webhook_secret_github)->not->toBe($plaintext);
|
||||
expect($app->manual_webhook_secret_github)->toBe($plaintext);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user