Harden module runtime: audited listener failures and DI-first resolver
This commit is contained in:
104
tests/Service/Access/AccessPolicyFactoryModuleResolverTest.php
Normal file
104
tests/Service/Access/AccessPolicyFactoryModuleResolverTest.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Service\Access;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\ModuleClassResolver;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\Service\Access\AccessPolicyFactory;
|
||||
use MintyPHP\Service\Access\AuthorizationDecision;
|
||||
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AccessPolicyFactoryModuleResolverTest extends TestCase
|
||||
{
|
||||
public function testModulePolicyWithMultipleDependenciesIsResolvedViaContainer(): void
|
||||
{
|
||||
$fixturesDir = sys_get_temp_dir() . '/access-policy-factory-' . uniqid('', true);
|
||||
$moduleDir = $fixturesDir . '/mod-policy';
|
||||
mkdir($moduleDir, 0777, true);
|
||||
|
||||
file_put_contents(
|
||||
$moduleDir . '/module.php',
|
||||
'<?php return ' . var_export([
|
||||
'id' => 'mod-policy',
|
||||
'authorization_policies' => [TestMultiDependencyPolicy::class],
|
||||
], true) . ';'
|
||||
);
|
||||
|
||||
try {
|
||||
$registry = ModuleRegistry::boot($fixturesDir, ['mod-policy']);
|
||||
|
||||
$permissionService = $this->createMock(PermissionService::class);
|
||||
$tenantScopeService = $this->createMock(TenantScopeService::class);
|
||||
$dependency = new TestPolicyDependency();
|
||||
|
||||
$container = new AppContainer();
|
||||
$container->set(
|
||||
TestMultiDependencyPolicy::class,
|
||||
static fn () => new TestMultiDependencyPolicy($permissionService, $dependency)
|
||||
);
|
||||
|
||||
$factory = new AccessPolicyFactory(
|
||||
$permissionService,
|
||||
$tenantScopeService,
|
||||
$registry,
|
||||
static fn (string $class): ?object => ModuleClassResolver::resolve($container, $class)
|
||||
);
|
||||
|
||||
$authorizationService = $factory->createAuthorizationService();
|
||||
$decision = $authorizationService->authorize(TestMultiDependencyPolicy::ABILITY, [
|
||||
'actor_user_id' => 7,
|
||||
]);
|
||||
|
||||
self::assertTrue($decision->isAllowed());
|
||||
self::assertSame('container', $decision->attribute('resolver'));
|
||||
} finally {
|
||||
@unlink($moduleDir . '/module.php');
|
||||
@rmdir($moduleDir);
|
||||
@rmdir($fixturesDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class TestPolicyDependency
|
||||
{
|
||||
public function value(): string
|
||||
{
|
||||
return 'container';
|
||||
}
|
||||
}
|
||||
|
||||
final class TestMultiDependencyPolicy implements AuthorizationPolicyInterface
|
||||
{
|
||||
public const ABILITY = 'module.multi.dep';
|
||||
|
||||
public function __construct(
|
||||
private readonly PermissionService $permissionService,
|
||||
private readonly TestPolicyDependency $dependency
|
||||
) {
|
||||
}
|
||||
|
||||
public function supports(string $ability): bool
|
||||
{
|
||||
return $ability === self::ABILITY;
|
||||
}
|
||||
|
||||
public function authorize(string $ability, array $context = []): AuthorizationDecision
|
||||
{
|
||||
if ($ability !== self::ABILITY) {
|
||||
return AuthorizationDecision::deny(500, 'authorization_ability_not_supported');
|
||||
}
|
||||
|
||||
if ((int) ($context['actor_user_id'] ?? 0) <= 0) {
|
||||
return AuthorizationDecision::deny(403, 'forbidden');
|
||||
}
|
||||
|
||||
return AuthorizationDecision::allow([
|
||||
'resolver' => $this->dependency->value(),
|
||||
'permission_service' => $this->permissionService::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -299,43 +299,11 @@ class LdapAuthServiceTest extends TestCase
|
||||
return $conn;
|
||||
}
|
||||
|
||||
private function createMockSearchResult(): \LDAP\Result
|
||||
private function createMockSearchResult(): object
|
||||
{
|
||||
// LDAP\Result can't be mocked directly, but since our gateway wraps
|
||||
// all ldap_* calls, the actual result object is opaque to LdapAuthService.
|
||||
// The gateway mock returns it, and we just need a type-compatible value.
|
||||
// Use a real connection + search if ldap is available; otherwise skip.
|
||||
if (!extension_loaded('ldap')) {
|
||||
$this->markTestSkipped('LDAP extension not available');
|
||||
}
|
||||
|
||||
// Create a real but unused Result object via reflection workaround.
|
||||
// Since we mock getEntries(), the actual result content doesn't matter.
|
||||
// But LDAP\Result has no public constructor, so we use a trick:
|
||||
// We accept that the gateway returns LDAP\Result|false and our mocks
|
||||
// already handle this. For this test helper, return a mock-compatible value.
|
||||
// Since LdapConnectionGateway::search returns \LDAP\Result|false,
|
||||
// and we mock the gateway, we can use createMock on a stdClass and
|
||||
// rely on the mock returning it. But PHPUnit mock of LdapConnectionGateway
|
||||
// allows any return value when configured via willReturn().
|
||||
// So we just need any object - the gateway mock will return it to the service,
|
||||
// and the service passes it back to gateway->getEntries().
|
||||
$conn = ldap_connect('ldap://127.0.0.1:1');
|
||||
if ($conn === false) {
|
||||
$this->markTestSkipped('Could not create LDAP connection');
|
||||
}
|
||||
|
||||
// We can't get a real LDAP\Result without a real server, but since
|
||||
// the service only passes it to the mocked gateway, we can create
|
||||
// a test double. PHPUnit allows returning any value from willReturn().
|
||||
// Let's use a simple approach: the gateway mock already handles this.
|
||||
// For the type system, we use an anonymous class that extends nothing
|
||||
// but the mock system handles the actual passing.
|
||||
|
||||
// Actually, since we mock LdapConnectionGateway::search() to return
|
||||
// this value, and LdapConnectionGateway::getEntries() is also mocked,
|
||||
// the value just needs to be truthy (not false).
|
||||
// We can use createStub to get a compatible value.
|
||||
return $this->createStub(\LDAP\Result::class);
|
||||
// The service treats the search handle as opaque and forwards it to
|
||||
// gateway->getEntries(); in tests the gateway is mocked, so any object
|
||||
// is sufficient and avoids doubling final internal LDAP classes.
|
||||
return new \stdClass();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace MintyPHP\Tests\Unit\Module;
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\Contracts\EventListener;
|
||||
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||
use MintyPHP\Http\RequestContext;
|
||||
use MintyPHP\Service\Audit\SystemAuditService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ModuleEventDispatcherTest extends TestCase
|
||||
@@ -62,6 +64,8 @@ final class ModuleEventDispatcherTest extends TestCase
|
||||
|
||||
public function testDispatchContinuesAfterListenerException(): void
|
||||
{
|
||||
RequestContext::resetForTests();
|
||||
|
||||
$secondCalled = false;
|
||||
|
||||
$failingListener = new class () implements EventListener {
|
||||
@@ -100,4 +104,64 @@ final class ModuleEventDispatcherTest extends TestCase
|
||||
|
||||
self::assertTrue($secondCalled, 'Second listener should be called even after the first one throws');
|
||||
}
|
||||
|
||||
public function testDispatchAuditsListenerFailureWithoutPayloadLeakage(): void
|
||||
{
|
||||
RequestContext::resetForTests();
|
||||
|
||||
$failingListener = new class () implements EventListener {
|
||||
public function handle(string $event, array $payload): void
|
||||
{
|
||||
throw new \RuntimeException('Do not leak this message');
|
||||
}
|
||||
};
|
||||
|
||||
$container = new AppContainer();
|
||||
$container->set($failingListener::class, static fn () => $failingListener);
|
||||
|
||||
$systemAuditService = $this->createMock(SystemAuditService::class);
|
||||
$systemAuditService->expects($this->once())
|
||||
->method('record')
|
||||
->with(
|
||||
'module.listener.failed',
|
||||
'failed',
|
||||
$this->callback(static function (array $context): bool {
|
||||
$metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : [];
|
||||
$keys = array_keys($metadata);
|
||||
sort($keys);
|
||||
|
||||
if ($keys !== ['exception_class', 'listener_class', 'listener_method', 'module_event', 'request_id']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($metadata['module_event'] ?? '') !== 'user.created') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($metadata['exception_class'] ?? '') !== \RuntimeException::class) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (($metadata['listener_method'] ?? '') !== 'handle') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is_string($metadata['request_id'] ?? null) || trim((string) $metadata['request_id']) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !array_key_exists('payload', $metadata)
|
||||
&& !array_key_exists('exception_message', $metadata)
|
||||
&& !array_key_exists('trace', $metadata);
|
||||
})
|
||||
);
|
||||
|
||||
$dispatcher = new ModuleEventDispatcher(
|
||||
['user.created' => [['class' => $failingListener::class, 'method' => 'handle']]],
|
||||
$container,
|
||||
$systemAuditService
|
||||
);
|
||||
|
||||
$dispatcher->dispatch('user.created', ['secret' => 'must-not-be-logged']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user