1
0
Files
breadcrumb-the-shire/tests/Support/Helpers/ActionContextHelperTest.php
fs f9c09f8746 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>
2026-04-25 23:08:52 +02:00

485 lines
18 KiB
PHP

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