M2 -> M3 $this->m1 = Entity::createMaster('ACME', 'Acme Corp'); $this->m2 = $this->m1->createFacade('SHOP', 'Acme Shopfront'); $this->m3 = $this->m2->createDropshipper('DROP', 'Acme Dropshipper'); // Default config: strict mode on, training mode off config([ 'commerce.matrix.training_mode' => false, 'commerce.matrix.strict_mode' => true, 'commerce.matrix.log_all_checks' => false, 'commerce.matrix.log_denials' => false, 'commerce.matrix.default_allow' => false, ]); $this->service = new PermissionMatrixService; }); // --------------------------------------------------------------------------- // Hierarchy & Entity Setup // --------------------------------------------------------------------------- describe('entity hierarchy', function () { it('builds a valid three-tier hierarchy', function () { expect($this->m1->isMaster())->toBeTrue() ->and($this->m1->depth)->toBe(0) ->and($this->m1->path)->toBe('ACME'); expect($this->m2->isFacade())->toBeTrue() ->and($this->m2->depth)->toBe(1) ->and($this->m2->path)->toBe('ACME/SHOP'); expect($this->m3->isDropshipper())->toBeTrue() ->and($this->m3->depth)->toBe(2) ->and($this->m3->path)->toBe('ACME/SHOP/DROP'); }); it('returns correct ancestors for M3', function () { $ancestors = $this->m3->getAncestors(); expect($ancestors)->toHaveCount(2) ->and($ancestors->first()->id)->toBe($this->m1->id) ->and($ancestors->last()->id)->toBe($this->m2->id); }); it('returns correct ancestors for M2', function () { $ancestors = $this->m2->getAncestors(); expect($ancestors)->toHaveCount(1) ->and($ancestors->first()->id)->toBe($this->m1->id); }); it('returns empty ancestors for M1', function () { $ancestors = $this->m1->getAncestors(); expect($ancestors)->toBeEmpty(); }); }); // --------------------------------------------------------------------------- // can() — basic permission checks // --------------------------------------------------------------------------- describe('PermissionMatrixService::can()', function () { it('returns undefined when no permission exists', function () { $result = $this->service->can($this->m2, 'products.create'); expect($result)->toBeInstanceOf(PermissionResult::class) ->and($result->isUndefined())->toBeTrue() ->and($result->key)->toBe('products.create'); }); it('returns allowed when entity has explicit allow', function () { PermissionMatrix::create([ 'entity_id' => $this->m2->id, 'key' => 'products.create', 'scope' => null, 'allowed' => true, 'locked' => false, 'source' => PermissionMatrix::SOURCE_EXPLICIT, ]); $result = $this->service->can($this->m2, 'products.create'); expect($result->isAllowed())->toBeTrue(); }); it('returns denied when entity has explicit deny', function () { PermissionMatrix::create([ 'entity_id' => $this->m2->id, 'key' => 'products.create', 'scope' => null, 'allowed' => false, 'locked' => false, 'source' => PermissionMatrix::SOURCE_EXPLICIT, ]); $result = $this->service->can($this->m2, 'products.create'); expect($result->isDenied())->toBeTrue() ->and($result->reason)->toBe('Denied by own policy'); }); it('respects scope in permission checks', function () { PermissionMatrix::create([ 'entity_id' => $this->m2->id, 'key' => 'products.view', 'scope' => 'category-a', 'allowed' => true, 'locked' => false, 'source' => PermissionMatrix::SOURCE_EXPLICIT, ]); $allowed = $this->service->can($this->m2, 'products.view', 'category-a'); $undefined = $this->service->can($this->m2, 'products.view', 'category-b'); expect($allowed->isAllowed())->toBeTrue() ->and($undefined->isUndefined())->toBeTrue(); }); }); // --------------------------------------------------------------------------- // can() — top-down immutable hierarchy rules // --------------------------------------------------------------------------- describe('top-down immutable permission cascade', function () { it('denies child when M1 has locked denial', function () { // M1 locks a denial PermissionMatrix::create([ 'entity_id' => $this->m1->id, 'key' => 'products.delete', 'scope' => null, 'allowed' => false, 'locked' => true, 'source' => PermissionMatrix::SOURCE_EXPLICIT, ]); // Even if M2 allows it locally, the check traverses ancestors first PermissionMatrix::create([ 'entity_id' => $this->m2->id, 'key' => 'products.delete', 'scope' => null, 'allowed' => true, 'locked' => false, 'source' => PermissionMatrix::SOURCE_EXPLICIT, ]); $result = $this->service->can($this->m2, 'products.delete'); expect($result->isDenied())->toBeTrue() ->and($result->isLocked())->toBeTrue() ->and($result->lockedBy->id)->toBe($this->m1->id) ->and($result->reason)->toContain('Locked by'); }); it('denies M3 when M1 locks denial even with M2 allow', function () { PermissionMatrix::create([ 'entity_id' => $this->m1->id, 'key' => 'orders.refund', 'scope' => null, 'allowed' => false, 'locked' => true, 'source' => PermissionMatrix::SOURCE_EXPLICIT, ]); PermissionMatrix::create([ 'entity_id' => $this->m2->id, 'key' => 'orders.refund', 'scope' => null, 'allowed' => true, 'locked' => false, 'source' => PermissionMatrix::SOURCE_EXPLICIT, ]); $result = $this->service->can($this->m3, 'orders.refund'); expect($result->isDenied())->toBeTrue() ->and($result->isLocked())->toBeTrue(); }); it('denies M3 when M2 has unlocked denial', function () { // M1 allows PermissionMatrix::create([ 'entity_id' => $this->m1->id, 'key' => 'products.edit', 'scope' => null, 'allowed' => true, 'locked' => false, 'source' => PermissionMatrix::SOURCE_EXPLICIT, ]); // M2 denies (not locked) PermissionMatrix::create([ 'entity_id' => $this->m2->id, 'key' => 'products.edit', 'scope' => null, 'allowed' => false, 'locked' => false, 'source' => PermissionMatrix::SOURCE_EXPLICIT, ]); $result = $this->service->can($this->m3, 'products.edit'); expect($result->isDenied())->toBeTrue() ->and($result->reason)->toContain('Denied by'); }); it('allows M2 to deny itself even when M1 allows', function () { PermissionMatrix::create([ 'entity_id' => $this->m1->id, 'key' => 'analytics.export', 'scope' => null, 'allowed' => true, 'locked' => false, 'source' => PermissionMatrix::SOURCE_EXPLICIT, ]); PermissionMatrix::create([ 'entity_id' => $this->m2->id, 'key' => 'analytics.export', 'scope' => null, 'allowed' => false, 'locked' => false, 'source' => PermissionMatrix::SOURCE_EXPLICIT, ]); $result = $this->service->can($this->m2, 'analytics.export'); expect($result->isDenied())->toBeTrue() ->and($result->reason)->toBe('Denied by own policy'); }); it('allows when entire hierarchy permits', function () { // M1 allows (not locked) PermissionMatrix::create([ 'entity_id' => $this->m1->id, 'key' => 'products.view', 'scope' => null, 'allowed' => true, 'locked' => false, 'source' => PermissionMatrix::SOURCE_EXPLICIT, ]); // M2 allows (not locked) PermissionMatrix::create([ 'entity_id' => $this->m2->id, 'key' => 'products.view', 'scope' => null, 'allowed' => true, 'locked' => false, 'source' => PermissionMatrix::SOURCE_EXPLICIT, ]); // M3 allows PermissionMatrix::create([ 'entity_id' => $this->m3->id, 'key' => 'products.view', 'scope' => null, 'allowed' => true, 'locked' => false, 'source' => PermissionMatrix::SOURCE_EXPLICIT, ]); $result = $this->service->can($this->m3, 'products.view'); expect($result->isAllowed())->toBeTrue(); }); }); // --------------------------------------------------------------------------- // lock() / unlock() // --------------------------------------------------------------------------- describe('lock() and unlock()', function () { it('locks a permission and cascades to descendants', function () { $this->service->lock($this->m1, 'products.delete', false); // M1 should have a locked denial $m1Perm = PermissionMatrix::where('entity_id', $this->m1->id) ->where('key', 'products.delete') ->first(); expect($m1Perm->locked)->toBeTrue() ->and($m1Perm->allowed)->toBeFalse() ->and($m1Perm->source)->toBe(PermissionMatrix::SOURCE_EXPLICIT); // Descendants should inherit the lock $m2Perm = PermissionMatrix::where('entity_id', $this->m2->id) ->where('key', 'products.delete') ->first(); $m3Perm = PermissionMatrix::where('entity_id', $this->m3->id) ->where('key', 'products.delete') ->first(); expect($m2Perm->locked)->toBeTrue() ->and($m2Perm->allowed)->toBeFalse() ->and($m2Perm->source)->toBe(PermissionMatrix::SOURCE_INHERITED) ->and($m2Perm->set_by_entity_id)->toBe($this->m1->id); expect($m3Perm->locked)->toBeTrue() ->and($m3Perm->allowed)->toBeFalse() ->and($m3Perm->source)->toBe(PermissionMatrix::SOURCE_INHERITED); }); it('locks an allow permission and cascades to descendants', function () { $this->service->lock($this->m1, 'products.view', true); $m1Perm = PermissionMatrix::where('entity_id', $this->m1->id) ->where('key', 'products.view') ->first(); expect($m1Perm->locked)->toBeTrue() ->and($m1Perm->allowed)->toBeTrue(); $m2Perm = PermissionMatrix::where('entity_id', $this->m2->id) ->where('key', 'products.view') ->first(); expect($m2Perm->locked)->toBeTrue() ->and($m2Perm->allowed)->toBeTrue(); }); it('locks with scope', function () { $this->service->lock($this->m1, 'products.view', false, 'premium'); $m1Perm = PermissionMatrix::where('entity_id', $this->m1->id) ->where('key', 'products.view') ->where('scope', 'premium') ->first(); expect($m1Perm)->not->toBeNull() ->and($m1Perm->locked)->toBeTrue(); }); it('unlocks a permission and removes inherited locks from descendants', function () { // First lock it $this->service->lock($this->m1, 'products.delete', false); // Verify it cascaded expect(PermissionMatrix::where('key', 'products.delete')->count())->toBe(3); // Now unlock $this->service->unlock($this->m1, 'products.delete'); // M1 should be unlocked $m1Perm = PermissionMatrix::where('entity_id', $this->m1->id) ->where('key', 'products.delete') ->first(); expect($m1Perm->locked)->toBeFalse() ->and($m1Perm->source)->toBe(PermissionMatrix::SOURCE_EXPLICIT); // Descendants' inherited locks should be removed $m2Perm = PermissionMatrix::where('entity_id', $this->m2->id) ->where('key', 'products.delete') ->first(); $m3Perm = PermissionMatrix::where('entity_id', $this->m3->id) ->where('key', 'products.delete') ->first(); expect($m2Perm)->toBeNull() ->and($m3Perm)->toBeNull(); }); }); // --------------------------------------------------------------------------- // setPermission() // --------------------------------------------------------------------------- describe('setPermission()', function () { it('creates an explicit permission', function () { $perm = $this->service->setPermission($this->m2, 'products.create', true); expect($perm)->toBeInstanceOf(PermissionMatrix::class) ->and($perm->entity_id)->toBe($this->m2->id) ->and($perm->key)->toBe('products.create') ->and($perm->allowed)->toBeTrue() ->and($perm->locked)->toBeFalse() ->and($perm->source)->toBe(PermissionMatrix::SOURCE_EXPLICIT); }); it('creates a scoped permission', function () { $perm = $this->service->setPermission($this->m2, 'products.view', true, 'category-a'); expect($perm->scope)->toBe('category-a'); }); it('updates an existing permission', function () { $this->service->setPermission($this->m2, 'products.create', true); $this->service->setPermission($this->m2, 'products.create', false); $perms = PermissionMatrix::where('entity_id', $this->m2->id) ->where('key', 'products.create') ->get(); expect($perms)->toHaveCount(1) ->and($perms->first()->allowed)->toBeFalse(); }); it('throws PermissionLockedException when parent has locked denial and trying to allow', function () { // M1 locks a denial $this->service->lock($this->m1, 'products.delete', false); expect(fn () => $this->service->setPermission($this->m2, 'products.delete', true)) ->toThrow(PermissionLockedException::class); }); it('allows setting denial even when parent has locked denial', function () { // M1 locks a denial $this->service->lock($this->m1, 'products.delete', false); // Setting a denial should work (not trying to override the lock) $perm = $this->service->setPermission($this->m2, 'products.delete', false); expect($perm->allowed)->toBeFalse(); }); it('allows setting permission when no parent lock exists', function () { $perm = $this->service->setPermission($this->m3, 'products.create', true); expect($perm->allowed)->toBeTrue(); }); }); // --------------------------------------------------------------------------- // train() // --------------------------------------------------------------------------- describe('train()', function () { it('creates a trained permission', function () { $perm = $this->service->train($this->m2, 'orders.create', null, true, '/api/orders'); expect($perm)->toBeInstanceOf(PermissionMatrix::class) ->and($perm->allowed)->toBeTrue() ->and($perm->source)->toBe(PermissionMatrix::SOURCE_TRAINED) ->and($perm->trained_route)->toBe('/api/orders') ->and($perm->trained_at)->not->toBeNull(); }); it('updates existing permission via training', function () { $this->service->train($this->m2, 'orders.create', null, true); $this->service->train($this->m2, 'orders.create', null, false); $perms = PermissionMatrix::where('entity_id', $this->m2->id) ->where('key', 'orders.create') ->get(); expect($perms)->toHaveCount(1) ->and($perms->first()->allowed)->toBeFalse(); }); it('throws PermissionLockedException when parent has locked denial', function () { $this->service->lock($this->m1, 'products.delete', false); expect(fn () => $this->service->train($this->m2, 'products.delete', null, true)) ->toThrow(PermissionLockedException::class); }); it('allows training a denial even when parent has locked denial', function () { $this->service->lock($this->m1, 'products.delete', false); // Training a denial should not throw (the lock check only prevents allow) $perm = $this->service->train($this->m2, 'products.delete', null, false); expect($perm->allowed)->toBeFalse(); }); }); // --------------------------------------------------------------------------- // getPermissions() / getEffectivePermissions() // --------------------------------------------------------------------------- describe('getPermissions()', function () { it('returns only own permissions', function () { $this->service->setPermission($this->m1, 'products.view', true); $this->service->setPermission($this->m2, 'products.create', true); $this->service->setPermission($this->m2, 'orders.view', false); $m2Perms = $this->service->getPermissions($this->m2); expect($m2Perms)->toHaveCount(2) ->and($m2Perms->pluck('key')->toArray())->toEqualCanonicalizing(['orders.view', 'products.create']); }); it('returns empty collection when no permissions set', function () { $perms = $this->service->getPermissions($this->m3); expect($perms)->toBeEmpty(); }); }); describe('getEffectivePermissions()', function () { it('includes inherited permissions from ancestors', function () { $this->service->setPermission($this->m1, 'products.view', true); $this->service->setPermission($this->m2, 'orders.create', true); $this->service->setPermission($this->m3, 'reports.view', true); $effective = $this->service->getEffectivePermissions($this->m3); expect($effective)->toHaveCount(3) ->and($effective->keys()->toArray())->toEqualCanonicalizing([ 'products.view', 'orders.create', 'reports.view', ]); }); it('locked denial wins over entity own allow', function () { // M1 locks denial $this->service->lock($this->m1, 'products.delete', false); // M2 explicitly allows (should not matter) $this->service->setPermission($this->m2, 'products.delete', true); $effective = $this->service->getEffectivePermissions($this->m2); $deletePerm = $effective->get('products.delete'); expect($deletePerm)->not->toBeNull() ->and($deletePerm->allowed)->toBeFalse() ->and($deletePerm->locked)->toBeTrue(); }); it('returns entity own permission when no locks exist', function () { $this->service->setPermission($this->m1, 'products.view', true); $this->service->setPermission($this->m2, 'products.view', false); $effective = $this->service->getEffectivePermissions($this->m2); $viewPerm = $effective->get('products.view'); expect($viewPerm->entity_id)->toBe($this->m2->id) ->and($viewPerm->allowed)->toBeFalse(); }); }); // --------------------------------------------------------------------------- // gateRequest() // --------------------------------------------------------------------------- describe('gateRequest()', function () { it('returns denied for undefined permission in strict mode', function () { config(['commerce.matrix.strict_mode' => true]); $service = new PermissionMatrixService; $request = Request::create('/api/products', 'GET'); $result = $service->gateRequest($request, $this->m2, 'products.create'); expect($result->isDenied())->toBeTrue() ->and($result->reason)->toContain('No permission defined'); }); it('returns denied for undefined permission when non-strict and default_allow is false', function () { config([ 'commerce.matrix.strict_mode' => false, 'commerce.matrix.training_mode' => false, 'commerce.matrix.default_allow' => false, ]); $service = new PermissionMatrixService; $request = Request::create('/api/products', 'GET'); $result = $service->gateRequest($request, $this->m2, 'products.create'); expect($result->isDenied())->toBeTrue() ->and($result->reason)->toContain('No permission defined'); }); it('returns allowed for undefined permission when non-strict and default_allow is true', function () { config([ 'commerce.matrix.strict_mode' => false, 'commerce.matrix.training_mode' => false, 'commerce.matrix.default_allow' => true, ]); $service = new PermissionMatrixService; $request = Request::create('/api/products', 'GET'); $result = $service->gateRequest($request, $this->m2, 'products.create'); expect($result->isAllowed())->toBeTrue(); }); it('returns allowed when permission is explicitly set', function () { $this->service->setPermission($this->m2, 'products.view', true); $request = Request::create('/api/products', 'GET'); $result = $this->service->gateRequest($request, $this->m2, 'products.view'); expect($result->isAllowed())->toBeTrue(); }); it('returns denied when permission is explicitly denied', function () { $this->service->setPermission($this->m2, 'products.delete', false); $request = Request::create('/api/products/1', 'DELETE'); $result = $this->service->gateRequest($request, $this->m2, 'products.delete'); expect($result->isDenied())->toBeTrue(); }); }); // --------------------------------------------------------------------------- // Training mode // --------------------------------------------------------------------------- describe('training mode', function () { it('reports training mode status', function () { config(['commerce.matrix.training_mode' => true]); $service = new PermissionMatrixService; expect($service->isTrainingMode())->toBeTrue(); config(['commerce.matrix.training_mode' => false]); $service2 = new PermissionMatrixService; expect($service2->isTrainingMode())->toBeFalse(); }); it('reports strict mode status', function () { config(['commerce.matrix.strict_mode' => true]); $service = new PermissionMatrixService; expect($service->isStrictMode())->toBeTrue(); config(['commerce.matrix.strict_mode' => false]); $service2 = new PermissionMatrixService; expect($service2->isStrictMode())->toBeFalse(); }); }); // --------------------------------------------------------------------------- // getPendingRequests() / markRequestsTrained() // --------------------------------------------------------------------------- describe('pending requests and training workflow', function () { it('returns pending untrained requests', function () { PermissionRequest::create([ 'entity_id' => $this->m2->id, 'method' => 'GET', 'route' => '/api/products', 'action' => 'products.view', 'status' => PermissionRequest::STATUS_PENDING, 'was_trained' => false, ]); PermissionRequest::create([ 'entity_id' => $this->m2->id, 'method' => 'POST', 'route' => '/api/products', 'action' => 'products.create', 'status' => PermissionRequest::STATUS_PENDING, 'was_trained' => false, ]); $pending = $this->service->getPendingRequests(); expect($pending)->toHaveCount(2); }); it('filters pending requests by entity', function () { PermissionRequest::create([ 'entity_id' => $this->m2->id, 'method' => 'GET', 'route' => '/api/products', 'action' => 'products.view', 'status' => PermissionRequest::STATUS_PENDING, 'was_trained' => false, ]); PermissionRequest::create([ 'entity_id' => $this->m3->id, 'method' => 'GET', 'route' => '/api/products', 'action' => 'products.view', 'status' => PermissionRequest::STATUS_PENDING, 'was_trained' => false, ]); $m2Pending = $this->service->getPendingRequests($this->m2); $m3Pending = $this->service->getPendingRequests($this->m3); expect($m2Pending)->toHaveCount(1) ->and($m3Pending)->toHaveCount(1); }); it('excludes already-trained requests', function () { PermissionRequest::create([ 'entity_id' => $this->m2->id, 'method' => 'GET', 'route' => '/api/products', 'action' => 'products.view', 'status' => PermissionRequest::STATUS_PENDING, 'was_trained' => true, 'trained_at' => now(), ]); $pending = $this->service->getPendingRequests($this->m2); expect($pending)->toBeEmpty(); }); it('excludes non-pending requests', function () { PermissionRequest::create([ 'entity_id' => $this->m2->id, 'method' => 'GET', 'route' => '/api/products', 'action' => 'products.view', 'status' => PermissionRequest::STATUS_ALLOWED, 'was_trained' => false, ]); $pending = $this->service->getPendingRequests($this->m2); expect($pending)->toBeEmpty(); }); it('marks pending requests as trained', function () { PermissionRequest::create([ 'entity_id' => $this->m2->id, 'method' => 'GET', 'route' => '/api/products', 'action' => 'products.view', 'status' => PermissionRequest::STATUS_PENDING, 'was_trained' => false, ]); PermissionRequest::create([ 'entity_id' => $this->m2->id, 'method' => 'GET', 'route' => '/api/products/1', 'action' => 'products.view', 'status' => PermissionRequest::STATUS_PENDING, 'was_trained' => false, ]); $count = $this->service->markRequestsTrained($this->m2, 'products.view'); expect($count)->toBe(2); $remaining = $this->service->getPendingRequests($this->m2); expect($remaining)->toBeEmpty(); }); it('only marks matching action requests as trained', function () { PermissionRequest::create([ 'entity_id' => $this->m2->id, 'method' => 'GET', 'route' => '/api/products', 'action' => 'products.view', 'status' => PermissionRequest::STATUS_PENDING, 'was_trained' => false, ]); PermissionRequest::create([ 'entity_id' => $this->m2->id, 'method' => 'POST', 'route' => '/api/products', 'action' => 'products.create', 'status' => PermissionRequest::STATUS_PENDING, 'was_trained' => false, ]); $count = $this->service->markRequestsTrained($this->m2, 'products.view'); expect($count)->toBe(1); $pending = $this->service->getPendingRequests($this->m2); expect($pending)->toHaveCount(1) ->and($pending->first()->action)->toBe('products.create'); }); }); // --------------------------------------------------------------------------- // PermissionResult value object // --------------------------------------------------------------------------- describe('PermissionResult', function () { it('creates allowed result', function () { $result = PermissionResult::allowed(); expect($result->isAllowed())->toBeTrue() ->and($result->isDenied())->toBeFalse() ->and($result->isPending())->toBeFalse() ->and($result->isUndefined())->toBeFalse() ->and($result->isLocked())->toBeFalse(); }); it('creates denied result with reason', function () { $result = PermissionResult::denied('Test reason'); expect($result->isDenied())->toBeTrue() ->and($result->reason)->toBe('Test reason') ->and($result->isLocked())->toBeFalse(); }); it('creates denied result with locked-by entity', function () { $result = PermissionResult::denied('Locked by ACME', $this->m1); expect($result->isDenied())->toBeTrue() ->and($result->isLocked())->toBeTrue() ->and($result->lockedBy->id)->toBe($this->m1->id); }); it('creates undefined result with key and scope', function () { $result = PermissionResult::undefined('products.view', 'premium'); expect($result->isUndefined())->toBeTrue() ->and($result->key)->toBe('products.view') ->and($result->scope)->toBe('premium'); }); it('converts to array correctly', function () { $result = PermissionResult::denied('Locked by ACME', $this->m1); $array = $result->toArray(); expect($array)->toHaveKey('status', 'denied') ->and($array)->toHaveKey('reason', 'Locked by ACME') ->and($array)->toHaveKey('locked_by', 'Acme Corp'); }); it('filters null values from array', function () { $result = PermissionResult::allowed(); $array = $result->toArray(); expect($array)->toBe(['status' => 'allowed']) ->and($array)->not->toHaveKey('reason') ->and($array)->not->toHaveKey('locked_by') ->and($array)->not->toHaveKey('key') ->and($array)->not->toHaveKey('scope'); }); });