-fragment($id).php ← action * -fragment(none).phtml ← view (no-layout) * * This test scans JS files for `initDetailDrawer` calls, extracts the fragment * path from the fetchUrl template literal, and verifies both files exist. */ class DetailDrawerFragmentContractTest extends TestCase { use ProjectFileAssertionSupport; public function testEveryDrawerConsumerHasMatchingFragmentEndpoint(): void { $root = $this->projectRootPath(); $scanRoots = ['web/js', 'modules']; $consumers = []; foreach ($scanRoots as $scanRoot) { $basePath = $root . '/' . $scanRoot; if (!is_dir($basePath)) { continue; } $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($basePath, \FilesystemIterator::SKIP_DOTS)); /** @var \SplFileInfo $file */ foreach ($iterator as $file) { if (!$file->isFile() || $file->getExtension() !== 'js') { continue; } // Exclude the drawer implementation itself. $relativePath = str_replace($root . '/', '', $file->getPathname()); if ($relativePath === 'web/js/components/app-detail-drawer.js') { continue; } $content = file_get_contents($file->getPathname()); if ($content === false || !str_contains($content, 'initDetailDrawer(')) { continue; } // Find `fetchUrl: (uuid) => new URL(`/${uuid}`, ...)` — extract . // The path must contain "-fragment" per the convention. if (preg_match_all( '/fetchUrl\s*:\s*\([^)]*\)\s*=>\s*new\s+URL\s*\(\s*`([^`${]+)\$\{[^}]+\}[^`]*`/', $content, $matches )) { foreach ($matches[1] as $prefix) { $prefix = rtrim($prefix, '/'); $consumers[$prefix] = $relativePath; } } } } $this->assertNotEmpty( $consumers, 'No initDetailDrawer fetchUrl patterns found. Either the convention changed or the regex is stale.' ); $violations = []; foreach ($consumers as $fragmentPath => $consumerFile) { if (!str_contains($fragmentPath, '-fragment')) { $violations[] = sprintf( "Fragment path does not follow *-fragment convention: %s (in %s)", $fragmentPath, $consumerFile ); continue; } [$actionFound, $viewFound, $searchedDirs] = $this->locateFragmentFiles($fragmentPath); if (!$actionFound) { $violations[] = sprintf( "Missing action file for fragment path '%s'. Expected somewhere like:\n pages/%s(\$id).php\n modules/*/pages/%s(\$id).php\nSearched in: %s\n(Consumer: %s)", $fragmentPath, $fragmentPath, $fragmentPath, implode(', ', $searchedDirs), $consumerFile ); } if (!$viewFound) { $violations[] = sprintf( "Missing (none) view file for fragment path '%s'. Expected:\n %s(none).phtml\nSearched in: %s\n(Consumer: %s)", $fragmentPath, $fragmentPath, implode(', ', $searchedDirs), $consumerFile ); } } $this->assertSame( [], $violations, "Detail-drawer fragment-contract violations:\n" . implode("\n", $violations) ); } /** * AuthZ-Parity: a drawer fragment must enforce the same authorization as * its full-page counterpart (CLAUDE.md: "Must enforce auth + scope exactly * like the full-page view"). This test extracts authorize / requireAbility * arguments from each fragment and compares them to the matched counterpart. * * Allowlist of semantic equivalences (verified manually against codebase): * - admin/users/view-fragment ↔ admin/users/edit * Reason: there is no view($id).php under pages/admin/users/, so the * full-page detail action is edit. The fragment uses ABILITY_VIEW; * edit uses ABILITY_EDIT_CONTEXT — semantically the fragment is the * read-only subset of the edit context. Documented anomaly. * - addressbook/view-fragment ↔ addressbook/view * Reason: both call requireAbilityOrForbidden(ABILITY_VIEW). * - helpdesk/ticket-fragment ↔ helpdesk/ticket * Reason: both call requireAbilityOrForbidden(ABILITY_ACCESS). * * The test fails explicitly if an authorize/requireAbility call is nested * inside an if/else block (not statically extractable) — a parity check * cannot be made automatically in that case. */ public function testFragmentAuthzMatchesFullPage(): void { $root = $this->projectRootPath(); // counterpart map: fragment-relative-path => full-page-relative-path $pairs = [ 'pages/admin/users/view-fragment($id).php' => 'pages/admin/users/edit($id).php', 'modules/addressbook/pages/address-book/view-fragment($id).php' => 'modules/addressbook/pages/address-book/view($id).php', 'modules/helpdesk/pages/helpdesk/ticket-fragment($id).php' => 'modules/helpdesk/pages/helpdesk/ticket($id).php', ]; // semantic-equivalence allowlist — full-page ability => fragment ability accepted as parity $equivalents = [ 'UserAuthorizationPolicy::ABILITY_ADMIN_USERS_EDIT_CONTEXT' => ['UserAuthorizationPolicy::ABILITY_ADMIN_USERS_VIEW'], ]; $violations = []; foreach ($pairs as $fragmentRel => $fullPageRel) { $fragmentPath = $root . '/' . $fragmentRel; $fullPagePath = $root . '/' . $fullPageRel; $this->assertFileExists($fragmentPath, "Fragment file missing: {$fragmentRel}"); $this->assertFileExists($fullPagePath, "Full-page file missing: {$fullPageRel}"); $fragmentAbility = $this->extractTopLevelAbility((string) file_get_contents($fragmentPath), $fragmentRel, $violations); $fullPageAbility = $this->extractTopLevelAbility((string) file_get_contents($fullPagePath), $fullPageRel, $violations); if ($fragmentAbility === null || $fullPageAbility === null) { continue; // already recorded as violation } // Strip leading namespace separators for comparison resilience. $fragNorm = ltrim($fragmentAbility, '\\'); $fullNorm = ltrim($fullPageAbility, '\\'); if ($fragNorm === $fullNorm) { continue; } $accepted = $equivalents[$fullNorm] ?? []; if (in_array($fragNorm, $accepted, true)) { continue; } $violations[] = sprintf( "AuthZ parity mismatch: %s requires '%s' but full-page %s requires '%s' (no documented allowlist entry).", $fragmentRel, $fragNorm, $fullPageRel, $fullNorm ); } $this->assertSame([], $violations, "Drawer-fragment AuthZ-parity violations:\n" . implode("\n", $violations)); } /** * Extract the first top-level (non-nested) authorize / requireAbility ability * argument from a PHP source string. Returns null and records a violation * when no statically extractable call is found. */ private function extractTopLevelAbility(string $source, string $fileRel, array &$violations): ?string { // Strip block comments and line comments to avoid false matches. $stripped = preg_replace('/\/\*.*?\*\//s', '', $source) ?? $source; $stripped = preg_replace('/\/\/[^\n]*/', '', $stripped) ?? $stripped; $lines = explode("\n", $stripped); $depth = 0; $hasTopLevelMatch = false; $hasNestedMatch = false; $extracted = null; foreach ($lines as $line) { // Update brace depth AFTER matching this line so a `{` on the same // line as an authorize call (rare) still counts as top-level. $matchPattern = '/(?:Guard::requireAbility(?:OrForbidden|DecisionOrForbidden)?|->authorize|::authorize)\s*\(\s*([A-Za-z_\\\\][A-Za-z0-9_\\\\:]*)/'; if (preg_match($matchPattern, $line, $m)) { if ($depth === 0) { if (!$hasTopLevelMatch) { $extracted = $m[1]; $hasTopLevelMatch = true; } } else { $hasNestedMatch = true; } } $opens = substr_count($line, '{'); $closes = substr_count($line, '}'); $depth += $opens - $closes; if ($depth < 0) { $depth = 0; } } if (!$hasTopLevelMatch) { if ($hasNestedMatch) { $violations[] = sprintf( "Cannot verify parity when authorization is conditional — review manually: %s", $fileRel ); } else { $violations[] = sprintf( "No authorize/requireAbility call found in %s — fragment must enforce auth.", $fileRel ); } return null; } return $extracted; } /** * @return array{0: bool, 1: bool, 2: list} [actionFound, viewFound, searchedDirs] */ private function locateFragmentFiles(string $fragmentPath): array { $root = $this->projectRootPath(); $searchBases = [$root . '/pages']; $modulesDir = $root . '/modules'; if (is_dir($modulesDir)) { foreach (scandir($modulesDir) ?: [] as $moduleEntry) { if ($moduleEntry === '.' || $moduleEntry === '..') { continue; } $modulePages = $modulesDir . '/' . $moduleEntry . '/pages'; if (is_dir($modulePages)) { $searchBases[] = $modulePages; } } } $actionFound = false; $viewFound = false; foreach ($searchBases as $base) { $actionCandidate = $base . '/' . $fragmentPath . '($id).php'; $viewCandidate = $base . '/' . $fragmentPath . '(none).phtml'; if (is_file($actionCandidate)) { $actionFound = true; } if (is_file($viewCandidate)) { $viewFound = true; } } return [$actionFound, $viewFound, $searchBases]; } }