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:
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