1
0

Harden module runtime: audited listener failures and DI-first resolver

This commit is contained in:
2026-03-25 10:02:03 +01:00
parent a721ec0043
commit cb5f7037b2
12 changed files with 314 additions and 79 deletions

View 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,
]);
}
}

View File

@@ -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();
}
}