This commit is contained in:
Fabian @ Blax Software 2026-02-24 12:07:32 +01:00
parent 7878069c0b
commit 2d3f5ec00e
7 changed files with 1632 additions and 33 deletions

View File

@ -54,7 +54,11 @@
"minimum-stability": "dev", "minimum-stability": "dev",
"prefer-stable": true, "prefer-stable": true,
"autoload-dev": { "autoload-dev": {
"psr-4": {} "psr-4": {
"Blax\\Roles\\Tests\\": "tests",
"Workbench\\App\\": "workbench/app/",
"Workbench\\Database\\Factories\\": "workbench/database/factories/"
}
}, },
"scripts": { "scripts": {
"post-autoload-dump": [ "post-autoload-dump": [

18
phpunit.xml Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

View File

@ -99,12 +99,14 @@ trait HasRoles
$role = config('roles.models.role', \Blax\Roles\Models\Role::class)::where('slug', $role)->first(); $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::where('slug', $role)->first();
} elseif (is_numeric($role)) { } elseif (is_numeric($role)) {
$role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role);
} elseif ($role instanceof Role) { } elseif (!$role instanceof Role) {
$this->roles()->detach($role);
} else {
throw new \InvalidArgumentException('Role must be a string, numeric ID, or an instance of Role.'); throw new \InvalidArgumentException('Role must be a string, numeric ID, or an instance of Role.');
} }
if ($role) {
$this->roles()->detach($role);
}
return $this; return $this;
} }

View File

@ -0,0 +1,644 @@
<?php
namespace Blax\Roles\Tests\Unit;
use Blax\Roles\Models\Access;
use Blax\Roles\Models\Permission;
use Blax\Roles\Models\Role;
use Blax\Roles\RolesServiceProvider;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Orchestra\Testbench\TestCase;
use Workbench\App\Models\Article;
use Workbench\App\Models\User;
class HasAccessTest extends TestCase
{
use RefreshDatabase;
protected function getPackageProviders($app): array
{
return [RolesServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations');
}
// ─── accesses relationship ───────────────────────────────────
public function test_accesses_returns_empty_by_default(): void
{
$user = User::factory()->create();
$this->assertCount(0, $user->accesses);
}
// ─── grantAccess ─────────────────────────────────────────────
public function test_grant_access_creates_access_entry(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Test Article']);
$access = $user->grantAccess($article);
$this->assertInstanceOf(Access::class, $access);
$this->assertEquals($user->getMorphClass(), $access->entity_type);
$this->assertEquals($user->id, $access->entity_id);
$this->assertEquals($article->getMorphClass(), $access->accessible_type);
$this->assertEquals($article->id, $access->accessible_id);
}
public function test_grant_access_with_context(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Contextual']);
$access = $user->grantAccess($article, ['reason' => 'purchased']);
$this->assertEquals(['reason' => 'purchased'], $access->context);
}
public function test_grant_access_with_expiration(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Expiring']);
$expiresAt = Carbon::now()->addDays(30);
$access = $user->grantAccess($article, null, $expiresAt);
$this->assertNotNull($access->expires_at);
// Compare with second precision to avoid microsecond drift
$this->assertEquals(
$expiresAt->format('Y-m-d H:i:s'),
$access->expires_at->format('Y-m-d H:i:s')
);
}
public function test_grant_access_is_idempotent(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Idempotent']);
$access1 = $user->grantAccess($article);
$access2 = $user->grantAccess($article);
$this->assertEquals($access1->id, $access2->id);
$this->assertEquals(1, $user->accesses()->count());
}
// ─── hasAccess ───────────────────────────────────────────────
public function test_has_access_returns_false_when_no_access(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Locked']);
$this->assertFalse($user->hasAccess($article));
}
public function test_has_access_returns_true_with_direct_access(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Unlocked']);
$user->grantAccess($article);
$this->assertTrue($user->hasAccess($article));
}
public function test_has_access_with_class_name_and_id(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'ByClassName']);
$user->grantAccess($article);
$this->assertTrue($user->hasAccess(Article::class, $article->id));
}
public function test_has_access_with_class_name_without_id_throws(): void
{
$user = User::factory()->create();
$this->expectException(\InvalidArgumentException::class);
$user->hasAccess(Article::class);
}
public function test_has_access_via_role(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Premium', 'slug' => 'premium']);
$article = Article::create(['title' => 'Premium Article']);
// Grant access to the role (Role uses HasPermissions which uses HasAccess)
$role->grantAccess($article);
// Assign role to user
$user->assignRole($role);
$this->assertTrue($user->hasAccess($article));
}
public function test_has_access_via_permission(): void
{
$user = User::factory()->create();
$perm = Permission::create(['slug' => 'blog.premium']);
$article = Article::create(['title' => 'Permission Article']);
// Grant access to the permission (Permission uses HasAccess)
$perm->grantAccess($article);
// Assign permission to user
$user->assignPermission($perm);
$this->assertTrue($user->hasAccess($article));
}
public function test_has_access_via_role_permission_chain(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Learner', 'slug' => 'learner']);
$perm = Permission::create(['slug' => 'lection']);
$article = Article::create(['title' => 'Lesson']);
// Permission has access to article
$perm->grantAccess($article);
// Role has permission
$role->assignPermission($perm);
// User has role
$user->assignRole($role);
// User should have access via: user → role → permission → access
$this->assertTrue($user->hasAccess($article));
}
public function test_has_access_expired_direct_access_returns_false(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Expired']);
$user->grantAccess($article, null, Carbon::now()->subDay());
// grantAccess uses firstOrCreate, so it won't overwrite.
// We need to update the entry directly.
$access = $user->accesses()->first();
$access->update(['expires_at' => Carbon::now()->subDay()]);
$this->assertFalse($user->hasAccess($article));
}
public function test_has_access_non_expired_returns_true(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'NotExpired']);
$user->grantAccess($article, null, Carbon::now()->addWeek());
$this->assertTrue($user->hasAccess($article));
}
public function test_has_access_null_expiry_returns_true(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Permanent']);
$user->grantAccess($article);
$this->assertTrue($user->hasAccess($article));
}
public function test_has_access_via_expired_role_returns_false(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'ExpRole', 'slug' => 'exprole']);
$article = Article::create(['title' => 'RoleExpired']);
$role->grantAccess($article);
// Manually insert expired role membership
DB::table(config('roles.table_names.role_member'))->insert([
'role_id' => $role->id,
'member_id' => $user->id,
'member_type' => $user->getMorphClass(),
'expires_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
]);
$this->assertFalse($user->hasAccess($article));
}
// ─── revokeAccess ────────────────────────────────────────────
public function test_revoke_access_by_model(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'Revoke']);
$user->grantAccess($article);
$deleted = $user->revokeAccess($article);
$this->assertEquals(1, $deleted);
$this->assertFalse($user->hasAccess($article));
}
public function test_revoke_access_by_class_name_and_id(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'RevokeByClass']);
$user->grantAccess($article);
$deleted = $user->revokeAccess(Article::class, $article->id);
$this->assertEquals(1, $deleted);
$this->assertFalse($user->hasAccess($article));
}
public function test_revoke_access_returns_zero_when_nothing_to_revoke(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'NothingToRevoke']);
$deleted = $user->revokeAccess($article);
$this->assertEquals(0, $deleted);
}
public function test_revoke_access_does_not_affect_other_accessibles(): void
{
$user = User::factory()->create();
$article1 = Article::create(['title' => 'Keep']);
$article2 = Article::create(['title' => 'Remove']);
$user->grantAccess($article1);
$user->grantAccess($article2);
$user->revokeAccess($article2);
$this->assertTrue($user->hasAccess($article1));
$this->assertFalse($user->hasAccess($article2));
}
// ─── revokeAllAccess ─────────────────────────────────────────
public function test_revoke_all_access_removes_everything(): void
{
$user = User::factory()->create();
$article1 = Article::create(['title' => 'A1']);
$article2 = Article::create(['title' => 'A2']);
$user->grantAccess($article1);
$user->grantAccess($article2);
$deleted = $user->revokeAllAccess();
$this->assertEquals(2, $deleted);
$this->assertFalse($user->hasAccess($article1));
$this->assertFalse($user->hasAccess($article2));
}
public function test_revoke_all_access_filtered_by_type(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'FilteredArticle']);
// Grant access to user model (different type) + article
$otherUser = User::factory()->create();
$user->grantAccess($article);
$user->grantAccess($otherUser);
$deleted = $user->revokeAllAccess(Article::class);
$this->assertEquals(1, $deleted);
$this->assertFalse($user->hasAccess($article));
$this->assertTrue($user->hasAccess($otherUser));
}
// ─── allAccess ───────────────────────────────────────────────
public function test_all_access_returns_direct_accesses(): void
{
$user = User::factory()->create();
$article1 = Article::create(['title' => 'AA1']);
$article2 = Article::create(['title' => 'AA2']);
$user->grantAccess($article1);
$user->grantAccess($article2);
$accesses = $user->allAccess();
$this->assertCount(2, $accesses);
}
public function test_all_access_includes_role_based_accesses(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Reader', 'slug' => 'reader']);
$article = Article::create(['title' => 'RoleAccess']);
$role->grantAccess($article);
$user->assignRole($role);
$accesses = $user->allAccess();
$this->assertCount(1, $accesses);
$this->assertEquals($article->id, $accesses->first()->accessible_id);
}
public function test_all_access_includes_permission_based_accesses(): void
{
$user = User::factory()->create();
$perm = Permission::create(['slug' => 'premium.content']);
$article = Article::create(['title' => 'PermAccess']);
$perm->grantAccess($article);
$user->assignPermission($perm);
$accesses = $user->allAccess();
$this->assertCount(1, $accesses);
}
public function test_all_access_filtered_by_type(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'TypeFilter']);
$otherUser = User::factory()->create();
$user->grantAccess($article);
$user->grantAccess($otherUser);
$articleAccesses = $user->allAccess(Article::class);
$this->assertCount(1, $articleAccesses);
}
public function test_all_access_excludes_expired_entries(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'ExpAccess']);
$user->grantAccess($article);
// Manually expire
$user->accesses()->update(['expires_at' => now()->subHour()]);
$accesses = $user->allAccess();
$this->assertCount(0, $accesses);
}
// ─── accessibleIds ───────────────────────────────────────────
public function test_accessible_ids_returns_correct_ids(): void
{
$user = User::factory()->create();
$a1 = Article::create(['title' => 'AI1']);
$a2 = Article::create(['title' => 'AI2']);
$a3 = Article::create(['title' => 'AI3']);
$user->grantAccess($a1);
$user->grantAccess($a3);
$ids = $user->accessibleIds(Article::class);
$this->assertCount(2, $ids);
$this->assertTrue($ids->contains($a1->id));
$this->assertTrue($ids->contains($a3->id));
$this->assertFalse($ids->contains($a2->id));
}
public function test_accessible_ids_includes_role_based(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Sub', 'slug' => 'sub']);
$article = Article::create(['title' => 'RoleAI']);
$role->grantAccess($article);
$user->assignRole($role);
$ids = $user->accessibleIds(Article::class);
$this->assertCount(1, $ids);
$this->assertTrue($ids->contains($article->id));
}
public function test_accessible_ids_returns_empty_when_no_access(): void
{
$user = User::factory()->create();
Article::create(['title' => 'NoAccess']);
$ids = $user->accessibleIds(Article::class);
$this->assertCount(0, $ids);
}
// ─── syncAccess ──────────────────────────────────────────────
public function test_sync_access_adds_and_removes_entries(): void
{
$user = User::factory()->create();
$a1 = Article::create(['title' => 'S1']);
$a2 = Article::create(['title' => 'S2']);
$a3 = Article::create(['title' => 'S3']);
// Initial: access to a1 and a2
$user->grantAccess($a1);
$user->grantAccess($a2);
// Sync to a2 and a3
$user->syncAccess(Article::class, [$a2->id, $a3->id]);
$this->assertFalse($user->hasAccess($a1));
$this->assertTrue($user->hasAccess($a2));
$this->assertTrue($user->hasAccess($a3));
}
public function test_sync_access_empty_removes_all_of_that_type(): void
{
$user = User::factory()->create();
$a1 = Article::create(['title' => 'SE1']);
$a2 = Article::create(['title' => 'SE2']);
$user->grantAccess($a1);
$user->grantAccess($a2);
$user->syncAccess(Article::class, []);
$this->assertFalse($user->hasAccess($a1));
$this->assertFalse($user->hasAccess($a2));
}
public function test_sync_access_does_not_affect_other_types(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'TypeKeep']);
$otherUser = User::factory()->create();
$user->grantAccess($article);
$user->grantAccess($otherUser);
// Sync articles to empty — should keep user access
$user->syncAccess(Article::class, []);
$this->assertFalse($user->hasAccess($article));
$this->assertTrue($user->hasAccess($otherUser));
}
public function test_sync_access_with_context_and_expiry(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'CtxSync']);
$expiresAt = Carbon::now()->addMonth();
$user->syncAccess(Article::class, [$article->id], ['reason' => 'promo'], $expiresAt);
$access = $user->accesses()->first();
$this->assertEquals(['reason' => 'promo'], $access->context);
$this->assertEquals(
$expiresAt->format('Y-m-d H:i:s'),
$access->expires_at->format('Y-m-d H:i:s')
);
}
public function test_sync_access_preserves_existing_entries_in_new_set(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'PreserveSync']);
$user->grantAccess($article, ['reason' => 'original']);
$originalId = $user->accesses()->first()->id;
// Sync with the same ID — should NOT recreate the entry
$user->syncAccess(Article::class, [$article->id]);
$currentId = $user->accesses()->first()->id;
$this->assertEquals($originalId, $currentId);
}
// ─── Access independence ─────────────────────────────────────
public function test_access_is_independent_between_users(): void
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$article = Article::create(['title' => 'Independent']);
$user1->grantAccess($article);
$this->assertTrue($user1->hasAccess($article));
$this->assertFalse($user2->hasAccess($article));
}
// ─── Access scopes on model ──────────────────────────────────
public function test_access_active_scope(): void
{
$user = User::factory()->create();
$article1 = Article::create(['title' => 'Active']);
$article2 = Article::create(['title' => 'Expired']);
$user->grantAccess($article1); // no expiry = active
$user->grantAccess($article2, null, Carbon::now()->subDay());
// Manually expire
$user->accesses()->where('accessible_id', $article2->id)->update(['expires_at' => now()->subDay()]);
$activeAccesses = Access::active()->where('entity_id', $user->id)->get();
$this->assertCount(1, $activeAccesses);
}
public function test_access_expired_scope(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'ExpiredScope']);
$user->grantAccess($article);
$user->accesses()->update(['expires_at' => now()->subHour()]);
$expiredAccesses = Access::expired()->where('entity_id', $user->id)->get();
$this->assertCount(1, $expiredAccesses);
}
// ─── Complex multi-source access scenarios ───────────────────
public function test_has_access_combines_direct_and_role_sources(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Sub', 'slug' => 'sub']);
$directArticle = Article::create(['title' => 'Direct']);
$roleArticle = Article::create(['title' => 'ViaRole']);
$user->grantAccess($directArticle);
$role->grantAccess($roleArticle);
$user->assignRole($role);
$this->assertTrue($user->hasAccess($directArticle));
$this->assertTrue($user->hasAccess($roleArticle));
}
public function test_has_access_combines_all_three_sources(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Sub', 'slug' => 'sub']);
$perm = Permission::create(['slug' => 'premium']);
$a1 = Article::create(['title' => 'DirectAll']);
$a2 = Article::create(['title' => 'RoleAll']);
$a3 = Article::create(['title' => 'PermAll']);
$user->grantAccess($a1);
$role->grantAccess($a2);
$perm->grantAccess($a3);
$user->assignRole($role);
$user->assignPermission($perm);
$this->assertTrue($user->hasAccess($a1));
$this->assertTrue($user->hasAccess($a2));
$this->assertTrue($user->hasAccess($a3));
$allAccess = $user->allAccess(Article::class);
$this->assertCount(3, $allAccess);
}
public function test_model_without_roles_has_no_role_access(): void
{
// Permission model has HasAccess but NOT HasRoles
$perm = Permission::create(['slug' => 'simple']);
$article = Article::create(['title' => 'Solo']);
$perm->grantAccess($article);
$this->assertTrue($perm->hasAccess($article));
// allAccess should work even without roles
$accesses = $perm->allAccess();
$this->assertCount(1, $accesses);
}
// ─── Access entity/accessible relationships ──────────────────
public function test_access_entity_relationship(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'RelEntity']);
$access = $user->grantAccess($article);
$entity = $access->entity;
$this->assertInstanceOf(User::class, $entity);
$this->assertEquals($user->id, $entity->id);
}
public function test_access_accessible_relationship(): void
{
$user = User::factory()->create();
$article = Article::create(['title' => 'RelAccessible']);
$access = $user->grantAccess($article);
$accessible = $access->accessible;
$this->assertInstanceOf(Article::class, $accessible);
$this->assertEquals($article->id, $accessible->id);
}
}

View File

@ -0,0 +1,467 @@
<?php
namespace Blax\Roles\Tests\Unit;
use Blax\Roles\Models\Permission;
use Blax\Roles\Models\Role;
use Blax\Roles\RolesServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Orchestra\Testbench\TestCase;
use Workbench\App\Models\User;
class HasPermissionsTest extends TestCase
{
use RefreshDatabase;
protected function getPackageProviders($app): array
{
return [RolesServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations');
}
// ─── hasPermission ───────────────────────────────────────────
public function test_has_permission_returns_false_when_user_has_no_permissions(): void
{
$user = User::factory()->create();
$this->assertFalse($user->hasPermission('blog'));
}
public function test_has_permission_with_direct_exact_match(): void
{
$user = User::factory()->create();
$user->assignPermission('blog');
$this->assertTrue($user->hasPermission('blog'));
}
public function test_has_permission_with_hierarchical_parent_grants_child(): void
{
$user = User::factory()->create();
$user->assignPermission('lection');
$this->assertTrue($user->hasPermission('lection'));
$this->assertTrue($user->hasPermission('lection.45'));
$this->assertTrue($user->hasPermission('lection.foo.bar'));
}
public function test_has_permission_hierarchical_child_does_not_grant_parent(): void
{
$user = User::factory()->create();
$user->assignPermission('lection.45');
$this->assertTrue($user->hasPermission('lection.45'));
$this->assertFalse($user->hasPermission('lection'));
}
public function test_has_permission_hierarchical_sibling_not_granted(): void
{
$user = User::factory()->create();
$user->assignPermission('lection.45');
$this->assertFalse($user->hasPermission('lection.99'));
}
public function test_has_permission_wildcard_star_grants_everything(): void
{
$user = User::factory()->create();
$user->assignPermission('*');
$this->assertTrue($user->hasPermission('anything'));
$this->assertTrue($user->hasPermission('blog.edit'));
$this->assertTrue($user->hasPermission('deep.nested.permission.chain'));
}
public function test_has_permission_via_role(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Editor', 'slug' => 'editor']);
$perm = Permission::create(['slug' => 'blog.edit']);
// Assign permission to role
$role->assignPermission($perm);
// Assign role to user
$user->assignRole($role);
$this->assertTrue($user->hasPermission('blog.edit'));
}
public function test_has_permission_via_role_hierarchical(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Learner', 'slug' => 'learner']);
$perm = Permission::create(['slug' => 'lection']);
$role->assignPermission($perm);
$user->assignRole($role);
$this->assertTrue($user->hasPermission('lection'));
$this->assertTrue($user->hasPermission('lection.45'));
$this->assertTrue($user->hasPermission('lection.foo.bar'));
$this->assertFalse($user->hasPermission('blog'));
}
public function test_has_permission_does_not_match_partial_slug(): void
{
$user = User::factory()->create();
$user->assignPermission('blog');
// "blogging" is NOT a child of "blog" — it doesn't start with "blog."
$this->assertFalse($user->hasPermission('blogging'));
}
// ─── rolePermissions ─────────────────────────────────────────
public function test_role_permissions_returns_empty_when_no_roles(): void
{
$user = User::factory()->create();
$this->assertCount(0, $user->rolePermissions());
}
public function test_role_permissions_returns_permissions_from_assigned_role(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Admin', 'slug' => 'admin']);
$perm1 = Permission::create(['slug' => 'blog.edit']);
$perm2 = Permission::create(['slug' => 'blog.delete']);
$role->assignPermission($perm1);
$role->assignPermission($perm2);
$user->assignRole($role);
$perms = $user->rolePermissions();
$this->assertCount(2, $perms);
$this->assertTrue($perms->contains('slug', 'blog.edit'));
$this->assertTrue($perms->contains('slug', 'blog.delete'));
}
public function test_role_permissions_deduplicates_across_multiple_roles(): void
{
$user = User::factory()->create();
$role1 = Role::create(['name' => 'Editor', 'slug' => 'editor']);
$role2 = Role::create(['name' => 'Reviewer', 'slug' => 'reviewer']);
$perm = Permission::create(['slug' => 'blog.view']);
$role1->assignPermission($perm);
$role2->assignPermission($perm);
$user->assignRole($role1);
$user->assignRole($role2);
// rolePermissions returns raw — but permissions() deduplicates
$perms = $user->permissions();
$this->assertCount(1, $perms);
}
public function test_role_permissions_excludes_expired_role_membership(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Temp', 'slug' => 'temp']);
$perm = Permission::create(['slug' => 'temp.access']);
$role->assignPermission($perm);
// Manually insert an expired role membership
DB::table(config('roles.table_names.role_member'))->insert([
'role_id' => $role->id,
'member_id' => $user->id,
'member_type' => $user->getMorphClass(),
'expires_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
]);
$this->assertCount(0, $user->rolePermissions());
}
public function test_role_permissions_includes_non_expired_role_membership(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Active', 'slug' => 'active']);
$perm = Permission::create(['slug' => 'active.access']);
$role->assignPermission($perm);
// Manually insert a future-expiry role membership
DB::table(config('roles.table_names.role_member'))->insert([
'role_id' => $role->id,
'member_id' => $user->id,
'member_type' => $user->getMorphClass(),
'expires_at' => now()->addDays(7),
'created_at' => now(),
'updated_at' => now(),
]);
$this->assertCount(1, $user->rolePermissions());
}
public function test_role_permissions_includes_null_expiry_role_membership(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Permanent', 'slug' => 'permanent']);
$perm = Permission::create(['slug' => 'permanent.access']);
$role->assignPermission($perm);
$user->assignRole($role);
$this->assertCount(1, $user->rolePermissions());
}
public function test_role_permissions_excludes_expired_permission_on_role(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Editor', 'slug' => 'editor']);
$perm = Permission::create(['slug' => 'temp.perm']);
// Manually attach permission to role with expired membership
DB::table(config('roles.table_names.permission_member'))->insert([
'permission_id' => $perm->id,
'member_id' => $role->id,
'member_type' => $role->getMorphClass(),
'expires_at' => now()->subHour(),
'created_at' => now(),
'updated_at' => now(),
]);
$user->assignRole($role);
$this->assertCount(0, $user->rolePermissions());
}
// ─── individualPermissions ───────────────────────────────────
public function test_individual_permissions_returns_empty_when_none_assigned(): void
{
$user = User::factory()->create();
$this->assertCount(0, $user->individualPermissions()->get());
}
public function test_individual_permissions_returns_directly_assigned(): void
{
$user = User::factory()->create();
$user->assignPermission('direct.perm');
$perms = $user->individualPermissions()->get();
$this->assertCount(1, $perms);
$this->assertEquals('direct.perm', $perms->first()->slug);
}
// ─── permissions (merged) ────────────────────────────────────
public function test_permissions_merges_role_and_direct_permissions(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Writer', 'slug' => 'writer']);
$rolePerm = Permission::create(['slug' => 'blog.write']);
$role->assignPermission($rolePerm);
$user->assignRole($role);
$user->assignPermission('profile.edit');
$all = $user->permissions();
$this->assertCount(2, $all);
$this->assertTrue($all->contains('slug', 'blog.write'));
$this->assertTrue($all->contains('slug', 'profile.edit'));
}
public function test_permissions_deduplicates_when_same_permission_from_role_and_direct(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Writer', 'slug' => 'writer']);
$perm = Permission::create(['slug' => 'shared.perm']);
$role->assignPermission($perm);
$user->assignRole($role);
$user->assignPermission('shared.perm');
$all = $user->permissions();
$this->assertCount(1, $all);
}
// ─── assignPermission ────────────────────────────────────────
public function test_assign_permission_by_slug_string(): void
{
$user = User::factory()->create();
$user->assignPermission('new.permission');
$this->assertTrue($user->hasPermission('new.permission'));
// Should have created the permission
$this->assertDatabaseHas('permissions', ['slug' => 'new.permission']);
}
public function test_assign_permission_by_id(): void
{
$perm = Permission::create(['slug' => 'by.id']);
$user = User::factory()->create();
$user->assignPermission($perm->id);
$this->assertTrue($user->hasPermission('by.id'));
}
public function test_assign_permission_by_model_instance(): void
{
$perm = Permission::create(['slug' => 'by.instance']);
$user = User::factory()->create();
$user->assignPermission($perm);
$this->assertTrue($user->hasPermission('by.instance'));
}
public function test_assign_permission_is_idempotent(): void
{
$user = User::factory()->create();
$user->assignPermission('idempotent.test');
$user->assignPermission('idempotent.test');
// Should only have one entry in the pivot table
$count = DB::table(config('roles.table_names.permission_member'))
->where('member_id', $user->id)
->where('member_type', $user->getMorphClass())
->count();
$this->assertEquals(1, $count);
}
public function test_assign_permission_throws_on_invalid_argument(): void
{
$user = User::factory()->create();
$this->expectException(\InvalidArgumentException::class);
$user->assignPermission(['invalid']);
}
public function test_assign_permission_creates_permission_if_not_exists(): void
{
$user = User::factory()->create();
$this->assertDatabaseMissing('permissions', ['slug' => 'auto.created']);
$user->assignPermission('auto.created');
$this->assertDatabaseHas('permissions', ['slug' => 'auto.created']);
}
// ─── removePermission ────────────────────────────────────────
public function test_remove_permission_by_slug(): void
{
$user = User::factory()->create();
$user->assignPermission('to.remove');
$this->assertTrue($user->hasPermission('to.remove'));
$user->removePermission('to.remove');
// Reload permissions
$this->assertFalse($user->hasPermission('to.remove'));
}
public function test_remove_permission_by_id(): void
{
$perm = Permission::create(['slug' => 'remove.by.id']);
$user = User::factory()->create();
$user->assignPermission($perm);
$user->removePermission($perm->id);
$count = $user->individualPermissions()->count();
$this->assertEquals(0, $count);
}
public function test_remove_permission_by_model(): void
{
$perm = Permission::create(['slug' => 'remove.by.model']);
$user = User::factory()->create();
$user->assignPermission($perm);
$user->removePermission($perm);
$count = $user->individualPermissions()->count();
$this->assertEquals(0, $count);
}
public function test_remove_permission_that_does_not_exist_returns_true(): void
{
$user = User::factory()->create();
$result = $user->removePermission('nonexistent');
$this->assertTrue($result);
}
public function test_remove_permission_does_not_affect_other_permissions(): void
{
$user = User::factory()->create();
$user->assignPermission('keep.this');
$user->assignPermission('remove.this');
$user->removePermission('remove.this');
$this->assertTrue($user->hasPermission('keep.this'));
$this->assertFalse($user->hasPermission('remove.this'));
}
public function test_remove_permission_does_not_affect_role_permissions(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Admin', 'slug' => 'admin']);
$perm = Permission::create(['slug' => 'role.perm']);
$role->assignPermission($perm);
$user->assignRole($role);
// User also directly has the same permission
$user->assignPermission($perm);
// Remove the direct assignment
$user->removePermission($perm);
// The user should still have it via role
$this->assertTrue($user->hasPermission('role.perm'));
}
// ─── Role model also uses HasPermissions ─────────────────────
public function test_role_can_have_permissions_assigned(): void
{
$role = Role::create(['name' => 'Moderator', 'slug' => 'moderator']);
$role->assignPermission('moderate.posts');
$this->assertTrue($role->hasPermission('moderate.posts'));
}
public function test_role_has_permission_hierarchical(): void
{
$role = Role::create(['name' => 'Learner', 'slug' => 'learner']);
$role->assignPermission('lection');
$this->assertTrue($role->hasPermission('lection.42'));
$this->assertFalse($role->hasPermission('blog'));
}
// ─── Multiple users independence ─────────────────────────────
public function test_permissions_are_independent_between_users(): void
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$user1->assignPermission('user1.only');
$user2->assignPermission('user2.only');
$this->assertTrue($user1->hasPermission('user1.only'));
$this->assertFalse($user1->hasPermission('user2.only'));
$this->assertTrue($user2->hasPermission('user2.only'));
$this->assertFalse($user2->hasPermission('user1.only'));
}
}

493
tests/Unit/HasRolesTest.php Normal file
View File

@ -0,0 +1,493 @@
<?php
namespace Blax\Roles\Tests\Unit;
use Blax\Roles\Models\Permission;
use Blax\Roles\Models\Role;
use Blax\Roles\RolesServiceProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Orchestra\Testbench\TestCase;
use Workbench\App\Models\User;
class HasRolesTest extends TestCase
{
use RefreshDatabase;
protected function getPackageProviders($app): array
{
return [RolesServiceProvider::class];
}
protected function defineEnvironment($app): void
{
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
}
protected function defineDatabaseMigrations(): void
{
$this->loadMigrationsFrom(__DIR__ . '/../../workbench/database/migrations');
}
// ─── roles relationship ──────────────────────────────────────
public function test_user_has_no_roles_by_default(): void
{
$user = User::factory()->create();
$this->assertCount(0, $user->roles);
}
// ─── hasRole ─────────────────────────────────────────────────
public function test_has_role_returns_false_when_not_assigned(): void
{
$user = User::factory()->create();
Role::create(['name' => 'Admin', 'slug' => 'admin']);
$this->assertFalse($user->hasRole('admin'));
}
public function test_has_role_by_slug_string(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Admin', 'slug' => 'admin']);
$user->assignRole($role);
$this->assertTrue($user->hasRole('admin'));
}
public function test_has_role_by_model_instance(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Editor', 'slug' => 'editor']);
$user->assignRole($role);
$this->assertTrue($user->hasRole($role));
}
public function test_has_role_returns_false_for_nonexistent_slug(): void
{
$user = User::factory()->create();
$this->assertFalse($user->hasRole('nonexistent'));
}
// ─── assignRole ──────────────────────────────────────────────
public function test_assign_role_by_string_creates_role(): void
{
$user = User::factory()->create();
$user->assignRole('new-role');
$this->assertDatabaseHas('roles', ['slug' => 'new-role']);
$this->assertTrue($user->hasRole('new-role'));
}
public function test_assign_role_by_model_instance(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Writer', 'slug' => 'writer']);
$user->assignRole($role);
$this->assertTrue($user->hasRole($role));
}
public function test_assign_role_by_numeric_id(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Viewer', 'slug' => 'viewer']);
$user->assignRole($role->id);
$this->assertTrue($user->hasRole('viewer'));
}
public function test_assign_role_respects_max_times_limit(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Limited', 'slug' => 'limited']);
// max_times = 1 (default), so second assign should be ignored
$user->assignRole($role, 1);
$user->assignRole($role, 1);
$count = DB::table(config('roles.table_names.role_member'))
->where('member_id', $user->id)
->where('member_type', $user->getMorphClass())
->where('role_id', $role->id)
->count();
$this->assertEquals(1, $count);
}
public function test_assign_role_allows_duplicates_when_max_times_higher(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Stackable', 'slug' => 'stackable']);
$user->assignRole($role, 3);
$user->assignRole($role, 3);
$user->assignRole($role, 3);
$user->assignRole($role, 3); // should be blocked
$count = DB::table(config('roles.table_names.role_member'))
->where('member_id', $user->id)
->where('member_type', $user->getMorphClass())
->where('role_id', $role->id)
->count();
$this->assertEquals(3, $count);
}
public function test_assign_role_throws_on_invalid_argument(): void
{
$user = User::factory()->create();
// Type-hinted as string|Role, so stdClass triggers TypeError
$this->expectException(\TypeError::class);
$user->assignRole(new \stdClass());
}
// ─── removeRole ──────────────────────────────────────────────
public function test_remove_role_by_model(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Temp', 'slug' => 'temp']);
$user->assignRole($role);
$this->assertTrue($user->hasRole($role));
$user->removeRole($role);
// Refresh to clear cached relations
$user->load('roles');
$this->assertFalse($user->hasRole($role));
}
public function test_remove_role_by_slug(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Removable', 'slug' => 'removable']);
$user->assignRole($role);
$user->removeRole('removable');
$user->load('roles');
$this->assertFalse($user->hasRole('removable'));
}
public function test_remove_role_does_not_affect_other_roles(): void
{
$user = User::factory()->create();
$keepRole = Role::create(['name' => 'Keep', 'slug' => 'keep']);
$removeRole = Role::create(['name' => 'Remove', 'slug' => 'remove']);
$user->assignRole($keepRole);
$user->assignRole($removeRole);
$user->removeRole($removeRole);
$user->load('roles');
$this->assertTrue($user->hasRole($keepRole));
$this->assertFalse($user->hasRole($removeRole));
}
// ─── syncRoles ───────────────────────────────────────────────
public function test_sync_roles_by_slug_strings(): void
{
$user = User::factory()->create();
$user->assignRole('old-role');
$user->syncRoles(['new-role-1', 'new-role-2']);
$user->load('roles');
$this->assertFalse($user->hasRole('old-role'));
$this->assertTrue($user->hasRole('new-role-1'));
$this->assertTrue($user->hasRole('new-role-2'));
}
public function test_sync_roles_by_model_instances(): void
{
$user = User::factory()->create();
$role1 = Role::create(['name' => 'Role1', 'slug' => 'role1']);
$role2 = Role::create(['name' => 'Role2', 'slug' => 'role2']);
$user->syncRoles([$role1, $role2]);
$user->load('roles');
$this->assertTrue($user->hasRole($role1));
$this->assertTrue($user->hasRole($role2));
}
public function test_sync_roles_by_numeric_ids(): void
{
$user = User::factory()->create();
$role1 = Role::create(['name' => 'R1', 'slug' => 'r1']);
$role2 = Role::create(['name' => 'R2', 'slug' => 'r2']);
$user->syncRoles([$role1->id, $role2->id]);
$user->load('roles');
$this->assertTrue($user->hasRole('r1'));
$this->assertTrue($user->hasRole('r2'));
}
public function test_sync_roles_by_objects_with_id(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'ObjRole', 'slug' => 'objrole']);
$user->syncRoles([(object) ['id' => $role->id]]);
$user->load('roles');
$this->assertTrue($user->hasRole('objrole'));
}
public function test_sync_roles_by_arrays_with_id(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'ArrRole', 'slug' => 'arrrole']);
$user->syncRoles([['id' => $role->id]]);
$user->load('roles');
$this->assertTrue($user->hasRole('arrrole'));
}
public function test_sync_roles_empty_removes_all(): void
{
$user = User::factory()->create();
$user->assignRole('existing');
$user->syncRoles([]);
$user->load('roles');
$this->assertCount(0, $user->roles);
}
// ─── hasAnyRole ──────────────────────────────────────────────
public function test_has_any_role_returns_true_when_one_matches(): void
{
$user = User::factory()->create();
$user->assignRole('editor');
$this->assertTrue($user->hasAnyRole(['admin', 'editor', 'viewer']));
}
public function test_has_any_role_returns_false_when_none_match(): void
{
$user = User::factory()->create();
$user->assignRole('writer');
$this->assertFalse($user->hasAnyRole(['admin', 'editor']));
}
public function test_has_any_role_returns_false_for_empty_array(): void
{
$user = User::factory()->create();
$this->assertFalse($user->hasAnyRole([]));
}
// ─── hasAllRoles ─────────────────────────────────────────────
public function test_has_all_roles_returns_true_when_all_match(): void
{
$user = User::factory()->create();
$user->assignRole('admin');
$user->assignRole('editor');
$this->assertTrue($user->hasAllRoles(['admin', 'editor']));
}
public function test_has_all_roles_returns_false_when_one_missing(): void
{
$user = User::factory()->create();
$user->assignRole('admin');
$this->assertFalse($user->hasAllRoles(['admin', 'editor']));
}
public function test_has_all_roles_returns_true_for_empty_array(): void
{
$user = User::factory()->create();
$this->assertTrue($user->hasAllRoles([]));
}
// ─── Expiration ──────────────────────────────────────────────
public function test_expired_role_is_not_returned_in_roles_relation(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Expired', 'slug' => 'expired']);
DB::table(config('roles.table_names.role_member'))->insert([
'role_id' => $role->id,
'member_id' => $user->id,
'member_type' => $user->getMorphClass(),
'expires_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
]);
$this->assertCount(0, $user->roles);
$this->assertFalse($user->hasRole($role));
}
public function test_non_expired_role_is_returned_in_roles_relation(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Future', 'slug' => 'future']);
DB::table(config('roles.table_names.role_member'))->insert([
'role_id' => $role->id,
'member_id' => $user->id,
'member_type' => $user->getMorphClass(),
'expires_at' => now()->addWeek(),
'created_at' => now(),
'updated_at' => now(),
]);
$this->assertCount(1, $user->roles);
$this->assertTrue($user->hasRole($role));
}
// ─── extendOrAddRole ─────────────────────────────────────────
public function test_extend_or_add_role_creates_new_membership(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Extend', 'slug' => 'extend']);
$user->extendOrAddRole($role, 48);
$membership = DB::table(config('roles.table_names.role_member'))
->where('role_id', $role->id)
->where('member_id', $user->id)
->first();
$this->assertNotNull($membership);
$this->assertNotNull($membership->expires_at);
}
public function test_extend_or_add_role_extends_existing_future_membership(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Ext', 'slug' => 'ext']);
// Create initial membership expiring in 24 hours
DB::table(config('roles.table_names.role_member'))->insert([
'role_id' => $role->id,
'member_id' => $user->id,
'member_type' => $user->getMorphClass(),
'expires_at' => now()->addHours(24),
'created_at' => now(),
'updated_at' => now(),
]);
$user->extendOrAddRole($role, 24);
// Should now expire in ~48 hours from original base
$membership = DB::table(config('roles.table_names.role_member'))
->where('role_id', $role->id)
->where('member_id', $user->id)
->first();
$this->assertNotNull($membership);
// Should be extended: approximately 48 hours from now
$expiresAt = \Carbon\Carbon::parse($membership->expires_at);
$this->assertTrue($expiresAt->gt(now()->addHours(40)));
}
public function test_extend_or_add_role_does_not_modify_null_expiry(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'NoExp', 'slug' => 'noexp']);
// Create a permanent membership
DB::table(config('roles.table_names.role_member'))->insert([
'role_id' => $role->id,
'member_id' => $user->id,
'member_type' => $user->getMorphClass(),
'expires_at' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$user->extendOrAddRole($role, 24);
$membership = DB::table(config('roles.table_names.role_member'))
->where('role_id', $role->id)
->where('member_id', $user->id)
->first();
// Should still be null (permanent)
$this->assertNull($membership->expires_at);
}
public function test_extend_or_add_role_with_zero_hours_does_nothing(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'Zero', 'slug' => 'zero']);
$user->extendOrAddRole($role, 0);
$count = DB::table(config('roles.table_names.role_member'))
->where('role_id', $role->id)
->where('member_id', $user->id)
->count();
$this->assertEquals(0, $count);
}
public function test_extend_or_add_role_by_slug_string(): void
{
$user = User::factory()->create();
// extendOrAddRole resolves strings via firstOrCreate by name
$role = Role::create(['name' => 'byslug', 'slug' => 'byslug']);
$user->extendOrAddRole('byslug', 12);
$this->assertTrue($user->hasRole('byslug'));
}
public function test_extend_or_add_role_by_numeric_id(): void
{
$user = User::factory()->create();
$role = Role::create(['name' => 'ByNum', 'slug' => 'bynum']);
$user->extendOrAddRole($role->id, 12);
$this->assertTrue($user->hasRole('bynum'));
}
// ─── Roles independence between users ────────────────────────
public function test_roles_are_independent_between_users(): void
{
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$user1->assignRole('admin');
$user2->assignRole('editor');
$this->assertTrue($user1->hasRole('admin'));
$this->assertFalse($user1->hasRole('editor'));
$this->assertTrue($user2->hasRole('editor'));
$this->assertFalse($user2->hasRole('admin'));
}
// ─── Role slug uniqueness ────────────────────────────────────
public function test_role_slug_is_auto_suffixed_on_conflict(): void
{
$role1 = Role::create(['name' => 'Admin', 'slug' => 'admin']);
$role2 = Role::create(['name' => 'Admin', 'slug' => 'admin']);
$this->assertEquals('admin', $role1->slug);
$this->assertEquals('admin-1', $role2->slug);
}
}

View File

@ -1,29 +0,0 @@
<?php
namespace Blax\Roles\Tests\Unit;
use Orchestra\Testbench\PHPUnit\TestCase;
// TODO complete this WIP
class PermissionTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->loadLaravelMigrations(['--database' => 'testbench']);
$this->artisan('migrate', ['--database' => 'testbench']);
}
public function testHasPermission()
{
// Assuming you have a User model with the HasPermissions trait
$user = \App\Models\User::factory()->create();
// Add a permission to the user
$user->permissions()->attach(1, ['context' => 'test']);
// Check if the user has the permission
$this->assertTrue($user->hasPermission('view_posts', ['context' => 'test']));
$this->assertFalse($user->hasPermission('edit_posts', ['context' => 'test']));
}
}