الاختبارات و TDD

اختبار المصادقة والتفويض

30 دقيقة الدرس 19 من 35

اختبار أنظمة المصادقة

المصادقة والتفويض مكونات أمان حرجة تتطلب اختباراً شاملاً. تتحقق المصادقة من هوية المستخدم (تسجيل الدخول/الخروج)، بينما يحدد التفويض ما يمكنه فعله (الأذونات، الأدوار). تمنع أنظمة المصادقة المختبرة بشكل صحيح الوصول غير المصرح به وخروقات البيانات وهجمات تصعيد الامتيازات.

مبدأ أساسي: لا تتخطى أبداً اختبار منطق المصادقة والتفويض. هذه هي حراس أمان تطبيقك. يمكن أن يؤدي خطأ في المصادقة إلى كشف بيانات حساسة أو السماح بإجراءات ضارة. اختبر المسارات السعيدة والحالات الحدية وحدود الأمان بشكل شامل.

اختبار تسجيل دخول المستخدم

تتحقق اختبارات تسجيل الدخول من أن المستخدمين يمكنهم المصادقة ببيانات اعتماد صالحة ويتم رفضهم ببيانات غير صالحة:

اختبار مصادقة Laravel

<?php namespace Tests\Feature\Auth; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class LoginTest extends TestCase { use RefreshDatabase; public function test_user_can_login_with_valid_credentials() { $user = User::factory()->create([ 'email' => 'john@example.com', 'password' => bcrypt('password123') ]); $response = $this->post('/login', [ 'email' => 'john@example.com', 'password' => 'password123' ]); $response->assertRedirect('/dashboard'); $this->assertAuthenticatedAs($user); } public function test_user_cannot_login_with_invalid_password() { $user = User::factory()->create([ 'email' => 'john@example.com', 'password' => bcrypt('password123') ]); $response = $this->post('/login', [ 'email' => 'john@example.com', 'password' => 'wrongpassword' ]); $response->assertSessionHasErrors('email'); $this->assertGuest(); } public function test_user_cannot_login_with_nonexistent_email() { $response = $this->post('/login', [ 'email' => 'nonexistent@example.com', 'password' => 'password123' ]); $response->assertSessionHasErrors('email'); $this->assertGuest(); } public function test_login_requires_email_and_password() { $response = $this->post('/login', []); $response->assertSessionHasErrors(['email', 'password']); $this->assertGuest(); } public function test_login_validates_email_format() { $response = $this->post('/login', [ 'email' => 'not-an-email', 'password' => 'password123' ]); $response->assertSessionHasErrors('email'); } }
تأكيدات مصادقة Laravel:
  • $this->assertAuthenticated() - يتحقق من تسجيل دخول المستخدم
  • $this->assertAuthenticatedAs($user) - يتحقق من تسجيل دخول مستخدم معين
  • $this->assertGuest() - يتحقق من عدم تسجيل دخول المستخدم
  • $this->assertCredentials($credentials) - يتحقق من مطابقة بيانات الاعتماد لقاعدة البيانات
  • $this->assertInvalidCredentials($credentials) - يتحقق من عدم مطابقة بيانات الاعتماد

اختبار تسجيل خروج المستخدم

<?php public function test_authenticated_user_can_logout() { $user = User::factory()->create(); $response = $this->actingAs($user)->post('/logout'); $response->assertRedirect('/'); $this->assertGuest(); } public function test_guest_cannot_logout() { $response = $this->post('/logout'); $response->assertRedirect('/login'); } public function test_logout_invalidates_session() { $user = User::factory()->create(); // تسجيل الدخول $this->actingAs($user); $sessionId = session()->getId(); // تسجيل الخروج $this->post('/logout'); // محاولة استخدام الجلسة القديمة $response = $this->withSession(['_token' => $sessionId]) ->get('/dashboard'); $response->assertRedirect('/login'); }

اختبار تسجيل المستخدم

<?php public function test_new_users_can_register() { $response = $this->post('/register', [ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'password123', 'password_confirmation' => 'password123' ]); $this->assertDatabaseHas('users', [ 'email' => 'john@example.com' ]); $response->assertRedirect('/dashboard'); $this->assertAuthenticated(); } public function test_registration_requires_password_confirmation() { $response = $this->post('/register', [ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'password123', 'password_confirmation' => 'different' ]); $response->assertSessionHasErrors('password'); $this->assertDatabaseMissing('users', [ 'email' => 'john@example.com' ]); } public function test_registration_enforces_unique_email() { User::factory()->create(['email' => 'john@example.com']); $response = $this->post('/register', [ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'password123', 'password_confirmation' => 'password123' ]); $response->assertSessionHasErrors('email'); } public function test_registration_requires_minimum_password_length() { $response = $this->post('/register', [ 'name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'short', 'password_confirmation' => 'short' ]); $response->assertSessionHasErrors('password'); }

اختبار Middleware المصادقة

تتحقق اختبارات Middleware من أن المسارات المحمية تتطلب المصادقة:

<?php public function test_dashboard_requires_authentication() { $response = $this->get('/dashboard'); $response->assertRedirect('/login'); } public function test_authenticated_user_can_access_dashboard() { $user = User::factory()->create(); $response = $this->actingAs($user)->get('/dashboard'); $response->assertOk(); } public function test_api_routes_require_token_authentication() { $response = $this->getJson('/api/user'); $response->assertUnauthorized(); } public function test_verified_middleware_blocks_unverified_users() { $user = User::factory()->unverified()->create(); $response = $this->actingAs($user)->get('/verified-only'); $response->assertRedirect('/verify-email'); } public function test_verified_user_can_access_verified_routes() { $user = User::factory()->create([ 'email_verified_at' => now() ]); $response = $this->actingAs($user)->get('/verified-only'); $response->assertOk(); }

اختبار التفويض القائم على الدور

تتحقق اختبارات الدور من أن المستخدمين يمكنهم فقط تنفيذ الإجراءات المناسبة لدورهم:

<?php public function test_admin_can_delete_any_post() { $admin = User::factory()->admin()->create(); $post = Post::factory()->create(); $response = $this->actingAs($admin) ->delete("/posts/{$post->id}"); $response->assertStatus(204); $this->assertModelMissing($post); } public function test_regular_user_cannot_delete_other_users_posts() { $user = User::factory()->create(); $otherUser = User::factory()->create(); $post = Post::factory()->for($otherUser)->create(); $response = $this->actingAs($user) ->delete("/posts/{$post->id}"); $response->assertForbidden(); $this->assertModelExists($post); } public function test_user_can_delete_own_posts() { $user = User::factory()->create(); $post = Post::factory()->for($user)->create(); $response = $this->actingAs($user) ->delete("/posts/{$post->id}"); $response->assertStatus(204); $this->assertModelMissing($post); } public function test_moderator_can_edit_any_post() { $moderator = User::factory()->moderator()->create(); $post = Post::factory()->create(); $response = $this->actingAs($moderator) ->put("/posts/{$post->id}", [ 'title' => 'Updated Title', 'content' => 'Updated content' ]); $response->assertOk(); $this->assertDatabaseHas('posts', [ 'id' => $post->id, 'title' => 'Updated Title' ]); }
تذكير اختبار الأمان: اختبر دائماً كلاً من الحالات الإيجابية والسلبية:
  • إيجابي: يمكن للمستخدمين المصرح لهم تنفيذ الإجراءات المسموح بها
  • سلبي: لا يمكن للمستخدمين غير المصرح لهم تنفيذ الإجراءات المقيدة
  • حالات حدية: المستخدمون المحذوفون، الحسابات المعلقة، الأذونات المنتهية الصلاحية
يمكن أن يؤدي اختبار سلبي واحد مفقود إلى إنشاء ثغرة أمنية.

اختبار سياسات Laravel

تركز سياسات Laravel منطق التفويض. اختبرها بدقة:

<?php namespace Tests\Unit\Policies; use App\Models\Post; use App\Models\User; use App\Policies\PostPolicy; use Tests\TestCase; class PostPolicyTest extends TestCase { private PostPolicy $policy; protected function setUp(): void { parent::setUp(); $this->policy = new PostPolicy(); } public function test_user_can_view_published_post() { $user = User::factory()->create(); $post = Post::factory()->published()->create(); $this->assertTrue($this->policy->view($user, $post)); } public function test_user_can_view_own_draft() { $user = User::factory()->create(); $post = Post::factory()->draft()->for($user)->create(); $this->assertTrue($this->policy->view($user, $post)); } public function test_user_cannot_view_other_users_draft() { $user = User::factory()->create(); $otherUser = User::factory()->create(); $post = Post::factory()->draft()->for($otherUser)->create(); $this->assertFalse($this->policy->view($user, $post)); } public function test_admin_can_view_any_draft() { $admin = User::factory()->admin()->create(); $post = Post::factory()->draft()->create(); $this->assertTrue($this->policy->view($admin, $post)); } public function test_user_can_update_own_post() { $user = User::factory()->create(); $post = Post::factory()->for($user)->create(); $this->assertTrue($this->policy->update($user, $post)); } public function test_user_cannot_update_other_users_post() { $user = User::factory()->create(); $post = Post::factory()->create(); $this->assertFalse($this->policy->update($user, $post)); } }

اختبار مصادقة رمز API

اختبر المصادقة القائمة على الرمز لواجهات برمجة التطبيقات (Laravel Sanctum، Passport، JWT):

اختبار Laravel Sanctum

<?php use Laravel\Sanctum\Sanctum; public function test_authenticated_user_can_access_api() { $user = User::factory()->create(); Sanctum::actingAs($user); $response = $this->getJson('/api/user'); $response->assertOk() ->assertJson([ 'id' => $user->id, 'email' => $user->email ]); } public function test_api_requires_valid_token() { $response = $this->getJson('/api/user'); $response->assertUnauthorized(); } public function test_user_can_create_token() { $user = User::factory()->create(); $response = $this->actingAs($user) ->postJson('/api/tokens', [ 'name' => 'mobile-app' ]); $response->assertOk() ->assertJsonStructure(['token']); $this->assertDatabaseHas('personal_access_tokens', [ 'tokenable_id' => $user->id, 'name' => 'mobile-app' ]); } public function test_token_can_be_revoked() { $user = User::factory()->create(); $token = $user->createToken('test-token'); $response = $this->actingAs($user) ->deleteJson("/api/tokens/{$token->accessToken->id}"); $response->assertNoContent(); $this->assertDatabaseMissing('personal_access_tokens', [ 'id' => $token->accessToken->id ]); } public function test_token_with_abilities_restricts_access() { $user = User::factory()->create(); Sanctum::actingAs($user, ['read']); // فقط قدرة القراءة $response = $this->postJson('/api/posts', [ 'title' => 'Test Post' ]); $response->assertForbidden(); }

اختبار أنظمة الأذونات

اختبر أنظمة الأذونات الدقيقة (مثل spatie/laravel-permission):

<?php use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role; public function test_user_with_permission_can_perform_action() { $user = User::factory()->create(); $permission = Permission::create(['name' => 'delete posts']); $user->givePermissionTo($permission); $post = Post::factory()->create(); $response = $this->actingAs($user) ->delete("/posts/{$post->id}"); $response->assertNoContent(); } public function test_user_without_permission_cannot_perform_action() { $user = User::factory()->create(); $post = Post::factory()->create(); $response = $this->actingAs($user) ->delete("/posts/{$post->id}"); $response->assertForbidden(); } public function test_role_grants_permissions_to_user() { $role = Role::create(['name' => 'editor']); $role->givePermissionTo(['create posts', 'edit posts']); $user = User::factory()->create(); $user->assignRole($role); $this->assertTrue($user->can('create posts')); $this->assertTrue($user->can('edit posts')); $this->assertFalse($user->can('delete posts')); } public function test_direct_permission_overrides_role() { $role = Role::create(['name' => 'viewer']); // الدور ليس له أذونات $user = User::factory()->create(); $user->assignRole($role); $user->givePermissionTo('delete posts'); $this->assertTrue($user->can('delete posts')); }

اختبار مصادقة JavaScript

اختبر المصادقة في تطبيقات JavaScript:

// tests/auth/login.test.js import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { LoginForm } from '../LoginForm'; import { authService } from '../../services/auth'; jest.mock('../../services/auth'); describe('LoginForm', () => { test('successful login redirects to dashboard', async () => { authService.login.mockResolvedValue({ user: { id: 1, email: 'john@example.com' }, token: 'fake-token' }); render(<LoginForm />); fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'john@example.com' } }); fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } }); fireEvent.click(screen.getByRole('button', { name: 'Login' })); await waitFor(() => { expect(authService.login).toHaveBeenCalledWith( 'john@example.com', 'password123' ); expect(window.location.pathname).toBe('/dashboard'); }); }); test('invalid credentials show error message', async () => { authService.login.mockRejectedValue({ message: 'Invalid credentials' }); render(<LoginForm />); fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'john@example.com' } }); fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'wrongpassword' } }); fireEvent.click(screen.getByRole('button', { name: 'Login' })); await waitFor(() => { expect(screen.getByText('Invalid credentials')).toBeInTheDocument(); }); }); }); // tests/auth/protected-route.test.js import { render } from '@testing-library/react'; import { ProtectedRoute } from '../ProtectedRoute'; import { useAuth } from '../../hooks/useAuth'; jest.mock('../../hooks/useAuth'); test('redirects to login when not authenticated', () => { useAuth.mockReturnValue({ isAuthenticated: false }); render(<ProtectedRoute component={DashboardPage} />); expect(window.location.pathname).toBe('/login'); }); test('renders component when authenticated', () => { useAuth.mockReturnValue({ isAuthenticated: true, user: { id: 1, email: 'john@example.com' } }); const { getByText } = render( <ProtectedRoute component={() => <div>Dashboard</div>} /> ); expect(getByText('Dashboard')).toBeInTheDocument(); });

تمرين عملي

التمرين 1: اختبار تدفق مصادقة كامل

<?php // إنشاء اختبارات لـ: // 1. تسجيل المستخدم (مدخلات صالحة/غير صالحة) // 2. متطلب التحقق من البريد الإلكتروني // 3. تسجيل الدخول مع بريد إلكتروني مُحقق // 4. حظر تسجيل الدخول للمستخدمين غير المُحققين // 5. طلب إعادة تعيين كلمة المرور // 6. إعادة تعيين كلمة المرور برمز صالح // 7. إعادة تعيين كلمة المرور برمز منتهي الصلاحية // تلميح: استخدم Laravel\Fortify أو Breeze كمرجع

التمرين 2: اختبار نظام تفويض متعدد الأدوار

// الأدوار: Admin، Editor، Author، Subscriber // مصفوفة الاختبار: // - Admin: يمكنه فعل كل شيء // - Editor: يمكنه تحرير أي منشور، نشر أي منشور // - Author: يمكنه إنشاء/تحرير منشوراته الخاصة، لا يمكنه النشر // - Subscriber: يمكنه فقط قراءة المنشورات المنشورة // اكتب اختبارات تتحقق من أذونات كل دور

التمرين 3: اختبار مصادقة API مع تحديد المعدل

// اختبار: // 1. نقطة نهاية API تتطلب مصادقة // 2. الرمز الصالح يمنح الوصول // 3. الرمز غير الصالح يُرجع 401 // 4. الرمز المنتهي الصلاحية يُرجع 401 // 5. تحديد المعدل يحظر بعد N طلبات // 6. تحديد المعدل يعيد التعيين بعد نافذة الوقت