authorizationPolicies as $policyClass) { if (!class_exists($policyClass)) { continue; } $reflection = new \ReflectionClass($policyClass); foreach ($reflection->getConstants() as $constValue) { if (is_string($constValue)) { $supportedAbilities[] = $constValue; } } } if ($supportedAbilities === []) { continue; } foreach ($module['manifest']->uiSlots as $slotName => $contributions) { $contributionList = is_array($contributions) ? $contributions : [$contributions]; foreach ($contributionList as $idx => $contribution) { if (!is_array($contribution)) { continue; } $ability = trim((string) ($contribution['permission'] ?? '')); if ($ability === '') { continue; } self::assertContains( $ability, $supportedAbilities, "Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] permission '{$ability}' " . "is not declared as a constant on any module authorization policy" ); } } } } public function testPermissionsUseObjectContract(): void { foreach (self::$modules as $module) { foreach ($module['manifest']->permissions as $index => $permission) { self::assertArrayHasKey('key', $permission, "Module '{$module['id']}' permissions[{$index}] missing key"); self::assertArrayHasKey('description', $permission, "Module '{$module['id']}' permissions[{$index}] missing description"); self::assertNotEmpty(trim((string) $permission['key']), "Module '{$module['id']}' permissions[{$index}] key must be non-empty"); self::assertNotEmpty(trim((string) $permission['description']), "Module '{$module['id']}' permissions[{$index}] description must be non-empty"); self::assertContains((int) $permission['active'], [0, 1], "Module '{$module['id']}' permissions[{$index}] active must be 0|1"); self::assertContains((int) $permission['is_system'], [0, 1], "Module '{$module['id']}' permissions[{$index}] is_system must be 0|1"); } } } public function testSchedulerJobsUseFullContractSchema(): void { foreach (self::$modules as $module) { foreach ($module['manifest']->schedulerJobs as $index => $job) { $required = [ 'job_key', 'handler', 'label', 'description', 'default_enabled', 'default_timezone', 'default_schedule_type', 'default_schedule_interval', 'default_schedule_time', 'default_schedule_weekdays_csv', 'default_catchup_once', 'allowed_schedule_types', ]; foreach ($required as $requiredKey) { self::assertArrayHasKey( $requiredKey, $job, "Module '{$module['id']}' scheduler_jobs[{$index}] missing '{$requiredKey}'" ); } self::assertNotEmpty(trim((string) $job['job_key']), "Module '{$module['id']}' scheduler_jobs[{$index}] job_key must be non-empty"); self::assertNotEmpty(trim((string) $job['handler']), "Module '{$module['id']}' scheduler_jobs[{$index}] handler must be non-empty"); self::assertNotEmpty(trim((string) $job['label']), "Module '{$module['id']}' scheduler_jobs[{$index}] label must be non-empty"); self::assertContains( (string) $job['default_schedule_type'], ['hourly', 'daily', 'weekly'], "Module '{$module['id']}' scheduler_jobs[{$index}] default_schedule_type invalid" ); $allowedTypes = array_values($job['allowed_schedule_types']); self::assertNotEmpty($allowedTypes, "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types must be non-empty"); foreach ($allowedTypes as $allowedType) { self::assertContains( (string) $allowedType, ['hourly', 'daily', 'weekly'], "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types entry invalid" ); } } } self::addToAssertionCount(1); } public function testUiSlotContributionsHaveRequiredKeys(): void { $slotKeyRequirements = [ 'aside.tab_panel' => ['key', 'label', 'icon', 'permission', 'panel_template'], 'search.resource_item' => ['key', 'label', 'base_url', 'permission'], 'user.edit.aside_action' => ['key', 'type', 'label', 'permission'], 'topbar.right_item' => ['key', 'template'], 'layout.body_end_template' => ['key', 'template'], 'layout.head_style' => ['key', 'path'], 'runtime.component' => ['key', 'script'], ]; foreach (self::$modules as $module) { foreach ($module['manifest']->uiSlots as $slotName => $contributions) { if (!isset($slotKeyRequirements[$slotName])) { continue; } $required = $slotKeyRequirements[$slotName]; $contributionList = is_array($contributions) ? $contributions : [$contributions]; foreach ($contributionList as $idx => $contribution) { if (!is_array($contribution)) { continue; } foreach ($required as $requiredKey) { self::assertArrayHasKey( $requiredKey, $contribution, "Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] is missing required key '{$requiredKey}'" ); } } } } } public function testPanelTemplatesExist(): void { foreach (self::$modules as $module) { $templateSlots = [ 'aside.tab_panel' => 'panel_template', 'topbar.right_item' => 'template', 'layout.body_end_template' => 'template', ]; foreach ($templateSlots as $slotName => $templateKey) { $contributions = $module['manifest']->uiSlots[$slotName] ?? []; if (!is_array($contributions)) { continue; } foreach ($contributions as $contribution) { if (!is_array($contribution)) { continue; } $template = $contribution[$templateKey] ?? null; if ($template === null) { continue; } $templatePath = (string) $template; if (!str_starts_with($templatePath, '/')) { $templatePath = $module['manifest']->basePath . '/' . $templatePath; } self::assertFileExists( $templatePath, "Module '{$module['id']}' {$slotName} {$templateKey} does not exist: {$templatePath}" ); } } } } public function testRuntimeComponentPhasesAreValid(): void { foreach (self::$modules as $module) { $runtimeComponents = $module['manifest']->uiSlots['runtime.component'] ?? []; if (!is_array($runtimeComponents)) { continue; } foreach ($runtimeComponents as $index => $contribution) { if (!is_array($contribution)) { continue; } $phase = strtolower(trim((string) ($contribution['phase'] ?? 'late'))); self::assertContains( $phase, ['early', 'default', 'late'], "Module '{$module['id']}' runtime.component[{$index}] has invalid phase '{$phase}'" ); } } } public function testSessionProvidersUseModulePrefixedKeys(): void { foreach (self::$modules as $module) { foreach ($module['manifest']->sessionProviders as $providerClass) { if (!class_exists($providerClass)) { continue; } $reflection = new \ReflectionClass($providerClass); $file = $reflection->getFileName(); if ($file === false) { continue; } $content = file_get_contents($file); if ($content === false) { continue; } $expectedPrefix = 'module.' . $module['id'] . '.'; if (preg_match_all('/\$_SESSION\[\'([^\']+)\'\]/', $content, $matches)) { foreach ($matches[1] as $key) { self::assertStringStartsWith( $expectedPrefix, $key, "Module '{$module['id']}' session provider '{$providerClass}' uses session key " . "'{$key}' which does not follow the '{$expectedPrefix}*' convention" ); } } if (preg_match_all('/->set\(\s*\'([^\']+)\'/', $content, $matches)) { foreach ($matches[1] as $key) { self::assertStringStartsWith( $expectedPrefix, $key, "Module '{$module['id']}' session provider '{$providerClass}' uses session store key " . "'{$key}' which does not follow the '{$expectedPrefix}*' convention" ); } } } } self::addToAssertionCount(1); } }