Files
breadcrumb-the-shire/tests/Support/Helpers/ActionContextHelperTest.php

562 lines
21 KiB
PHP
Raw Normal View History

<?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());
}
refactor(departments-edit): migrate to actionEditContext (step 2) Pilot migration of pages/admin/departments/edit($id).php onto the actionEditContext aggregator introduced in step 1. The CONTEXT-stage vorspiel (lookup → authorize → tenant-scope → can_view_page → viewAuth) collapses into a single declarative call; the POST branch (CSRF → SUBMIT-authorize → can_update gate → service call → PRG) stays callsite-specific as planned. Three deliberate touches beyond a 1:1 lift: * Additive aggregator extension: actionEditContext gains an optional notFoundFlashScopeKey arg so the dedup scope-key 'department_not_found' is preserved without widening the frozen actionResolveModelOrFail building-block signature. Pattern is documented as the forward-compatibility mechanism for future cluster migrations. * t() consistency: the not-found message now flows through t() via the aggregator. To avoid a partial-translation mix, Flash::success calls for 'Department updated' (×2) are also wrapped — German users now see fully translated messages instead of a German/English mix. * Defensive scope consumption: the action now consults $tenantScope['scope'] before falling through to the strict-mode fallback. The Departments policy never emits can_manage_all_tenants today (so behavior is identical), but the action is now resilient to future policies that might. New ActionContextCsrfPairingContractTest enforces actionRequireCsrf() before any POST body access for actions that use the aggregators — preventing CSRF-pairing regressions during the cluster-wide rollout (step 3). The step-1 testNoProductionCallSitesYet guard is removed, since departments-edit is now the first legitimate caller; the new pairing test takes over its protective role with a more substantive guarantee. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:52:27 +02:00
public function testEditContextWithFlashScopeKeyOverride(): void
{
// Departments-Edit pilot use-case: aggregator must emit a flash with
// the caller-specific scope-key ('department_not_found') instead of
// the building-block default ('not_found') when notFoundFlashScopeKey
// is provided. Required for Step 2 behavior identity (Run
// 2026-04-25-action-context-rollout-step2-departments).
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
'capabilities' => ['can_view_page' => true],
]));
$_SESSION['flash_messages'] = [];
actionEditContext([
'finder' => static fn (): mixed => null,
'rawId' => 'missing',
'notFoundFlashKey' => 'Department not found',
'notFoundRedirectPath' => 'admin/departments',
'notFoundFlashScopeKey' => 'department_not_found',
'abilityKey' => 'ABILITY_DEPARTMENTS_EDIT_CONTEXT',
'context' => [],
'viewAuthFlags' => [],
]);
$this->assertStringContainsString('admin/departments', $this->capturedRedirect());
$entry = $this->findFlashEntryByKey('department_not_found');
$this->assertNotNull($entry, "Expected flash with key 'department_not_found' to be set by aggregator override.");
$this->assertSame('error', $entry['type'] ?? null);
$this->assertSame('admin/departments', $entry['scope'] ?? null);
}
public function testEditContextWithoutFlashScopeKeyKeepsBuildingBlockDefault(): void
{
// Without notFoundFlashScopeKey, the aggregator delegates to
// actionResolveModelOrFail which hardcodes scope-key 'not_found'.
// This test pins the default-path behavior so a future addition
// doesn't accidentally widen building-block semantics.
$this->stubAuthorizationService(static fn (): AuthorizationDecision => AuthorizationDecision::allow([
'capabilities' => ['can_view_page' => true],
]));
$_SESSION['flash_messages'] = [];
actionEditContext([
'finder' => static fn (): mixed => null,
'rawId' => 'missing',
'notFoundFlashKey' => 'action.context.model_not_found',
'notFoundRedirectPath' => 'admin/users',
'abilityKey' => 'ABILITY_USERS_EDIT_CONTEXT',
'context' => [],
'viewAuthFlags' => [],
]);
$entry = $this->findFlashEntryByKey('not_found');
$this->assertNotNull($entry, "Expected default building-block scope-key 'not_found' when notFoundFlashScopeKey is absent.");
}
/**
* @return array<string, mixed>|null
*/
private function findFlashEntryByKey(string $key): ?array
{
/** @var mixed $rawMessages */
$rawMessages = $_SESSION['flash_messages'] ?? [];
if (!is_array($rawMessages)) {
return null;
}
foreach ($rawMessages as $entry) {
if (!is_array($entry)) {
continue;
}
if (($entry['key'] ?? null) === $key) {
return $entry;
}
}
return null;
}
/* ----------------------------------------------------------------
* 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);
}
}
}
}