*/ private array $serverBackup = []; /** @var array */ 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 $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 $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); } /* ---------------------------------------------------------------- * 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()); } 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|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; } /* ---------------------------------------------------------------- * 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()); } /* ---------------------------------------------------------------- * 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); } } } }