test: add comprehensive tests for PermissionMatrixService
Cover permission checks (can), top-down immutable hierarchy cascade, lock/unlock, setPermission, train, getPermissions/getEffective, gateRequest (strict/non-strict/default_allow), training mode, pending requests workflow, and PermissionResult value object. Fixes #10 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5bce748a0f
commit
6bb546be77
1 changed files with 849 additions and 0 deletions
849
tests/Feature/PermissionMatrixServiceTest.php
Normal file
849
tests/Feature/PermissionMatrixServiceTest.php
Normal file
|
|
@ -0,0 +1,849 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Commerce\Models\Entity;
|
||||
use Core\Mod\Commerce\Models\PermissionMatrix;
|
||||
use Core\Mod\Commerce\Models\PermissionRequest;
|
||||
use Core\Mod\Commerce\Services\PermissionLockedException;
|
||||
use Core\Mod\Commerce\Services\PermissionMatrixService;
|
||||
use Core\Mod\Commerce\Services\PermissionResult;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Build a three-tier hierarchy: M1 -> 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue