2026-04-22 15:22:26 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Tests\Architecture;
|
|
|
|
|
|
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Every `initDetailDrawer({ fetchUrl })` consumer must expose a matching
|
|
|
|
|
* fragment endpoint following the convention:
|
|
|
|
|
* <route>-fragment($id).php ← action
|
|
|
|
|
* <route>-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(`<path>/${uuid}`, ...)` — extract <path>.
|
|
|
|
|
// 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)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 23:08:52 +02:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:22:26 +02:00
|
|
|
/**
|
|
|
|
|
* @return array{0: bool, 1: bool, 2: list<string>} [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];
|
|
|
|
|
}
|
|
|
|
|
}
|