diff --git a/core/Support/helpers/action_context.php b/core/Support/helpers/action_context.php index 4b76ea6..8d5a6c3 100644 --- a/core/Support/helpers/action_context.php +++ b/core/Support/helpers/action_context.php @@ -1,13 +1,15 @@ $context Authorize context payload. - * - * @return array{status: 'ok'|'forbidden'|'not_found'|'invalid_id', model?: mixed, capabilities?: array, 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. * @@ -356,29 +309,3 @@ function actionCreateContext(array $args): array '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 - * - * @param array $args - * @return array{status: 'ok'|'forbidden'|'not_found'|'invalid_id', model?: mixed, capabilities?: array, 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); -} diff --git a/tests/Architecture/ActionContextHelperContractTest.php b/tests/Architecture/ActionContextHelperContractTest.php index 29b0a13..06580e3 100644 --- a/tests/Architecture/ActionContextHelperContractTest.php +++ b/tests/Architecture/ActionContextHelperContractTest.php @@ -7,22 +7,27 @@ use ReflectionFunction; use ReflectionNamedType; /** - * Freezes the API of the 6 orthogonal building blocks in + * Freezes the API of the 5 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. + * Aggregators (actionEditContext, actionCreateContext) are intentionally NOT + * frozen — they may evolve additively across 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. + * This test also asserts that the Tenant-Scope helper carries its PHPStan + * array-shape return docblock. Acceptance check SC-004 relies on the shape + * staying stable. * * Step 2 (Pilot-Migration Departments-Edit, Run * 2026-04-25-action-context-rollout-step2-departments) is the first * production caller. The previous testNoProductionCallSitesYet assertion * has been removed; the CSRF↔Aggregator pairing is enforced by * tests/Architecture/ActionContextCsrfPairingContractTest.php instead. + * + * Run 2026-04-25-action-fragment-context-removal pruned the dead + * Cluster-10 Drawer-Fragment helpers (actionFragmentResolveOrStatus building + * block + actionFragmentContext aggregator) after Step 11 declared all three + * fragment actions structural exceptions that delegate to domain-services + * with their own status models. */ class ActionContextHelperContractTest extends TestCase { @@ -60,12 +65,6 @@ class ActionContextHelperContractTest extends TestCase ['capabilities', null, false], ['whitelistedFlags', null, false], ]], - ['actionFragmentResolveOrStatus', [ - ['finder', null, false], - ['rawId', null, false], - ['abilityKey', null, false], - ['context', null, false], - ]], ]; } @@ -132,18 +131,6 @@ class ActionContextHelperContractTest extends TestCase ); } - 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'); @@ -181,19 +168,25 @@ class ActionContextHelperContractTest extends TestCase '/MUST call actionRequireCsrf\(\) BEFORE this aggregator/', $contents ); + // Expected count is 2: actionEditContext + actionCreateContext. + // Run 2026-04-25-action-fragment-context-removal removed the + // actionFragmentContext aggregator (Cluster 10 has no aggregator — + // drawer-fragments use domain-service status models instead), reducing + // the warning count from 3 to 2. $this->assertSame( - 3, + 2, $count, - 'Each of the 3 aggregators (actionEditContext, actionCreateContext, actionFragmentContext) must carry the explicit CSRF-warning in its docblock (GR-SEC-001).' + 'Each of the 2 aggregators (actionEditContext, actionCreateContext) 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. + // We assert existence only — signatures may evolve. + // Run 2026-04-25-action-fragment-context-removal removed + // actionFragmentContext; only EditContext and CreateContext remain. $this->assertTrue(function_exists('actionEditContext')); $this->assertTrue(function_exists('actionCreateContext')); - $this->assertTrue(function_exists('actionFragmentContext')); } /** diff --git a/tests/Support/Helpers/ActionContextHelperTest.php b/tests/Support/Helpers/ActionContextHelperTest.php index 87f6f50..11a20cf 100644 --- a/tests/Support/Helpers/ActionContextHelperTest.php +++ b/tests/Support/Helpers/ActionContextHelperTest.php @@ -234,57 +234,6 @@ class ActionContextHelperTest extends TestCase $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 * ---------------------------------------------------------------- */ @@ -449,42 +398,6 @@ class ActionContextHelperTest extends TestCase $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 * ---------------------------------------------------------------- */