refactor(actions): introduce action-context helpers (step 1)
Six orthogonal building blocks plus three cluster aggregators in
core/Support/helpers/action_context.php, preparing consolidation of the
~40-60 line vorspiel duplicated across edit/create/view-fragment actions.
Step 1 of a planned 3-step rollout: no production call sites yet —
pages/ and modules/ are untouched. Architecture tests freeze the
building-block signatures and verify drawer-fragment AuthZ parity.
GR-SEC-009 is structurally enforced via the actionDeriveTenantScope
return shape (PHPStan array{scope: 'all'|'list', ids: list<int>});
'all' is unreachable without an explicit can_manage_all_tenants flag.
Aggregator docblocks carry a mandatory CSRF-pairing warning per
GR-SEC-001; actionBuildViewAuth flags the e()-escape obligation per
GR-SEC-010.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
|
||||
351
core/Support/helpers/action_context.php
Normal file
351
core/Support/helpers/action_context.php
Normal file
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Step 1: helpers introduced without production callers; produced by run
|
||||
* 2026-04-25-action-context-helper. The 6 orthogonal building blocks plus
|
||||
* the 3 cluster aggregators centralize the ~40-80 line pre-render preamble
|
||||
* that every Edit/Create/View/Drawer-Fragment action repeats today.
|
||||
*
|
||||
* No page action or module page is allowed to call these yet — Step 2 is the
|
||||
* Departments-Edit pilot and Step 3 is the cluster-wide rollout.
|
||||
*/
|
||||
|
||||
use MintyPHP\Router;
|
||||
use MintyPHP\Service\Access\AuthorizationService;
|
||||
use MintyPHP\Support\Flash;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
/**
|
||||
* Resolve a model by raw id (uuid, integer or code) using the caller's finder.
|
||||
*
|
||||
* The finder MUST apply tenant scope itself — this helper deliberately stays
|
||||
* scope-agnostic so it can serve UUID, integer-id and code-keyed actions.
|
||||
*
|
||||
* On miss the helper flashes the i18n key and redirects to $redirectPath; the
|
||||
* caller's page execution ends inside Router::redirect().
|
||||
*
|
||||
* @param callable(string): mixed $finder Returns the resolved model or null.
|
||||
* @param string $rawId Raw id from the URL (already trimmed by router).
|
||||
* @param string $notFoundFlashKey Translation key for the flash message.
|
||||
* @param string $redirectPath Target path on miss (e.g. 'admin/users').
|
||||
*
|
||||
* @return mixed The resolved model. Never returns when not found (redirect ends execution).
|
||||
*/
|
||||
function actionResolveModelOrFail(
|
||||
callable $finder,
|
||||
string $rawId,
|
||||
string $notFoundFlashKey,
|
||||
string $redirectPath
|
||||
): mixed {
|
||||
$model = $finder($rawId);
|
||||
if ($model !== null) {
|
||||
return $model;
|
||||
}
|
||||
|
||||
Flash::error(t($notFoundFlashKey), $redirectPath, 'not_found');
|
||||
Router::redirect($redirectPath);
|
||||
// Router::redirect() calls die() in production. Guard for tests where
|
||||
// executeRedirect=false: caller MUST not rely on this being reached.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run AuthorizationService::authorize and extract the capabilities map.
|
||||
*
|
||||
* Both forbidden strategies set a final HTTP response and end page execution:
|
||||
* - 'redirect' → Router::redirect('error/forbidden') (302)
|
||||
* - 'deny' → Guard::deny() (403)
|
||||
*
|
||||
* Neither strategy can be bypassed from the caller — both terminate via
|
||||
* Router::redirect() / die() in production.
|
||||
*
|
||||
* @param string $abilityKey Policy ability constant.
|
||||
* @param array<string, mixed> $context Authorization context payload.
|
||||
* @param string $forbiddenStrategy 'redirect' (default) or 'deny'.
|
||||
*
|
||||
* @return array<string, mixed> 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<string, mixed> $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<int>}
|
||||
*/
|
||||
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<string, mixed> $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<string, mixed> $capabilities Full capabilities map.
|
||||
* @param list<string> $whitelistedFlags Flag names to expose to the view.
|
||||
*
|
||||
* @return array{page: array<string, mixed>}
|
||||
*/
|
||||
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<string, mixed> $context Authorize context payload.
|
||||
*
|
||||
* @return array{status: 'ok'|'forbidden'|'not_found'|'invalid_id', model?: mixed, capabilities?: array<string, mixed>, 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<string, mixed>
|
||||
* - viewAuthFlags: list<string>
|
||||
* Optional keys:
|
||||
* - forbiddenStrategy: 'redirect'|'deny' (default 'redirect')
|
||||
* - tenantScopeFlagKey: string
|
||||
* - tenantScopeListKey: string
|
||||
* - canViewPageFlagKey: string (default 'can_view_page')
|
||||
*
|
||||
* @param array<string, mixed> $args
|
||||
* @return array{model: mixed, capabilities: array<string, mixed>, tenantScope: array{scope: 'all'|'list', ids: list<int>}, viewAuth: array{page: array<string, mixed>}}
|
||||
*/
|
||||
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<string, mixed>
|
||||
* - viewAuthFlags: list<string>
|
||||
* Optional keys:
|
||||
* - forbiddenStrategy: 'redirect'|'deny'
|
||||
* - tenantScopeFlagKey: string
|
||||
* - tenantScopeListKey: string
|
||||
* - canViewPageFlagKey: string
|
||||
*
|
||||
* @param array<string, mixed> $args
|
||||
* @return array{capabilities: array<string, mixed>, tenantScope: array{scope: 'all'|'list', ids: list<int>}, viewAuth: array{page: array<string, mixed>}}
|
||||
*/
|
||||
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<string, mixed>
|
||||
*
|
||||
* @param array<string, mixed> $args
|
||||
* @return array{status: 'ok'|'forbidden'|'not_found'|'invalid_id', model?: mixed, capabilities?: array<string, mixed>, 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
248
tests/Architecture/ActionContextHelperContractTest.php
Normal file
248
tests/Architecture/ActionContextHelperContractTest.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Tests\Architecture;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionFunction;
|
||||
use ReflectionNamedType;
|
||||
|
||||
/**
|
||||
* Freezes the API of the 6 orthogonal building blocks in
|
||||
* core/Support/helpers/action_context.php.
|
||||
*
|
||||
* Aggregators (actionEditContext, actionCreateContext, actionFragmentContext)
|
||||
* are intentionally NOT frozen — they may evolve additively in Step 2 of the
|
||||
* rollout.
|
||||
*
|
||||
* This test also asserts that the Tenant-Scope and Fragment-Resolver helpers
|
||||
* carry their PHPStan array-shape return docblocks. Acceptance check SC-004
|
||||
* relies on the shape staying stable.
|
||||
*/
|
||||
class ActionContextHelperContractTest extends TestCase
|
||||
{
|
||||
use ProjectFileAssertionSupport;
|
||||
|
||||
/**
|
||||
* @return array<int, array{0: string, 1: list<array{0: string, 1?: mixed, 2?: bool}>}>
|
||||
*/
|
||||
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<int>}",
|
||||
$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());
|
||||
}
|
||||
}
|
||||
@@ -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<string>} [actionFound, viewFound, searchedDirs]
|
||||
*/
|
||||
|
||||
484
tests/Support/Helpers/ActionContextHelperTest.php
Normal file
484
tests/Support/Helpers/ActionContextHelperTest.php
Normal file
@@ -0,0 +1,484 @@
|
||||
<?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());
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user