651 lines
22 KiB
PHP
651 lines
22 KiB
PHP
<?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 Illuminate\Support\Str;
|
|
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' => '',
|
|
]);
|
|
// Tests use workbench-specific UUID-aware migrations; disable the
|
|
// package's auto-load so the same tables aren't created twice.
|
|
$app['config']->set('roles.run_migrations', false);
|
|
}
|
|
|
|
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([
|
|
'id' => (string) \Illuminate\Support\Str::uuid(),
|
|
'role_id' => $role->id,
|
|
'member_id' => $user->id,
|
|
'id' => (string) Str::uuid(),
|
|
'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);
|
|
}
|
|
}
|