Files
breadcrumb-the-shire/tests/Architecture/ActionContextCsrfPairingContractTest.php
fs 5378209fed refactor(departments-edit): migrate to actionEditContext (step 2)
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>
2026-04-25 23:52:27 +02:00

258 lines
9.1 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,
* actionFragmentContext) 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',
'actionFragmentContext',
];
/**
* 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;
// Drawer-fragment allowlist: actionFragmentContext-only callers
// without any POST indicator are GET-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;
}
}