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:
2026-04-25 23:08:52 +02:00
parent 61c1d18e95
commit f9c09f8746
7 changed files with 1225 additions and 0 deletions

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

View File

@@ -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]
*/

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