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>
212 lines
7.8 KiB
PHP
212 lines
7.8 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.
|
|
*
|
|
* 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.
|
|
*/
|
|
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());
|
|
}
|
|
}
|