diff --git a/core/Support/helpers.php b/core/Support/helpers.php index 6ca411c..f1bfb80 100644 --- a/core/Support/helpers.php +++ b/core/Support/helpers.php @@ -3,6 +3,7 @@ // Load all global helper groups used by templates and page actions. require __DIR__ . '/helpers/admin_settings.php'; require __DIR__ . '/helpers/app.php'; +require __DIR__ . '/helpers/action_context.php'; require __DIR__ . '/helpers/array.php'; require __DIR__ . '/helpers/branding.php'; require __DIR__ . '/helpers/export.php'; diff --git a/core/Support/helpers/action_context.php b/core/Support/helpers/action_context.php new file mode 100644 index 0000000..246550d --- /dev/null +++ b/core/Support/helpers/action_context.php @@ -0,0 +1,351 @@ + $context Authorization context payload. + * @param string $forbiddenStrategy 'redirect' (default) or 'deny'. + * + * @return array The decision's capabilities attribute, or []. + */ +function actionAuthorizeAndExtractCapabilities( + string $abilityKey, + array $context, + string $forbiddenStrategy = 'redirect' +): array { + $decision = app(AuthorizationService::class)->authorize($abilityKey, $context); + + if (!$decision->isAllowed()) { + if ($forbiddenStrategy === 'deny') { + Guard::deny(); + return []; + } + Router::redirect('error/forbidden'); + return []; + } + + $capabilities = $decision->attribute('capabilities', []); + return is_array($capabilities) ? $capabilities : []; +} + +/** + * Derive the tenant scope tuple from a capabilities map. + * + * Strict semantics (GR-SEC-009): scope='all' is ONLY returned when the + * $allTenantsFlagKey is present and === true. A null/missing/non-true value + * for the flag NEVER widens the scope — it falls through to 'list', which + * may legitimately be empty. + * + * The Departments-Edit semantics are the calibration target. Users-Edit has + * a different "null = preserve out-of-scope" semantic that is handled by + * mergeTenantIdsPreservingOutOfScope and is NOT in scope for this helper. + * + * @param array $capabilities Capabilities map. + * @param string $allTenantsFlagKey Flag key that grants scope='all'. + * @param string $allowedListKey List key for explicit tenant ids. + * + * @return array{scope: 'all'|'list', ids: list} + */ +function actionDeriveTenantScope( + array $capabilities, + string $allTenantsFlagKey = 'can_manage_all_tenants', + string $allowedListKey = 'allowed_tenant_ids' +): array { + if (($capabilities[$allTenantsFlagKey] ?? null) === true) { + return ['scope' => 'all', 'ids' => []]; + } + + $raw = $capabilities[$allowedListKey] ?? null; + if (!is_array($raw)) { + return ['scope' => 'list', 'ids' => []]; + } + + $ids = []; + foreach ($raw as $value) { + $intValue = is_numeric($value) ? (int) $value : 0; + if ($intValue > 0) { + $ids[] = $intValue; + } + } + + return ['scope' => 'list', 'ids' => array_values(array_unique($ids))]; +} + +/** + * Enforce a can_view_page-style flag from the capabilities map. + * + * Strict comparison (=== true). No truthy coercion: integers, strings, + * arrays, null all fail. Capabilities are system-internal PHP booleans + * produced by AuthorizationService — no vendor enum coercion. If a capability + * source produces non-boolean truthy values, normalize at the source, not + * here. + * + * On failure either Router::redirect('error/forbidden') or Guard::deny() is + * invoked depending on $forbiddenStrategy. Both end page execution. + * + * @param array $capabilities Capabilities map. + * @param string $flagKey Flag to check (default 'can_view_page'). + * @param string $forbiddenStrategy 'redirect' (default) or 'deny'. + */ +function actionEnforceCanViewPage( + array $capabilities, + string $flagKey = 'can_view_page', + string $forbiddenStrategy = 'redirect' +): void { + if (($capabilities[$flagKey] ?? false) === true) { + return; + } + + if ($forbiddenStrategy === 'deny') { + Guard::deny(); + return; + } + Router::redirect('error/forbidden'); +} + +/** + * Build the view-auth payload from a whitelisted slice of capabilities. + * + * Return value contains MIXED types from capabilities (bool, int, string, + * array). Caller MUST e()-escape all non-boolean values before view output. + * See GR-SEC-010. Test ActionContextHelperTest verifies the array shape but + * does not enforce escaping — that is the view's responsibility. + * + * @param array $capabilities Full capabilities map. + * @param list $whitelistedFlags Flag names to expose to the view. + * + * @return array{page: array} + */ +function actionBuildViewAuth(array $capabilities, array $whitelistedFlags): array +{ + return [ + 'page' => array_intersect_key($capabilities, array_flip($whitelistedFlags)), + ]; +} + +/** + * Drawer-fragment variant of actionResolveModelOrFail + Authorize. + * + * Drawer fragments must NOT redirect (CLAUDE.md). They return HTTP status + * codes 400/403/404 instead. This helper produces a status DTO that the + * caller translates into http_response_code() + early return. + * + * Usage: + * $r = actionFragmentResolveOrStatus($finder, $rawId, ABILITY_VIEW, [...]); + * if ($r['status'] !== 'ok') { http_response_code($r['http_code']); return; } + * + * @param callable(string): mixed $finder Returns the resolved model or null. + * @param string $rawId Raw id from the URL. + * @param string $abilityKey Ability constant for authorize(). + * @param array $context Authorize context payload. + * + * @return array{status: 'ok'|'forbidden'|'not_found'|'invalid_id', model?: mixed, capabilities?: array, http_code?: int} + */ +function actionFragmentResolveOrStatus( + callable $finder, + string $rawId, + string $abilityKey, + array $context +): array { + Guard::requireLogin(); + + $trimmedId = trim($rawId); + if ($trimmedId === '') { + return ['status' => 'invalid_id', 'http_code' => 400]; + } + + $decision = app(AuthorizationService::class)->authorize($abilityKey, $context); + if (!$decision->isAllowed()) { + return ['status' => 'forbidden', 'http_code' => 403]; + } + + $model = $finder($trimmedId); + if ($model === null) { + return ['status' => 'not_found', 'http_code' => 404]; + } + + $capabilities = $decision->attribute('capabilities', []); + return [ + 'status' => 'ok', + 'model' => $model, + 'capabilities' => is_array($capabilities) ? $capabilities : [], + ]; +} + +/** + * CALLERS MUST call actionRequireCsrf() BEFORE this aggregator for POST requests. This helper does NOT verify CSRF (GR-SEC-001). For GET-only edit-render flows, CSRF is not required. + * + * Cluster-1/2/3 Edit aggregator: resolve model → authorize → derive tenant + * scope → enforce can_view_page → build viewAuth. + * + * Two-Level-Authorize (CONTEXT + SUBMIT, used by Roles/Permissions) is NOT + * built in — actions that need it call the building blocks directly. + * + * Required keys in $args: + * - finder: callable(string): mixed + * - rawId: string + * - notFoundFlashKey: string + * - notFoundRedirectPath: string + * - abilityKey: string + * - context: array + * - viewAuthFlags: list + * Optional keys: + * - forbiddenStrategy: 'redirect'|'deny' (default 'redirect') + * - tenantScopeFlagKey: string + * - tenantScopeListKey: string + * - canViewPageFlagKey: string (default 'can_view_page') + * + * @param array $args + * @return array{model: mixed, capabilities: array, tenantScope: array{scope: 'all'|'list', ids: list}, viewAuth: array{page: array}} + */ +function actionEditContext(array $args): array +{ + $finder = $args['finder']; + $rawId = (string) $args['rawId']; + $notFoundFlashKey = (string) $args['notFoundFlashKey']; + $notFoundRedirectPath = (string) $args['notFoundRedirectPath']; + $abilityKey = (string) $args['abilityKey']; + $context = is_array($args['context'] ?? null) ? $args['context'] : []; + $viewAuthFlags = is_array($args['viewAuthFlags'] ?? null) ? $args['viewAuthFlags'] : []; + $forbiddenStrategy = (string) ($args['forbiddenStrategy'] ?? 'redirect'); + $tenantScopeFlagKey = (string) ($args['tenantScopeFlagKey'] ?? 'can_manage_all_tenants'); + $tenantScopeListKey = (string) ($args['tenantScopeListKey'] ?? 'allowed_tenant_ids'); + $canViewPageFlagKey = (string) ($args['canViewPageFlagKey'] ?? 'can_view_page'); + + $model = actionResolveModelOrFail($finder, $rawId, $notFoundFlashKey, $notFoundRedirectPath); + $capabilities = actionAuthorizeAndExtractCapabilities($abilityKey, $context, $forbiddenStrategy); + $tenantScope = actionDeriveTenantScope($capabilities, $tenantScopeFlagKey, $tenantScopeListKey); + actionEnforceCanViewPage($capabilities, $canViewPageFlagKey, $forbiddenStrategy); + $viewAuth = actionBuildViewAuth($capabilities, $viewAuthFlags); + + return [ + 'model' => $model, + 'capabilities' => $capabilities, + 'tenantScope' => $tenantScope, + 'viewAuth' => $viewAuth, + ]; +} + +/** + * CALLERS MUST call actionRequireCsrf() BEFORE this aggregator for POST requests. This helper does NOT verify CSRF (GR-SEC-001). For GET-only create-render flows, CSRF is not required. + * + * Cluster-7 Create aggregator: authorize → derive tenant scope → enforce + * can_view_page → build viewAuth. No model resolution. + * + * Required keys in $args: + * - abilityKey: string + * - context: array + * - viewAuthFlags: list + * Optional keys: + * - forbiddenStrategy: 'redirect'|'deny' + * - tenantScopeFlagKey: string + * - tenantScopeListKey: string + * - canViewPageFlagKey: string + * + * @param array $args + * @return array{capabilities: array, tenantScope: array{scope: 'all'|'list', ids: list}, viewAuth: array{page: array}} + */ +function actionCreateContext(array $args): array +{ + $abilityKey = (string) $args['abilityKey']; + $context = is_array($args['context'] ?? null) ? $args['context'] : []; + $viewAuthFlags = is_array($args['viewAuthFlags'] ?? null) ? $args['viewAuthFlags'] : []; + $forbiddenStrategy = (string) ($args['forbiddenStrategy'] ?? 'redirect'); + $tenantScopeFlagKey = (string) ($args['tenantScopeFlagKey'] ?? 'can_manage_all_tenants'); + $tenantScopeListKey = (string) ($args['tenantScopeListKey'] ?? 'allowed_tenant_ids'); + $canViewPageFlagKey = (string) ($args['canViewPageFlagKey'] ?? 'can_view_page'); + + $capabilities = actionAuthorizeAndExtractCapabilities($abilityKey, $context, $forbiddenStrategy); + $tenantScope = actionDeriveTenantScope($capabilities, $tenantScopeFlagKey, $tenantScopeListKey); + actionEnforceCanViewPage($capabilities, $canViewPageFlagKey, $forbiddenStrategy); + $viewAuth = actionBuildViewAuth($capabilities, $viewAuthFlags); + + return [ + 'capabilities' => $capabilities, + 'tenantScope' => $tenantScope, + 'viewAuth' => $viewAuth, + ]; +} + +/** + * CALLERS MUST call actionRequireCsrf() BEFORE this aggregator for POST requests. This helper does NOT verify CSRF (GR-SEC-001). Drawer fragments are typically GET, but the warning is intentionally defensive and consistent across aggregators. + * + * Cluster-10 Drawer-Fragment aggregator: thin wrapper around + * actionFragmentResolveOrStatus. Returns the status DTO unchanged so the + * caller can translate it to http_response_code(...) + early return. + * + * Required keys in $args: + * - finder: callable(string): mixed + * - rawId: string + * - abilityKey: string + * - context: array + * + * @param array $args + * @return array{status: 'ok'|'forbidden'|'not_found'|'invalid_id', model?: mixed, capabilities?: array, http_code?: int} + */ +function actionFragmentContext(array $args): array +{ + $finder = $args['finder']; + $rawId = (string) $args['rawId']; + $abilityKey = (string) $args['abilityKey']; + $context = is_array($args['context'] ?? null) ? $args['context'] : []; + + return actionFragmentResolveOrStatus($finder, $rawId, $abilityKey, $context); +} diff --git a/i18n/default_de.json b/i18n/default_de.json index 43becf7..237db84 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -1,4 +1,7 @@ { + "action.context.forbidden": "Zugriff verweigert", + "action.context.model_not_found": "Datensatz nicht gefunden", + "action.context.tenant_scope_violation": "Zugriff außerhalb des erlaubten Mandantenbereichs", "%d active API tokens": "%d aktive API-Tokens", "%d active login tokens": "%d aktive Login-Tokens", "%d API audit entries purged": "%d API-Protokoll-Einträge bereinigt", diff --git a/i18n/default_en.json b/i18n/default_en.json index 9450ff5..8583382 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -1,4 +1,7 @@ { + "action.context.forbidden": "Access denied", + "action.context.model_not_found": "Record not found", + "action.context.tenant_scope_violation": "Access outside allowed tenant scope", "%d active API tokens": "%d active API tokens", "%d active login tokens": "%d active login tokens", "%d API audit entries purged": "%d API log entries purged", diff --git a/tests/Architecture/ActionContextHelperContractTest.php b/tests/Architecture/ActionContextHelperContractTest.php new file mode 100644 index 0000000..87e4067 --- /dev/null +++ b/tests/Architecture/ActionContextHelperContractTest.php @@ -0,0 +1,248 @@ +}> + */ + public static function buildingBlocks(): array + { + // [function-name, [[paramName, ?defaultValue, hasDefault], ...]] + return [ + ['actionResolveModelOrFail', [ + ['finder', null, false], + ['rawId', null, false], + ['notFoundFlashKey', null, false], + ['redirectPath', null, false], + ]], + ['actionAuthorizeAndExtractCapabilities', [ + ['abilityKey', null, false], + ['context', null, false], + ['forbiddenStrategy', 'redirect', true], + ]], + ['actionDeriveTenantScope', [ + ['capabilities', null, false], + ['allTenantsFlagKey', 'can_manage_all_tenants', true], + ['allowedListKey', 'allowed_tenant_ids', true], + ]], + ['actionEnforceCanViewPage', [ + ['capabilities', null, false], + ['flagKey', 'can_view_page', true], + ['forbiddenStrategy', 'redirect', true], + ]], + ['actionBuildViewAuth', [ + ['capabilities', null, false], + ['whitelistedFlags', null, false], + ]], + ['actionFragmentResolveOrStatus', [ + ['finder', null, false], + ['rawId', null, false], + ['abilityKey', null, false], + ['context', null, false], + ]], + ]; + } + + public function testHelperFileExistsAndIsRegistered(): void + { + $this->assertFileExists($this->projectRootPath() . '/core/Support/helpers/action_context.php'); + + $helpersFile = $this->readProjectFile('core/Support/helpers.php'); + $this->assertStringContainsString( + "require __DIR__ . '/helpers/action_context.php';", + $helpersFile, + 'helpers/action_context.php must be required from core/Support/helpers.php' + ); + } + + public function testBuildingBlocksExistWithStableSignatures(): void + { + foreach (self::buildingBlocks() as [$fnName, $expectedParams]) { + $this->assertTrue( + function_exists($fnName), + "Helper function '{$fnName}' must exist (frozen by Step 1)." + ); + + $reflection = new ReflectionFunction($fnName); + $actualParams = $reflection->getParameters(); + + $this->assertSameSize( + $expectedParams, + $actualParams, + "Parameter count for '{$fnName}' has changed." + ); + + foreach ($expectedParams as $i => [$expectedName, $expectedDefault, $hasDefault]) { + $this->assertSame( + $expectedName, + $actualParams[$i]->getName(), + "Parameter #{$i} of '{$fnName}' has been renamed." + ); + $this->assertSame( + $hasDefault, + $actualParams[$i]->isDefaultValueAvailable(), + "Parameter '{$expectedName}' of '{$fnName}' default-availability has changed." + ); + if ($hasDefault) { + $this->assertSame( + $expectedDefault, + $actualParams[$i]->getDefaultValue(), + "Parameter '{$expectedName}' of '{$fnName}' default has changed." + ); + } + } + } + } + + public function testTenantScopeReturnDocblockIsFrozen(): void + { + $reflection = new ReflectionFunction('actionDeriveTenantScope'); + $doc = (string) $reflection->getDocComment(); + + $this->assertStringContainsString( + "@return array{scope: 'all'|'list', ids: list}", + $doc, + "actionDeriveTenantScope must declare PHPStan array-shape return for GR-SEC-009." + ); + } + + public function testFragmentResolveReturnDocblockIsFrozen(): void + { + $reflection = new ReflectionFunction('actionFragmentResolveOrStatus'); + $doc = (string) $reflection->getDocComment(); + + $this->assertMatchesRegularExpression( + "/@return array\{status:[^}]*'ok'[^}]*'forbidden'[^}]*'not_found'[^}]*'invalid_id'/", + $doc, + "actionFragmentResolveOrStatus must declare PHPStan array-shape return covering all 4 status values." + ); + } + + public function testEnforceCanViewPageDocblockHasStrictComparisonNote(): void + { + $reflection = new ReflectionFunction('actionEnforceCanViewPage'); + $doc = (string) $reflection->getDocComment(); + + $this->assertStringContainsString( + 'Strict comparison (=== true)', + $doc, + "actionEnforceCanViewPage docblock must document strict-comparison contract." + ); + $this->assertStringContainsString( + 'No truthy coercion', + $doc, + "actionEnforceCanViewPage docblock must document no-truthy-coercion contract." + ); + } + + public function testBuildViewAuthDocblockHasEscapeWarning(): void + { + $reflection = new ReflectionFunction('actionBuildViewAuth'); + $doc = (string) $reflection->getDocComment(); + + $this->assertStringContainsString( + 'MUST e()-escape', + $doc, + "actionBuildViewAuth docblock must require e()-escaping per GR-SEC-010." + ); + } + + public function testAggregatorDocblocksWarnAboutCsrf(): void + { + $contents = $this->readProjectFile('core/Support/helpers/action_context.php'); + + $count = preg_match_all( + '/MUST call actionRequireCsrf\(\) BEFORE this aggregator/', + $contents + ); + $this->assertSame( + 3, + $count, + 'Each of the 3 aggregators (actionEditContext, actionCreateContext, actionFragmentContext) must carry the explicit CSRF-warning in its docblock (GR-SEC-001).' + ); + } + + public function testAggregatorsExistButAreNotFrozen(): void + { + // We assert existence only — signatures may evolve in Step 2. + $this->assertTrue(function_exists('actionEditContext')); + $this->assertTrue(function_exists('actionCreateContext')); + $this->assertTrue(function_exists('actionFragmentContext')); + } + + public function testNoProductionCallSitesYet(): void + { + $root = $this->projectRootPath(); + $names = [ + 'actionResolveModelOrFail', + 'actionAuthorizeAndExtractCapabilities', + 'actionDeriveTenantScope', + 'actionEnforceCanViewPage', + 'actionBuildViewAuth', + 'actionFragmentResolveOrStatus', + 'actionEditContext', + 'actionCreateContext', + 'actionFragmentContext', + ]; + + $hits = []; + foreach (['pages', 'modules'] as $dir) { + $base = $root . '/' . $dir; + if (!is_dir($base)) { + continue; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS)); + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + $content = (string) file_get_contents($file->getPathname()); + foreach ($names as $name) { + if (preg_match('/\b' . preg_quote($name, '/') . '\s*\(/', $content)) { + $hits[] = $name . ' in ' . str_replace($root . '/', '', $file->getPathname()); + } + } + } + } + + $this->assertSame( + [], + $hits, + "Step 1 must not introduce production call-sites — defer to Step 2 (Departments-Edit pilot):\n" . implode("\n", $hits) + ); + } + + /** + * Ensure the `mixed` return type of building blocks is preserved where + * documented. Catches accidental tightening that would break Step 2. + */ + public function testResolveModelOrFailReturnsMixed(): void + { + $reflection = new ReflectionFunction('actionResolveModelOrFail'); + $returnType = $reflection->getReturnType(); + $this->assertInstanceOf(ReflectionNamedType::class, $returnType); + /** @var ReflectionNamedType $returnType */ + $this->assertSame('mixed', $returnType->getName()); + } +} diff --git a/tests/Architecture/DetailDrawerFragmentContractTest.php b/tests/Architecture/DetailDrawerFragmentContractTest.php index 20087f6..a7aac77 100644 --- a/tests/Architecture/DetailDrawerFragmentContractTest.php +++ b/tests/Architecture/DetailDrawerFragmentContractTest.php @@ -109,6 +109,141 @@ class DetailDrawerFragmentContractTest extends TestCase ); } + /** + * AuthZ-Parity: a drawer fragment must enforce the same authorization as + * its full-page counterpart (CLAUDE.md: "Must enforce auth + scope exactly + * like the full-page view"). This test extracts authorize / requireAbility + * arguments from each fragment and compares them to the matched counterpart. + * + * Allowlist of semantic equivalences (verified manually against codebase): + * - admin/users/view-fragment ↔ admin/users/edit + * Reason: there is no view($id).php under pages/admin/users/, so the + * full-page detail action is edit. The fragment uses ABILITY_VIEW; + * edit uses ABILITY_EDIT_CONTEXT — semantically the fragment is the + * read-only subset of the edit context. Documented anomaly. + * - addressbook/view-fragment ↔ addressbook/view + * Reason: both call requireAbilityOrForbidden(ABILITY_VIEW). + * - helpdesk/ticket-fragment ↔ helpdesk/ticket + * Reason: both call requireAbilityOrForbidden(ABILITY_ACCESS). + * + * The test fails explicitly if an authorize/requireAbility call is nested + * inside an if/else block (not statically extractable) — a parity check + * cannot be made automatically in that case. + */ + public function testFragmentAuthzMatchesFullPage(): void + { + $root = $this->projectRootPath(); + + // counterpart map: fragment-relative-path => full-page-relative-path + $pairs = [ + 'pages/admin/users/view-fragment($id).php' => 'pages/admin/users/edit($id).php', + 'modules/addressbook/pages/address-book/view-fragment($id).php' => 'modules/addressbook/pages/address-book/view($id).php', + 'modules/helpdesk/pages/helpdesk/ticket-fragment($id).php' => 'modules/helpdesk/pages/helpdesk/ticket($id).php', + ]; + + // semantic-equivalence allowlist — full-page ability => fragment ability accepted as parity + $equivalents = [ + 'UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_CONTEXT' => ['UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW'], + ]; + + $violations = []; + + foreach ($pairs as $fragmentRel => $fullPageRel) { + $fragmentPath = $root . '/' . $fragmentRel; + $fullPagePath = $root . '/' . $fullPageRel; + + $this->assertFileExists($fragmentPath, "Fragment file missing: {$fragmentRel}"); + $this->assertFileExists($fullPagePath, "Full-page file missing: {$fullPageRel}"); + + $fragmentAbility = $this->extractTopLevelAbility((string) file_get_contents($fragmentPath), $fragmentRel, $violations); + $fullPageAbility = $this->extractTopLevelAbility((string) file_get_contents($fullPagePath), $fullPageRel, $violations); + + if ($fragmentAbility === null || $fullPageAbility === null) { + continue; // already recorded as violation + } + + // Strip leading namespace separators for comparison resilience. + $fragNorm = ltrim($fragmentAbility, '\\'); + $fullNorm = ltrim($fullPageAbility, '\\'); + + if ($fragNorm === $fullNorm) { + continue; + } + + $accepted = $equivalents[$fullNorm] ?? []; + if (in_array($fragNorm, $accepted, true)) { + continue; + } + + $violations[] = sprintf( + "AuthZ parity mismatch: %s requires '%s' but full-page %s requires '%s' (no documented allowlist entry).", + $fragmentRel, + $fragNorm, + $fullPageRel, + $fullNorm + ); + } + + $this->assertSame([], $violations, "Drawer-fragment AuthZ-parity violations:\n" . implode("\n", $violations)); + } + + /** + * Extract the first top-level (non-nested) authorize / requireAbility ability + * argument from a PHP source string. Returns null and records a violation + * when no statically extractable call is found. + */ + private function extractTopLevelAbility(string $source, string $fileRel, array &$violations): ?string + { + // Strip block comments and line comments to avoid false matches. + $stripped = preg_replace('/\/\*.*?\*\//s', '', $source) ?? $source; + $stripped = preg_replace('/\/\/[^\n]*/', '', $stripped) ?? $stripped; + + $lines = explode("\n", $stripped); + $depth = 0; + $hasTopLevelMatch = false; + $hasNestedMatch = false; + $extracted = null; + + foreach ($lines as $line) { + // Update brace depth AFTER matching this line so a `{` on the same + // line as an authorize call (rare) still counts as top-level. + $matchPattern = '/(?:Guard::requireAbility(?:OrForbidden|DecisionOrForbidden)?|->authorize|::authorize)\s*\(\s*([A-Za-z_\\\\][A-Za-z0-9_\\\\:]*)/'; + if (preg_match($matchPattern, $line, $m)) { + if ($depth === 0) { + if (!$hasTopLevelMatch) { + $extracted = $m[1]; + $hasTopLevelMatch = true; + } + } else { + $hasNestedMatch = true; + } + } + $opens = substr_count($line, '{'); + $closes = substr_count($line, '}'); + $depth += $opens - $closes; + if ($depth < 0) { + $depth = 0; + } + } + + if (!$hasTopLevelMatch) { + if ($hasNestedMatch) { + $violations[] = sprintf( + "Cannot verify parity when authorization is conditional — review manually: %s", + $fileRel + ); + } else { + $violations[] = sprintf( + "No authorize/requireAbility call found in %s — fragment must enforce auth.", + $fileRel + ); + } + return null; + } + + return $extracted; + } + /** * @return array{0: bool, 1: bool, 2: list} [actionFound, viewFound, searchedDirs] */ diff --git a/tests/Support/Helpers/ActionContextHelperTest.php b/tests/Support/Helpers/ActionContextHelperTest.php new file mode 100644 index 0000000..938f95f --- /dev/null +++ b/tests/Support/Helpers/ActionContextHelperTest.php @@ -0,0 +1,484 @@ + */ + 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); + } + + /* ---------------------------------------------------------------- + * 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()); + } + + /* ---------------------------------------------------------------- + * 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); + } + } + } +}