refactor(users-edit): migrate to actionEditContext (step 6)

Cluster-2 pilot — first migration of a standard edit action whose
tenant-scope semantics use null = "manage all" (instead of the
boolean-flag pattern in cluster 1). The CONTEXT-stage vorspiel
collapses into one declarative actionEditContext call; everything
below the vorspiel (conditional audit, custom fields, security
artifacts, two-level submit-authorize, mergeTenantIdsPreservingOutOfScope,
post-save theme/locale/session hooks) stays callsite — domain logic.

Confirms the analyst hypothesis: actionEditContext +
tenantScopeFlagKey:'can_manage_tenants' is enough — no helper
extension. The override key was built in step 1, unit-tested at the
building-block level, and now production-validated.

Two callsite tenant-filter rewrites (GET line 98-105, POST line
190-208) replace is_array($allowedTenantIds) with
$tenantScope['scope']/$tenantScope['ids'] discrimination.
mergeTenantIdsPreservingOutOfScope still receives a list<int> — only
the argument source shifts; the function itself is unchanged.

Three drift decisions reproduced: notFoundFlashScopeKey:'user_not_found',
t() consistency on Flash::success('User updated'), defensive
$canManageAllTenants = $tenantScope['scope'] === 'all'. The legacy
$canManageTenants capability boolean stays alongside (it still gates
strict-mode fallback — both variables now coexist by design).

DetailDrawerFragmentContractTest gets an additive recognizer for
actionEditContext / actionCreateContext / actionFragmentContext
ability-key extraction. Without it the test couldn't see the
aggregator-mediated authorize call in users-edit, so the auth-parity
check against users/view-fragment would regress. Pure addition; the
legacy direct-authorize() regex path is untouched.

ActionContextCsrfPairingContractTest now covers five callers
(departments, tenants, roles, permissions, users) and stays green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 14:10:08 +02:00
parent a038d0921b
commit 480b57ef04
2 changed files with 83 additions and 43 deletions

View File

@@ -191,6 +191,11 @@ class DetailDrawerFragmentContractTest extends TestCase
* 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.
*
* Also recognizes the actionEditContext / actionCreateContext / actionFragmentContext
* aggregator pattern: if the file calls one of those aggregators at top level,
* we look for an 'abilityKey' => SomePolicy::CONST entry inside the args array
* and treat it as the top-level ability.
*/
private function extractTopLevelAbility(string $source, string $fileRel, array &$violations): ?string
{
@@ -203,6 +208,7 @@ class DetailDrawerFragmentContractTest extends TestCase
$hasTopLevelMatch = false;
$hasNestedMatch = false;
$extracted = null;
$insideTopLevelAggregator = false;
foreach ($lines as $line) {
// Update brace depth AFTER matching this line so a `{` on the same
@@ -218,6 +224,18 @@ class DetailDrawerFragmentContractTest extends TestCase
$hasNestedMatch = true;
}
}
// actionEditContext / actionCreateContext / actionFragmentContext aggregator pattern.
// The aggregator args use a [ ... ] array literal (not a { ... } block),
// so brace-depth tracking does not bracket them; we track the array
// opening with a separate "inside aggregator args" sticky flag instead.
if (!$insideTopLevelAggregator && $depth === 0 && preg_match('/actionEditContext\s*\(|actionCreateContext\s*\(|actionFragmentContext\s*\(/', $line)) {
$insideTopLevelAggregator = true;
}
if ($insideTopLevelAggregator && !$hasTopLevelMatch && preg_match('/[\'"]abilityKey[\'"]\s*=>\s*([A-Za-z_\\\\][A-Za-z0-9_\\\\:]*)/', $line, $am)) {
$extracted = $am[1];
$hasTopLevelMatch = true;
$insideTopLevelAggregator = false;
}
$opens = substr_count($line, '{');
$closes = substr_count($line, '}');
$depth += $opens - $closes;