From e66a78659e22b10eec3e0572f977c53e4beef955 Mon Sep 17 00:00:00 2001 From: Andrey Potyomkin Date: Thu, 23 May 2024 17:16:14 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=94=D0=BE=D1=81=D1=82=D1=83=D0=BF?= =?UTF-8?q?=D1=8B.=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=BA=D0=B0=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8E=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/permissions/2fa.md | 2 +- docs/permissions/_category_.json | 2 +- docs/permissions/example.md | 2 +- docs/permissions/featureToggle.md | 2 +- docs/permissions/testing.md | 417 ++++++++++++++++++++++++++++++ 5 files changed, 421 insertions(+), 4 deletions(-) create mode 100644 docs/permissions/testing.md diff --git a/docs/permissions/2fa.md b/docs/permissions/2fa.md index 7ecd188..4501226 100644 --- a/docs/permissions/2fa.md +++ b/docs/permissions/2fa.md @@ -1,5 +1,5 @@ --- -sidebar_position: 10 +sidebar_position: 11 --- # 2FA (Two-factor Auth) diff --git a/docs/permissions/_category_.json b/docs/permissions/_category_.json index 85fba92..3a13012 100644 --- a/docs/permissions/_category_.json +++ b/docs/permissions/_category_.json @@ -1,5 +1,5 @@ { - "label": "Permissions. Паттерн реализации доступов на клиенте", + "label": "Permissions. Паттерн доступов на клиенте", "position": 1, "link": { "type": "generated-index", diff --git a/docs/permissions/example.md b/docs/permissions/example.md index 8027b86..71898ce 100644 --- a/docs/permissions/example.md +++ b/docs/permissions/example.md @@ -1,5 +1,5 @@ --- -sidebar_position: 11 +sidebar_position: 10 --- # Пример реализации паттерна diff --git a/docs/permissions/featureToggle.md b/docs/permissions/featureToggle.md index 45a90ef..ff2715e 100644 --- a/docs/permissions/featureToggle.md +++ b/docs/permissions/featureToggle.md @@ -1,5 +1,5 @@ --- -sidebar_position: 9 +sidebar_position: 12 --- # Feature Toggle и Permissions diff --git a/docs/permissions/testing.md b/docs/permissions/testing.md new file mode 100644 index 0000000..bd9250b --- /dev/null +++ b/docs/permissions/testing.md @@ -0,0 +1,417 @@ +--- +sidebar_position: 9 +--- + +# Принципы тестирования доступов + +Функционал доступов обязательно должен быть покрыт тестами. + +## Алгоритм покрытия Policy тестами + +Пример policy: +```ts +export class BooksPolicyStore { + private readonly policy: PermissionsPolicy; + + constructor( + policyManager: PolicyManagerStore, + private readonly billingRepo: BillingRepository, + private readonly userRepo: UserRepository, + ) { + makeAutoObservable(this, {}, { autoBind: true }); + + this.policy = policyManager.createPolicy({ + name: 'books', + prepareData: async () => { + await Promise.all([ + this.userRepo.getRolesQuery().async(), + this.billingRepo.getBillingInfoQuery().async(), + ]); + }, + }); + } + + /** + * Возможность добавить на полку книгу + */ + public get addingToShelf() { + return this.policy.createPermission((allow, deny) => { + if (this.userRepo.getRolesQuery().data?.isAdmin) { + return allow(); + } + + const billingInfo = this.billingRepo.getBillingInfoQuery()?.data; + + if (!billingInfo?.paid) { + return deny(PermissionDenialReason.NoPayAccount); + } + + if ( + billingInfo.info.shelf.currentCount >= + billingInfo.info.shelf.allowedCount + ) { + return deny(PermissionDenialReason.ExceedShelfCount); + } + + allow(); + }); + } +} +``` + +На каждый permission, определенный в policy, необходимо писать тесты. + +#### Для каждого permission необходимо создавать отдельный describe + +```ts +describe('AdministrationPolicyStore', () => { + describe('Добавление книги на полку', () => {}); +}); +``` + +#### Для каждого permission необходимо обработать положительные и отрицательные кейсы + +Формирование кейсов происходит в соответствии с вызовом allow и deny в коде: + +```ts +public get addingToShelf() { + return this.policy.createPermission((allow, deny) => { + // Тест-кейс: Доступно администратору + if (this.userRepo.getRolesQuery().data?.isAdmin) { + return allow(); + } + + const billingInfo = this.billingRepo.getBillingInfoQuery()?.data; + + // Тест-кейс: Недоступно, если аккаунт не оплачен + if (!billingInfo?.paid) { + return deny(PermissionDenialReason.NoPayAccount); + } + + // Тест-кейс: Недоступно, если превышено количество добавлений + if ( + billingInfo.info.shelf.currentCount >= + billingInfo.info.shelf.allowedCount + ) { + return deny(PermissionDenialReason.ExceedShelfCount); + } + + // Тест-кейс: Доступно, если аккаунт оплачен и не превышено максимальное количество книг на полке + allow(); + }); +} +``` + +Реализуемые тест-кейсы: +```ts +describe('BooksPolicyStore', () => { + describe('Добавление книги на полку', () => { + it('Доступно администратору', async () => { + const { sut } = await setup({ isAdmin: true }); + + expect(sut.addingToShelf.isAllowed).toBeTruthy(); + }); + it('Недоступно, если аккаунт не оплачен', async () => {}); + it('Недоступно, если превышено количество добавлений', async () => {}); + it('Недоступно, если достигнуто максимальное количество добавлений', async () => {}); + it('Доступно, если аккаунт оплачен и не превышено максимальное количество книг на полке', async () => {}); + }); +}); +``` + +#### Перед началом выполнения теста необходимо всегда вызывать prepareData + +`PolicyManagerStore` поддерживает асинхронный вызов prepareData - `prepareDataAsync`. + +```ts +describe('BooksPolicyStore', () => { + const setup = async ({ + isAdmin, + billingInfo, + }: { + isAdmin: boolean; + billingInfo?: Partial; + }) => { + const policyManager = createPolicyManagerStore(); + const cacheService = createCacheService(); + + const userRepoMock = mock({ + getRolesQuery: () => + cacheService.createQuery(['roles'], async () => ({ + isAdmin, + })), + }); + const billingRepoMock = mock({ + getBillingInfoQuery: () => + cacheService.createQuery(['billing'], async () => + billingRepositoryFaker.makeBillingInfo(billingInfo), + ), + }); + + const sut = new BooksPolicyStore( + policyManager, + billingRepoMock, + userRepoMock, + ); + + await policyManager.prepareDataAsync(); + + return { sut }; + }; + + describe('Добавление книги на полку', () => { + it('Доступно администратору', async () => { + const { sut } = await setup({ isAdmin: true }); + + expect(sut.addingToShelf.isAllowed).toBeTruthy(); + }); + }); +}); +``` + +Если не вызвать prepareData, то все доступы будут недоступны. + +#### При тестировании отказа в доступе, необходимо проверять reason + +Тест-кейс `Недоступно, если аккаунт не оплачен` должен считаться пройденным только если reason соответствует `PermissionDenialReason.NoPayAccount`: +```ts +it('Недоступно, если аккаунт не оплачен', async () => { + const { sut } = await setup({ + isAdmin: false, + billingInfo: { paid: false }, + }); + + expect(sut.addingToShelf.isAllowed).toBeFalsy(); + + expect(sut.addingToShelf.reason).toBe( + PermissionDenialReason.NoPayAccount, + ); +}); +``` + +#### Финальный вызов allow или deny должен обрабатываться одним тест-кейсом + +```ts +public get addingToShelf() { + return this.policy.createPermission((allow, deny) => { + if (this.userRepo.getRolesQuery().data?.isAdmin) { + return allow(); + } + + const billingInfo = this.billingRepo.getBillingInfoQuery()?.data; + + if (!billingInfo?.paid) { + return deny(PermissionDenialReason.NoPayAccount); + } + + if ( + billingInfo.info.shelf.currentCount >= + billingInfo.info.shelf.allowedCount + ) { + return deny(PermissionDenialReason.ExceedShelfCount); + } + + // Этот allow будет иметь один тест-кейс + allow(); + }); +} +``` + +Финальный вызов allow или deny должен аккумулировать условия, которые не описаны в коде: +```ts +it('Доступно, если аккаунт оплачен и не превышено максимальное количество книг на полке', async () => { + const { sut } = await setup({ + isAdmin: false, + billingInfo: { + paid: true, + info: billingRepositoryFaker.makeBillingDetails({ + shelf: { currentCount: 1, allowedCount: 2 }, + }), + }, + }); + + expect(sut.addingToShelf.isAllowed).toBeTruthy(); +}); +``` + +**Мотивация** + +Позволяет избежать роста количества тест-кейсов. + +## Тестирование Rules + +При тестировании rules необходимо: +- Покрыть тестами положительные и отрицательные сценарии. Допустима группировка +- При тестировании отказа в доступе проверять reason +- Последний вызов allow или deny покрывать один тест-кейсом + +```ts +export const calcAcceptableAge = ( + acceptableAge?: number, + userBirthday?: string, +) => + createRule((allow, deny) => { + if (!acceptableAge) { + return deny(PermissionDenialReason.MissingData); + } + + if (!userBirthday) { + return deny(PermissionDenialReason.MissingUserAge); + } + + if ( + Math.abs(getDateYearDiff(new Date(userBirthday), new Date())) < + acceptableAge + ) { + return deny(PermissionDenialReason.NotForYourAge); + } + + allow(); + }); +``` + +```ts +describe('calcAcceptableAge', () => { + describe('Доступа нет', () => { + it('Если нет данных о доступном возрасте', () => { + const permission = calcAcceptableAge(); + + expect(permission.isAllowed).toBeFalsy(); + expect(permission.reason).toBe(PermissionDenialReason.MissingData); + }); + + it('Если у пользователя не заполнена дата рождения', () => {}); + it('Если возраст пользователя не соответствует допустимому', () => {}); + }); + + it('Доступ открыт, если есть доступный возраст + день рождения пользователя и возраст соответствует допустимому', () => {}); +}); +``` + +## Тестирование UIStore, использующего permissions + +**Пример**: + +Реализованный `UIStore` использует `permissions.books.addingToShelf`. +Логика формирования `addingToShelf` уже протестирована в permissions module, поэтому в `UIStore` необходимо протестировать только реакцию на разрешение и отказ в доступе: + +```ts +export class UIStore { + public isOpenAccountPayment = false; + + constructor( + private readonly permissions: PermissionsStore, + private readonly notifyService: Notify, + ) { + makeAutoObservable(this); + } + + public addToShelf = (bookId: string) => { + // Тест-кейс: Показывает информационное уведомление, если книга была успешно добавлена + if (this.permissions.books.addingToShelf.isAllowed) { + this.notifyService.info(`Книга ${bookId} добавлена на полку`); + + return; + } + + // Тест-кейс: Открывает модалку оплаты, если было отказано в доступе с соответствующей причиной + if ( + this.permissions.books.addingToShelf.hasReason( + PermissionDenialReason.NoPayAccount, + ) + ) { + this.openPaymentAccount(); + + return; + } + + // Тест-кейс: Показывает уведомление с ошибкой, если было превышено максимальное количество прочтений + if ( + this.permissions.books.addingToShelf.hasReason( + PermissionDenialReason.ExceedReadingCount, + ) + ) { + this.notifyService.error( + 'Достигнуто максимальное количество книг на полке', + ); + + return; + } + + // Тест-кейс: Показывает уведомление с ошибкой, если было произошла непредвиденная ошибка при вычислении доступа + this.notifyService.error( + 'Добавить книгу на полку нельзя. Попробуйте перезагрузить страницу', + ); + }; + + public openPaymentAccount = () => { + this.isOpenAccountPayment = true; + }; + + public closePaymentAccount = () => { + this.isOpenAccountPayment = false; + }; +} +``` + +Реализуемые тест-кейсы: +```ts +describe('GoodsListStore', () => { + describe('Добавление книги на полку', () => { + it('Показывает информационное уведомление, если книга была успешно добавлена', () => {}); + it('Открывает модалку оплаты, если было отказано в доступе с соответствующей причиной', () => {}); + it('Показывает уведомление с ошибкой, если было превышено максимальное количество прочтений', () => {}); + it('Показывает уведомление с ошибкой, если было произошла непредвиденная ошибка при вычислении доступа', () => {}); + }); +}); +``` + +### Мок permissions + +Для подмены permissions необходимо использовать `mockDeep` из библиотеки `vitest-mock-extended` и `createDenialPermission` из `@astral/permissions`: +```ts +import { mockDeep } from 'vitest-mock-extended'; +import { + createAllowedPermission, + createDenialPermission +} from '@astral/permissions'; + +describe('GoodsListStore', () => { + describe('Добавление книги на полку', () => { + const setup = (permissionsStoreMock: PermissionsStore) => { + const notifyMock = mock(); + const sut = new UIStore(permissionsStoreMock, notifyMock); + + sut.addToShelf('id'); + + return { notifyMock, sut }; + }; + + it('Показывает информационное уведомление, если книга была успешно добавлена', () => { + // permissionsStoreMock делает addingToShelf доступным + const permissionsStoreMock = mockDeep({ + books: { + addingToShelf: createAllowedPermission(), + }, + }); + const { notifyMock } = setup(permissionsStoreMock); + + expect(notifyMock.info).toBeCalledWith('Книга id добавлена на полку'); + }); + + it('Открывает модалку оплаты, если было отказано в доступе с соответствующей причиной', () => { + // permissionsStoreMock делает addingToShelf недоступным с причиной NoPayAccount + const permissionsStoreMock = mockDeep({ + books: { + addingToShelf: createDenialPermission( + PermissionDenialReason.NoPayAccount, + ), + }, + }); + const { sut } = setup(permissionsStoreMock); + + expect(sut.isOpenAccountPayment).toBeTruthy(); + }); + }); +}); +```