2026-04-25 23:08:52 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Tests\Support\Helpers;
|
|
|
|
|
|
|
|
|
|
use MintyPHP\App\AppContainer;
|
|
|
|
|
use MintyPHP\Router;
|
|
|
|
|
use MintyPHP\Service\Access\AuthorizationDecision;
|
|
|
|
|
use MintyPHP\Service\Access\AuthorizationService;
|
|
|
|
|
use MintyPHP\Support\Guard;
|
|
|
|
|
use MintyPHP\Tests\Support\AppContainerIsolationTrait;
|
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
|
use ReflectionClass;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Helper-contract tests for core/Support/helpers/action_context.php.
|
|
|
|
|
*
|
|
|
|
|
* Step 1 of the action-context-helper rollout. The helpers have no production
|
|
|
|
|
* call-sites yet; this suite documents the contract.
|
|
|
|
|
*/
|
|
|
|
|
class ActionContextHelperTest extends TestCase
|
|
|
|
|
{
|
|
|
|
|
use AppContainerIsolationTrait;
|
|
|
|
|
|
|
|
|
|
/** @var bool */
|
|
|
|
|
private bool $previousExecuteRedirect = true;
|
|
|
|
|
|
|
|
|
|
/** @var array<string, mixed> */
|
|
|
|
|
private array $serverBackup = [];
|
|
|
|
|
|
|
|
|
|
/** @var array<string, mixed> */
|
|
|
|
|
private array $sessionBackup = [];
|
|
|
|
|
|
|
|
|
|
protected function setUp(): void
|
|
|
|
|
{
|
|
|
|
|
parent::setUp();
|
|
|
|
|
$this->previousExecuteRedirect = Router::$executeRedirect;
|
|
|
|
|
Router::$executeRedirect = false;
|
|
|
|
|
$this->resetRouterState();
|
|
|
|
|
|
|
|
|
|
$this->serverBackup = $_SERVER;
|
|
|
|
|
$this->sessionBackup = $_SESSION ?? [];
|
|
|
|
|
$_SERVER['REQUEST_URI'] = '/test';
|
|
|
|
|
$_SERVER['REQUEST_METHOD'] = 'GET';
|
|
|
|
|
// Setting tenant_context_refreshed_at to "now" prevents Guard::requireLogin()
|
|
|
|
|
// from calling AuthService::loadTenantDataIntoSession (60s throttle window).
|
|
|
|
|
$_SESSION = [
|
|
|
|
|
'user' => ['id' => 1],
|
|
|
|
|
'tenant_context_refreshed_at' => time(),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
Guard::configure(
|
|
|
|
|
authServiceResolver: static fn () => throw new \RuntimeException('AuthService not stubbed in this test'),
|
|
|
|
|
tenantServiceResolver: static fn () => throw new \RuntimeException('TenantService not stubbed in this test'),
|
|
|
|
|
authorizationServiceResolver: static fn (): AuthorizationService => self::makeAuthorizationServiceStub(static fn () => AuthorizationDecision::allow()),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function tearDown(): void
|
|
|
|
|
{
|
|
|
|
|
Router::$executeRedirect = $this->previousExecuteRedirect;
|
|
|
|
|
$this->resetRouterState();
|
|
|
|
|
$_SERVER = $this->serverBackup;
|
|
|
|
|
$_SESSION = $this->sessionBackup;
|
|
|
|
|
$this->restoreAppContainer();
|
|
|
|
|
parent::tearDown();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
* actionResolveModelOrFail
|
|
|
|
|
* ---------------------------------------------------------------- */
|
|
|
|
|
|
|
|
|
|
public function testResolveModelOrFailReturnsModelOnHit(): void
|
|
|
|
|
{
|
|
|
|
|
$finder = static fn (string $id): array => ['id' => $id, 'name' => 'demo'];
|
|
|
|
|
|
|
|
|
|
$model = actionResolveModelOrFail($finder, 'abc', 'action.context.model_not_found', 'admin/users');
|
|
|
|
|
|
|
|
|
|
$this->assertSame(['id' => 'abc', 'name' => 'demo'], $model);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testResolveModelOrFailRedirectsOnMiss(): void
|
|
|
|
|
{
|
|
|
|
|
$finder = static fn (): mixed => null;
|
|
|
|
|
|
|
|
|
|
$result = actionResolveModelOrFail($finder, 'missing', 'action.context.model_not_found', 'admin/users');
|
|
|
|
|
|
|
|
|
|
$this->assertNull($result);
|
|
|
|
|
$this->assertStringEndsWith('admin/users', $this->capturedRedirect());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
* actionAuthorizeAndExtractCapabilities
|
|
|
|
|
* ---------------------------------------------------------------- */
|
|
|
|
|
|
|
|
|
|
public function testAuthorizeAndExtractCapabilitiesReturnsAttributesOnAllow(): void
|
|
|
|
|
{
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
|
|
|
|
|
'capabilities' => ['can_view_page' => true, 'can_edit' => true],
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
$caps = actionAuthorizeAndExtractCapabilities('ABILITY_X', ['actor_user_id' => 1]);
|
|
|
|
|
|
|
|
|
|
$this->assertSame(['can_view_page' => true, 'can_edit' => true], $caps);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testAuthorizeAndExtractCapabilitiesRedirectsOnDeny(): void
|
|
|
|
|
{
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::deny());
|
|
|
|
|
|
|
|
|
|
$caps = actionAuthorizeAndExtractCapabilities('ABILITY_X', [], 'redirect');
|
|
|
|
|
|
|
|
|
|
$this->assertSame([], $caps);
|
|
|
|
|
$this->assertStringContainsString('error/forbidden', $this->capturedRedirect());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testAuthorizeAndExtractCapabilitiesUsesGuardDenyStrategy(): void
|
|
|
|
|
{
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::deny());
|
|
|
|
|
|
|
|
|
|
$caps = actionAuthorizeAndExtractCapabilities('ABILITY_X', [], 'deny');
|
|
|
|
|
|
|
|
|
|
// Guard::deny() ultimately also calls Router::redirect('error/forbidden?url=...')
|
|
|
|
|
// when Request::wantsJson() is false. With executeRedirect=false this is captured.
|
|
|
|
|
$this->assertSame([], $caps);
|
|
|
|
|
$this->assertStringContainsString('error/forbidden', $this->capturedRedirect());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testAuthorizeAndExtractCapabilitiesNormalizesNonArrayAttribute(): void
|
|
|
|
|
{
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
|
|
|
|
|
'capabilities' => 'not-an-array',
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
$caps = actionAuthorizeAndExtractCapabilities('ABILITY_X', []);
|
|
|
|
|
|
|
|
|
|
$this->assertSame([], $caps);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
* actionDeriveTenantScope (PHPStan array-shape)
|
|
|
|
|
* ---------------------------------------------------------------- */
|
|
|
|
|
|
|
|
|
|
public function testTenantScopeReturnsAllOnlyWhenFlagIsLiteralTrue(): void
|
|
|
|
|
{
|
|
|
|
|
$this->assertSame(
|
|
|
|
|
['scope' => 'all', 'ids' => []],
|
|
|
|
|
actionDeriveTenantScope(['can_manage_all_tenants' => true])
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testTenantScopeWithoutFlagNeverReturnsAll(): void
|
|
|
|
|
{
|
|
|
|
|
// Even if allowed_tenant_ids is missing, scope must NOT widen to 'all'.
|
|
|
|
|
$this->assertSame(
|
|
|
|
|
['scope' => 'list', 'ids' => []],
|
|
|
|
|
actionDeriveTenantScope([])
|
|
|
|
|
);
|
|
|
|
|
// Truthy-but-not-true must NOT widen to 'all'.
|
|
|
|
|
foreach ([1, '1', 'yes', [true]] as $truthy) {
|
|
|
|
|
/** @var array<string, mixed> $caps */
|
|
|
|
|
$caps = ['can_manage_all_tenants' => $truthy];
|
|
|
|
|
$this->assertSame(
|
|
|
|
|
'list',
|
|
|
|
|
actionDeriveTenantScope($caps)['scope'],
|
|
|
|
|
'Truthy non-true flag must not widen scope: ' . var_export($truthy, true)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testTenantScopeBuildsListFromAllowedIds(): void
|
|
|
|
|
{
|
|
|
|
|
$result = actionDeriveTenantScope([
|
|
|
|
|
'allowed_tenant_ids' => [1, '2', 3, 0, -5, '4', 1],
|
|
|
|
|
]);
|
|
|
|
|
$this->assertSame(['scope' => 'list', 'ids' => [1, 2, 3, 4]], $result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testTenantScopeNonArrayListReturnsEmpty(): void
|
|
|
|
|
{
|
|
|
|
|
$result = actionDeriveTenantScope(['allowed_tenant_ids' => 'whatever']);
|
|
|
|
|
$this->assertSame(['scope' => 'list', 'ids' => []], $result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
* actionEnforceCanViewPage (strict ===true)
|
|
|
|
|
* ---------------------------------------------------------------- */
|
|
|
|
|
|
|
|
|
|
public function testEnforceCanViewPageAcceptsLiteralTrue(): void
|
|
|
|
|
{
|
|
|
|
|
actionEnforceCanViewPage(['can_view_page' => true]);
|
|
|
|
|
$this->assertSame('', $this->capturedRedirect());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testEnforceCanViewPageRejectsTruthyValues(): void
|
|
|
|
|
{
|
|
|
|
|
foreach ([1, '1', 'yes', [true], 'true', 0.0, null] as $bad) {
|
|
|
|
|
$this->resetRouterState();
|
|
|
|
|
/** @var array<string, mixed> $caps */
|
|
|
|
|
$caps = ['can_view_page' => $bad];
|
|
|
|
|
actionEnforceCanViewPage($caps);
|
|
|
|
|
$this->assertStringContainsString(
|
|
|
|
|
'error/forbidden',
|
|
|
|
|
$this->capturedRedirect(),
|
|
|
|
|
'Should have blocked truthy value: ' . var_export($bad, true)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testEnforceCanViewPageRejectsMissingFlag(): void
|
|
|
|
|
{
|
|
|
|
|
actionEnforceCanViewPage([]);
|
|
|
|
|
$this->assertStringContainsString('error/forbidden', $this->capturedRedirect());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
* actionBuildViewAuth
|
|
|
|
|
* ---------------------------------------------------------------- */
|
|
|
|
|
|
|
|
|
|
public function testBuildViewAuthLimitsToWhitelist(): void
|
|
|
|
|
{
|
|
|
|
|
$caps = ['can_view_page' => true, 'can_edit' => true, 'secret_flag' => 'leaked'];
|
|
|
|
|
$result = actionBuildViewAuth($caps, ['can_view_page', 'can_edit']);
|
|
|
|
|
|
|
|
|
|
$this->assertSame(
|
|
|
|
|
['page' => ['can_view_page' => true, 'can_edit' => true]],
|
|
|
|
|
$result
|
|
|
|
|
);
|
|
|
|
|
$this->assertArrayNotHasKey('secret_flag', $result['page']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testBuildViewAuthIgnoresUnknownFlags(): void
|
|
|
|
|
{
|
|
|
|
|
$result = actionBuildViewAuth(['can_view_page' => true], ['can_view_page', 'nonexistent']);
|
|
|
|
|
$this->assertSame(['page' => ['can_view_page' => true]], $result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
* actionFragmentResolveOrStatus
|
|
|
|
|
* ---------------------------------------------------------------- */
|
|
|
|
|
|
|
|
|
|
public function testFragmentResolveReturnsOkWithModelAndCapabilities(): void
|
|
|
|
|
{
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
|
|
|
|
|
'capabilities' => ['can_view' => true],
|
|
|
|
|
]));
|
|
|
|
|
$finder = static fn (string $id): array => ['id' => $id];
|
|
|
|
|
|
|
|
|
|
$r = actionFragmentResolveOrStatus($finder, 'abc-123', 'ABILITY_VIEW', []);
|
|
|
|
|
|
|
|
|
|
$this->assertSame('ok', $r['status']);
|
|
|
|
|
$this->assertSame(['id' => 'abc-123'], $r['model'] ?? null);
|
|
|
|
|
$this->assertSame(['can_view' => true], $r['capabilities'] ?? null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testFragmentResolveReturnsForbidden(): void
|
|
|
|
|
{
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::deny());
|
|
|
|
|
$finder = static fn (string $id): array => ['id' => $id];
|
|
|
|
|
|
|
|
|
|
$r = actionFragmentResolveOrStatus($finder, 'abc', 'ABILITY_VIEW', []);
|
|
|
|
|
|
|
|
|
|
$this->assertSame('forbidden', $r['status']);
|
|
|
|
|
$this->assertSame(403, $r['http_code'] ?? null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testFragmentResolveReturnsNotFound(): void
|
|
|
|
|
{
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow());
|
|
|
|
|
$finder = static fn (): mixed => null;
|
|
|
|
|
|
|
|
|
|
$r = actionFragmentResolveOrStatus($finder, 'abc', 'ABILITY_VIEW', []);
|
|
|
|
|
|
|
|
|
|
$this->assertSame('not_found', $r['status']);
|
|
|
|
|
$this->assertSame(404, $r['http_code'] ?? null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testFragmentResolveReturnsInvalidIdForBlankRawId(): void
|
|
|
|
|
{
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow());
|
|
|
|
|
$finder = static fn (): mixed => null;
|
|
|
|
|
|
|
|
|
|
$r = actionFragmentResolveOrStatus($finder, ' ', 'ABILITY_VIEW', []);
|
|
|
|
|
|
|
|
|
|
$this->assertSame('invalid_id', $r['status']);
|
|
|
|
|
$this->assertSame(400, $r['http_code'] ?? null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
* Aggregator: actionEditContext
|
|
|
|
|
* ---------------------------------------------------------------- */
|
|
|
|
|
|
|
|
|
|
public function testEditContextHappyPathReturnsModelAndScope(): void
|
|
|
|
|
{
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
|
|
|
|
|
'capabilities' => [
|
|
|
|
|
'can_view_page' => true,
|
|
|
|
|
'can_edit' => true,
|
|
|
|
|
'allowed_tenant_ids' => [1, 2],
|
|
|
|
|
],
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
$result = actionEditContext([
|
|
|
|
|
'finder' => static fn (string $id): array => ['id' => $id],
|
|
|
|
|
'rawId' => 'u-1',
|
|
|
|
|
'notFoundFlashKey' => 'action.context.model_not_found',
|
|
|
|
|
'notFoundRedirectPath' => 'admin/users',
|
|
|
|
|
'abilityKey' => 'ABILITY_USERS_EDIT_CONTEXT',
|
|
|
|
|
'context' => ['actor_user_id' => 1],
|
|
|
|
|
'viewAuthFlags' => ['can_view_page', 'can_edit'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->assertSame(['id' => 'u-1'], $result['model']);
|
|
|
|
|
$this->assertSame('list', $result['tenantScope']['scope']);
|
|
|
|
|
$this->assertSame([1, 2], $result['tenantScope']['ids']);
|
|
|
|
|
$this->assertArrayHasKey('can_view_page', $result['viewAuth']['page']);
|
|
|
|
|
$this->assertArrayNotHasKey('allowed_tenant_ids', $result['viewAuth']['page']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testEditContextMissingModelRedirects(): void
|
|
|
|
|
{
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
|
|
|
|
|
'capabilities' => ['can_view_page' => true],
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
actionEditContext([
|
|
|
|
|
'finder' => static fn (): mixed => null,
|
|
|
|
|
'rawId' => 'missing',
|
|
|
|
|
'notFoundFlashKey' => 'action.context.model_not_found',
|
|
|
|
|
'notFoundRedirectPath' => 'admin/users',
|
|
|
|
|
'abilityKey' => 'ABILITY_USERS_EDIT_CONTEXT',
|
|
|
|
|
'context' => [],
|
|
|
|
|
'viewAuthFlags' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->assertStringContainsString('admin/users', $this->capturedRedirect());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 23:52:27 +02:00
|
|
|
public function testEditContextWithFlashScopeKeyOverride(): void
|
|
|
|
|
{
|
|
|
|
|
// Departments-Edit pilot use-case: aggregator must emit a flash with
|
|
|
|
|
// the caller-specific scope-key ('department_not_found') instead of
|
|
|
|
|
// the building-block default ('not_found') when notFoundFlashScopeKey
|
|
|
|
|
// is provided. Required for Step 2 behavior identity (Run
|
|
|
|
|
// 2026-04-25-action-context-rollout-step2-departments).
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
|
|
|
|
|
'capabilities' => ['can_view_page' => true],
|
|
|
|
|
]));
|
|
|
|
|
$_SESSION['flash_messages'] = [];
|
|
|
|
|
|
|
|
|
|
actionEditContext([
|
|
|
|
|
'finder' => static fn (): mixed => null,
|
|
|
|
|
'rawId' => 'missing',
|
|
|
|
|
'notFoundFlashKey' => 'Department not found',
|
|
|
|
|
'notFoundRedirectPath' => 'admin/departments',
|
|
|
|
|
'notFoundFlashScopeKey' => 'department_not_found',
|
|
|
|
|
'abilityKey' => 'ABILITY_DEPARTMENTS_EDIT_CONTEXT',
|
|
|
|
|
'context' => [],
|
|
|
|
|
'viewAuthFlags' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->assertStringContainsString('admin/departments', $this->capturedRedirect());
|
|
|
|
|
|
|
|
|
|
$entry = $this->findFlashEntryByKey('department_not_found');
|
|
|
|
|
$this->assertNotNull($entry, "Expected flash with key 'department_not_found' to be set by aggregator override.");
|
|
|
|
|
$this->assertSame('error', $entry['type'] ?? null);
|
|
|
|
|
$this->assertSame('admin/departments', $entry['scope'] ?? null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testEditContextWithoutFlashScopeKeyKeepsBuildingBlockDefault(): void
|
|
|
|
|
{
|
|
|
|
|
// Without notFoundFlashScopeKey, the aggregator delegates to
|
|
|
|
|
// actionResolveModelOrFail which hardcodes scope-key 'not_found'.
|
|
|
|
|
// This test pins the default-path behavior so a future addition
|
|
|
|
|
// doesn't accidentally widen building-block semantics.
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
|
|
|
|
|
'capabilities' => ['can_view_page' => true],
|
|
|
|
|
]));
|
|
|
|
|
$_SESSION['flash_messages'] = [];
|
|
|
|
|
|
|
|
|
|
actionEditContext([
|
|
|
|
|
'finder' => static fn (): mixed => null,
|
|
|
|
|
'rawId' => 'missing',
|
|
|
|
|
'notFoundFlashKey' => 'action.context.model_not_found',
|
|
|
|
|
'notFoundRedirectPath' => 'admin/users',
|
|
|
|
|
'abilityKey' => 'ABILITY_USERS_EDIT_CONTEXT',
|
|
|
|
|
'context' => [],
|
|
|
|
|
'viewAuthFlags' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$entry = $this->findFlashEntryByKey('not_found');
|
|
|
|
|
$this->assertNotNull($entry, "Expected default building-block scope-key 'not_found' when notFoundFlashScopeKey is absent.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return array<string, mixed>|null
|
|
|
|
|
*/
|
|
|
|
|
private function findFlashEntryByKey(string $key): ?array
|
|
|
|
|
{
|
|
|
|
|
/** @var mixed $rawMessages */
|
|
|
|
|
$rawMessages = $_SESSION['flash_messages'] ?? [];
|
|
|
|
|
if (!is_array($rawMessages)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
foreach ($rawMessages as $entry) {
|
|
|
|
|
if (!is_array($entry)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (($entry['key'] ?? null) === $key) {
|
|
|
|
|
return $entry;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 23:08:52 +02:00
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
* Aggregator: actionCreateContext
|
|
|
|
|
* ---------------------------------------------------------------- */
|
|
|
|
|
|
|
|
|
|
public function testCreateContextHappyPath(): void
|
|
|
|
|
{
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
|
|
|
|
|
'capabilities' => [
|
|
|
|
|
'can_view_page' => true,
|
|
|
|
|
'can_manage_all_tenants' => true,
|
|
|
|
|
],
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
$result = actionCreateContext([
|
|
|
|
|
'abilityKey' => 'ABILITY_USERS_CREATE',
|
|
|
|
|
'context' => [],
|
|
|
|
|
'viewAuthFlags' => ['can_view_page'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->assertSame('all', $result['tenantScope']['scope']);
|
|
|
|
|
$this->assertSame([], $result['tenantScope']['ids']);
|
|
|
|
|
$this->assertSame(['page' => ['can_view_page' => true]], $result['viewAuth']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testCreateContextDeniedRedirects(): void
|
|
|
|
|
{
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::deny());
|
|
|
|
|
|
|
|
|
|
actionCreateContext([
|
|
|
|
|
'abilityKey' => 'ABILITY_USERS_CREATE',
|
|
|
|
|
'context' => [],
|
|
|
|
|
'viewAuthFlags' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->assertStringContainsString('error/forbidden', $this->capturedRedirect());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
* Aggregator: actionFragmentContext
|
|
|
|
|
* ---------------------------------------------------------------- */
|
|
|
|
|
|
|
|
|
|
public function testFragmentContextOk(): void
|
|
|
|
|
{
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
|
|
|
|
|
'capabilities' => ['can_view' => true],
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
$result = actionFragmentContext([
|
|
|
|
|
'finder' => static fn (string $id): array => ['uuid' => $id],
|
|
|
|
|
'rawId' => 'u-1',
|
|
|
|
|
'abilityKey' => 'ABILITY_VIEW',
|
|
|
|
|
'context' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->assertSame('ok', $result['status']);
|
|
|
|
|
$this->assertSame(['uuid' => 'u-1'], $result['model'] ?? null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testFragmentContextForbidden(): void
|
|
|
|
|
{
|
|
|
|
|
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::deny());
|
|
|
|
|
|
|
|
|
|
$result = actionFragmentContext([
|
|
|
|
|
'finder' => static fn (string $id): array => ['uuid' => $id],
|
|
|
|
|
'rawId' => 'u-1',
|
|
|
|
|
'abilityKey' => 'ABILITY_VIEW',
|
|
|
|
|
'context' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->assertSame('forbidden', $result['status']);
|
|
|
|
|
$this->assertSame(403, $result['http_code'] ?? null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ----------------------------------------------------------------
|
|
|
|
|
* helpers
|
|
|
|
|
* ---------------------------------------------------------------- */
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Replace the AuthorizationService in the AppContainer with a stub that
|
|
|
|
|
* returns the decision produced by $factory on every authorize() call.
|
|
|
|
|
*
|
|
|
|
|
* @param callable(): AuthorizationDecision $factory
|
|
|
|
|
*/
|
|
|
|
|
private function stubAuthorizationService(callable $factory): void
|
|
|
|
|
{
|
|
|
|
|
$service = self::makeAuthorizationServiceStub($factory);
|
|
|
|
|
|
|
|
|
|
$container = new AppContainer();
|
|
|
|
|
$container->set(AuthorizationService::class, static fn (): AuthorizationService => $service);
|
|
|
|
|
$this->pushAppContainer($container);
|
|
|
|
|
|
|
|
|
|
// Guard::deny() is unused in most tests; if a forbidden-strategy=deny
|
|
|
|
|
// test runs, Guard's authorizationServiceResolver also resolves to the
|
|
|
|
|
// same stub via the closure captured in setUp().
|
|
|
|
|
Guard::configure(
|
|
|
|
|
authServiceResolver: static fn () => throw new \RuntimeException('AuthService not stubbed'),
|
|
|
|
|
tenantServiceResolver: static fn () => throw new \RuntimeException('TenantService not stubbed'),
|
|
|
|
|
authorizationServiceResolver: static fn (): AuthorizationService => $service,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param callable(): AuthorizationDecision $factory
|
|
|
|
|
*/
|
|
|
|
|
private static function makeAuthorizationServiceStub(callable $factory): AuthorizationService
|
|
|
|
|
{
|
|
|
|
|
return new class ($factory) extends AuthorizationService {
|
|
|
|
|
/** @var callable */
|
|
|
|
|
private $factory;
|
|
|
|
|
|
|
|
|
|
public function __construct(callable $factory)
|
|
|
|
|
{
|
|
|
|
|
parent::__construct([]);
|
|
|
|
|
$this->factory = $factory;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function authorize(string $ability, array $context = []): AuthorizationDecision
|
|
|
|
|
{
|
|
|
|
|
return ($this->factory)();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function capturedRedirect(): string
|
|
|
|
|
{
|
|
|
|
|
$reflection = new ReflectionClass(Router::class);
|
|
|
|
|
$prop = $reflection->getProperty('redirect');
|
|
|
|
|
$value = $prop->getValue();
|
|
|
|
|
return is_string($value) ? $value : '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function resetRouterState(): void
|
|
|
|
|
{
|
|
|
|
|
$reflection = new ReflectionClass(Router::class);
|
|
|
|
|
foreach (['redirect', 'initialized'] as $name) {
|
|
|
|
|
if (!$reflection->hasProperty($name)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
$prop = $reflection->getProperty($name);
|
|
|
|
|
if ($name === 'initialized') {
|
|
|
|
|
$prop->setValue(null, true); // skip initialize() (which reads $_SERVER routing state)
|
|
|
|
|
} else {
|
|
|
|
|
$prop->setValue(null, null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|