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>
249 lines
9.1 KiB
PHP
249 lines
9.1 KiB
PHP
<?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.
|
|
*/
|
|
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'));
|
|
}
|
|
|
|
public function testNoProductionCallSitesYet(): void
|
|
{
|
|
$root = $this->projectRootPath();
|
|
$names = [
|
|
'actionResolveModelOrFail',
|
|
'actionAuthorizeAndExtractCapabilities',
|
|
'actionDeriveTenantScope',
|
|
'actionEnforceCanViewPage',
|
|
'actionBuildViewAuth',
|
|
'actionFragmentResolveOrStatus',
|
|
'actionEditContext',
|
|
'actionCreateContext',
|
|
'actionFragmentContext',
|
|
];
|
|
|
|
$hits = [];
|
|
foreach (['pages', 'modules'] as $dir) {
|
|
$base = $root . '/' . $dir;
|
|
if (!is_dir($base)) {
|
|
continue;
|
|
}
|
|
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS));
|
|
/** @var \SplFileInfo $file */
|
|
foreach ($iterator as $file) {
|
|
if (!$file->isFile() || $file->getExtension() !== 'php') {
|
|
continue;
|
|
}
|
|
$content = (string) file_get_contents($file->getPathname());
|
|
foreach ($names as $name) {
|
|
if (preg_match('/\b' . preg_quote($name, '/') . '\s*\(/', $content)) {
|
|
$hits[] = $name . ' in ' . str_replace($root . '/', '', $file->getPathname());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$hits,
|
|
"Step 1 must not introduce production call-sites — defer to Step 2 (Departments-Edit pilot):\n" . implode("\n", $hits)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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());
|
|
}
|
|
}
|