diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index f76c58c0f..53b611afa 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -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; + } } /** diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index bab5c59b0..715ce82a7 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -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')); diff --git a/app/Models/User.php b/app/Models/User.php index 990c34b0b..cefdf3d3e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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; } diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 190938221..0769a5732 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -70,14 +70,14 @@ @can('update', $github_app) - Rename - Update Repositories @@ -141,9 +141,9 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans

Permissions

@can('view', $github_app) - Refetch + Refetch
- + Update @@ -151,11 +151,11 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans @endcan
- - -
@@ -167,7 +167,7 @@ class="bg-transparent border-transparent hover:bg-transparent hover:border-trans No resources are currently using this GitHub App. @else - +
@@ -259,7 +259,7 @@ class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:b
@if (!isCloud() || isDev()) - @if ($fqdn) @@ -279,14 +279,14 @@ class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-coollabs/10 dark:b @endif
- -
- Register Now @@ -314,7 +314,7 @@ class="px-2 py-1 text-xs font-bold uppercase tracking-wide bg-neutral-100 dark:b

- + Continue
diff --git a/tests/Feature/CrossTeamDestinationAttachTest.php b/tests/Feature/CrossTeamDestinationAttachTest.php index e90c8334c..f79c9a1e0 100644 --- a/tests/Feature/CrossTeamDestinationAttachTest.php +++ b/tests/Feature/CrossTeamDestinationAttachTest.php @@ -1,5 +1,6 @@ $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(); + }); }); diff --git a/tests/Feature/GithubSourceChangeTest.php b/tests/Feature/GithubSourceChangeTest.php index 2f79de795..8437f09af 100644 --- a/tests/Feature/GithubSourceChangeTest.php +++ b/tests/Feature/GithubSourceChangeTest.php @@ -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( + '/]*\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([ diff --git a/tests/Feature/SentinelPushDeduplicationTest.php b/tests/Feature/SentinelPushDeduplicationTest.php index b61e9933c..c14d5c67e 100644 --- a/tests/Feature/SentinelPushDeduplicationTest.php +++ b/tests/Feature/SentinelPushDeduplicationTest.php @@ -1,8 +1,10 @@ [['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(); diff --git a/tests/Feature/UserRootTeamTest.php b/tests/Feature/UserRootTeamTest.php new file mode 100644 index 000000000..5adaba6d8 --- /dev/null +++ b/tests/Feature/UserRootTeamTest.php @@ -0,0 +1,32 @@ +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'); +});