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\Models\Server;
use Exception;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Validator;
@ -119,20 +120,24 @@ private function shouldDispatchUpdate(Server $server, array $data): bool
$forceKey = "sentinel:push-force:{$server->id}";
$lockKey = "sentinel:push-lock:{$server->id}";
return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool {
$cachedHash = Cache::get($hashKey);
$forceActive = Cache::has($forceKey);
try {
return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool {
$cachedHash = Cache::get($hashKey);
$forceActive = Cache::has($forceKey);
$shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive;
$shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive;
if ($shouldDispatch) {
// Day-long TTL bounds memory if a server stops pushing entirely.
Cache::put($hashKey, $hash, now()->addDay());
Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300));
}
if ($shouldDispatch) {
// Day-long TTL bounds memory if a server stops pushing entirely.
Cache::put($hashKey, $hash, now()->addDay());
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\StandaloneDocker;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
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);
$this->authorize('update', $this->resource);
DB::transaction(function () use ($network, $server) {
$this->resource->getConnection()->transaction(function () use ($network, $server) {
$main_destination = $this->resource->destination;
$this->resource->update([
'destination_id' => $network->id,
'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->refreshServers();
$this->resource->refresh();
});
$this->resource->refresh();
$this->refreshServers();
} catch (\Exception $e) {
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);
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->dispatch('refresh');
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->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;
}

View file

@ -70,14 +70,14 @@
</x-forms.button>
@can('update', $github_app)
<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">
Rename
<x-external-link />
</x-forms.button>
</a>
<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">
Update Repositories
<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">
<h2>Permissions</h2>
@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) }}">
<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
<x-external-link />
</x-forms.button>
@ -151,11 +151,11 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans
@endcan
</div>
<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" />
<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" />
<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."
label="Pull Request" readonly placeholder="N/A" />
</div>
@ -167,7 +167,7 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans
No resources are currently using this GitHub App.
</div>
@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="inline-block min-w-full">
<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 class="flex flex-col gap-3 pt-4 border-t border-neutral-200 dark:border-coolgray-400">
@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.">
@if ($fqdn)
<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
<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" />
<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" />
</div>
</div>
<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) }})">
Register Now
</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>
</div>
<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
</x-forms.button>
</div>

View file

@ -1,5 +1,6 @@
<?php
use App\Actions\Docker\GetContainersStatus;
use App\Livewire\Project\Shared\Destination;
use App\Models\Application;
use App\Models\Environment;
@ -10,6 +11,7 @@
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
@ -65,6 +67,10 @@
session(['currentTeam' => $this->teamA]);
});
afterEach(function () {
GetContainersStatus::clearFake();
});
describe('Destination::addServer GHSA-j395-3pqh-9r5g', function () {
test('cannot attach another team\'s server + network to own application', function () {
try {
@ -158,4 +164,69 @@
expect($additional->first()->id)->toBe($this->destinationA->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 () {
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 () {
// Create a GitHub app without app_id (simulating a newly created source)
$githubApp = GithubApp::create([

View file

@ -1,8 +1,10 @@
<?php
use App\Http\Controllers\Api\SentinelController;
use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use App\Models\User;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
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']];
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) {
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');
});