Add normalized email identity rate limiting for registration and forgot-password requests, and refresh Sentinel status from restart broadcasts. Rework server sidebars and navbar for mobile menus and active status visibility.
198 lines
7.4 KiB
PHP
198 lines
7.4 KiB
PHP
<?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;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Queue;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
config(['app.maintenance.store' => 'array']);
|
|
|
|
Queue::fake();
|
|
Cache::flush();
|
|
|
|
$user = User::factory()->create();
|
|
$this->team = $user->teams()->first();
|
|
|
|
$this->server = Server::factory()->create([
|
|
'team_id' => $this->team->id,
|
|
]);
|
|
$this->server->settings->update([
|
|
'is_reachable' => true,
|
|
'is_usable' => true,
|
|
]);
|
|
|
|
$this->token = $this->server->settings->sentinel_token;
|
|
});
|
|
|
|
function pushSentinel(string $token, array $payload)
|
|
{
|
|
return test()->postJson('/api/v1/sentinel/push', $payload, [
|
|
'Authorization' => 'Bearer '.$token,
|
|
]);
|
|
}
|
|
|
|
function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): array
|
|
{
|
|
return [
|
|
'containers' => $containers,
|
|
'filesystem_usage_root' => ['used_percentage' => $diskPercentage],
|
|
];
|
|
}
|
|
|
|
$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();
|
|
|
|
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
|
});
|
|
|
|
it('skips the job when the second push is identical', function () use ($running) {
|
|
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
|
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
|
|
|
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
|
});
|
|
|
|
it('does not audit successful sentinel pushes', function () use ($running) {
|
|
Log::shouldReceive('channel')->with('audit')->never();
|
|
|
|
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
|
|
|
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
|
});
|
|
|
|
it('updates the heartbeat even when the job is skipped', function () use ($running) {
|
|
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
|
|
|
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
|
|
|
|
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
|
|
|
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
|
expect(Carbon::parse($this->server->fresh()->sentinel_updated_at)->diffInSeconds(now()))->toBeLessThan(5);
|
|
});
|
|
|
|
it('accepts an empty container list as a heartbeat when no containers are running', function () {
|
|
$this->server->update(['sentinel_updated_at' => now()->subHour()]);
|
|
|
|
pushSentinel($this->token, sentinelPayload([]))->assertOk();
|
|
|
|
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
|
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']],
|
|
]);
|
|
|
|
it('guards the dedupe decision with a server scoped atomic cache lock', function () {
|
|
$controller = file_get_contents(app_path('Http/Controllers/Api/SentinelController.php'));
|
|
|
|
expect($controller)
|
|
->toContain('$lockKey = "sentinel:push-lock:{$server->id}";')
|
|
->toContain('Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool')
|
|
->toContain('Cache::put($hashKey, $hash, now()->addDay())')
|
|
->toContain("Cache::put(\$forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300))");
|
|
});
|
|
|
|
it('dispatches the job when container state changes', function () use ($running) {
|
|
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
|
|
|
$exited = [['name' => 'app-1', 'state' => 'exited', 'health_status' => 'unhealthy']];
|
|
pushSentinel($this->token, sentinelPayload($exited))->assertOk();
|
|
|
|
Queue::assertPushed(PushServerUpdateJob::class, 2);
|
|
});
|
|
|
|
it('ignores health status changes while container lifecycle state is unchanged', function () {
|
|
$healthy = [['name' => 'app-1', 'state' => 'running', 'health_status' => 'healthy']];
|
|
$unhealthy = [['name' => 'app-1', 'state' => 'running', 'health_status' => 'unhealthy']];
|
|
|
|
pushSentinel($this->token, sentinelPayload($healthy))->assertOk();
|
|
pushSentinel($this->token, sentinelPayload($unhealthy))->assertOk();
|
|
|
|
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
|
});
|
|
|
|
it('ignores disk percentage changes (excluded from the hash)', function () use ($running) {
|
|
pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 42.0))->assertOk();
|
|
pushSentinel($this->token, sentinelPayload($running(), diskPercentage: 88.0))->assertOk();
|
|
|
|
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
|
});
|
|
|
|
it('ignores container reordering (hash is sorted by name)', function () {
|
|
$order1 = [
|
|
['name' => 'app-a', 'state' => 'running', 'health_status' => 'healthy'],
|
|
['name' => 'app-b', 'state' => 'running', 'health_status' => 'healthy'],
|
|
];
|
|
$order2 = [
|
|
['name' => 'app-b', 'state' => 'running', 'health_status' => 'healthy'],
|
|
['name' => 'app-a', 'state' => 'running', 'health_status' => 'healthy'],
|
|
];
|
|
|
|
pushSentinel($this->token, sentinelPayload($order1))->assertOk();
|
|
pushSentinel($this->token, sentinelPayload($order2))->assertOk();
|
|
|
|
Queue::assertPushed(PushServerUpdateJob::class, 1);
|
|
});
|
|
|
|
it('force-dispatches an identical push after the force window expires', function () use ($running) {
|
|
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
|
|
|
// Simulate the force key TTL elapsing.
|
|
Cache::forget('sentinel:push-force:'.$this->server->id);
|
|
|
|
pushSentinel($this->token, sentinelPayload($running()))->assertOk();
|
|
|
|
Queue::assertPushed(PushServerUpdateJob::class, 2);
|
|
});
|
|
|
|
it('rejects an invalid token without dispatching', function () use ($running) {
|
|
pushSentinel('not-a-real-token', sentinelPayload($running()))->assertUnauthorized();
|
|
|
|
Queue::assertNotPushed(PushServerUpdateJob::class);
|
|
});
|