1
0
Files
breadcrumb-the-shire/tests/Architecture/ActionContextCsrfPairingContractTest.php
fs 01f5265eff test(architecture): drop stale actionFragmentContext mentions
Cleanup follow-up to commit 9ec10f5, which removed the unused
actionFragmentContext aggregator and its building block. Two
architecture tests still mentioned the removed aggregator in their
allowlists and recognizer regexes — patterns that now match an empty
set, harmless but misleading.

* ActionContextCsrfPairingContractTest: drop 'actionFragmentContext'
  from the AGGREGATORS constant, update the docblock to list only the
  two remaining aggregators, and rewrite the GET-only-allowlist
  comment to no longer reference the fragment-specific case (the
  guard itself stays — any future GET-only aggregator caller would
  still hit it).
* DetailDrawerFragmentContractTest: drop the third alternative from
  the aggregator-recognizer regex inside extractTopLevelAbility, and
  trim two comments accordingly.

The historical documentation in ActionContextHelperContractTest is
deliberately kept — those comments explain to future readers why the
test only freezes 5 building blocks and 2 aggregators (instead of
the original 6/3) and why the CSRF-warning expectation is 2 instead
of 3. That is contextual documentation, not stale references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:47:36 +02:00

257 lines
9.0 KiB
PHP

<?php
namespace MintyPHP\Tests\Architecture;
use FilesystemIterator;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
/**
* GR-SEC-001 extension: every page action that calls one of the
* action-context aggregators (actionEditContext, actionCreateContext)
* AND handles POST data MUST also call actionRequireCsrf() before it
* accesses the request body.
*
* Closes plan-stage finding SEC-PLAN-004 from Run
* 2026-04-25-action-context-helper. Replaces the obsolete
* ActionContextHelperContractTest::testNoProductionCallSitesYet which became
* stale once Step 2 introduced the first production caller (Departments-Edit
* pilot, Run 2026-04-25-action-context-rollout-step2-departments).
*
* Order requirement: actionRequireCsrf() must appear BEFORE the first
* POST-body indicator (bodyAll(), bodyInt(), body( etc.). It does NOT need to
* appear before the aggregator call itself — the aggregator only resolves
* model + capabilities for the GET-render and is safe to run before CSRF
* verification, mirroring the canonical Departments-Edit ordering preserved by
* Run 2026-04-25-action-context-rollout-step2-departments
* (Verhaltens-Identitäts-Pflicht).
*
* Detection is conservative: when an aggregator call cannot be statically
* located (e.g. wrapped in a heredoc or dynamic dispatch), the test fails
* explicitly with "Cannot verify CSRF pairing — review manually" rather than
* silently passing. Pattern reference: DetailDrawerFragmentContractTest.
*/
class ActionContextCsrfPairingContractTest extends TestCase
{
use ProjectFileAssertionSupport;
private const AGGREGATORS = [
'actionEditContext',
'actionCreateContext',
];
/**
* POST-handling presence indicators (does the file handle POST at all?).
* Mirrors PostEndpointCsrfContractTest::handlesPostData.
*/
private const POST_PRESENCE_INDICATORS = [
"isMethod('POST')",
'requestInput()->method()',
'hasBody(',
'bodyAll()',
'bodyInt(',
'bodyString(',
];
/**
* Body-access indicators. These must NOT happen before
* actionRequireCsrf(). isMethod('POST')/method() are guards, not body
* accesses, so they're excluded here — guards may legitimately come
* before the CSRF call (in the very same line as actionRequireCsrf).
*/
private const BODY_ACCESS_INDICATORS = [
'hasBody(',
'bodyAll()',
'bodyInt(',
'bodyString(',
];
public function testActionsUsingAggregatorsHaveCsrfBeforeAggregator(): void
{
$root = $this->projectRootPath();
$missing = [];
$unverifiable = [];
$orderViolations = [];
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());
$aggregatorOffsets = $this->locateAggregatorCalls($content);
if ($aggregatorOffsets === []) {
continue;
}
$relativePath = str_replace($root . '/', '', $file->getPathname());
// Non-static-extractable aggregator pattern (heredoc, eval, …).
if ($aggregatorOffsets === false) {
$unverifiable[] = $relativePath;
continue;
}
$isPostHandler = $this->hasAny($content, self::POST_PRESENCE_INDICATORS)
|| preg_match('/->body\(/', $content) === 1;
// GET-only allowlist: aggregator callers without any POST
// indicator are render-only and don't need CSRF.
if (!$isPostHandler) {
continue;
}
$csrfOffset = $this->locateActionRequireCsrf($content);
if ($csrfOffset === null) {
$missing[] = $relativePath;
continue;
}
$bodyAccessOffset = $this->locateFirstBodyAccess($content);
if ($bodyAccessOffset !== null && $csrfOffset >= $bodyAccessOffset) {
$orderViolations[] = sprintf(
'%s — actionRequireCsrf() at offset %d but POST body access at offset %d (CSRF must precede body access)',
$relativePath,
$csrfOffset,
$bodyAccessOffset
);
}
}
}
sort($missing);
sort($unverifiable);
sort($orderViolations);
$messages = [];
if ($missing !== []) {
$messages[] = "Aggregator-callers handling POST without actionRequireCsrf():\n " . implode("\n ", $missing);
}
if ($orderViolations !== []) {
$messages[] = "Aggregator-callers with actionRequireCsrf() AFTER body access:\n " . implode("\n ", $orderViolations);
}
if ($unverifiable !== []) {
$messages[] = "Cannot verify CSRF pairing — review manually:\n " . implode("\n ", $unverifiable);
}
$this->assertSame(
[],
$messages,
"GR-SEC-001 / aggregator pairing violations:\n" . implode("\n", $messages)
);
}
/**
* @return list<int>|false list of byte offsets of aggregator calls;
* false when at least one match is wrapped in a
* heredoc/string literal and cannot be statically
* verified.
*/
private function locateAggregatorCalls(string $content): array|false
{
$offsets = [];
foreach (self::AGGREGATORS as $name) {
$needle = $name . '(';
$pos = 0;
while (($found = strpos($content, $needle, $pos)) !== false) {
if ($this->isInsideStringLiteral($content, $found)) {
return false;
}
$offsets[] = $found;
$pos = $found + strlen($needle);
}
}
sort($offsets);
return $offsets;
}
private function locateFirstBodyAccess(string $content): ?int
{
$earliest = null;
foreach (self::BODY_ACCESS_INDICATORS as $needle) {
$pos = strpos($content, $needle);
if ($pos !== false && ($earliest === null || $pos < $earliest)) {
$earliest = $pos;
}
}
if (preg_match('/->body\(/', $content, $m, PREG_OFFSET_CAPTURE) === 1) {
$pos = (int) $m[0][1];
if ($earliest === null || $pos < $earliest) {
$earliest = $pos;
}
}
return $earliest;
}
private function locateActionRequireCsrf(string $content): ?int
{
$pos = strpos($content, 'actionRequireCsrf(');
return $pos === false ? null : $pos;
}
/**
* @param list<string> $needles
*/
private function hasAny(string $content, array $needles): bool
{
foreach ($needles as $needle) {
if (str_contains($content, $needle)) {
return true;
}
}
return false;
}
/**
* Heuristic: a match is considered inside a heredoc/string literal when
* the immediately preceding non-whitespace character is a backtick or an
* unescaped quote on the same line. Conservative — favours raising
* "review manually" over silent pass.
*/
private function isInsideStringLiteral(string $content, int $offset): bool
{
if ($offset <= 0) {
return false;
}
// Walk back to start of line.
$lineStart = strrpos(substr($content, 0, $offset), "\n");
$lineStart = $lineStart === false ? 0 : $lineStart + 1;
$prefix = substr($content, $lineStart, $offset - $lineStart);
// Count unescaped single/double quotes on the line so far.
$singles = 0;
$doubles = 0;
$len = strlen($prefix);
for ($i = 0; $i < $len; $i++) {
$ch = $prefix[$i];
if ($ch === '\\') {
$i++;
continue;
}
if ($ch === "'") {
$singles++;
} elseif ($ch === '"') {
$doubles++;
}
}
// Odd counts → currently inside that quote type.
if (($singles % 2) === 1 || ($doubles % 2) === 1) {
return true;
}
// Heredoc detection — simplistic: if the line above ended with <<<TAG
// and we haven't seen the closing tag yet, we'd be inside. Skip — the
// test fails-loud on unverifiable cases.
return false;
}
}