feat(config): add central maintenance mode
Adds a platform-wide maintenance switch controlled from the admin panel. A single kernel.request subscriber (priority 6, after the firewall listener) short-circuits every request with 503, so no controller has to check it and all API clients — the admin SPA, nobat724_front and clinic-pro-tauri — are covered at once. - SiteConfig gains five maintenance_* keys; no entity change, no migration - MaintenanceService caches the state in Redis for 30s and is fail-open: a Redis or database failure never takes the site down by itself - API responses reuse the BaseController::error() envelope with code MAINTENANCE_MODE plus a Retry-After header; browsers get a self-contained Twig page (inline CSS, noindex) that renders even mid-deploy - Whitelist keeps /oauth/*, the login endpoints and /api/v1/admin/settings reachable, otherwise an admin could neither sign in nor switch it back off - Admin bypass falls back to decoding the Authorization JWT, because several admin-panel endpoints sit in the public_endpoints firewall (security: false) where no token is ever resolved and isGranted always returns false - A kernel.exception handler at priority 20 covers routing 404/405 and firewall 401, which are thrown before the request listener runs - app:maintenance on|off|status is the escape hatch when the panel is down Also removes a stray `APP_SECRET = ...` line from .env.dev: the spaces around `=` are rejected by Symfony Dotenv, which made every console command and the whole app fatal. The secret already lives in .env.local, as the comment above that line instructs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Shared;
|
||||
|
||||
use App\Config\Repository\SiteConfigRepository;
|
||||
use App\Config\Service\MaintenanceService;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* Maintenance mode is enforced centrally, so these cases cover the three ways a
|
||||
* request can legitimately get through (whitelist, allowed IP, admin) plus the
|
||||
* blocking behaviour for everyone else.
|
||||
*/
|
||||
class MaintenanceModeTest extends ApiTestCase
|
||||
{
|
||||
private SiteConfigRepository $configRepo;
|
||||
private MaintenanceService $maintenance;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->configRepo = static::getContainer()->get(SiteConfigRepository::class);
|
||||
$this->maintenance = static::getContainer()->get(MaintenanceService::class);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->setMaintenance('0');
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function setMaintenance(string $enabled, string $allowedIps = ''): void
|
||||
{
|
||||
$this->configRepo->set('maintenance_enabled', $enabled);
|
||||
$this->configRepo->set('maintenance_allowed_ips', $allowedIps);
|
||||
static::getContainer()->get(EntityManagerInterface::class)->flush();
|
||||
$this->maintenance->invalidate();
|
||||
}
|
||||
|
||||
public function testAnonymousApiRequestIsBlockedWithMaintenanceEnvelope(): void
|
||||
{
|
||||
$this->setMaintenance('1');
|
||||
|
||||
$this->client->request('GET', '/api/v1/doctors');
|
||||
$response = $this->client->getResponse();
|
||||
$body = json_decode($response->getContent(), true);
|
||||
|
||||
self::assertSame(503, $response->getStatusCode());
|
||||
self::assertSame('MAINTENANCE_MODE', $body['errors'][0]['code']);
|
||||
self::assertFalse($body['success']);
|
||||
self::assertNull($body['data']);
|
||||
self::assertNotEmpty($response->headers->get('Retry-After'));
|
||||
}
|
||||
|
||||
public function testRoutingErrorsAlsoReturnMaintenance(): void
|
||||
{
|
||||
$this->setMaintenance('1');
|
||||
|
||||
// 404 is thrown by the router at a higher priority than the request
|
||||
// listener, so it is only covered by the kernel.exception path.
|
||||
$this->client->request('GET', '/api/v1/definitely-not-a-route');
|
||||
|
||||
self::assertSame(503, $this->responseCode());
|
||||
self::assertSame(
|
||||
'MAINTENANCE_MODE',
|
||||
json_decode($this->client->getResponse()->getContent(), true)['errors'][0]['code'],
|
||||
);
|
||||
}
|
||||
|
||||
public function testAdminBypassesMaintenance(): void
|
||||
{
|
||||
$this->setMaintenance('1');
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
|
||||
$this->authJson('GET', '/api/v1/admin/pre-registrations', $admin);
|
||||
|
||||
self::assertNotSame(503, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testNonAdminRoleIsBlocked(): void
|
||||
{
|
||||
$this->setMaintenance('1');
|
||||
$doctor = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/admin/pre-registrations', $doctor);
|
||||
|
||||
self::assertSame(503, $this->responseCode());
|
||||
self::assertSame('MAINTENANCE_MODE', $body['errors'][0]['code']);
|
||||
}
|
||||
|
||||
public function testSettingsEndpointStaysReachableSoMaintenanceCanBeTurnedOff(): void
|
||||
{
|
||||
$this->setMaintenance('1');
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
|
||||
$this->authJson('GET', '/api/v1/admin/settings', $admin);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAllowedIpBypassesWithoutAuthentication(): void
|
||||
{
|
||||
$this->setMaintenance('1', '127.0.0.1, 10.0.0.1');
|
||||
|
||||
$this->client->request('GET', '/api/v1/doctors', server: ['REMOTE_ADDR' => '10.0.0.1']);
|
||||
|
||||
self::assertNotSame(503, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testDisabledMaintenanceLetsEveryoneThrough(): void
|
||||
{
|
||||
$this->setMaintenance('0');
|
||||
|
||||
$this->client->request('GET', '/api/v1/doctors');
|
||||
|
||||
self::assertNotSame(503, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user