diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index 4a469f09c..734353386 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -8,6 +8,7 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Validator; class SentinelController extends Controller { @@ -77,6 +78,17 @@ public function push(Request $request) return response()->json(['message' => 'Unauthorized'], 401); } + $validator = Validator::make($request->all(), [ + 'containers' => ['required', 'array', 'min:1'], + ]); + + if ($validator->fails()) { + return response()->json(serializeApiResponse([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ]), 422); + } + $data = $request->all(); // Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping. diff --git a/tests/Feature/SentinelPushDeduplicationTest.php b/tests/Feature/SentinelPushDeduplicationTest.php index e5995a687..c4a887da4 100644 --- a/tests/Feature/SentinelPushDeduplicationTest.php +++ b/tests/Feature/SentinelPushDeduplicationTest.php @@ -11,6 +11,8 @@ uses(RefreshDatabase::class); beforeEach(function () { + config(['app.maintenance.store' => 'array']); + Queue::fake(); Cache::flush(); @@ -69,6 +71,25 @@ function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): arra expect(Carbon::parse($this->server->fresh()->sentinel_updated_at)->diffInSeconds(now()))->toBeLessThan(5); }); +it('rejects malformed sentinel payloads before touching server state', function (array $payload) { + $this->server->update(['sentinel_updated_at' => now()->subHour()]); + $originalHeartbeat = $this->server->fresh()->sentinel_updated_at; + + pushSentinel($this->token, $payload) + ->assertUnprocessable() + ->assertJsonPath('message', 'Validation failed.') + ->assertJsonValidationErrors('containers'); + + Queue::assertNotPushed(PushServerUpdateJob::class); + expect($this->server->fresh()->sentinel_updated_at)->toBe($originalHeartbeat); + expect(Cache::has('sentinel:push-hash:'.$this->server->id))->toBeFalse(); + expect(Cache::has('sentinel:push-force:'.$this->server->id))->toBeFalse(); +})->with([ + 'missing containers' => [[]], + 'non-array containers' => [['containers' => 'not-an-array']], + 'empty containers' => [['containers' => []]], +]); + it('dispatches the job when container state changes', function () use ($running) { pushSentinel($this->token, sentinelPayload($running()))->assertOk();