Skip to content

Commit 5e0f842

Browse files
authored
fix: capability-permission relationships not being synced in sync method (#42)
1 parent 2d86c94 commit 5e0f842

File tree

3 files changed

+104
-2
lines changed

3 files changed

+104
-2
lines changed

src/Mandate.php

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Illuminate\Support\Collection;
1010
use OffloadProject\Mandate\CodeFirst\DefinitionCache;
1111
use OffloadProject\Mandate\CodeFirst\DefinitionDiscoverer;
12+
use OffloadProject\Mandate\CodeFirst\PermissionDefinition;
1213
use OffloadProject\Mandate\Concerns\HasRoles;
1314
use OffloadProject\Mandate\Contracts\Capability as CapabilityContract;
1415
use OffloadProject\Mandate\Contracts\FeatureAccessHandler;
@@ -690,7 +691,10 @@ public function sync(
690691
$capabilitiesCreated = 0;
691692
$capabilitiesUpdated = 0;
692693

693-
$syncAll = ! $permissions && ! $roles && ! $capabilities && ! $seedOnly;
694+
// Sync all if no specific flags passed, or if seed is used with code-first enabled
695+
// This ensures discovered permissions are synced before seeding assignments
696+
$syncAll = ! $permissions && ! $roles && ! $capabilities
697+
&& (! $seedOnly || $codeFirstEnabled);
694698

695699
// Determine what to sync (skip if seed-only mode)
696700
$syncPermissions = $codeFirstEnabled && ($syncAll || $permissions);
@@ -825,6 +829,9 @@ private function syncDefinitionsToDatabase(
825829
->where('guard', $definition->guard)
826830
->first();
827831

832+
/** @var Permission|Role|Capability|null $model */
833+
$model = $existing;
834+
828835
if ($existing) {
829836
$needsUpdate = false;
830837
$updates = [];
@@ -855,14 +862,45 @@ private function syncDefinitionsToDatabase(
855862
$attributes['description'] = $definition->description;
856863
}
857864

858-
$modelClass::query()->create($attributes);
865+
$model = $modelClass::query()->create($attributes);
859866
$created++;
860867
}
868+
869+
// Sync capability-permission relationships for permissions
870+
if ($entityType === 'permissions' && $model instanceof Permission && $definition instanceof PermissionDefinition) {
871+
$this->syncPermissionCapabilities($model, $definition->capabilities);
872+
}
861873
}
862874

863875
return ['created' => $created, 'updated' => $updated];
864876
}
865877

878+
/**
879+
* Sync a permission's capability relationships.
880+
*
881+
* @param array<string> $capabilityNames
882+
*/
883+
private function syncPermissionCapabilities(Permission $permission, array $capabilityNames): void
884+
{
885+
if (empty($capabilityNames) || ! $this->capabilitiesEnabled()) {
886+
return;
887+
}
888+
889+
/** @var class-string<Capability> $capabilityClass */
890+
$capabilityClass = config('mandate.models.capability', Capability::class);
891+
892+
foreach ($capabilityNames as $capabilityName) {
893+
/** @var Capability $capability */
894+
$capability = $capabilityClass::query()
895+
->firstOrCreate(
896+
['name' => $capabilityName, 'guard' => $permission->guard]
897+
);
898+
899+
// Grant the permission to the capability (uses syncWithoutDetaching internally)
900+
$capability->grantPermission($permission);
901+
}
902+
}
903+
866904
/**
867905
* Seed role-permission and role-capability assignments from config.
868906
*/

tests/Feature/Commands/SyncCommandTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,5 +316,15 @@
316316

317317
// user:delete should also have user-management (from class-level)
318318
expect($userManagement->hasPermission('user:delete'))->toBeTrue();
319+
320+
// Verify the pivot table actually has records
321+
$pivotTable = config('mandate.tables.capability_permission', 'capability_permission');
322+
$pivotCount = Illuminate\Support\Facades\DB::table($pivotTable)->count();
323+
expect($pivotCount)->toBe(4); // user:view, user:edit, user:delete -> user-management (3) + user:delete -> admin-only (1)
324+
325+
// Also verify by loading permissions relationship
326+
$userManagement->load('permissions');
327+
expect($userManagement->permissions)->toHaveCount(3);
328+
expect($userManagement->permissions->pluck('name')->toArray())->toContain('user:view', 'user:edit', 'user:delete');
319329
});
320330
});

tests/Feature/SyncMethodTest.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,60 @@
266266
expect($role->hasCapability('another-capability'))->toBeTrue();
267267
});
268268

269+
it('syncs capability-permission relationships from Capability attributes', function () {
270+
$this->enableCapabilities();
271+
config(['mandate.code_first.enabled' => true]);
272+
config(['mandate.code_first.paths.permissions' => __DIR__.'/../Fixtures/CodeFirst']);
273+
274+
Mandate::sync(permissions: true);
275+
276+
$capabilityClass = config('mandate.models.capability');
277+
278+
// UserPermissions has class-level #[Capability('user-management')]
279+
// So user:view, user:edit, user:delete should have user-management capability
280+
$userManagement = $capabilityClass::where('name', 'user-management')->first();
281+
expect($userManagement)->not->toBeNull();
282+
expect($userManagement->hasPermission('user:view'))->toBeTrue();
283+
expect($userManagement->hasPermission('user:edit'))->toBeTrue();
284+
expect($userManagement->hasPermission('user:delete'))->toBeTrue();
285+
286+
// user:delete also has constant-level #[Capability('admin-only')]
287+
$adminOnly = $capabilityClass::where('name', 'admin-only')->first();
288+
expect($adminOnly)->not->toBeNull();
289+
expect($adminOnly->hasPermission('user:delete'))->toBeTrue();
290+
291+
// Verify pivot table records
292+
$pivotTable = config('mandate.tables.capability_permission', 'capability_permission');
293+
$pivotCount = Illuminate\Support\Facades\DB::table($pivotTable)->count();
294+
expect($pivotCount)->toBe(4); // 3 for user-management + 1 for admin-only
295+
});
296+
297+
it('syncs all discovered permissions when using seed with code-first enabled', function () {
298+
config(['mandate.code_first.enabled' => true]);
299+
config(['mandate.code_first.paths.permissions' => __DIR__.'/../Fixtures/CodeFirst']);
300+
config(['mandate.code_first.paths.roles' => __DIR__.'/../Fixtures/CodeFirst']);
301+
302+
// Only assign one permission, but all should be synced
303+
config(['mandate.assignments' => [
304+
'partial-role' => [
305+
'permissions' => ['article:view'],
306+
],
307+
]]);
308+
309+
Mandate::sync(seed: true);
310+
311+
// All permissions from code-first should be synced, not just those in assignments
312+
expect(Permission::where('name', 'article:view')->exists())->toBeTrue();
313+
expect(Permission::where('name', 'article:create')->exists())->toBeTrue();
314+
expect(Permission::where('name', 'article:edit')->exists())->toBeTrue();
315+
expect(Permission::where('name', 'article:delete')->exists())->toBeTrue();
316+
317+
// But only article:view should be assigned to the role
318+
$role = Role::where('name', 'partial-role')->first();
319+
expect($role->hasPermission('article:view'))->toBeTrue();
320+
expect($role->hasPermission('article:create'))->toBeFalse();
321+
});
322+
269323
it('respects guard filter when seeding', function () {
270324
config(['mandate.code_first.enabled' => false]);
271325

0 commit comments

Comments
 (0)