From cf8c59d3f8d76d811bcf8671d038c58d4d3bdd37 Mon Sep 17 00:00:00 2001 From: fs Date: Sun, 22 Mar 2026 15:08:02 +0100 Subject: [PATCH] feat: add read-only System Info admin page with health checks and module inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- db/init/init.sql | 3 +- .../2026-03-22-system-info-permission.sql | 14 + i18n/default_de.json | 25 +- i18n/default_en.json | 25 +- .../Registrars/AppServicesRegistrar.php | 13 + .../System/SystemHealthRepository.php | 113 ++++++++ .../SystemHealthRepositoryInterface.php | 36 +++ .../Access/OperationsAuthorizationPolicy.php | 3 + lib/Service/Access/PermissionService.php | 1 + lib/Service/Access/UiCapabilityMap.php | 1 + lib/Service/System/SystemHealthService.php | 263 ++++++++++++++++++ lib/Service/System/SystemInfoService.php | 121 ++++++++ pages/admin/system-info/index().php | 16 ++ pages/admin/system-info/index(default).phtml | 182 ++++++++++++ templates/partials/app-main-aside.phtml | 10 +- .../System/SystemHealthServiceTest.php | 195 +++++++++++++ .../Service/System/SystemInfoServiceTest.php | 147 ++++++++++ 17 files changed, 1164 insertions(+), 4 deletions(-) create mode 100644 db/updates/2026-03-22-system-info-permission.sql create mode 100644 lib/Repository/System/SystemHealthRepository.php create mode 100644 lib/Repository/System/SystemHealthRepositoryInterface.php create mode 100644 lib/Service/System/SystemHealthService.php create mode 100644 lib/Service/System/SystemInfoService.php create mode 100644 pages/admin/system-info/index().php create mode 100644 pages/admin/system-info/index(default).phtml create mode 100644 tests/Service/System/SystemHealthServiceTest.php create mode 100644 tests/Service/System/SystemInfoServiceTest.php diff --git a/db/init/init.sql b/db/init/init.sql index 8ac269d..6f436b1 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -1052,6 +1052,7 @@ VALUES ('system_audit.purge', 'Can purge system audit logs', 1, 1), ('api_tokens.manage', 'Can manage user API tokens', 1, 1), ('stats.view', 'Can view statistics', 1, 1), + ('system_info.view', 'Can view system info and health status', 1, 1), ('roles.assign_all', 'Can assign all roles (bypass assignable-roles check)', 1, 1), ('notifications.view', 'Can view and manage own notifications', 1, 1) ON DUPLICATE KEY UPDATE @@ -1153,7 +1154,7 @@ JOIN permissions p ON p.`key` IN ( 'api_docs.view', 'docs.view', 'api_tokens.manage', 'mail_log.view', 'api_audit.view', 'system_audit.view', 'system_audit.purge', - 'stats.view', + 'stats.view', 'system_info.view', 'roles.assign_all' ) WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 diff --git a/db/updates/2026-03-22-system-info-permission.sql b/db/updates/2026-03-22-system-info-permission.sql new file mode 100644 index 0000000..989c3c0 --- /dev/null +++ b/db/updates/2026-03-22-system-info-permission.sql @@ -0,0 +1,14 @@ +-- Add system_info.view permission for existing installs. +-- Idempotent: ON DUPLICATE KEY prevents re-insert. + +INSERT INTO `permissions` (`key`, `description`, `active`, `is_system`) +VALUES ('system_info.view', 'Can view system info and health status', 1, 1) +ON DUPLICATE KEY UPDATE `description` = VALUES(`description`); + +-- Assign to Admin role(s) for existing installs. +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) +SELECT r.id, p.id, NOW() +FROM roles r +JOIN permissions p ON p.`key` = 'system_info.view' +WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 +ON DUPLICATE KEY UPDATE role_id = role_id; diff --git a/i18n/default_de.json b/i18n/default_de.json index 86e5499..47460e9 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -1285,5 +1285,28 @@ "Session expiring": "Sitzung läuft ab", "Your session will expire in {countdown}. Would you like to continue?": "Ihre Sitzung läuft in {countdown} ab. Möchten Sie fortfahren?", "Extend session": "Sitzung verlängern", - "Log out": "Abmelden" + "Log out": "Abmelden", + "System Info": "Systeminfo", + "Health Status": "Systemstatus", + "Check": "Prüfung", + "Details": "Details", + "PHP version": "PHP-Version", + "PHP SAPI": "PHP SAPI", + "Active modules": "Aktive Module", + "No modules active.": "Keine Module aktiv.", + "Module": "Modul", + "Default": "Standard", + "Dependencies": "Abhängigkeiten", + "Permission summary": "Berechtigungsübersicht", + "Active permissions": "Aktive Berechtigungen", + "Inactive (orphaned) permissions": "Inaktive (verwaiste) Berechtigungen", + "Permissions by source": "Berechtigungen nach Quelle", + "Source": "Quelle", + "Count": "Anzahl", + "No health checks available.": "Keine Systemprüfungen verfügbar.", + "Warning": "Warnung", + "Fail": "Fehler", + "Environment": "Umgebung", + "Modules": "Module", + "Version": "Version" } diff --git a/i18n/default_en.json b/i18n/default_en.json index ff41ad2..5ed9629 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -1285,5 +1285,28 @@ "Session expiring": "Session expiring", "Your session will expire in {countdown}. Would you like to continue?": "Your session will expire in {countdown}. Would you like to continue?", "Extend session": "Extend session", - "Log out": "Log out" + "Log out": "Log out", + "System Info": "System Info", + "Health Status": "Health Status", + "Check": "Check", + "Details": "Details", + "PHP version": "PHP version", + "PHP SAPI": "PHP SAPI", + "Active modules": "Active modules", + "No modules active.": "No modules active.", + "Module": "Module", + "Default": "Default", + "Dependencies": "Dependencies", + "Permission summary": "Permission summary", + "Active permissions": "Active permissions", + "Inactive (orphaned) permissions": "Inactive (orphaned) permissions", + "Permissions by source": "Permissions by source", + "Source": "Source", + "Count": "Count", + "No health checks available.": "No health checks available.", + "Warning": "Warning", + "Fail": "Fail", + "Environment": "Environment", + "Modules": "Modules", + "Version": "Version" } diff --git a/lib/App/Container/Registrars/AppServicesRegistrar.php b/lib/App/Container/Registrars/AppServicesRegistrar.php index 5a8066e..57a273a 100644 --- a/lib/App/Container/Registrars/AppServicesRegistrar.php +++ b/lib/App/Container/Registrars/AppServicesRegistrar.php @@ -4,6 +4,7 @@ namespace MintyPHP\App\Container\Registrars; use MintyPHP\App\AppContainer; use MintyPHP\App\Container\ContainerRegistrar; +use MintyPHP\App\Module\ModuleRegistry; use MintyPHP\Http\ApiSystemAuditReporter; use MintyPHP\Http\CookieStore; use MintyPHP\Http\CookieStoreInterface; @@ -42,6 +43,9 @@ use MintyPHP\Service\Security\RateLimiterService; use MintyPHP\Service\Security\SecurityServicesFactory; use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway; use MintyPHP\Service\Stats\AdminStatsViewDataService; +use MintyPHP\Service\System\SystemHealthService; +use MintyPHP\Service\System\SystemInfoService; +use MintyPHP\Repository\System\SystemHealthRepository; final class AppServicesRegistrar implements ContainerRegistrar { @@ -50,6 +54,15 @@ final class AppServicesRegistrar implements ContainerRegistrar $container->set(AdminStatsViewDataService::class, static fn (AppContainer $c): AdminStatsViewDataService => new AdminStatsViewDataService( $c->get(AdminStatsRepository::class) )); + $container->set(SystemHealthRepository::class, static fn (): SystemHealthRepository => new SystemHealthRepository()); + $container->set(SystemHealthService::class, static fn (AppContainer $c): SystemHealthService => new SystemHealthService( + $c->get(SystemHealthRepository::class) + )); + $container->set(SystemInfoService::class, static fn (AppContainer $c): SystemInfoService => new SystemInfoService( + $c->get(SystemHealthService::class), + $c->get(SystemHealthRepository::class), + $c->has(ModuleRegistry::class) ? $c->get(ModuleRegistry::class) : null + )); $container->set(SearchDataService::class, static fn (AppContainer $c): SearchDataService => new SearchDataService( $c->get(PermissionService::class), $c->get(UserTenantRepository::class), diff --git a/lib/Repository/System/SystemHealthRepository.php b/lib/Repository/System/SystemHealthRepository.php new file mode 100644 index 0000000..c87b662 --- /dev/null +++ b/lib/Repository/System/SystemHealthRepository.php @@ -0,0 +1,113 @@ + Table names present in the current schema. + */ + public function listPresentTables(array $requiredTables): array + { + $rows = DB::select( + 'select table_name from information_schema.tables where table_schema = database() and table_name in (???)', + $requiredTables + ); + + $present = []; + foreach ((array) $rows as $row) { + $table = (string) (($row['tables']['table_name'] ?? $row['table_name'] ?? '')); + if ($table !== '') { + $present[] = $table; + } + } + + return array_values(array_unique($present)); + } + + /** + * @return array{last_heartbeat_at: string, last_result: string, last_error_code: string}|null + */ + public function getSchedulerStatus(): ?array + { + $row = DB::selectOne('select last_heartbeat_at, last_result, last_error_code from scheduler_runtime_status where id = 1 limit 1'); + $status = is_array($row) ? ($row['scheduler_runtime_status'] ?? $row) : null; + if (!is_array($status)) { + return null; + } + + return [ + 'last_heartbeat_at' => trim((string) ($status['last_heartbeat_at'] ?? '')), + 'last_result' => trim((string) ($status['last_result'] ?? 'unknown')), + 'last_error_code' => trim((string) ($status['last_error_code'] ?? '')), + ]; + } + + /** + * @return list Active permission keys matching the given list. + */ + public function listActivePermissionKeys(array $keys): array + { + $rows = DB::select( + 'select `key` from permissions where active = 1 and `key` in (???)', + $keys + ); + + $present = []; + foreach ((array) $rows as $row) { + $key = (string) (($row['permissions']['key'] ?? $row['key'] ?? '')); + if ($key !== '') { + $present[] = $key; + } + } + + return array_values(array_unique($present)); + } + + public function countAdminUsers(): int + { + return (int) (DB::selectValue( + 'select count(distinct ur.user_id) from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 where r.description in (?, ?) or r.id = 1', + 'Admin', + 'Administrator' + ) ?? 0); + } + + public function countActivePermissions(): int + { + return (int) (DB::selectValue('select count(*) from permissions where active = 1') ?? 0); + } + + public function countInactivePermissions(): int + { + return (int) (DB::selectValue('select count(*) from permissions where active = 0') ?? 0); + } + + /** + * @return list All active permission keys. + */ + public function listAllActivePermissionKeys(): array + { + $rows = DB::select('select `key` from permissions where active = 1 order by `key`'); + + $keys = []; + foreach ((array) $rows as $row) { + $key = (string) (($row['permissions']['key'] ?? $row['key'] ?? '')); + if ($key !== '') { + $keys[] = $key; + } + } + + return $keys; + } +} diff --git a/lib/Repository/System/SystemHealthRepositoryInterface.php b/lib/Repository/System/SystemHealthRepositoryInterface.php new file mode 100644 index 0000000..162861b --- /dev/null +++ b/lib/Repository/System/SystemHealthRepositoryInterface.php @@ -0,0 +1,36 @@ + $requiredTables + * @return list + */ + public function listPresentTables(array $requiredTables): array; + + /** + * @return array{last_heartbeat_at: string, last_result: string, last_error_code: string}|null + */ + public function getSchedulerStatus(): ?array; + + /** + * @param list $keys + * @return list + */ + public function listActivePermissionKeys(array $keys): array; + + public function countAdminUsers(): int; + + public function countActivePermissions(): int; + + public function countInactivePermissions(): int; + + /** + * @return list + */ + public function listAllActivePermissionKeys(): array; +} diff --git a/lib/Service/Access/OperationsAuthorizationPolicy.php b/lib/Service/Access/OperationsAuthorizationPolicy.php index 54706bc..f3cd5c5 100644 --- a/lib/Service/Access/OperationsAuthorizationPolicy.php +++ b/lib/Service/Access/OperationsAuthorizationPolicy.php @@ -17,6 +17,7 @@ final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterfac public const ABILITY_ADMIN_JOBS_MANAGE = 'ops.admin.jobs.manage'; public const ABILITY_ADMIN_MAIL_LOG_VIEW = 'ops.admin.mail_log.view'; public const ABILITY_ADMIN_STATS_VIEW = 'ops.admin.stats.view'; + public const ABILITY_ADMIN_SYSTEM_INFO_VIEW = 'ops.admin.system_info.view'; public const ABILITY_ADMIN_API_DOCS_VIEW = 'ops.admin.api_docs.view'; public const ABILITY_ADMIN_IMPORTS_TYPE_USERS = 'ops.admin.imports.type.users'; public const ABILITY_ADMIN_IMPORTS_TYPE_DEPARTMENTS = 'ops.admin.imports.type.departments'; @@ -61,6 +62,7 @@ final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterfac self::ABILITY_ADMIN_JOBS_MANAGE, self::ABILITY_ADMIN_MAIL_LOG_VIEW, self::ABILITY_ADMIN_STATS_VIEW, + self::ABILITY_ADMIN_SYSTEM_INFO_VIEW, self::ABILITY_ADMIN_API_DOCS_VIEW, self::ABILITY_ADMIN_IMPORTS_TYPE_USERS, self::ABILITY_ADMIN_IMPORTS_TYPE_DEPARTMENTS, @@ -109,6 +111,7 @@ final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterfac self::ABILITY_ADMIN_JOBS_MANAGE => $this->allowIfHas($actorUserId, PermissionService::JOBS_MANAGE), self::ABILITY_ADMIN_MAIL_LOG_VIEW => $this->allowIfHas($actorUserId, PermissionService::MAIL_LOG_VIEW), self::ABILITY_ADMIN_STATS_VIEW => $this->allowIfHas($actorUserId, PermissionService::STATS_VIEW), + self::ABILITY_ADMIN_SYSTEM_INFO_VIEW => $this->allowIfHas($actorUserId, PermissionService::SYSTEM_INFO_VIEW), self::ABILITY_ADMIN_API_DOCS_VIEW => $this->allowIfHas($actorUserId, PermissionService::API_DOCS_VIEW), self::ABILITY_ADMIN_IMPORTS_TYPE_USERS => $this->allowIfHas($actorUserId, PermissionService::USERS_IMPORT), self::ABILITY_ADMIN_IMPORTS_TYPE_DEPARTMENTS => $this->allowIfHas($actorUserId, PermissionService::DEPARTMENTS_IMPORT), diff --git a/lib/Service/Access/PermissionService.php b/lib/Service/Access/PermissionService.php index 09c7ebb..2c5261e 100644 --- a/lib/Service/Access/PermissionService.php +++ b/lib/Service/Access/PermissionService.php @@ -82,6 +82,7 @@ class PermissionService public const SYSTEM_AUDIT_VIEW = 'system_audit.view'; public const SYSTEM_AUDIT_PURGE = 'system_audit.purge'; public const STATS_VIEW = 'stats.view'; + public const SYSTEM_INFO_VIEW = 'system_info.view'; public const API_TOKENS_MANAGE = 'api_tokens.manage'; public function userHas(int $userId, string $permissionKey): bool diff --git a/lib/Service/Access/UiCapabilityMap.php b/lib/Service/Access/UiCapabilityMap.php index f2aed8f..be63e47 100644 --- a/lib/Service/Access/UiCapabilityMap.php +++ b/lib/Service/Access/UiCapabilityMap.php @@ -29,6 +29,7 @@ final class UiCapabilityMap 'can_view_imports_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_IMPORTS_AUDIT_VIEW, 'can_view_user_lifecycle_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW, 'can_view_stats' => OperationsAuthorizationPolicy::ABILITY_ADMIN_STATS_VIEW, + 'can_view_system_info' => OperationsAuthorizationPolicy::ABILITY_ADMIN_SYSTEM_INFO_VIEW, ]; /** diff --git a/lib/Service/System/SystemHealthService.php b/lib/Service/System/SystemHealthService.php new file mode 100644 index 0000000..91a27fb --- /dev/null +++ b/lib/Service/System/SystemHealthService.php @@ -0,0 +1,263 @@ + + */ + public function runAll(): array + { + return [ + $this->checkDatabase(), + $this->checkDatabaseSchema(), + $this->checkStorageWriteability(), + $this->checkRbacBaseline(), + $this->checkAdminRoleAssignment(), + $this->checkSchedulerHeartbeat(), + ]; + } + + /** + * @return array{status: string, name: string, message: string} + */ + public function checkDatabase(): array + { + try { + $ok = $this->repository->checkDatabaseConnectivity(); + + return [ + 'status' => $ok ? 'ok' : 'fail', + 'name' => 'Database connectivity', + 'message' => $ok ? 'connection established' : 'select 1 did not return expected value', + ]; + } catch (\Throwable $e) { + return [ + 'status' => 'fail', + 'name' => 'Database connectivity', + 'message' => 'check failed', + ]; + } + } + + /** + * @return array{status: string, name: string, message: string} + */ + public function checkDatabaseSchema(): array + { + try { + $present = $this->repository->listPresentTables(self::REQUIRED_TABLES); + sort($present, SORT_STRING); + $missing = array_values(array_diff(self::REQUIRED_TABLES, $present)); + + if ($missing) { + return [ + 'status' => 'fail', + 'name' => 'Database schema', + 'message' => 'missing tables: ' . implode(', ', $missing), + ]; + } + + return [ + 'status' => 'ok', + 'name' => 'Database schema', + 'message' => sprintf('%d core tables present', count(self::REQUIRED_TABLES)), + ]; + } catch (\Throwable $e) { + return [ + 'status' => 'fail', + 'name' => 'Database schema', + 'message' => 'check failed', + ]; + } + } + + /** + * @return array{status: string, name: string, message: string} + */ + public function checkStorageWriteability(): array + { + try { + $storagePath = defined('APP_STORAGE_PATH') && APP_STORAGE_PATH + ? rtrim((string) APP_STORAGE_PATH, '/') + : rtrim(dirname(__DIR__, 3) . '/storage', '/'); + + if (!is_dir($storagePath)) { + return [ + 'status' => 'fail', + 'name' => 'Storage writeability', + 'message' => 'storage directory not found', + ]; + } + if (!is_writable($storagePath)) { + return [ + 'status' => 'fail', + 'name' => 'Storage writeability', + 'message' => 'storage directory not writable', + ]; + } + + $probeFile = $storagePath . '/.doctor-write-probe-' . uniqid('', true); + $written = @file_put_contents($probeFile, 'ok'); + if ($written === false) { + return [ + 'status' => 'fail', + 'name' => 'Storage writeability', + 'message' => 'write probe failed', + ]; + } + @unlink($probeFile); + + return [ + 'status' => 'ok', + 'name' => 'Storage writeability', + 'message' => 'storage path is writable', + ]; + } catch (\Throwable $e) { + return [ + 'status' => 'fail', + 'name' => 'Storage writeability', + 'message' => 'check failed', + ]; + } + } + + /** + * @return array{status: string, name: string, message: string} + */ + public function checkRbacBaseline(): array + { + try { + $present = $this->repository->listActivePermissionKeys(self::REQUIRED_PERMISSIONS); + sort($present, SORT_STRING); + $missing = array_values(array_diff(self::REQUIRED_PERMISSIONS, $present)); + + if ($missing) { + return [ + 'status' => 'fail', + 'name' => 'RBAC baseline', + 'message' => 'missing active permissions: ' . implode(', ', $missing), + ]; + } + + return [ + 'status' => 'ok', + 'name' => 'RBAC baseline', + 'message' => sprintf('%d baseline permissions active', count(self::REQUIRED_PERMISSIONS)), + ]; + } catch (\Throwable $e) { + return [ + 'status' => 'fail', + 'name' => 'RBAC baseline', + 'message' => 'check failed', + ]; + } + } + + /** + * @return array{status: string, name: string, message: string} + */ + public function checkAdminRoleAssignment(): array + { + try { + $count = $this->repository->countAdminUsers(); + + if ($count <= 0) { + return [ + 'status' => 'fail', + 'name' => 'Admin role assignment', + 'message' => 'no active user assigned to Admin/Administrator role', + ]; + } + + return [ + 'status' => 'ok', + 'name' => 'Admin role assignment', + 'message' => sprintf('%d admin user(s) assigned', $count), + ]; + } catch (\Throwable $e) { + return [ + 'status' => 'fail', + 'name' => 'Admin role assignment', + 'message' => 'check failed', + ]; + } + } + + /** + * @return array{status: string, name: string, message: string} + */ + public function checkSchedulerHeartbeat(): array + { + try { + $status = $this->repository->getSchedulerStatus(); + + if ($status === null) { + return [ + 'status' => 'warn', + 'name' => 'Scheduler heartbeat', + 'message' => 'no scheduler runtime status row found yet', + ]; + } + + $heartbeat = $status['last_heartbeat_at']; + $result = $status['last_result']; + $errorCode = $status['last_error_code']; + + if ($heartbeat === '') { + return [ + 'status' => 'warn', + 'name' => 'Scheduler heartbeat', + 'message' => 'scheduler heartbeat is empty', + ]; + } + + $seconds = max(0, time() - strtotime($heartbeat . ' UTC')); + $detail = "last heartbeat {$seconds}s ago (result={$result}" . ($errorCode !== '' ? ", error={$errorCode}" : '') . ')'; + + return [ + 'status' => $seconds > self::SCHEDULER_STALE_THRESHOLD_SECONDS ? 'warn' : 'ok', + 'name' => 'Scheduler heartbeat', + 'message' => $detail, + ]; + } catch (\Throwable $e) { + return [ + 'status' => 'fail', + 'name' => 'Scheduler heartbeat', + 'message' => 'check failed', + ]; + } + } +} diff --git a/lib/Service/System/SystemInfoService.php b/lib/Service/System/SystemInfoService.php new file mode 100644 index 0000000..7257080 --- /dev/null +++ b/lib/Service/System/SystemInfoService.php @@ -0,0 +1,121 @@ + + */ + public function buildPageData(): array + { + return [ + 'overview' => $this->buildOverview(), + 'modules' => $this->buildModuleInventory(), + 'permissions' => $this->buildPermissionSummary(), + ]; + } + + /** + * @return array + */ + private function buildOverview(): array + { + return [ + 'php_version' => PHP_VERSION, + 'php_sapi' => PHP_SAPI, + 'app_environment' => $this->resolveEnvironment(), + 'health_checks' => $this->healthService->runAll(), + ]; + } + + /** + * @return list, permissions_count: int}> + */ + private function buildModuleInventory(): array + { + if ($this->moduleRegistry === null) { + return []; + } + + $modules = []; + foreach ($this->moduleRegistry->getModules() as $manifest) { + $modules[] = [ + 'id' => $manifest->id, + 'version' => $manifest->version, + 'enabled_by_default' => $manifest->enabledByDefault, + 'requires' => $manifest->requires, + 'permissions_count' => count($manifest->permissions), + ]; + } + + usort($modules, static fn (array $a, array $b): int => strcmp($a['id'], $b['id'])); + + return $modules; + } + + /** + * @return array{active_count: int, inactive_count: int, by_source: array} + */ + private function buildPermissionSummary(): array + { + $activeKeys = $this->healthRepository->listAllActivePermissionKeys(); + $bySource = $this->derivePermissionsBySource($activeKeys); + + return [ + 'active_count' => $this->healthRepository->countActivePermissions(), + 'inactive_count' => $this->healthRepository->countInactivePermissions(), + 'by_source' => $bySource, + ]; + } + + /** + * @param list $activeKeys + * @return array + */ + private function derivePermissionsBySource(array $activeKeys): array + { + $moduleKeys = []; + if ($this->moduleRegistry !== null) { + foreach ($this->moduleRegistry->getModules() as $manifest) { + foreach ($manifest->permissions as $perm) { + $key = $perm['key'] ?? ''; + if ($key !== '') { + $moduleKeys[$key] = $manifest->id; + } + } + } + } + + $counts = []; + foreach ($activeKeys as $key) { + $source = $moduleKeys[$key] ?? 'core'; + $counts[$source] = ($counts[$source] ?? 0) + 1; + } + + ksort($counts); + + return $counts; + } + + private function resolveEnvironment(): string + { + if (defined('APP_ENV')) { + return (string) APP_ENV; + } + + $env = getenv('APP_ENV'); + + return is_string($env) && $env !== '' ? $env : 'unknown'; + } +} diff --git a/pages/admin/system-info/index().php b/pages/admin/system-info/index().php new file mode 100644 index 0000000..aa2c143 --- /dev/null +++ b/pages/admin/system-info/index().php @@ -0,0 +1,16 @@ +buildPageData(); +$overview = $pageData['overview'] ?? []; +$modules = $pageData['modules'] ?? []; +$permissions = $pageData['permissions'] ?? []; +$healthChecks = $overview['health_checks'] ?? []; + +Buffer::set('title', t('System Info')); diff --git a/pages/admin/system-info/index(default).phtml b/pages/admin/system-info/index(default).phtml new file mode 100644 index 0000000..218407f --- /dev/null +++ b/pages/admin/system-info/index(default).phtml @@ -0,0 +1,182 @@ +, permissions_count: int}> $modules + * @var array{active_count: int, inactive_count: int, by_source: array} $permissions + * @var list $healthChecks + */ + +$overview = $overview ?? []; +$modules = $modules ?? []; +$permissions = $permissions ?? []; +$healthChecks = $healthChecks ?? []; + +?> + +
+

+
+
+
+
+
+ + + +
+ + +
+
+
+ +
+ + + + + + + + + + + + + + + +
+
+ +
+
+ +
+ +
+

+
+ + + + + + + + + + + + + + + + + + + +
+ + OK + + + + + +
+ +
+
+ + +
+ +
+

+
+ +
+
+ () +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + +
+
+
+ +
+ + + + + + + + + + + +
+
+ + + +
+
+ +
+ + + + + + + + + $count): ?> + + + + + + +
+
+ +
+
+
diff --git a/templates/partials/app-main-aside.phtml b/templates/partials/app-main-aside.phtml index b8e34bf..fac6053 100644 --- a/templates/partials/app-main-aside.phtml +++ b/templates/partials/app-main-aside.phtml @@ -19,6 +19,7 @@ $canViewSystemAudit = (bool) ($layoutAuth['can_view_system_audit'] ?? false); $canViewImportsAudit = (bool) ($layoutAuth['can_view_imports_audit'] ?? false); $canViewUserLifecycleAudit = (bool) ($layoutAuth['can_view_user_lifecycle_audit'] ?? false); $canViewStats = (bool) ($layoutAuth['can_view_stats'] ?? false); +$canViewSystemInfo = (bool) ($layoutAuth['can_view_system_info'] ?? false); $docsDefaultSlug = DocsCatalogService::defaultSlug(); $docsDefaultPath = 'admin/docs/' . $docsDefaultSlug; $hasOrganization = $canViewTenants || $canViewDepartments || $canViewUsers; @@ -26,7 +27,7 @@ $hasUsersSection = $canViewRoles || $canViewPermissions; $hasAutomationSection = $canViewImports || $canViewJobs || $canViewApiDocs; $hasMonitoringSection = $canViewStats; $hasLogsSection = $canViewMailLog || $canViewApiAudit || $canViewSystemAudit || $canViewImportsAudit || $canViewUserLifecycleAudit; -$hasSystemSection = $canViewSettings || $canViewDocs; +$hasSystemSection = $canViewSettings || $canViewSystemInfo || $canViewDocs; $hasAdminPanel = layoutHasAdminPanel($layoutAuth); // Declarative nav config for admin panel groups @@ -182,6 +183,13 @@ $adminNavGroups = [ 'visible' => $canViewSettings, 'withTenant' => false, ], + [ + 'label' => t('System Info'), + 'path' => 'admin/system-info', + 'active' => navActive('admin/system-info', true), + 'visible' => $canViewSystemInfo, + 'withTenant' => false, + ], [ 'label' => t('Documentation'), 'path' => $docsDefaultPath, diff --git a/tests/Service/System/SystemHealthServiceTest.php b/tests/Service/System/SystemHealthServiceTest.php new file mode 100644 index 0000000..b1e2783 --- /dev/null +++ b/tests/Service/System/SystemHealthServiceTest.php @@ -0,0 +1,195 @@ +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); + } + } +} diff --git a/tests/Service/System/SystemInfoServiceTest.php b/tests/Service/System/SystemInfoServiceTest.php new file mode 100644 index 0000000..006ac74 --- /dev/null +++ b/tests/Service/System/SystemInfoServiceTest.php @@ -0,0 +1,147 @@ +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', + ' 'alpha', + 'version' => '1.0.0', + 'enabled_by_default' => true, + ], true) . ';' + ); + + mkdir($this->fixturesDir . '/beta', 0777, true); + file_put_contents( + $this->fixturesDir . '/beta/module.php', + ' '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); + } +}