chore: inspect commit message guidance

This commit is contained in:
Andras Bacsai 2026-05-27 07:14:54 +02:00
parent d443758b03
commit 9b996b4dc9
8 changed files with 185 additions and 30 deletions

View file

@ -6,6 +6,7 @@
use App\Jobs\PushServerUpdateJob; use App\Jobs\PushServerUpdateJob;
use App\Models\Server; use App\Models\Server;
use Exception; use Exception;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@ -119,20 +120,24 @@ private function shouldDispatchUpdate(Server $server, array $data): bool
$forceKey = "sentinel:push-force:{$server->id}"; $forceKey = "sentinel:push-force:{$server->id}";
$lockKey = "sentinel:push-lock:{$server->id}"; $lockKey = "sentinel:push-lock:{$server->id}";
return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool { try {
$cachedHash = Cache::get($hashKey); return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool {
$forceActive = Cache::has($forceKey); $cachedHash = Cache::get($hashKey);
$forceActive = Cache::has($forceKey);
$shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive; $shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive;
if ($shouldDispatch) { if ($shouldDispatch) {
// Day-long TTL bounds memory if a server stops pushing entirely. // Day-long TTL bounds memory if a server stops pushing entirely.
Cache::put($hashKey, $hash, now()->addDay()); Cache::put($hashKey, $hash, now()->addDay());
Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300)); Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300));
} }
return $shouldDispatch; return $shouldDispatch;
}); });
} catch (LockTimeoutException) {
return false;
}
} }
/** /**

View file

@ -8,7 +8,6 @@
use App\Models\Server; use App\Models\Server;
use App\Models\StandaloneDocker; use App\Models\StandaloneDocker;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
use Visus\Cuid2\Cuid2; use Visus\Cuid2\Cuid2;
@ -116,17 +115,19 @@ public function promote(int $network_id, int $server_id)
$network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id); $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
DB::transaction(function () use ($network, $server) { $this->resource->getConnection()->transaction(function () use ($network, $server) {
$main_destination = $this->resource->destination; $main_destination = $this->resource->destination;
$this->resource->update([ $this->resource->update([
'destination_id' => $network->id, 'destination_id' => $network->id,
'destination_type' => StandaloneDocker::class, 'destination_type' => StandaloneDocker::class,
]); ]);
$this->resource->additional_networks()->detach($network->id, ['server_id' => $server->id]); $this->resource->additional_networks()
->wherePivot('server_id', $server->id)
->detach($network->id);
$this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]); $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
$this->refreshServers();
$this->resource->refresh();
}); });
$this->resource->refresh();
$this->refreshServers();
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -167,7 +168,9 @@ public function removeServer(int $network_id, int $server_id, $password, $select
} }
$server = Server::ownedByCurrentTeam()->findOrFail($server_id); $server = Server::ownedByCurrentTeam()->findOrFail($server_id);
StopApplicationOneServer::run($this->resource, $server); StopApplicationOneServer::run($this->resource, $server);
$this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]); $this->resource->additional_networks()
->wherePivot('server_id', $server_id)
->detach($network_id);
$this->loadData(); $this->loadData();
$this->dispatch('refresh'); $this->dispatch('refresh');
ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id')); ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id'));

View file

@ -104,6 +104,12 @@ protected static function boot()
$new_team->forceFill($team); $new_team->forceFill($team);
$new_team->save(); $new_team->save();
if (! $user->teams()->whereKey($new_team->id)->exists()) {
$user->teams()->attach($new_team, ['role' => 'owner']);
} else {
$user->teams()->updateExistingPivot($new_team->id, ['role' => 'owner']);
}
return; return;
} }

View file

@ -70,14 +70,14 @@
</x-forms.button> </x-forms.button>
@can('update', $github_app) @can('update', $github_app)
<a href="{{ $this->getGithubAppNameUpdatePath() }}"> <a href="{{ $this->getGithubAppNameUpdatePath() }}">
<x-forms.button <x-forms.button canGate="update" :canResource="$github_app"
class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline"> class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline">
Rename Rename
<x-external-link /> <x-external-link />
</x-forms.button> </x-forms.button>
</a> </a>
<a href="{{ getInstallationPath($github_app) }}" class="w-fit"> <a href="{{ getInstallationPath($github_app) }}" class="w-fit">
<x-forms.button <x-forms.button canGate="update" :canResource="$github_app"
class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline whitespace-nowrap"> class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline whitespace-nowrap">
Update Repositories Update Repositories
<x-external-link /> <x-external-link />
@ -141,9 +141,9 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans
<div class="flex flex-col sm:flex-row items-start sm:items-end gap-2"> <div class="flex flex-col sm:flex-row items-start sm:items-end gap-2">
<h2>Permissions</h2> <h2>Permissions</h2>
@can('view', $github_app) @can('view', $github_app)
<x-forms.button wire:click.prevent="checkPermissions">Refetch</x-forms.button> <x-forms.button canGate="view" :canResource="$github_app" wire:click.prevent="checkPermissions">Refetch</x-forms.button>
<a href="{{ getPermissionsPath($github_app) }}"> <a href="{{ getPermissionsPath($github_app) }}">
<x-forms.button class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline"> <x-forms.button canGate="view" :canResource="$github_app" class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline">
Update Update
<x-external-link /> <x-external-link />
</x-forms.button> </x-forms.button>
@ -151,11 +151,11 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans
@endcan @endcan
</div> </div>
<div class="flex flex-col sm:flex-row gap-2"> <div class="flex flex-col sm:flex-row gap-2">
<x-forms.input id="contents" helper="read - mandatory." label="Content" readonly <x-forms.input canGate="view" :canResource="$github_app" id="contents" helper="read - mandatory." label="Content" readonly
placeholder="N/A" /> placeholder="N/A" />
<x-forms.input id="metadata" helper="read - mandatory." label="Metadata" readonly <x-forms.input canGate="view" :canResource="$github_app" id="metadata" helper="read - mandatory." label="Metadata" readonly
placeholder="N/A" /> placeholder="N/A" />
<x-forms.input id="pullRequests" <x-forms.input canGate="view" :canResource="$github_app" id="pullRequests"
helper="write access needed to use deployment status update in previews." helper="write access needed to use deployment status update in previews."
label="Pull Request" readonly placeholder="N/A" /> label="Pull Request" readonly placeholder="N/A" />
</div> </div>
@ -167,7 +167,7 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans
No resources are currently using this GitHub App. No resources are currently using this GitHub App.
</div> </div>
@else @else
<x-forms.input placeholder="Search resources..." x-model="search" id="null" /> <x-forms.input canGate="view" :canResource="$github_app" placeholder="Search resources..." x-model="search" id="null" />
<div class="overflow-x-auto pt-4"> <div class="overflow-x-auto pt-4">
<div class="inline-block min-w-full"> <div class="inline-block min-w-full">
<div class="overflow-hidden"> <div class="overflow-hidden">
@ -259,7 +259,7 @@ class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:b
</div> </div>
<div class="flex flex-col gap-3 pt-4 border-t border-neutral-200 dark:border-coolgray-400"> <div class="flex flex-col gap-3 pt-4 border-t border-neutral-200 dark:border-coolgray-400">
@if (!isCloud() || isDev()) @if (!isCloud() || isDev())
<x-forms.select wire:model.live='webhook_endpoint' x-model="webhookEndpoint" label="Webhook Endpoint" <x-forms.select canGate="create" :canResource="$github_app" wire:model.live='webhook_endpoint' x-model="webhookEndpoint" label="Webhook Endpoint"
helper="All Git webhooks will be sent to this endpoint. <br><br>If you would like to use domain instead of IP address, set your Coolify instance's FQDN in the Settings menu."> helper="All Git webhooks will be sent to this endpoint. <br><br>If you would like to use domain instead of IP address, set your Coolify instance's FQDN in the Settings menu.">
@if ($fqdn) @if ($fqdn)
<option value="{{ $fqdn }}">Use {{ $fqdn }}</option> <option value="{{ $fqdn }}">Use {{ $fqdn }}</option>
@ -279,14 +279,14 @@ class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:b
@endif @endif
<div class="flex w-full flex-col gap-2"> <div class="flex w-full flex-col gap-2">
<x-forms.checkbox disabled id="default_permissions" label="Mandatory" <x-forms.checkbox canGate="create" :canResource="$github_app" disabled id="default_permissions" label="Mandatory"
helper="Contents: read<br>Metadata: read<br>Email: read" /> helper="Contents: read<br>Metadata: read<br>Email: read" />
<x-forms.checkbox id="preview_deployment_permissions" label="Preview Deployments" <x-forms.checkbox canGate="create" :canResource="$github_app" id="preview_deployment_permissions" label="Preview Deployments"
helper="Necessary for updating pull requests with useful comments (deployment status, links, etc.)<br><br>Pull Request: read & write" /> helper="Necessary for updating pull requests with useful comments (deployment status, links, etc.)<br><br>Pull Request: read & write" />
</div> </div>
</div> </div>
<div class="mt-auto pt-2"> <div class="mt-auto pt-2">
<x-forms.button class="w-full justify-center" isHighlighted <x-forms.button canGate="create" :canResource="$github_app" class="w-full justify-center" isHighlighted
x-on:click.prevent="createGithubApp(webhookEndpoint, {{ Illuminate\Support\Js::from($preview_deployment_permissions) }}, {{ Illuminate\Support\Js::from($administration) }})"> x-on:click.prevent="createGithubApp(webhookEndpoint, {{ Illuminate\Support\Js::from($preview_deployment_permissions) }}, {{ Illuminate\Support\Js::from($administration) }})">
Register Now Register Now
</x-forms.button> </x-forms.button>
@ -314,7 +314,7 @@ class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-neutral-100 dark:b
</p> </p>
</div> </div>
<div class="mt-auto pt-2"> <div class="mt-auto pt-2">
<x-forms.button class="w-full justify-center" wire:click.prevent="createGithubAppManually"> <x-forms.button canGate="create" :canResource="$github_app" class="w-full justify-center" wire:click.prevent="createGithubAppManually">
Continue Continue
</x-forms.button> </x-forms.button>
</div> </div>

View file

@ -1,5 +1,6 @@
<?php <?php
use App\Actions\Docker\GetContainersStatus;
use App\Livewire\Project\Shared\Destination; use App\Livewire\Project\Shared\Destination;
use App\Models\Application; use App\Models\Application;
use App\Models\Environment; use App\Models\Environment;
@ -10,6 +11,7 @@
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Livewire\Livewire; use Livewire\Livewire;
@ -65,6 +67,10 @@
session(['currentTeam' => $this->teamA]); session(['currentTeam' => $this->teamA]);
}); });
afterEach(function () {
GetContainersStatus::clearFake();
});
describe('Destination::addServer GHSA-j395-3pqh-9r5g', function () { describe('Destination::addServer GHSA-j395-3pqh-9r5g', function () {
test('cannot attach another team\'s server + network to own application', function () { test('cannot attach another team\'s server + network to own application', function () {
try { try {
@ -158,4 +164,69 @@
expect($additional->first()->id)->toBe($this->destinationA->id); expect($additional->first()->id)->toBe($this->destinationA->id);
expect($additional->first()->pivot->server_id)->toBe($this->serverA->id); expect($additional->first()->pivot->server_id)->toBe($this->serverA->id);
}); });
test('refresh failures after promote do not roll back promoted destination', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
GetContainersStatus::shouldRun()
->once()
->andThrow(new RuntimeException('refresh failed'));
try {
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationA2->id, $this->serverA2->id);
} catch (Throwable $e) {
// The refresh failure is intentionally outside the transaction; persistence is the assertion.
}
$application = $this->applicationA->fresh();
$additional = $application->additional_networks;
expect($application->destination_id)->toBe($this->destinationA2->id);
expect($additional)->toHaveCount(1);
expect($additional->first()->id)->toBe($this->destinationA->id);
expect($additional->first()->pivot->server_id)->toBe($this->serverA->id);
});
test('only detaches the promoted network for the selected pivot server', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA->id]);
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('promote', $this->destinationA2->id, $this->serverA2->id);
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA->id)
->exists())->toBeTrue();
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA2->id)
->exists())->toBeFalse();
});
});
describe('Destination::removeServer', function () {
test('only detaches the removed network for the selected pivot server', function () {
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA2->id]);
$this->applicationA->additional_networks()->attach($this->destinationA2->id, ['server_id' => $this->serverA->id]);
Livewire::test(Destination::class, ['resource' => $this->applicationA])
->call('removeServer', $this->destinationA2->id, $this->serverA2->id, 'password');
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA->id)
->exists())->toBeTrue();
expect(DB::table('additional_destinations')
->where('application_id', $this->applicationA->id)
->where('standalone_docker_id', $this->destinationA2->id)
->where('server_id', $this->serverA2->id)
->exists())->toBeFalse();
});
}); });

View file

@ -23,6 +23,23 @@
}); });
describe('GitHub Source Change Component', function () { describe('GitHub Source Change Component', function () {
test('all github app form controls declare explicit authorization', function () {
$view = file_get_contents(resource_path('views/livewire/source/github/change.blade.php'));
preg_match_all(
'/<x-forms\.(button|input|select|checkbox)\b(?![^>]*\bcanGate=)[^>]*>/s',
$view,
$matches,
PREG_OFFSET_CAPTURE
);
$missingAuthorization = collect($matches[0])
->map(fn (array $match): string => 'Line '.(substr_count(substr($view, 0, $match[1]), PHP_EOL) + 1).': '.trim(preg_replace('/\s+/', ' ', $match[0])))
->all();
expect($missingAuthorization)->toBeEmpty();
});
test('can mount with newly created github app with null app_id', function () { test('can mount with newly created github app with null app_id', function () {
// Create a GitHub app without app_id (simulating a newly created source) // Create a GitHub app without app_id (simulating a newly created source)
$githubApp = GithubApp::create([ $githubApp = GithubApp::create([

View file

@ -1,8 +1,10 @@
<?php <?php
use App\Http\Controllers\Api\SentinelController;
use App\Jobs\PushServerUpdateJob; use App\Jobs\PushServerUpdateJob;
use App\Models\Server; use App\Models\Server;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@ -47,6 +49,25 @@ function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): arra
$running = fn () => [['name' => 'app-1', 'state' => 'running', 'health_status' => 'healthy']]; $running = fn () => [['name' => 'app-1', 'state' => 'running', 'health_status' => 'healthy']];
it('skips dispatch decision when sentinel lock acquisition times out', function () use ($running) {
$lock = Mockery::mock();
$lock->shouldReceive('block')
->once()
->with(5, Mockery::type('callable'))
->andThrow(LockTimeoutException::class);
Cache::shouldReceive('lock')
->once()
->with('sentinel:push-lock:'.$this->server->id, 10)
->andReturn($lock);
$controller = new SentinelController;
$method = new ReflectionMethod($controller, 'shouldDispatchUpdate');
$method->setAccessible(true);
expect($method->invoke($controller, $this->server, sentinelPayload($running())))->toBeFalse();
});
it('dispatches the job on the first push', function () use ($running) { it('dispatches the job on the first push', function () use ($running) {
pushSentinel($this->token, sentinelPayload($running()))->assertOk(); pushSentinel($this->token, sentinelPayload($running()))->assertOk();

View file

@ -0,0 +1,32 @@
<?php
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('attaches the root user as owner when reusing an existing root team', function () {
Team::factory()->create(['id' => 0, 'name' => 'Existing Root Team']);
$rootUser = User::factory()->create(['id' => 0]);
expect($rootUser->teams()->whereKey(0)->first()?->pivot?->role)->toBe('owner');
});
it('promotes the root user to owner when the reused root team pivot already exists', function () {
Team::factory()->create(['id' => 0, 'name' => 'Existing Root Team']);
DB::table('team_user')->insert([
'team_id' => 0,
'user_id' => 0,
'role' => 'member',
'created_at' => now(),
'updated_at' => now(),
]);
$rootUser = User::factory()->create(['id' => 0]);
expect($rootUser->teams()->whereKey(0)->first()?->pivot?->role)->toBe('owner');
});