2026-04-25 23:08:52 +02:00
< ? 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 .
2026-04-25 23:52:27 +02:00
*
* 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 .
2026-04-25 23:08:52 +02:00
*/
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' ));
}
/**
* 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 ());
}
}