feat(modules): harden module platform with event constants, validation checks, and CLI improvements
- Add ModuleEvents constants class with all 8 event strings; update dispatch call sites - ValidateCommand: check AuthorizationPolicy container registration (check 20) - ValidateCommand: warn on unknown event_listeners keys (check 21) - make:module --simple flag for minimal manifests - module:deactivate reverse-dependency warning - 12 new PHPUnit tests covering all improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
42
core/App/Module/ModuleEvents.php
Normal file
42
core/App/Module/ModuleEvents.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MintyPHP\App\Module;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central registry of all module event names dispatched by core.
|
||||||
|
*
|
||||||
|
* Modules reference these constants in their event_listeners manifest declarations
|
||||||
|
* and in listener implementations. ValidateCommand warns on unknown event names.
|
||||||
|
*/
|
||||||
|
final class ModuleEvents
|
||||||
|
{
|
||||||
|
public const USER_LOGIN = 'user.login';
|
||||||
|
public const USER_LOGOUT = 'user.logout';
|
||||||
|
public const USER_CREATED = 'user.created';
|
||||||
|
public const USER_DELETED = 'user.deleted';
|
||||||
|
public const USER_ACTIVATED = 'user.activated';
|
||||||
|
public const USER_DEACTIVATED = 'user.deactivated';
|
||||||
|
public const USER_ASSIGNMENT_CHANGED = 'user.assignment_changed';
|
||||||
|
public const SCHEDULER_JOB_FAILED = 'scheduler.job_failed';
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::USER_LOGIN,
|
||||||
|
self::USER_LOGOUT,
|
||||||
|
self::USER_CREATED,
|
||||||
|
self::USER_DELETED,
|
||||||
|
self::USER_ACTIVATED,
|
||||||
|
self::USER_DEACTIVATED,
|
||||||
|
self::USER_ASSIGNMENT_CHANGED,
|
||||||
|
self::SCHEDULER_JOB_FAILED,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,17 +35,22 @@ final class ModuleCommand extends Command
|
|||||||
public function usage(): string
|
public function usage(): string
|
||||||
{
|
{
|
||||||
return <<<'USAGE'
|
return <<<'USAGE'
|
||||||
Usage: php bin/console make:module <module-id>
|
Usage: php bin/console make:module <module-id> [--simple]
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
module-id Lowercase identifier (e.g. notifications, activity-log)
|
module-id Lowercase identifier (e.g. notifications, activity-log)
|
||||||
Must match [a-z][a-z0-9-]* pattern.
|
Must match [a-z][a-z0-9-]* pattern.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--simple Generate a minimal manifest with only essential fields.
|
||||||
|
Ideal for lightweight UI-only modules.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
php bin/console make:module notifications
|
php bin/console make:module notifications
|
||||||
|
php bin/console make:module help-panel --simple
|
||||||
|
|
||||||
Generates the full module directory structure with:
|
Generates the full module directory structure with:
|
||||||
- module.php manifest (all fields, sensible defaults)
|
- module.php manifest (all fields or minimal with --simple)
|
||||||
- ContainerRegistrar stub
|
- ContainerRegistrar stub
|
||||||
- AuthorizationPolicy stub
|
- AuthorizationPolicy stub
|
||||||
- Empty pages/, templates/, web/css/, web/js/, tests/ directories
|
- Empty pages/, templates/, web/css/, web/js/, tests/ directories
|
||||||
@@ -96,8 +101,11 @@ final class ModuleCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate files
|
// Generate files
|
||||||
|
$simple = $options['simple'] ?? false;
|
||||||
$files = [
|
$files = [
|
||||||
'module.php' => self::manifestTemplate($moduleId, $pascalName, $snakeName),
|
'module.php' => $simple
|
||||||
|
? self::simpleManifestTemplate($moduleId, $pascalName)
|
||||||
|
: self::manifestTemplate($moduleId, $pascalName, $snakeName),
|
||||||
"lib/Module/{$pascalName}/{$pascalName}ContainerRegistrar.php" => self::registrarTemplate($pascalName),
|
"lib/Module/{$pascalName}/{$pascalName}ContainerRegistrar.php" => self::registrarTemplate($pascalName),
|
||||||
"lib/Module/{$pascalName}/{$pascalName}AuthorizationPolicy.php" => self::policyTemplate($pascalName, $snakeName),
|
"lib/Module/{$pascalName}/{$pascalName}AuthorizationPolicy.php" => self::policyTemplate($pascalName, $snakeName),
|
||||||
];
|
];
|
||||||
@@ -199,6 +207,37 @@ final class ModuleCommand extends Command
|
|||||||
PHP;
|
PHP;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function simpleManifestTemplate(string $id, string $pascalName): string
|
||||||
|
{
|
||||||
|
$registrarClass = "\\MintyPHP\\Module\\{$pascalName}\\{$pascalName}ContainerRegistrar::class";
|
||||||
|
$policyClass = "\\MintyPHP\\Module\\{$pascalName}\\{$pascalName}AuthorizationPolicy::class";
|
||||||
|
|
||||||
|
return <<<PHP
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {$pascalName} module manifest (minimal).
|
||||||
|
*
|
||||||
|
* Extend with routes, permissions, ui_slots, etc. as needed.
|
||||||
|
* See docs/howto-modul-erstellen.md for all available fields.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'id' => '{$id}',
|
||||||
|
'version' => '0.1.0',
|
||||||
|
'enabled_by_default' => false,
|
||||||
|
'load_order' => 100,
|
||||||
|
'requires' => [],
|
||||||
|
'container_registrars' => [
|
||||||
|
{$registrarClass},
|
||||||
|
],
|
||||||
|
'authorization_policies' => [
|
||||||
|
{$policyClass},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
PHP;
|
||||||
|
}
|
||||||
|
|
||||||
private static function registrarTemplate(string $pascalName): string
|
private static function registrarTemplate(string $pascalName): string
|
||||||
{
|
{
|
||||||
return <<<PHP
|
return <<<PHP
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use MintyPHP\App\Module\Contracts\LayoutContextProvider;
|
|||||||
use MintyPHP\App\Module\Contracts\ModuleDeactivationHandler;
|
use MintyPHP\App\Module\Contracts\ModuleDeactivationHandler;
|
||||||
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
|
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
|
||||||
use MintyPHP\App\Module\Contracts\SessionProvider;
|
use MintyPHP\App\Module\Contracts\SessionProvider;
|
||||||
|
use MintyPHP\App\Module\ModuleEvents;
|
||||||
use MintyPHP\App\Module\ModuleManifestLoader;
|
use MintyPHP\App\Module\ModuleManifestLoader;
|
||||||
use MintyPHP\Console\Command;
|
use MintyPHP\Console\Command;
|
||||||
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
|
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
|
||||||
@@ -292,9 +293,63 @@ final class ValidateCommand extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 20. AuthorizationPolicy classes referenced in ContainerRegistrar source
|
||||||
|
$registrarSources = $this->loadRegistrarSources($moduleDir, $manifest->containerRegistrars);
|
||||||
|
foreach ($manifest->authorizationPolicies as $policyClass) {
|
||||||
|
if (!class_exists($policyClass)) {
|
||||||
|
continue; // Already reported by check 9
|
||||||
|
}
|
||||||
|
$shortName = (new \ReflectionClass($policyClass))->getShortName();
|
||||||
|
$found = false;
|
||||||
|
foreach ($registrarSources as $source) {
|
||||||
|
if (str_contains($source, $shortName)) {
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$found) {
|
||||||
|
$warnings[] = "AuthorizationPolicy '{$shortName}' is not referenced in any ContainerRegistrar — may not be registered in the container";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 21. Event listener keys reference known events
|
||||||
|
$knownEvents = ModuleEvents::all();
|
||||||
|
foreach ($manifest->eventListeners as $eventName => $handlers) {
|
||||||
|
if (!in_array($eventName, $knownEvents, true)) {
|
||||||
|
$warnings[] = "Event listener subscribes to unknown event '{$eventName}' — not defined in ModuleEvents";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [$errors, $warnings];
|
return [$errors, $warnings];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<class-string> $registrarClasses
|
||||||
|
* @return list<string> Source code of each registrar file
|
||||||
|
*/
|
||||||
|
private function loadRegistrarSources(string $moduleDir, array $registrarClasses): array
|
||||||
|
{
|
||||||
|
$sources = [];
|
||||||
|
foreach ($registrarClasses as $class) {
|
||||||
|
if (!class_exists($class)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$refClass = new \ReflectionClass($class);
|
||||||
|
$file = $refClass->getFileName();
|
||||||
|
if ($file !== false && is_file($file)) {
|
||||||
|
$content = file_get_contents($file);
|
||||||
|
if ($content !== false) {
|
||||||
|
$sources[] = $content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\ReflectionException) {
|
||||||
|
// Skip unresolvable classes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $sources;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<string> &$errors
|
* @param list<string> &$errors
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -227,25 +227,31 @@ final class ModuleRunner implements ModuleRunnerInterface
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$manifest = ModuleManifestLoader::loadFromDisk(
|
$modulesDir = ModuleCliRuntime::projectRoot() . '/modules';
|
||||||
ModuleCliRuntime::projectRoot() . '/modules',
|
$manifest = ModuleManifestLoader::loadFromDisk($modulesDir, $moduleId);
|
||||||
$moduleId
|
|
||||||
);
|
// Check for reverse dependencies (other modules that require this one)
|
||||||
|
$dependents = $this->findReverseDependencies($modulesDir, $moduleId);
|
||||||
|
$warning = '';
|
||||||
|
if ($dependents !== []) {
|
||||||
|
$dependentList = implode(', ', $dependents);
|
||||||
|
$warning = "Warning: module(s) [{$dependentList}] declare '{$moduleId}' in their requires — deactivation may break them.\n";
|
||||||
|
}
|
||||||
|
|
||||||
$handlerClass = $manifest->deactivationHandler;
|
$handlerClass = $manifest->deactivationHandler;
|
||||||
if ($handlerClass === null) {
|
if ($handlerClass === null) {
|
||||||
$message = "Module '{$moduleId}' has no deactivation_handler declared — nothing to do.";
|
$message = $warning . "Module '{$moduleId}' has no deactivation_handler declared — nothing to do.";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!class_exists($handlerClass)) {
|
if (!class_exists($handlerClass)) {
|
||||||
$message = "Error: deactivation handler class '{$handlerClass}' not found.";
|
$message = $warning . "Error: deactivation handler class '{$handlerClass}' not found.";
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$container = $GLOBALS['minty_app_container'] ?? null;
|
$container = $GLOBALS['minty_app_container'] ?? null;
|
||||||
if (!$container instanceof AppContainer) {
|
if (!$container instanceof AppContainer) {
|
||||||
$message = 'Error: app container is not initialized.';
|
$message = $warning . 'Error: app container is not initialized.';
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
$handler = $container->has($handlerClass)
|
$handler = $container->has($handlerClass)
|
||||||
@@ -253,17 +259,17 @@ final class ModuleRunner implements ModuleRunnerInterface
|
|||||||
: new $handlerClass();
|
: new $handlerClass();
|
||||||
|
|
||||||
if (!$handler instanceof ModuleDeactivationHandler) {
|
if (!$handler instanceof ModuleDeactivationHandler) {
|
||||||
$message = "Error: '{$handlerClass}' does not implement ModuleDeactivationHandler.";
|
$message = $warning . "Error: '{$handlerClass}' does not implement ModuleDeactivationHandler.";
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$message = "Dry-run: handler '{$handlerClass}' found and valid for module '{$moduleId}'.";
|
$message = $warning . "Dry-run: handler '{$handlerClass}' found and valid for module '{$moduleId}'.";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$handler->deactivate($container);
|
$handler->deactivate($container);
|
||||||
$message = "Deactivation handler for module '{$moduleId}' completed.";
|
$message = $warning . "Deactivation handler for module '{$moduleId}' completed.";
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
@@ -345,4 +351,34 @@ final class ModuleRunner implements ModuleRunnerInterface
|
|||||||
'message' => $step['error'] ?? $message,
|
'message' => $step['error'] ?? $message,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string> Module IDs that declare $targetId in their requires
|
||||||
|
*/
|
||||||
|
private function findReverseDependencies(string $modulesDir, string $targetId): array
|
||||||
|
{
|
||||||
|
$dependents = [];
|
||||||
|
$entries = is_dir($modulesDir) ? (scandir($modulesDir) ?: []) : [];
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..' || $entry === $targetId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$manifestFile = $modulesDir . '/' . $entry . '/module.php';
|
||||||
|
if (!is_file($manifestFile)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$manifest = ModuleManifestLoader::loadFromDisk($modulesDir, $entry);
|
||||||
|
if (in_array($targetId, $manifest->requires, true)) {
|
||||||
|
$dependents[] = $entry;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Skip unparseable manifests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($dependents);
|
||||||
|
return $dependents;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace MintyPHP\Service\Auth;
|
namespace MintyPHP\Service\Auth;
|
||||||
|
|
||||||
use MintyPHP\App\Module\ModuleEventDispatcher;
|
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||||
|
use MintyPHP\App\Module\ModuleEvents;
|
||||||
use MintyPHP\Auth;
|
use MintyPHP\Auth;
|
||||||
use MintyPHP\Http\SessionStoreInterface;
|
use MintyPHP\Http\SessionStoreInterface;
|
||||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||||
@@ -132,7 +133,7 @@ class AuthService
|
|||||||
'actor_tenant_id' => $this->currentTenantIdFromSession(),
|
'actor_tenant_id' => $this->currentTenantIdFromSession(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->eventDispatcher?->dispatch('user.login', [
|
$this->eventDispatcher?->dispatch(ModuleEvents::USER_LOGIN, [
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'provider' => 'local',
|
'provider' => 'local',
|
||||||
]);
|
]);
|
||||||
@@ -226,7 +227,7 @@ class AuthService
|
|||||||
'metadata' => ['provider' => $loginProvider],
|
'metadata' => ['provider' => $loginProvider],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->eventDispatcher?->dispatch('user.login', [
|
$this->eventDispatcher?->dispatch(ModuleEvents::USER_LOGIN, [
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'provider' => $loginProvider,
|
'provider' => $loginProvider,
|
||||||
]);
|
]);
|
||||||
@@ -290,7 +291,7 @@ class AuthService
|
|||||||
$actorUserId = (int) ($this->sessionUser()['id'] ?? 0);
|
$actorUserId = (int) ($this->sessionUser()['id'] ?? 0);
|
||||||
$actorTenantId = $this->currentTenantIdFromSession();
|
$actorTenantId = $this->currentTenantIdFromSession();
|
||||||
|
|
||||||
$this->eventDispatcher?->dispatch('user.logout', [
|
$this->eventDispatcher?->dispatch(ModuleEvents::USER_LOGOUT, [
|
||||||
'user_id' => $actorUserId,
|
'user_id' => $actorUserId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace MintyPHP\Service\Scheduler;
|
namespace MintyPHP\Service\Scheduler;
|
||||||
|
|
||||||
use MintyPHP\App\Module\ModuleEventDispatcher;
|
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||||
|
use MintyPHP\App\Module\ModuleEvents;
|
||||||
use MintyPHP\Domain\Taxonomy\ScheduledJobRunStatus;
|
use MintyPHP\Domain\Taxonomy\ScheduledJobRunStatus;
|
||||||
use MintyPHP\Domain\Taxonomy\ScheduledJobTriggerType;
|
use MintyPHP\Domain\Taxonomy\ScheduledJobTriggerType;
|
||||||
use MintyPHP\Domain\Taxonomy\SchedulerRuntimeResult;
|
use MintyPHP\Domain\Taxonomy\SchedulerRuntimeResult;
|
||||||
@@ -423,7 +424,7 @@ class SchedulerRunService
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->eventDispatcher->dispatch('scheduler.job_failed', [
|
$this->eventDispatcher->dispatch(ModuleEvents::SCHEDULER_JOB_FAILED, [
|
||||||
'job_id' => (int) ($job['id'] ?? 0),
|
'job_id' => (int) ($job['id'] ?? 0),
|
||||||
'job_key' => (string) ($job['job_key'] ?? ''),
|
'job_key' => (string) ($job['job_key'] ?? ''),
|
||||||
'label' => (string) ($job['label'] ?? ''),
|
'label' => (string) ($job['label'] ?? ''),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace MintyPHP\Service\User;
|
namespace MintyPHP\Service\User;
|
||||||
|
|
||||||
use MintyPHP\App\Module\ModuleEventDispatcher;
|
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||||
|
use MintyPHP\App\Module\ModuleEvents;
|
||||||
use MintyPHP\I18n;
|
use MintyPHP\I18n;
|
||||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||||
use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
|
use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
|
||||||
@@ -118,7 +119,7 @@ class UserAccountService
|
|||||||
'target_uuid' => (string) ($user['uuid'] ?? ''),
|
'target_uuid' => (string) ($user['uuid'] ?? ''),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->eventDispatcher?->dispatch('user.deleted', [
|
$this->eventDispatcher?->dispatch(ModuleEvents::USER_DELETED, [
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'uuid' => (string) ($user['uuid'] ?? ''),
|
'uuid' => (string) ($user['uuid'] ?? ''),
|
||||||
'actor_user_id' => $currentUserId,
|
'actor_user_id' => $currentUserId,
|
||||||
@@ -191,7 +192,7 @@ class UserAccountService
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
foreach ($deletedUsers as $deletedUser) {
|
foreach ($deletedUsers as $deletedUser) {
|
||||||
$this->eventDispatcher?->dispatch('user.deleted', [
|
$this->eventDispatcher?->dispatch(ModuleEvents::USER_DELETED, [
|
||||||
'user_id' => (int) $deletedUser['id'],
|
'user_id' => (int) $deletedUser['id'],
|
||||||
'uuid' => (string) $deletedUser['uuid'],
|
'uuid' => (string) $deletedUser['uuid'],
|
||||||
'actor_user_id' => $currentUserId,
|
'actor_user_id' => $currentUserId,
|
||||||
@@ -336,7 +337,7 @@ class UserAccountService
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->eventDispatcher?->dispatch('user.created', [
|
$this->eventDispatcher?->dispatch(ModuleEvents::USER_CREATED, [
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'uuid' => is_string($uuid) ? $uuid : '',
|
'uuid' => is_string($uuid) ? $uuid : '',
|
||||||
'actor_user_id' => $currentUserId,
|
'actor_user_id' => $currentUserId,
|
||||||
@@ -467,7 +468,7 @@ class UserAccountService
|
|||||||
$tenantIds = $this->extractTenantIdsFromAssignments(
|
$tenantIds = $this->extractTenantIdsFromAssignments(
|
||||||
$this->safeAssignmentsForUser($userId)
|
$this->safeAssignmentsForUser($userId)
|
||||||
);
|
);
|
||||||
$eventType = ((int) $form['active'] === 1) ? 'user.activated' : 'user.deactivated';
|
$eventType = ((int) $form['active'] === 1) ? ModuleEvents::USER_ACTIVATED : ModuleEvents::USER_DEACTIVATED;
|
||||||
$this->eventDispatcher?->dispatch($eventType, [
|
$this->eventDispatcher?->dispatch($eventType, [
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'uuid' => (string) ($existing['uuid'] ?? ''),
|
'uuid' => (string) ($existing['uuid'] ?? ''),
|
||||||
@@ -521,7 +522,7 @@ class UserAccountService
|
|||||||
$tenantIds = $this->extractTenantIdsFromAssignments(
|
$tenantIds = $this->extractTenantIdsFromAssignments(
|
||||||
$this->safeAssignmentsForUser($userId)
|
$this->safeAssignmentsForUser($userId)
|
||||||
);
|
);
|
||||||
$this->eventDispatcher?->dispatch($active ? 'user.activated' : 'user.deactivated', [
|
$this->eventDispatcher?->dispatch($active ? ModuleEvents::USER_ACTIVATED : ModuleEvents::USER_DEACTIVATED, [
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'uuid' => (string) ($user['uuid'] ?? ''),
|
'uuid' => (string) ($user['uuid'] ?? ''),
|
||||||
'display_name' => trim((string) ($user['display_name'] ?? '')),
|
'display_name' => trim((string) ($user['display_name'] ?? '')),
|
||||||
@@ -587,7 +588,7 @@ class UserAccountService
|
|||||||
$tenantIds = $this->extractTenantIdsFromAssignments(
|
$tenantIds = $this->extractTenantIdsFromAssignments(
|
||||||
$this->safeAssignmentsForUser($targetUserId)
|
$this->safeAssignmentsForUser($targetUserId)
|
||||||
);
|
);
|
||||||
$this->eventDispatcher?->dispatch($active ? 'user.activated' : 'user.deactivated', [
|
$this->eventDispatcher?->dispatch($active ? ModuleEvents::USER_ACTIVATED : ModuleEvents::USER_DEACTIVATED, [
|
||||||
'user_id' => $targetUserId,
|
'user_id' => $targetUserId,
|
||||||
'uuid' => (string) ($user['uuid'] ?? ''),
|
'uuid' => (string) ($user['uuid'] ?? ''),
|
||||||
'display_name' => trim((string) ($user['display_name'] ?? '')),
|
'display_name' => trim((string) ($user['display_name'] ?? '')),
|
||||||
@@ -801,7 +802,7 @@ class UserAccountService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->eventDispatcher?->dispatch('user.assignment_changed', [
|
$this->eventDispatcher?->dispatch(ModuleEvents::USER_ASSIGNMENT_CHANGED, [
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'uuid' => (string) ($user['uuid'] ?? ''),
|
'uuid' => (string) ($user['uuid'] ?? ''),
|
||||||
'display_name' => trim((string) ($user['display_name'] ?? '')),
|
'display_name' => trim((string) ($user['display_name'] ?? '')),
|
||||||
|
|||||||
69
tests/App/Module/ModuleEventsTest.php
Normal file
69
tests/App/Module/ModuleEventsTest.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\App\Module;
|
||||||
|
|
||||||
|
use MintyPHP\App\Module\ModuleEvents;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ModuleEventsTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testAllConstantsAreReturnedByAll(): void
|
||||||
|
{
|
||||||
|
$reflection = new \ReflectionClass(ModuleEvents::class);
|
||||||
|
$constants = $reflection->getConstants();
|
||||||
|
|
||||||
|
self::assertNotEmpty($constants, 'ModuleEvents should define at least one constant');
|
||||||
|
self::assertSame(
|
||||||
|
array_values($constants),
|
||||||
|
ModuleEvents::all(),
|
||||||
|
'ModuleEvents::all() must return exactly the values of all class constants'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConstantValuesMatchExpectedEventStrings(): void
|
||||||
|
{
|
||||||
|
self::assertSame('user.login', ModuleEvents::USER_LOGIN);
|
||||||
|
self::assertSame('user.logout', ModuleEvents::USER_LOGOUT);
|
||||||
|
self::assertSame('user.created', ModuleEvents::USER_CREATED);
|
||||||
|
self::assertSame('user.deleted', ModuleEvents::USER_DELETED);
|
||||||
|
self::assertSame('user.activated', ModuleEvents::USER_ACTIVATED);
|
||||||
|
self::assertSame('user.deactivated', ModuleEvents::USER_DEACTIVATED);
|
||||||
|
self::assertSame('user.assignment_changed', ModuleEvents::USER_ASSIGNMENT_CHANGED);
|
||||||
|
self::assertSame('scheduler.job_failed', ModuleEvents::SCHEDULER_JOB_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllReturnsUniqueValues(): void
|
||||||
|
{
|
||||||
|
$all = ModuleEvents::all();
|
||||||
|
self::assertSame($all, array_unique($all), 'ModuleEvents::all() must not contain duplicates');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testClassIsNotInstantiable(): void
|
||||||
|
{
|
||||||
|
$reflection = new \ReflectionClass(ModuleEvents::class);
|
||||||
|
self::assertFalse($reflection->isInstantiable(), 'ModuleEvents should not be instantiable');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDispatchCallSitesUseConstants(): void
|
||||||
|
{
|
||||||
|
$root = dirname(__DIR__, 3);
|
||||||
|
$files = [
|
||||||
|
$root . '/core/Service/Auth/AuthService.php',
|
||||||
|
$root . '/core/Service/User/UserAccountService.php',
|
||||||
|
$root . '/core/Service/Scheduler/SchedulerRunService.php',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$content = file_get_contents($file);
|
||||||
|
self::assertIsString($content, "Could not read {$file}");
|
||||||
|
|
||||||
|
// Ensure no raw string dispatch calls remain (except in ternaries which use constants)
|
||||||
|
preg_match_all('/->dispatch\(\s*[\'"]([^"\']+)[\'"]\s*,/', $content, $matches);
|
||||||
|
self::assertSame(
|
||||||
|
[],
|
||||||
|
$matches[1],
|
||||||
|
basename($file) . ' still uses raw string event names: ' . implode(', ', $matches[1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
tests/Console/MakeModuleCommandTest.php
Normal file
109
tests/Console/MakeModuleCommandTest.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Console;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Commands\Make\ModuleCommand;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class MakeModuleCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $modulesDir;
|
||||||
|
|
||||||
|
/** @var list<string> Module IDs to clean up */
|
||||||
|
private array $cleanup = [];
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->modulesDir = dirname(__DIR__, 2) . '/modules';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach ($this->cleanup as $id) {
|
||||||
|
$this->removeDir($this->modulesDir . '/' . $id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSimpleFlagGeneratesMinimalManifest(): void
|
||||||
|
{
|
||||||
|
$id = 'ztest-simple-scaffold';
|
||||||
|
$this->cleanup[] = $id;
|
||||||
|
|
||||||
|
$command = new ModuleCommand();
|
||||||
|
$exitCode = @$command->execute([$id], ['simple' => true]);
|
||||||
|
|
||||||
|
self::assertSame(0, $exitCode);
|
||||||
|
|
||||||
|
$manifestFile = $this->modulesDir . '/' . $id . '/module.php';
|
||||||
|
self::assertFileExists($manifestFile);
|
||||||
|
|
||||||
|
$manifest = require $manifestFile;
|
||||||
|
self::assertIsArray($manifest);
|
||||||
|
|
||||||
|
// Essential fields present
|
||||||
|
self::assertSame($id, $manifest['id']);
|
||||||
|
self::assertSame('0.1.0', $manifest['version']);
|
||||||
|
self::assertArrayHasKey('container_registrars', $manifest);
|
||||||
|
self::assertArrayHasKey('authorization_policies', $manifest);
|
||||||
|
|
||||||
|
// Verbose fields NOT present in simple mode
|
||||||
|
self::assertArrayNotHasKey('routes', $manifest);
|
||||||
|
self::assertArrayNotHasKey('ui_slots', $manifest);
|
||||||
|
self::assertArrayNotHasKey('permissions', $manifest);
|
||||||
|
self::assertArrayNotHasKey('scheduler_jobs', $manifest);
|
||||||
|
self::assertArrayNotHasKey('event_listeners', $manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDefaultModeGeneratesFullManifest(): void
|
||||||
|
{
|
||||||
|
$id = 'ztest-full-scaffold';
|
||||||
|
$this->cleanup[] = $id;
|
||||||
|
|
||||||
|
$command = new ModuleCommand();
|
||||||
|
$exitCode = @$command->execute([$id], []);
|
||||||
|
|
||||||
|
self::assertSame(0, $exitCode);
|
||||||
|
|
||||||
|
$manifestFile = $this->modulesDir . '/' . $id . '/module.php';
|
||||||
|
$manifest = require $manifestFile;
|
||||||
|
|
||||||
|
// Full mode includes all fields
|
||||||
|
self::assertArrayHasKey('routes', $manifest);
|
||||||
|
self::assertArrayHasKey('ui_slots', $manifest);
|
||||||
|
self::assertArrayHasKey('permissions', $manifest);
|
||||||
|
self::assertArrayHasKey('event_listeners', $manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSimpleManifestIsValidForManifestLoader(): void
|
||||||
|
{
|
||||||
|
$id = 'ztest-simple-valid';
|
||||||
|
$this->cleanup[] = $id;
|
||||||
|
|
||||||
|
$command = new ModuleCommand();
|
||||||
|
$exitCode = @$command->execute([$id], ['simple' => true]);
|
||||||
|
self::assertSame(0, $exitCode);
|
||||||
|
|
||||||
|
$manifest = \MintyPHP\App\Module\ModuleManifestLoader::loadFromDisk($this->modulesDir, $id);
|
||||||
|
self::assertSame($id, $manifest->id);
|
||||||
|
self::assertSame('0.1.0', $manifest->version);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if ($item->isDir()) {
|
||||||
|
rmdir($item->getPathname());
|
||||||
|
} else {
|
||||||
|
unlink($item->getPathname());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
227
tests/Console/ValidateCommandTest.php
Normal file
227
tests/Console/ValidateCommandTest.php
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Console;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Commands\Module\ValidateCommand;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for module:validate enhancements:
|
||||||
|
* - AuthorizationPolicy container-registration check (check 20)
|
||||||
|
* - Event listener unknown event warning (check 21)
|
||||||
|
*
|
||||||
|
* Uses reflection to call the private validateModule() method directly,
|
||||||
|
* avoiding bootstrapApp() and STDOUT capture issues.
|
||||||
|
*/
|
||||||
|
final class ValidateCommandTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $modulesDir;
|
||||||
|
|
||||||
|
/** @var list<string> Module IDs to clean up */
|
||||||
|
private array $cleanup = [];
|
||||||
|
|
||||||
|
private \ReflectionMethod $validateModule;
|
||||||
|
private ValidateCommand $command;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->modulesDir = dirname(__DIR__, 2) . '/modules';
|
||||||
|
$this->command = new ValidateCommand();
|
||||||
|
$this->validateModule = new \ReflectionMethod($this->command, 'validateModule');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach ($this->cleanup as $id) {
|
||||||
|
$this->removeDir($this->modulesDir . '/' . $id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateWarnsOnUnknownEventListenerKey(): void
|
||||||
|
{
|
||||||
|
$id = 'ztest-unknown-event';
|
||||||
|
$this->scaffoldModule($id, <<<'PHP'
|
||||||
|
<?php
|
||||||
|
return [
|
||||||
|
'id' => 'ztest-unknown-event',
|
||||||
|
'version' => '0.1.0',
|
||||||
|
'event_listeners' => [
|
||||||
|
'user.created' => [
|
||||||
|
['class' => 'NonExistent\Listener', 'method' => 'handle'],
|
||||||
|
],
|
||||||
|
'totally.unknown.event' => [
|
||||||
|
['class' => 'NonExistent\Listener', 'method' => 'handle'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
PHP);
|
||||||
|
|
||||||
|
[$errors, $warnings] = $this->validateModule->invoke($this->command, $this->modulesDir, $id);
|
||||||
|
|
||||||
|
$warningText = implode("\n", $warnings);
|
||||||
|
self::assertStringContainsString('totally.unknown.event', $warningText);
|
||||||
|
self::assertStringContainsString('not defined in ModuleEvents', $warningText);
|
||||||
|
// user.created is a known event — should NOT trigger a warning
|
||||||
|
self::assertStringNotContainsString("unknown event 'user.created'", $warningText);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateDoesNotWarnOnKnownEventKeys(): void
|
||||||
|
{
|
||||||
|
$id = 'ztest-known-events';
|
||||||
|
$this->scaffoldModule($id, <<<'PHP'
|
||||||
|
<?php
|
||||||
|
return [
|
||||||
|
'id' => 'ztest-known-events',
|
||||||
|
'version' => '0.1.0',
|
||||||
|
'event_listeners' => [
|
||||||
|
'user.created' => [
|
||||||
|
['class' => 'NonExistent\Listener', 'method' => 'handle'],
|
||||||
|
],
|
||||||
|
'scheduler.job_failed' => [
|
||||||
|
['class' => 'NonExistent\Listener', 'method' => 'handle'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
PHP);
|
||||||
|
|
||||||
|
[$errors, $warnings] = $this->validateModule->invoke($this->command, $this->modulesDir, $id);
|
||||||
|
|
||||||
|
$warningText = implode("\n", $warnings);
|
||||||
|
self::assertStringNotContainsString('not defined in ModuleEvents', $warningText);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateWarnsWhenPolicyNotReferencedInRegistrar(): void
|
||||||
|
{
|
||||||
|
$id = 'ztest-policy-missing';
|
||||||
|
$moduleDir = $this->modulesDir . '/' . $id;
|
||||||
|
mkdir($moduleDir . '/lib/Module/ZtestPolicyMissing', 0777, true);
|
||||||
|
$this->cleanup[] = $id;
|
||||||
|
|
||||||
|
file_put_contents(
|
||||||
|
$moduleDir . '/lib/Module/ZtestPolicyMissing/ZtestPolicyMissingContainerRegistrar.php',
|
||||||
|
<<<'PHP'
|
||||||
|
<?php
|
||||||
|
namespace MintyPHP\Module\ZtestPolicyMissing;
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use MintyPHP\App\Container\ContainerRegistrar;
|
||||||
|
final class ZtestPolicyMissingContainerRegistrar implements ContainerRegistrar {
|
||||||
|
public function register(AppContainer $container): void {}
|
||||||
|
}
|
||||||
|
PHP
|
||||||
|
);
|
||||||
|
|
||||||
|
file_put_contents(
|
||||||
|
$moduleDir . '/lib/Module/ZtestPolicyMissing/ZtestPolicyMissingAuthorizationPolicy.php',
|
||||||
|
<<<'PHP'
|
||||||
|
<?php
|
||||||
|
namespace MintyPHP\Module\ZtestPolicyMissing;
|
||||||
|
use MintyPHP\Service\Access\AuthorizationDecision;
|
||||||
|
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
|
||||||
|
final class ZtestPolicyMissingAuthorizationPolicy implements AuthorizationPolicyInterface {
|
||||||
|
public function supports(string $ability): bool { return false; }
|
||||||
|
public function authorize(string $ability, array $context = []): AuthorizationDecision {
|
||||||
|
return AuthorizationDecision::deny(403, 'forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PHP
|
||||||
|
);
|
||||||
|
|
||||||
|
file_put_contents($moduleDir . '/module.php', <<<'PHP'
|
||||||
|
<?php
|
||||||
|
return [
|
||||||
|
'id' => 'ztest-policy-missing',
|
||||||
|
'version' => '0.1.0',
|
||||||
|
'container_registrars' => [\MintyPHP\Module\ZtestPolicyMissing\ZtestPolicyMissingContainerRegistrar::class],
|
||||||
|
'authorization_policies' => [\MintyPHP\Module\ZtestPolicyMissing\ZtestPolicyMissingAuthorizationPolicy::class],
|
||||||
|
];
|
||||||
|
PHP);
|
||||||
|
|
||||||
|
[$errors, $warnings] = $this->validateModule->invoke($this->command, $this->modulesDir, $id);
|
||||||
|
|
||||||
|
$warningText = implode("\n", $warnings);
|
||||||
|
self::assertStringContainsString('ZtestPolicyMissingAuthorizationPolicy', $warningText);
|
||||||
|
self::assertStringContainsString('not referenced in any ContainerRegistrar', $warningText);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testValidateNoWarningWhenPolicyReferencedInRegistrar(): void
|
||||||
|
{
|
||||||
|
$id = 'ztest-policy-ok';
|
||||||
|
$moduleDir = $this->modulesDir . '/' . $id;
|
||||||
|
mkdir($moduleDir . '/lib/Module/ZtestPolicyOk', 0777, true);
|
||||||
|
$this->cleanup[] = $id;
|
||||||
|
|
||||||
|
file_put_contents(
|
||||||
|
$moduleDir . '/lib/Module/ZtestPolicyOk/ZtestPolicyOkContainerRegistrar.php',
|
||||||
|
<<<'PHP'
|
||||||
|
<?php
|
||||||
|
namespace MintyPHP\Module\ZtestPolicyOk;
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use MintyPHP\App\Container\ContainerRegistrar;
|
||||||
|
final class ZtestPolicyOkContainerRegistrar implements ContainerRegistrar {
|
||||||
|
public function register(AppContainer $container): void {
|
||||||
|
$container->set(ZtestPolicyOkAuthorizationPolicy::class, static fn () => new ZtestPolicyOkAuthorizationPolicy());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PHP
|
||||||
|
);
|
||||||
|
|
||||||
|
file_put_contents(
|
||||||
|
$moduleDir . '/lib/Module/ZtestPolicyOk/ZtestPolicyOkAuthorizationPolicy.php',
|
||||||
|
<<<'PHP'
|
||||||
|
<?php
|
||||||
|
namespace MintyPHP\Module\ZtestPolicyOk;
|
||||||
|
use MintyPHP\Service\Access\AuthorizationDecision;
|
||||||
|
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
|
||||||
|
final class ZtestPolicyOkAuthorizationPolicy implements AuthorizationPolicyInterface {
|
||||||
|
public function supports(string $ability): bool { return false; }
|
||||||
|
public function authorize(string $ability, array $context = []): AuthorizationDecision {
|
||||||
|
return AuthorizationDecision::deny(403, 'forbidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PHP
|
||||||
|
);
|
||||||
|
|
||||||
|
file_put_contents($moduleDir . '/module.php', <<<'PHP'
|
||||||
|
<?php
|
||||||
|
return [
|
||||||
|
'id' => 'ztest-policy-ok',
|
||||||
|
'version' => '0.1.0',
|
||||||
|
'container_registrars' => [\MintyPHP\Module\ZtestPolicyOk\ZtestPolicyOkContainerRegistrar::class],
|
||||||
|
'authorization_policies' => [\MintyPHP\Module\ZtestPolicyOk\ZtestPolicyOkAuthorizationPolicy::class],
|
||||||
|
];
|
||||||
|
PHP);
|
||||||
|
|
||||||
|
[$errors, $warnings] = $this->validateModule->invoke($this->command, $this->modulesDir, $id);
|
||||||
|
|
||||||
|
self::assertSame([], $errors);
|
||||||
|
$warningText = implode("\n", $warnings);
|
||||||
|
self::assertStringNotContainsString('not referenced', $warningText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scaffoldModule(string $id, string $manifestContent): void
|
||||||
|
{
|
||||||
|
$moduleDir = $this->modulesDir . '/' . $id;
|
||||||
|
mkdir($moduleDir . '/lib', 0777, true);
|
||||||
|
file_put_contents($moduleDir . '/module.php', $manifestContent);
|
||||||
|
$this->cleanup[] = $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if ($item->isDir()) {
|
||||||
|
rmdir($item->getPathname());
|
||||||
|
} else {
|
||||||
|
unlink($item->getPathname());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user