feat: add read-only System Info admin page with health checks and module inventory

New page at /admin/system-info with three tabs:
- Overview: PHP version, SAPI, environment, 6 health checks (DB, schema,
  storage, RBAC baseline, admin role, scheduler heartbeat)
- Modules: table of active modules with version, dependencies, permission count
- Permissions: active/inactive counts and per-source breakdown

Gated behind new system_info.view permission assigned to Admin role.
No mutations — purely diagnostic/observability.

Includes SystemHealthService, SystemInfoService, SystemHealthRepository
with interface, DI registration, i18n keys (de+en), idempotent DB update
script, and 17 new PHPUnit tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 15:08:02 +01:00
parent be8bf496cb
commit cf8c59d3f8
17 changed files with 1164 additions and 4 deletions

View File

@@ -0,0 +1,195 @@
<?php
namespace MintyPHP\Tests\Service\System;
use MintyPHP\Repository\System\SystemHealthRepositoryInterface;
use MintyPHP\Service\System\SystemHealthService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class SystemHealthServiceTest extends TestCase
{
private SystemHealthRepositoryInterface&MockObject $repository;
private SystemHealthService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(SystemHealthRepositoryInterface::class);
$this->service = new SystemHealthService($this->repository);
}
public function testCheckDatabaseReturnsOkWhenConnected(): void
{
$this->repository->expects($this->once())
->method('checkDatabaseConnectivity')
->willReturn(true);
$result = $this->service->checkDatabase();
self::assertSame('ok', $result['status']);
self::assertSame('Database connectivity', $result['name']);
}
public function testCheckDatabaseReturnsFailWhenDisconnected(): void
{
$this->repository->expects($this->once())
->method('checkDatabaseConnectivity')
->willReturn(false);
$result = $this->service->checkDatabase();
self::assertSame('fail', $result['status']);
}
public function testCheckDatabaseReturnsFailOnException(): void
{
$this->repository->expects($this->once())
->method('checkDatabaseConnectivity')
->willThrowException(new \RuntimeException('Connection refused'));
$result = $this->service->checkDatabase();
self::assertSame('fail', $result['status']);
self::assertSame('check failed', $result['message']);
}
public function testCheckDatabaseSchemaReturnsOkWhenAllTablesPresent(): void
{
$this->repository->expects($this->once())
->method('listPresentTables')
->willReturn([
'users', 'roles', 'permissions', 'user_roles', 'role_permissions',
'tenants', 'departments', 'settings', 'scheduler_runtime_status',
]);
$result = $this->service->checkDatabaseSchema();
self::assertSame('ok', $result['status']);
}
public function testCheckDatabaseSchemaReturnsFailWhenTablesMissing(): void
{
$this->repository->expects($this->once())
->method('listPresentTables')
->willReturn(['users', 'roles']);
$result = $this->service->checkDatabaseSchema();
self::assertSame('fail', $result['status']);
self::assertStringContainsString('missing tables', $result['message']);
}
public function testCheckRbacBaselineReturnsOkWhenAllPermissionsActive(): void
{
$this->repository->expects($this->once())
->method('listActivePermissionKeys')
->willReturn([
'users.view', 'tenants.view', 'departments.view',
'roles.view', 'permissions.view', 'settings.view',
]);
$result = $this->service->checkRbacBaseline();
self::assertSame('ok', $result['status']);
}
public function testCheckRbacBaselineReturnsFailWhenPermissionsMissing(): void
{
$this->repository->expects($this->once())
->method('listActivePermissionKeys')
->willReturn(['users.view']);
$result = $this->service->checkRbacBaseline();
self::assertSame('fail', $result['status']);
self::assertStringContainsString('missing active permissions', $result['message']);
}
public function testCheckAdminRoleAssignmentReturnsOkWhenAdminsExist(): void
{
$this->repository->expects($this->once())
->method('countAdminUsers')
->willReturn(2);
$result = $this->service->checkAdminRoleAssignment();
self::assertSame('ok', $result['status']);
self::assertStringContainsString('2 admin user(s)', $result['message']);
}
public function testCheckAdminRoleAssignmentReturnsFailWhenNoAdmins(): void
{
$this->repository->expects($this->once())
->method('countAdminUsers')
->willReturn(0);
$result = $this->service->checkAdminRoleAssignment();
self::assertSame('fail', $result['status']);
}
public function testCheckSchedulerHeartbeatReturnsWarnWhenNoStatusRow(): void
{
$this->repository->expects($this->once())
->method('getSchedulerStatus')
->willReturn(null);
$result = $this->service->checkSchedulerHeartbeat();
self::assertSame('warn', $result['status']);
}
public function testCheckSchedulerHeartbeatReturnsOkWhenRecent(): void
{
$this->repository->expects($this->once())
->method('getSchedulerStatus')
->willReturn([
'last_heartbeat_at' => gmdate('Y-m-d H:i:s', time() - 10),
'last_result' => 'ok',
'last_error_code' => '',
]);
$result = $this->service->checkSchedulerHeartbeat();
self::assertSame('ok', $result['status']);
}
public function testCheckSchedulerHeartbeatReturnsWarnWhenStale(): void
{
$this->repository->expects($this->once())
->method('getSchedulerStatus')
->willReturn([
'last_heartbeat_at' => gmdate('Y-m-d H:i:s', time() - 600),
'last_result' => 'ok',
'last_error_code' => '',
]);
$result = $this->service->checkSchedulerHeartbeat();
self::assertSame('warn', $result['status']);
}
public function testRunAllReturnsAllSixChecks(): void
{
$this->repository->method('checkDatabaseConnectivity')->willReturn(true);
$this->repository->method('listPresentTables')->willReturn([
'users', 'roles', 'permissions', 'user_roles', 'role_permissions',
'tenants', 'departments', 'settings', 'scheduler_runtime_status',
]);
$this->repository->method('listActivePermissionKeys')->willReturn([
'users.view', 'tenants.view', 'departments.view',
'roles.view', 'permissions.view', 'settings.view',
]);
$this->repository->method('countAdminUsers')->willReturn(1);
$this->repository->method('getSchedulerStatus')->willReturn(null);
$results = $this->service->runAll();
self::assertCount(6, $results);
foreach ($results as $check) {
self::assertArrayHasKey('status', $check);
self::assertArrayHasKey('name', $check);
self::assertArrayHasKey('message', $check);
}
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace MintyPHP\Tests\Service\System;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Repository\System\SystemHealthRepositoryInterface;
use MintyPHP\Service\System\SystemHealthService;
use MintyPHP\Service\System\SystemInfoService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class SystemInfoServiceTest extends TestCase
{
private SystemHealthService&MockObject $healthService;
private SystemHealthRepositoryInterface&MockObject $healthRepository;
private string $fixturesDir;
protected function setUp(): void
{
$this->healthService = $this->createMock(SystemHealthService::class);
$this->healthRepository = $this->createMock(SystemHealthRepositoryInterface::class);
$this->fixturesDir = sys_get_temp_dir() . '/system-info-test-' . uniqid();
mkdir($this->fixturesDir, 0777, true);
}
protected function tearDown(): void
{
$this->removeDir($this->fixturesDir);
}
public function testBuildPageDataReturnsExpectedStructure(): void
{
$service = new SystemInfoService($this->healthService, $this->healthRepository, null);
$this->healthService->expects($this->once())
->method('runAll')
->willReturn([
['status' => 'ok', 'name' => 'DB', 'message' => 'connected'],
]);
$this->healthRepository->method('countActivePermissions')->willReturn(10);
$this->healthRepository->method('countInactivePermissions')->willReturn(2);
$this->healthRepository->method('listAllActivePermissionKeys')->willReturn(['users.view', 'settings.view']);
$data = $service->buildPageData();
self::assertArrayHasKey('overview', $data);
self::assertArrayHasKey('modules', $data);
self::assertArrayHasKey('permissions', $data);
self::assertSame(PHP_VERSION, $data['overview']['php_version']);
self::assertIsArray($data['overview']['health_checks']);
self::assertCount(1, $data['overview']['health_checks']);
}
public function testModuleInventoryReturnsSortedList(): void
{
$registry = $this->createRegistryWithModules();
$service = new SystemInfoService($this->healthService, $this->healthRepository, $registry);
$this->healthService->method('runAll')->willReturn([]);
$this->healthRepository->method('countActivePermissions')->willReturn(0);
$this->healthRepository->method('countInactivePermissions')->willReturn(0);
$this->healthRepository->method('listAllActivePermissionKeys')->willReturn([]);
$data = $service->buildPageData();
self::assertCount(2, $data['modules']);
self::assertSame('alpha', $data['modules'][0]['id']);
self::assertSame('beta', $data['modules'][1]['id']);
self::assertSame('2.0.0', $data['modules'][1]['version']);
self::assertSame(['alpha'], $data['modules'][1]['requires']);
}
public function testPermissionSummaryReturnsCorrectCounts(): void
{
$service = new SystemInfoService($this->healthService, $this->healthRepository, null);
$this->healthService->method('runAll')->willReturn([]);
$this->healthRepository->expects($this->once())->method('countActivePermissions')->willReturn(42);
$this->healthRepository->expects($this->once())->method('countInactivePermissions')->willReturn(3);
$this->healthRepository->expects($this->once())->method('listAllActivePermissionKeys')->willReturn([
'users.view', 'settings.view',
]);
$data = $service->buildPageData();
self::assertSame(42, $data['permissions']['active_count']);
self::assertSame(3, $data['permissions']['inactive_count']);
self::assertSame(['core' => 2], $data['permissions']['by_source']);
}
public function testBuildPageDataWorksWithoutModuleRegistry(): void
{
$service = new SystemInfoService($this->healthService, $this->healthRepository, null);
$this->healthService->method('runAll')->willReturn([]);
$this->healthRepository->method('countActivePermissions')->willReturn(5);
$this->healthRepository->method('countInactivePermissions')->willReturn(0);
$this->healthRepository->method('listAllActivePermissionKeys')->willReturn([]);
$data = $service->buildPageData();
self::assertSame([], $data['modules']);
}
private function createRegistryWithModules(): ModuleRegistry
{
mkdir($this->fixturesDir . '/alpha', 0777, true);
file_put_contents(
$this->fixturesDir . '/alpha/module.php',
'<?php return ' . var_export([
'id' => 'alpha',
'version' => '1.0.0',
'enabled_by_default' => true,
], true) . ';'
);
mkdir($this->fixturesDir . '/beta', 0777, true);
file_put_contents(
$this->fixturesDir . '/beta/module.php',
'<?php return ' . var_export([
'id' => 'beta',
'version' => '2.0.0',
'enabled_by_default' => false,
'requires' => ['alpha'],
], true) . ';'
);
return ModuleRegistry::boot($this->fixturesDir, ['alpha', 'beta']);
}
private function removeDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($dir);
}
}