actionFragmentContext was built spec-driven in step 1 to handle the anticipated drawer-fragment pattern: authorize + finder → status DTO. The action-context rollout (steps 2-11) found that none of the three real drawer fragments (users/view-fragment, addressbook/view-fragment, helpdesk/ticket-fragment) match that pattern — all three delegate to domain services that own their own status models. Step 11 declared them structural exceptions; the helper code remained unused. This commit removes the dead spec: * core/Support/helpers/action_context.php drops actionFragmentContext (~9 LOC) and its building block actionFragmentResolveOrStatus (~30 LOC) plus their docblocks. The MUST-call-actionRequireCsrf warning, present in three aggregator docblocks before, now appears twice (one per remaining aggregator). * tests/Support/Helpers/ActionContextHelperTest.php drops the six unit tests that exercised these functions (~83 LOC). * tests/Architecture/ActionContextHelperContractTest.php drops the fragment building block from the buildingBlocks() data provider (5 entries instead of 6) and removes testFragmentResolveReturnDocblockIsFrozen. The CSRF-warning expectation is updated from 3 to 2 with a code comment explaining the rollback. Verified: * No production caller exists in pages/ or modules/. * All 9 aggregator callers (5 actionEditContext + 4 actionCreateContext) remain unchanged. * ActionContextCsrfPairingContractTest and DetailDrawerFragmentContractTest are deliberately left untouched: their allowlists/recognizer regexes still mention actionFragmentContext, but the patterns now match an empty set — harmless dead text. Documented as open items in the run report; future cleanup is optional and orthogonal to this removal. * QGs all green (PHPUnit 2088 tests, PHPStan level 5, CS-fixer 0 diffs). Net: 3 files, +31/-198 LOC, behavior unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
205 lines
7.8 KiB
PHP
205 lines
7.8 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Tests\Architecture;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use ReflectionFunction;
|
|
use ReflectionNamedType;
|
|
|
|
/**
|
|
* Freezes the API of the 5 orthogonal building blocks in
|
|
* core/Support/helpers/action_context.php.
|
|
*
|
|
* Aggregators (actionEditContext, actionCreateContext) are intentionally NOT
|
|
* frozen — they may evolve additively across the rollout.
|
|
*
|
|
* 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
|
|
{
|
|
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],
|
|
]],
|
|
];
|
|
}
|
|
|
|
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 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
|
|
);
|
|
// 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(
|
|
2,
|
|
$count,
|
|
'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.
|
|
// 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'));
|
|
}
|
|
|
|
/**
|
|
* 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());
|
|
}
|
|
}
|