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:
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