diff --git a/composer.json b/composer.json index e2b0a49..4bd9d71 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,11 @@ "minimum-stability": "dev", "prefer-stable": true, "autoload-dev": { - "psr-4": {} + "psr-4": { + "Blax\\Roles\\Tests\\": "tests", + "Workbench\\App\\": "workbench/app/", + "Workbench\\Database\\Factories\\": "workbench/database/factories/" + } }, "scripts": { "post-autoload-dump": [ diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..818d395 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + tests/Unit + + + + + src + + + diff --git a/src/Traits/HasRoles.php b/src/Traits/HasRoles.php index 64b10df..f8962f3 100644 --- a/src/Traits/HasRoles.php +++ b/src/Traits/HasRoles.php @@ -99,12 +99,14 @@ trait HasRoles $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::where('slug', $role)->first(); } elseif (is_numeric($role)) { $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); - } elseif ($role instanceof Role) { - $this->roles()->detach($role); - } else { + } elseif (!$role instanceof Role) { throw new \InvalidArgumentException('Role must be a string, numeric ID, or an instance of Role.'); } + if ($role) { + $this->roles()->detach($role); + } + return $this; } diff --git a/tests/Unit/HasAccessTest.php b/tests/Unit/HasAccessTest.php new file mode 100644 index 0000000..9f07220 --- /dev/null +++ b/tests/Unit/HasAccessTest.php @@ -0,0 +1,644 @@ +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); + } +} diff --git a/tests/Unit/HasPermissionsTest.php b/tests/Unit/HasPermissionsTest.php new file mode 100644 index 0000000..d3b5254 --- /dev/null +++ b/tests/Unit/HasPermissionsTest.php @@ -0,0 +1,467 @@ +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')); + } +} diff --git a/tests/Unit/HasRolesTest.php b/tests/Unit/HasRolesTest.php new file mode 100644 index 0000000..fc0140b --- /dev/null +++ b/tests/Unit/HasRolesTest.php @@ -0,0 +1,493 @@ +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); + } +} diff --git a/tests/Unit/PermissionTest.php b/tests/Unit/PermissionTest.php deleted file mode 100644 index b708b27..0000000 --- a/tests/Unit/PermissionTest.php +++ /dev/null @@ -1,29 +0,0 @@ -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'])); - } -}