اذهب إلى المحتوى

ننهي في هذا المقال اﻷخير من سلسلتنا ما بدأناه في إنشاء واجهة برمجية REST باستخدام Express.js، ونناقش فيه موضوع السماحيات permissions والاختبارات المؤتمتة لعمل التطبيق.

سماحيات المستخدم

بعد التحقق من هوية مستثمر الواجهة البرمجية، علينا أن نعرف إن كان مسموحًا لهذا المستثمر الوصول إلى الموارد التي تتيحها الواجهة أم لا. ويشيع استخدام مجموعة من السماحيات لكل مستخدم، إذ يقدّم هذا اﻷسلوب طريقة أكثر مرونة من استراتيجية مستوى الوصول access level وأقل تعقيدًا. وبصرف النظر عن منطق عمل كل سماحية (تصريح)، من اﻷوضح إتباع طريقة عامة للتعامل معها.

عامل مقارنة البتات AND (&) وقوة العدد 2

نستخدم العامل & المضمّن في جافا سكريبت ﻹدارة السماحيات. إذ نخزّن كل التصريحات ضمن كيان واحد ورقم واحد يخص كل مستخدم. ثم باستخدام التمثيل الثنائي (0100 للرقم 4 مثلًا) للرقم الخاص بالمستخدم والعامل & يمكننا معرفة التصريحات المعطاة له. لا تعر اهتمامًا كبيرًا بالرياضيات طالما أن الفكرة سهلة الاستخدام.

نعرّف كل نوع من التصريحات (رايات سماحية permission flags) على شكل قوّة للرقم 2 أي: 2, 4, 8, 16… ، وهكذا حتى نحصل على حد أعظمي مكوّن من 31 راية.

لنأخذ مثالًا عمليًا عن مدوّنة تقدم محتوى صوتي إضافة إلى النصي تُعطى فيها السماحيات التالية:

  • 1: مؤلف ويمكنه التعديل على النص.
  • 2: مصوّر ويمكنه التعديل على الصور.
  • 4: معلّق ويمكنه تغيير الملف الصوتي الخاص بكل فقرة نصية.
  • 8: مترجم يمكنه التعديل على الترجمات.

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

  • المستخدم له صلاحية تعديل النص:سيُمنح الرقم 1 فقط.
  • المستخدم له صلاحية تعديل النص والتعليق عليه: سيُمنح الرقم 5=1+4.
  • المستخدم له صلاحية تعديل النص والصور: سيُمنح الرقم 3=2+1
  • المستخدم له صلاحية تعديل النص والصور والترجمة: سيُمنح الرقم 11=1+2+8
  • المستخدم مدير له جميع الصلاحيات الحالية والمستقبلية: سيُمنح الرقم 2,147,483,647 وهو أعلى عدد صحيح مكوّن من 32 بت.

لكن كيف سيجري اﻷمر؟

لنفرض الحالة التي يحمل فيها المستخدم الرقم 12 الذي يُمثّل ثنائيًا كالتالي 00001100، ويحاول التعليق الصوتي ذو التصريح رقم 4 الذي يُمثل ثنائيًا كالتالي 00000100. نستخدم اﻵن العملية & على الرقمين السابقين كالتالي: 00001100 & 00000100 ستكون النتيجة 00000100 وذلك بمقارنة قيمة كل خانة من الأول مه مقابلتها في الثاني ووضع النتيجة 1 إذا حملت الخانتين القيمة 1 وصفر فيما عدا ذلك. إن النتيجة هي الرقم 4 وبالتالي يُسمح له بالتعليق الصوتي. نطبق نفس الخوارزمية على رقم المستخدم والصلاحية التي يريد استخدامها فإن كانت نتيجة العملية & هي 0 يُمنع من استخدام الصلاحية وإلا سيُسمح له باستخدامها (جرّب أرقامًا أخرى!).

كتابة شيفرة رايات السماحيات

نخزن رايات السماحيات ضمن المجلد common لأن منطق العملية قد يتكرر في وحدات أخرى مستقبلًا، ثم ننشئ الملف common/middleware/common.permissionflag.enum.ts ليحمل قيم بعض الرايات:

export enum PermissionFlag {
    FREE_PERMISSION = 1,
    PAID_PERMISSION = 2,
    ANOTHER_PAID_PERMISSION = 4,
    ADMIN_PERMISSION = 8,
    ALL_PERMISSIONS = 2147483647,
}

ملاحظة: طالما أن التطبيق هو مجرد مثال، اخترنا أن تكون أسماء الصلاحيات معممة.

لنعد اﻵن إلى الدالة ()addUser ضمن كائن DAO لاستبدال الرقم 1 بالقيمة PermissionFlag.FREE_PERMISSION بعد إدراج الملف السابق في مكان مناسب. كما يمكن إدراج هذا الملف ضمن ملف جديد لوحدة وسيطة تضم صنف متفرّد يُدعى PermissionFlag.FREE_PERMISSION:

import express from 'express';
import { PermissionFlag } from './common.permissionflag.enum';
import debug from 'debug';

const log: debug.IDebugger = debug('app:common-permission-middleware');

وبدلًا من إنشاء عدة دوال وسيطة مشابهة، نستخدم نمط المصنع factory pattern ﻹنشاء مصنع للتوابع أو الدوال (أو ببساطة مصنع). تسمح لنا دالة المصنع توليد دوال وسيطة -عند تهيئة المسار- تتحقق من راية أية سماحية مطلوبة. وهكذا نتفادى تكرار الدوال الوسيطة عندما نضيف راية جديدة.

إليك شيفرة المصنع الذي يوّلد الدوال الوسيطة التي تتحقق من رايات السماحيات:

permissionFlagRequired(requiredPermissionFlag: PermissionFlag) {
    return (
        req: express.Request,
        res: express.Response,
        next: express.NextFunction
    ) => {
        try {
            const userPermissionFlags = parseInt(
                res.locals.jwt.permissionFlags
            );
            if (userPermissionFlags & requiredPermissionFlag) {
                next();
            } else {
                res.status(403).send();
            }
        } catch (e) {
            log(e);
        }
    };
}

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

async onlySameUserOrAdminCanDoThisAction(
    req: express.Request,
    res: express.Response,
    next: express.NextFunction
) {
    const userPermissionFlags = parseInt(res.locals.jwt.permissionFlags);
    if (
        req.params &&
        req.params.userId &&
        req.params.userId === res.locals.jwt.userId
    ) {
        return next();
    } else {
        if (userPermissionFlags & PermissionFlag.ADMIN_PERMISSION) {
            return next();
        } else {
            return res.status(403).send();
        }
    }
}

نضيف اﻵن دالة وسيطة جديدة إلى الملف users.middleware.ts:

async userCantChangePermission(
    req: express.Request,
    res: express.Response,
    next: express.NextFunction
) {
    if (
        'permissionFlags' in req.body &&
        req.body.permissionFlags !== res.locals.user.permissionFlags
    ) {
        res.status(400).send({
            errors: ['User cannot change permission flags'],
        });
    } else {
        next();
    }
}

وطالما أن الدالة السابقة تعتمد على القيمة res.locals.user، باﻹمكان نشرها ضمن الدالة ()validateUserExists قبل استدعاء ()next:

// ...
if (user) {
    res.locals.user = user;
    next();
} else {
// ...

إن ما فعلناه ضمن الدالة ()validateUserExists ينفي ضرورة تنفيذه ضمن الدالة ()validateSameEmailBelongToSameUser، لهذا يمكن حذف استدعاء قاعدة البيانات فيه واستبداله بالقيمة التي يمكن نضمّن أنها مخزّنة في res.locals:

-        const user = await userService.getUserByEmail(req.body.email);
-        if (user && user.id === req.params.userId) {
+        if (res.locals.user._id === req.params.userId) {

نستطيع اﻵن ربط منطق تحديد السماحيات بالملف users.routes.config.ts.

طلب التصريحات

ندرج بداية اﻷداة الوسيطة الجديدة والملف enum:

import jwtMiddleware from '../auth/middleware/jwt.middleware';
import permissionMiddleware from '../common/middleware/common.permission.middleware';
import { PermissionFlag } from '../common/middleware/common.permissionflag.enum';

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

this.app
    .route(`/users`)
    .get(
        jwtMiddleware.validJWTNeeded,
        permissionMiddleware.permissionFlagRequired(
            PermissionFlag.ADMIN_PERMISSION
        ),
        UsersController.listUsers
    )
// ...

وتذكّر أن استدعاء دالة المصنع يُعيد دالة وسيطة، وبالتالي تُعيد مرجعًا إلى الدوال الوسيطة اﻷخرى التي لا تنتج عنها دون أن تُنفّذ.

من القيود اﻷخرى هو منع الوصول إلى المسارات التي تضم معرّف المستخدم userId، ما لم يكن صاحب التصريح هو صاحب المعرّف أو المدير:

             .route(`/users/:userId`)
-            .all(UsersMiddleware.validateUserExists)
+            .all(
+                UsersMiddleware.validateUserExists,
+                jwtMiddleware.validJWTNeeded,
+                permissionMiddleware.onlySameUserOrAdminCanDoThisAction
+            )
             .get(UsersController.getUserById)

وعلينا أيضًا منع المستخدمين من رفع مستوى سماحياتهم عن طريق إضافة القيمة UsersMiddleware.userCantChangePermission قبل المرجع إلى الدالة في نهاية الوجهات التي تقود إلى العمليتين PUT و PATCH.

وسندعم أيضًا في الواجهة البرمجية منطق السماح للمستخدمين الذين يمتلكون التصريح PAID_PERMISSION فقط في تحديث بياناتهم، وقد لا يلائم هذه اﻷمر مشاريع أخرى، لكننا سننفذه هنا كي نميز بين السماحيات المدفوعة والمجانية.

باﻹمكان تنفيذ اﻷمر بإضافة دالة مولّدة أخرى بعد كل مرجع أضفناه إلى التصريح userCantChangePermission:

permissionMiddleware.permissionFlagRequired(
    PermissionFlag.PAID_PERMISSION
),

وهكذا نكون مستعدين ﻹعادة تشغيل Node.js والتجريب.

الاختبار اليدوي للتصريحات

لنحاول بداية أن نحصل على قائمة المستخدمين دون مفتاح:

curl --include --request GET 'localhost:3000/users' \
--header 'Content-Type: application/json'

ستكون الاستجابة HTTP 401 لاننا بحاجة إلى مفتاح JWT صالح، لهذا سنعيد المحاولة باستخدام مفتاح الوصول الذي حصلنا عليه في مقالنا السابق:

curl --include --request GET 'localhost:3000/users' \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS"

وسنحصل هذه المرة أيضًا على الاستجابة HTTP 403 لأن المفتاح صالح لكن الوصول إلى نقطة الوصول هذه مرفوض لعدم امتلاك الطلب التصريح ADMIN_PERMISSION.

,وبالطبع لن نحتاج إلى تصريح كهذا للحصول على سجل المستخدم نفسه:

curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS"

وستكون الاستجابة كالتالي:

{
    "_id": "UdgsQ0X1w",
    "email": "marcos.henrique@toptal.com",
    "permissionFlags": 1,
    "__v": 0
}

وبالمقابل، يُفترض أن يفشل طلب تحديث سجلات المستخدم نفسه لأن قيمة السماحية التي يمتلكها هي 1 (مجاني FREE_PERMISSION?

curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" \
--data-raw '{
    "firstName": "Marcos"
}'

والاستجابة كما نتوقع هي : HTTP 403.

اقتباس

للتمرين: اقترح تغيير رايات السماحية permissionFlags الخاصة بالمستخدم ضمن قاعدة البيانات المحلية على جهازك، ثم اطلب توليد مفتاح JWT عن طريق العملية POST إلى الوجهة auth/ يضم السماحيات الجديدة. حاول بعد ذلك تحديث بيانات السجل مجددًا. وتذكر استخدام القيم الرقمية لأحد التصريحين PAID_PERMISSION أو ALL_PERMISSIONS، لأن التصريح ADMIN_PERMISSION لا يسمح لك بتحديث بياناتك ولا بيانات أي مستخدم آخر وفقًا لمنطق العمل في تطبيقنا.

إن متطلبات استعلام الوجهة auth/ من خلال الطلب POST يُظهر سيناريو أمني لا بد من الانتباه إليه. فعندما يغير مالك الموقع سماحيات مستخدم، كأن يحاول إلغاء مستخدم يسئ السلوك، فلن يشعر المستخدم بذلك حتى يحصل تحديث على مفتاح JWT الذي يمتلكه، لأن التحقق من السماحيات يجري على بيانات مفتاح JWT لتقليل الوصول إلى قاعدة البيانات.

تساعدك خدمات مثل Auth0 بتقديم تدوير تلقائي للمفاتيح، لكن مع ذلك، سيلاحظ المستخدمين سلوكًا غريبًا في الفترة التي تفصل بين عمليات التدوير، ويدوم لفترة قصيرة عادة. ولتخفيف هذه الظاهرة، على المطور أن ينتبه إلى ضرورة تنفيذ آلية تحديث المفاتيح كاستجابة لتغيّر السماحيات.

ويجدر بمطوري الواجهات البرمجية REST الانتباه إلى الثغرات بتنفيذ الكثير من عمليات CRUD وبشكل دوري، لكن سرعان ما يصبح هذا الأمر مرهقًا ومنبعًا لمشاكل جديدة.

الاختبارات المؤتمتة

مع توسع وزيادة حجم وإمكانيات الواجهة البرمجية، سيصعب صيانتها والمحافظة على جودتها وخصوصًا مع التغيّر المستمر لمنطق العمل. ولتقليل الثغرات في الواجهة البرمجية بقدر المستطاع ونشر التغييرات الجديدة بثقة، يشيع استخدام أطقم برمجية لاختبار الواجهتين الأمامية والخلفية للتطبيقات.

وبدلًا من الغوص في تفاصيل كتابة اختبارات وشيفرة اختبارات، سنعرض آليات أساسية تقدّم مجموعة اختبارت يمكن لنا البناء عليها.

التعامل مع البيانات الناتجة عن الاختبارات

قبل أن نؤتمت الاختبارات، من الجيد التفكير بما سيحدث للبيانات الناتجة عن الاختبارات. إذ نستخدم في تطبيقنا Docker Compose لتشغيل قاعدة البيانات المحلية، وتؤثر الاختبارات على قاعدة البيانات بترك مجموعة جديدة من السجلات بعد كل اختبار. لا يمثل هذا اﻷمر مشكلة في أغلب اﻷوقات، لكن إن رأيت أنه كذلك، لا بأس بتعديل الملف docker-compose.yml ﻹنشاء قاعدة بيانات جديدة لأغراض الاختبار.

عادة ما يُجري المطورين اختباراتهم المؤتمتة في الواقع كجزء من خط التسليم المستمر. ولتنفيذ اﻷمر، من المنطقي إعداد طريقة ﻹنشاء قاعدة بيانات مؤقتة لتنفيذ كل اختبار. لهذا نستخدم Mocha و Chai و SuperTest لإنشاء اختباراتنا:

npm i --save-dev chai mocha supertest @types/chai @types/express @types/mocha @types/supertest ts-node

يدير Mocha تطبيقنا ويًجري الاختبارات، بينما يسهّل Chai قراءة تعابير الاختبارات، ويسهّل SuperTest اختبارات التكامل بين الواجهتين أو اختبارات طرف إلى طرف end-to-end بين الواجهة البرمجية RESTوعميل ريست.

علينا بداية تعديل السكريبت package.json:

"scripts": {
// ...
    "test": "mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict",
    "test-debug": "export DEBUG=* && npm test"
},

تسمح لنا الشيفرة السابقة بتنفيذ الاختبارات في مجلد جديد ندعوه test.

اختبار وصفي

لتجربة هيكلية الاختبار، سننشئ الملف test/app.test.ts:

import { expect } from 'chai';
describe('Index Test', function () {
    it('should always pass', function () {
        expect(true).to.equal(true);
    });
});

قد تبدو الصياغة غير مألوفة لكنها صحيحة. إذ نعرف الاختبار بتوقع سلوك معين ()expect ضمن الكتلة ()it التي تُستدعى بدورها ضمن الكتلة ()describe.

ننفذ التعليمة التالية في الطرفية:

npm run test

ومن المفترض أن نرى النتيجة التالية:

> mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict

  Index Test
    ✓ should always pass


  1 passing (6ms)

وهكذا تكون مكتبات الاختبار جاهزة للعمل.

تحضير الاختبارات للعمل

ليبقى خرج الاختبارات واضحًا، علينا إيقاف عمل المكتبة winston التي تسجّل الطلبات كليًا خلال تنفيذ الاختبارات، لهذا غيّر الفرع else (ليس في وضع التنقيح طبعًا) في الملف app.ts للتحقق من وجود الدالة ()it من Mocha:

if (!process.env.DEBUG) {
     loggerOptions.meta = false; // عندما لا نكون في وضع التنقيح
+    if (typeof global.it === 'function') {
+        loggerOptions.level = 'http'; // for non-debug test runs, squelch entirely
+    }
 }

علينا أخيرًا تصدير الملف app.ts ليكون عرضة للاختبارات، لهذا سنضيف السطر export default في نهاية الملف app.ts قبل السطر ()server.listen مباشرة، لأن ()listen يعيد الكائن http.Server.

تحقق من أن كل شيئ على ما يرام بتنفيذ اﻷمر: npm run test.

الاختبار المؤتمت اﻷول على واجهتنا البرمجية

حتى نبدأ إعداد الاختبارات على المستخدمين، سننشئ الملف test/users/users.test.ts ونبدؤه بإدراج المكتبات واﻹعتماديات اللازمة:

import app from '../../app';
import supertest from 'supertest';
import { expect } from 'chai';
import shortid from 'shortid';
import mongoose from 'mongoose';

let firstUserIdTest = ''; // سيضم لاحقًا قيمة تعيدها الواجهة البرمجية
const firstUserBody = {
    email: `marcos.henrique+${shortid.generate()}@toptal.com`,
    password: 'Sup3rSecret!23',
};`()server.listen``()server.listen`

let accessToken = '';
let refreshToken = '';
const newFirstName = 'Jose';
const newFirstName2 = 'Paulo';
const newLastName2 = 'Faraco';

ننشئ تاليًا الكتلة الخارجية ()describe مع بعض اﻹعدادات والتعريفات:

describe('users and auth endpoints', function () {
    let request: supertest.SuperAgentTest;
    before(function () {
        request = supertest.agent(app);
    });
    after(function (done) {
        //وأغلق الاتصال  Express.js أغلق خادم shut down the Express.js
        //بأننا انتهينا Mocha ثم أخبر  MongoDB مع
        app.close(() => {
            mongoose.connection.close(done);
        });
    });will later hold a value returned by our API
});

تُستدعى الدوال التي نمررها إلى ()before و ()after قبل وبعد كل الاختبارات التي نعرّفها عند استدعاء ()it ضمن نفس الكتلة ()describe. وللدالة التي نمررها إلى ()after دالة أخرى done نستدعيها بمجرّد انتهينا من إعادة التطبيق واتصاله مع قاعدة البيانات إلى الوضع الأساسي.

ملاحظة: لو لم نستخدم الدالة ()after، ستتوقف Mocha عن الاستجابة بعد إكمال كل اختبار بنجاح. وننصح هنا باستدعاء Mocha دومًا مع الوسيط exit-- لتفادي هذا السلوك مع أنه ينطوي على ثغرة. فلو توقف الاختبار عن الاستجابة لأسباب أخرى، نتيجة وعد لم يُكتب بطريقة صحيحة في الاختبار أو التطبيق مثلًا، عندها لن تنتظر Mocha وتعطي رسالة بنجاح الاختبار حتى لو حدث خطأ في التطبيق مما يزيد تعقيد عملية تنقيح اﻷخطاء

أصبحنا اﻵن جاهزين ﻹضافة اختبارات فردية طرف-إلى-طرف ضمن الكتلة ()describe:

it('should allow a POST to /users', async function () {
    const res = await request.post('/users').send(firstUserBody);

    expect(res.status).to.equal(201);
    expect(res.body).not.to.be.empty;
    expect(res.body).to.be.an('object');
    expect(res.body.id).to.be.a('string');
    firstUserIdTest = res.body.id;
});

تُنشئ هذه الدالة مستخدمًا جديدًا وفريدًا لأن البريد اﻹلكتروني للمستخدم قد ولد سابقًا باستخدام shortid. بضم المتغير request عميل SuperTest الذي يسمح لنا تنفيذ طلبات HTTP إلى الواجهة البرمجية. وعلى جميع الطلبات أن تستخدم await لهذا تكون جميع الدوال التي نمررها إلى ()it غير متزامنة async. ثم نستخدم بعد ذلك ()expect من Chai لاختبار مختلف نواحي النتيجة.

جرب تنفذ اﻷمر اﻵن لترى كيف يعمل الاختبار.

سلسلة من الاختبارات

سنضيف جميع كتل ()it التالية ضمن الكتلة ()describe، ولا بد من إضافتها وفق الترتيب المعروض تاليًا كي تعمل جميعها مع المتغيّرات التي نغيّرها مثل firstUserIdTest:

it('should allow a POST to /auth', async function () {
    const res = await request.post('/auth').send(firstUserBody);
    expect(res.status).to.equal(201);
    expect(res.body).not.to.be.empty;
    expect(res.body).to.be.an('object');
    expect(res.body.accessToken).to.be.a('string');
    accessToken = res.body.accessToken;
    refreshToken = res.body.refreshToken;
});

نحضر هنا مفتاحي الوصول والتحديث الجديدين من المستخدم الذي أضيف مؤخرًا:

it('should allow a GET from /users/:userId with an access token', async function () {
    const res = await request
        .get(`/users/${firstUserIdTest}`)
        .set({ Authorization: `Bearer ${accessToken}` })
        .send();
    expect(res.status).to.equal(200);
    expect(res.body).not.to.be.empty;
    expect(res.body).to.be.an('object');
    expect(res.body._id).to.be.a('string');
    expect(res.body._id).to.equal(firstUserIdTest);
    expect(res.body.email).to.equal(firstUserBody.email);
});

يدفع ذلك الطلب GET من المسار userID: (والذي يحمل مفتاح التشفير) إلى التحقق من أن بيانات المستخدم في الاستجابة تتطابق مع ما أرسلناه أساسًا.

الاختبارات المتداخلة وتجاوز الاختبارات وعزلها

يمكن للكتل ()itفي Mocha أن تضم كتل ()describe، أي يمكن أن تتداخل الاختبارات، وهذا ما سنفعله في اختبارنا التالي. سيجعل ذلك الاعتماديات المتعاقبة أوضح في نواتج الاختبار كما سنراها في النهاية:

describe('with a valid access token', function () {
    it('should allow a GET from /users', async function () {
        const res = await request
            .get(`/users`)
            .set({ Authorization: `Bearer ${accessToken}` })
            .send();
        expect(res.status).to.equal(403);
    });
});

لا تغطي الاختبارات الفعالة ما نتوقعه نجاحه فقط، بل ما نتوقعه أن يفشل أيضًا. إذ حاولنا هنا الحصول على قائمة بأسماء المستخدمين وتوقعنا أن تكون النتيجة الخطأ 403 لأن المستخدم الجديد سيحمل التصريح الافتراضي ولا يسمح له بمثل هذا الطلب إلى نقطة الوصول. وهكذا يمكننا الاستمرار في كتابة اختبارات ضمن الكتلة ()describe الجديدة.

وطالما أننا ناقشنا الميزات التي استخدمناها في بقية شيفرة الاختبار، يمكنك الاطلاع عليها في المستودع الخاص بالتطبيق.

مر مع تزوّدنا المكتبة Mocha بميزات عدة قد تجدها مفيدة أثناء تطوير وتنقيح الاختبارات منها:

  1. التابع ()skip:ويستخدم لتفادي اختبار وحيد أو كتلة كاملة من الاختبارات. فعندما نستبدل ()it بالدالة ()it.skip (وكذلك الأمر مع ()describe)، لن يعمل الاختبار أو الاختبارات محط الاهتمام بل ستؤجل ويشار إليها بالعبارة "بانتظار التنفيذ pending" في الخرج الأخير للمكتبة Mocha.
  2. التابع ()only: يُستخدم لتجاهل جميع الاختبارات غير الموسومة بالوسم only.، ولن يظهر أي شيء في الخرج على أنه "بانتظار التنفيذ".
  3. المعامل bail--: باﻹمكان استخدام هذا المعامل ضمن سطر تعريف Mocha في الملف package.json ﻹيقاف الاختبارات بمجرد فشل إحداها. ولهذا اﻷمر فائدة خاصة في تطبيق الواجهة البرمجية الذي نبنيه، لأننا أعددنا الاختبارات لتكون متعاقبة، وبالتالي عند فشل أول اختبار ستتوقف Mocha مباشرة بدلًا من المتابعة واﻹشارة إلى أخطاء في بقية الاختبارات المتعلقة بالاختبار الفاشل علمًا بأنها قد تكون ناجحة لكنها فشلت نتيجة لفشل أول اختبار.

عند تنفيذ مجموعة اختباراتنا اﻵن باستخدام سطر اﻷوامر npm run test، سنلاحظ إخفاق ثلاث اختبارات (لو أردنا ترك الدوال الثلاث التي تعتمد على شيفرة غير منجزة حتى اللحظة، فمن المناسب جدًا لتطبيق التابع ()skip عليها).

أخفقت الاختبارات الثلاث السابقة نظرًا لوجود قطعتين مفقودتين من شيفرة التطبيق حتى اللحظة، اﻷولى موجودة ضمن الملف npm run test:

this.app.put(`/users/:userId/permissionFlags/:permissionFlags`, [
    jwtMiddleware.validJWTNeeded,
    permissionMiddleware.onlySameUserOrAdminCanDoThisAction,

    //من الضروري وجود كتلتي الشيفرة السابقتين كجزء من الأداة الوسيطة
    //لأنها تغطي فقط .all() على الرغم من وجود مرجع إليهما ضمن الدوال
    // /users/:userId, وليس كل شيء تختها في الشجرة

    permissionMiddleware.permissionFlagRequired(
        PermissionFlag.FREE_PERMISSION
    ),
    UsersController.updatePermissionFlags,
]);

وعلينا أن نعدّل ثانيًا الملف users.controller.ts لأننا وضعنا مرجعًا إلى دالة غير موجودة فيه. لهذا علينا إضافة السطر ب;import { PatchUserDto } from '../dto/patch.user.dto' بالقرب من أعلى الصفحة وإضافة الدالة المفقودة إلى الصنف:

async updatePermissionFlags(req: express.Request, res: express.Response) {
    const patchUserDto: PatchUserDto = {
        permissionFlags: parseInt(req.params.permissionFlags),
    };
    log(await usersService.patchById(req.body.id, patchUserDto));
    res.status(204).send();
}

إن إضافة هذه اﻹمكانيات مفيد في حالات الاختبار لكنه لن يناسب معظم الاحتياجات في التطبيقات الحقيقية، لهذا نترك لك التمرينين التاليين للعمل عليهما:

  1. فكر بطرق تمنع فيها الشيفرة مجددًا المستخدم من تغيير رايات السماحية permissionFlags الخاصة به وتسمح مع ذلك باختبار نقاط الوصول المقيّدة بالتصريحات.
  2. أنشئ ونفّذ منطق عمل (بما في ذلك الاختبارات المتعلقة به) يعالج تغيير الماحيات permissionFlags من خلال الواجهة البرمجية (انتبه لمعضلة الدجاجة والبيضة هنا: فكيف يمكن لشخص معين الحصول على تصريح لتغيير التصريحات؟)

نفّذ اﻵن اﻷمر npm run test ومن المفترض أن تجري الاختبارات بنجاح ويكون الخرج منسقًا بالشكل التالي:

  Index Test
    ✓ should always pass

  users and auth endpoints
    ✓ should allow a POST to /users (76ms)
    ✓ should allow a POST to /auth
    ✓ should allow a GET from /users/:userId with an access token
    with a valid access token
      ✓ should allow a GET from /users
      ✓ should disallow a PATCH to /users/:userId
      ✓ should disallow a PUT to /users/:userId with an nonexistent ID
      ✓ should disallow a PUT to /users/:userId trying to change the permission flags
      ✓ should allow a PUT to /users/:userId/permissionFlags/2 for testing
      with a new permission level
        ✓ should allow a POST to /auth/refresh-token
        ✓ should allow a PUT to /users/:userId to change first and last names
        ✓ should allow a GET from /users/:userId and should have a new full name
        ✓ should allow a DELETE from /users/:userId


  13 passing (231ms)

وهكذا نكون قد أنجزنا أسلوبًا سريعًا في التحقق من عمل الواجهة البرمجية بالطريقة التي نتوقعها.

تنقيح التطبيق من خلال الاختبارات

يمكن للمطورين الذين يعانون من إخفاقات غير متوقعة لشيفرتهم تعزيز قدرة شيفرات التنقيح لكل من winstonو Node.js عند تنفيذ سلسلة الاختبارات. فمن السهل مثلًا التركيز على استعلامات Mongoose التي تُنفّذ باستخدام اﻷمر DEBUG=mquery npm run test (لاحظ عدم وجود البادئة export والعامل && في الوسط مما يجعل بيئة العمل تستمر لتنفيذ أوامر أخرى).

من الممكن أيضًا عرض خرج جميع عمليات التنقيح باستخدام npm run test-debug ويساعدنا في ذلك طريقة اﻹعداد التي اتبعناها في الملف package.json

وبهذا نكون قد بنينا واجهة برمجية REST قابلة للتوسع ومدعومة بقاعدة بيانات MongoDB وسلسلة من الاختبارات المناسبة، لكن هناك بعض النقص.

اﻷمان في تطبيقات Express.js

ينبغي دائمًا الاطلاع على توثيق ي عند العمل مع التطبيقات المبنية عليها وكحد أدنى ما يتعلق بأفضل ممارسات اﻷمان:

  • دعم إعدادات TLS.
  • إضافة أداة وسيطة للتحكم بمعدّل الطلبات إلى الواجهة البرمجية مثل express-limit-rate
  • التأكد من أمان اعتمديات npm (من الممكن تنفيذ البرنامج باستخدام npm audit، أو استخدام snyk).
  • استخدام المكتبة Helmet للمساعدة في حماية التطبيق من نقاط الضعف الشائعة وهذا ما سنضيفه مباشرة إلى تطبيقنا:
npm i --save helmet

ومن ثم ندرجه ضمن الملف app.ts ونضيف الاستدعاء ()app.use:

import helmet from 'helmet';
// ...
app.use(helmet());

ولا يعني استخدام Helmet أنك بأمان، فكل خطوة ولو كانت بسيطة لتأمين التطبيق لها فائدتها.

احتواء الواجهة البرمجية ضمن Docker

لم نخض في تفاصيل Docker كثيرًا في سلسلة مقالاتنا على الرغم من استخدامه لاحتواء قاعدة البيانات MongoDB. فإن كنت تريد التقدم خطوة أبعد في العمل مع Docker، أنشئ ملفًا بالاسم Dockerfile ودون لاحقة في جذر المشروع وضمّنه الشيفرة التالية:

FROM node:14-slim

RUN mkdir -p /usr/src/app

WORKDIR /usr/src/app

COPY . .

RUN npm install

EXPOSE 3000

CMD ["node", "./dist/app.js"]

تبدأ هذه اﻹعدادات بالسطر node:14-slim وهي النسخة الرسمية من Docker، ثم تشغّل الواجهة البرمجية ضمن الحاوية. يمكن أن تتغير هذه اﻹعدادات من حالة إلى أخرى، لكن هذه اﻹعدادات التي تبدو عامة مناسبة لمشروعنا.

ولبناء هذه النسخة نفّذ الأوامر التالية في جذر المشروع مستبدلًا tag_your_image_here بما يناسب):

docker build . -t tag_your_image_here

ثم نفّذ ما يلي لتشغيل الواجهة ضمن الحاوية:

docker run -p 3000:3000 tag_your_image_here

يمكن اﻵن لقاعدة البيانات MongoDB و Node.js استخدام Docker، لكن علينا تشغيلهما بطريقتين مختلفتين. ونترك لك البحث عن كيفية إضافة تطبيق Node.js اﻷساسي إلى الملف docker-compose.yml كي يعمل التطبيق ككل من خلال أمر docker-compose واحد.

خلاصة: استكشاف مهارات أخرى في بناء واجهات برمجية REST

أجرينا في هذا المقال تحسينات واسعة على الواجهة البرمجية REST التي بدأنا العمل عليها في سلسلة مقالاتنا، إذ أضفنا طريقة للاستيثاق باستخدام مفاتيح JWT وبنينا نظام سماحيات مرن، وكتبنا مجموعة من الاختبارات المؤتمتة.

يُعد ما فعلناه نقطة انطلاق قوية لمطوري الواجهة الخلفية الجدد والمتقدمين. ومع ذلك، قد لا يكون مشروعنا مثاليًا في بعض جوانبه وخاصة عند إنجاز نسخة إنتاج أو محاولة توسيعه. وإضافة إلى التمارين التي أو صينا بحلها خلال سلسلة المقالات السابقة، ما الذي يمكن تعلمه أيضًا؟

نوصي على مستوى بناء الواجهات البرمجية بالاطلاع على مواصفات بناء واجهات متوافقة مع OpenAPI. وننصح أيضًا المهتمين بتطوير المشاريع، تجربة إطار العمل NestJS المبني على أساس Express.js، فهو أكثر قوة وتجريدًا. لهذا، من الجيد العمل على مشروعنا كي تعتاد العمل مع أساسيات Express.js بداية. ولا ننسى بالطبع إطار العمل GraphQL الذي لا يقل أهمية عنه، إذ يلقى رواجًا وانتشارًا كبديل عن الواجهات البرمجية REST.

أما فيما يخص موضوع الاستيثاق والسماحيات،فقد أنشأنا أسلوبًا يعتمد على رايات تُختبر بالصيغة الثنائية ومولد دوال وسيطة لتحديد تلك الرايات يدويًا. ولكي تشعر بثقة أكبر إن قررت زيادة حجم تطبيقاتك، يجدر بك النظر في مكتبة CASL التي تتكامل مع Mongoose. إذ تزيد هذه المكتبة مرونة النهج الذي اتبعناه ويسمح بتعريفات موجزة للإمكانات التي يجب أن يسمح بها تصريح معين مثل:

can(['update', 'delete'], '(model name here)', { creator: 'me' });

بدلاً من الدوال الوسيطة المخصصة لهذا اﻷمر.

كما قدمنا نقطة انطلاق في عملية الاختبار المؤتمت، ولكن بعض الموضوعات المهمة كانت خارج نطاقنا سلسلة مقالاتنا، لهذا نوصيك أن:

  1. تستكشف اختبار الوحدة Unit test كي تختبر كل وحدة برمجية على حدى. يمكن استخدام Mocha و Chai لهذا الغرض أيضًا.

  2. تلق نظرة على أدوات تغطية التعليمات البرمجية، والتي تساعد في تحديد الثغرات في مجموعة الاختبارات من خلال عرض أسطر الأوامر التي لا تُنفَّذ أثناء الاختبار. تُمكّنك هذه اﻷدوات من استكمال الاختبارات في تطبيقنا، بما يناسبك، لكنها قد لا تكشف عن كل الحالات التي تسبب المشاكل، مثل الحالة التي تدرس إمكانية تعديل المستخدم لسماحياته من خلال طلب PATCH إلى الوجهة users /:userId/.

  3. تجرب أساليب أخرى للاختبارات المؤتمتة. لقد استخدمنا مبدأ التطوير القائم على السلوك (BDD) من خلال الدالة ()expect من CHAI، لكن المكتبة تدعم أيضًا الدوال ()should و ()assert. ومن الجيد أيضًا تعلم مكتبات اختبار أخرى، مثل JEST.

بصرف النظر عن هذه الموضوعات، فإن الواجهة البرمجية REST التي بنيناها اعتمادًا على Node.js/TypeScript قابلة للتوسع. فقد ترغب في في تنفيذ المزيد من البرامج الوسيطة لفرض منطق عمل مشترك لاستغلال مورد المستخدمين القياسي. لن نتعمق في ذلك هنا، لكنني سنكون سعداء بتقديم التوجيه والنصائح من خلال نقاش الصفحة.

للاطلاع على المشروع بصيغته النهائية راجع المستودع المخصص على جيتهب أو حمله من هنا مباشرة.rest-series.rar

ترجمة -وبتصرف للقسم الثاني من مقال Building a Node.js TypeScript REST API, Part 3 MongoDB, Authentication, and Automated Tests

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...