diff --git a/src/assets/assets.controller.spec.ts b/src/assets/assets.controller.spec.ts index 3865222c..46d7d3f4 100644 --- a/src/assets/assets.controller.spec.ts +++ b/src/assets/assets.controller.spec.ts @@ -5,9 +5,11 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { BigNumber } from '@polymeshassociation/polymesh-sdk'; import { + CustomPermissionGroup, Identity, KnownAssetType, SecurityIdentifierType, + TxGroup, } from '@polymeshassociation/polymesh-sdk/types'; import { MAX_CONTENT_HASH_LENGTH } from '~/assets/assets.consts'; @@ -468,4 +470,19 @@ describe('AssetsController', () => { expect(mockAssetsService.unlinkTickerFromAsset).toHaveBeenCalledWith(assetId, { signer }); }); }); + + describe('createGroup', () => { + it('should call the service and return the results', async () => { + const mockGroup = createMock({ id: 'someId' }); + + mockAssetsService.createPermissionGroup.mockResolvedValue({ ...txResult, result: mockGroup }); + + const result = await controller.createGroup( + { asset: assetId }, + { signer, transactionGroups: [TxGroup.Distribution] } + ); + + expect(result).toEqual({ ...processedTxResult, id: mockGroup.id }); + }); + }); }); diff --git a/src/assets/assets.controller.ts b/src/assets/assets.controller.ts index de72c1a6..5bf51375 100644 --- a/src/assets/assets.controller.ts +++ b/src/assets/assets.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, HttpStatus, Param, Post, Query } from '@nestjs/common'; import { ApiBadRequestResponse, ApiGoneResponse, @@ -10,13 +10,14 @@ import { ApiTags, ApiUnprocessableEntityResponse, } from '@nestjs/swagger'; -import { Asset } from '@polymeshassociation/polymesh-sdk/types'; +import { Asset, CustomPermissionGroup } from '@polymeshassociation/polymesh-sdk/types'; import { AssetsService } from '~/assets/assets.service'; import { createAssetDetailsModel } from '~/assets/assets.util'; import { AssetParamsDto } from '~/assets/dto/asset-params.dto'; import { ControllerTransferDto } from '~/assets/dto/controller-transfer.dto'; import { CreateAssetDto } from '~/assets/dto/create-asset.dto'; +import { CreatePermissionGroupDto } from '~/assets/dto/create-permission-group.dto'; import { IssueDto } from '~/assets/dto/issue.dto'; import { LinkTickerDto } from '~/assets/dto/link-ticker.dto'; import { RedeemTokensDto } from '~/assets/dto/redeem-tokens.dto'; @@ -26,11 +27,16 @@ import { AgentOperationModel } from '~/assets/models/agent-operation.model'; import { AssetDetailsModel } from '~/assets/models/asset-details.model'; import { AssetDocumentModel } from '~/assets/models/asset-document.model'; import { CreatedAssetModel } from '~/assets/models/created-asset.model'; +import { CreatedCustomPermissionGroupModel } from '~/assets/models/created-custom-permission-group.model'; import { IdentityBalanceModel } from '~/assets/models/identity-balance.model'; import { RequiredMediatorsModel } from '~/assets/models/required-mediators.model'; import { authorizationRequestResolver } from '~/authorizations/authorizations.util'; import { CreatedAuthorizationRequestModel } from '~/authorizations/models/created-authorization-request.model'; -import { ApiArrayResponse, ApiTransactionResponse } from '~/common/decorators/'; +import { + ApiArrayResponse, + ApiTransactionFailedResponse, + ApiTransactionResponse, +} from '~/common/decorators/'; import { PaginatedParamsDto } from '~/common/dto/paginated-params.dto'; import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; import { TransferOwnershipDto } from '~/common/dto/transfer-ownership.dto'; @@ -627,4 +633,42 @@ export class AssetsController { const result = await this.assetsService.unlinkTickerFromAsset(asset, params); return handleServiceResult(result); } + + @ApiOperation({ + summary: 'Create a permission group', + description: 'This endpoint allows for the creation of a permission group for an asset', + }) + @ApiTransactionResponse({ + description: 'Details about the transaction', + type: TransactionQueueModel, + }) + @ApiNotFoundResponse({ + description: 'The Asset does not exist', + }) + @ApiTransactionFailedResponse({ + [HttpStatus.BAD_REQUEST]: ['There already exists a group with the exact same permissions'], + [HttpStatus.UNAUTHORIZED]: [ + 'The signing identity does not have the required permissions to create a permission group', + ], + }) + @Post(':asset/create-permission-group') + public async createGroup( + @Param() { asset }: AssetParamsDto, + @Body() params: CreatePermissionGroupDto + ): Promise { + const result = await this.assetsService.createPermissionGroup(asset, params); + + const resolver: TransactionResolver = ({ + result: group, + transactions, + details, + }) => + new CreatedCustomPermissionGroupModel({ + id: group.id, + transactions, + details, + }); + + return handleServiceResult(result, resolver); + } } diff --git a/src/assets/assets.service.spec.ts b/src/assets/assets.service.spec.ts index 4c127370..8bc219ae 100644 --- a/src/assets/assets.service.spec.ts +++ b/src/assets/assets.service.spec.ts @@ -1,15 +1,24 @@ /* eslint-disable import/first */ const mockIsPolymeshTransaction = jest.fn(); +import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { BigNumber } from '@polymeshassociation/polymesh-sdk'; -import { AffirmationStatus, KnownAssetType, TxTags } from '@polymeshassociation/polymesh-sdk/types'; +import { + AffirmationStatus, + CustomPermissionGroup, + KnownAssetType, + PermissionType, + TxGroup, + TxTags, +} from '@polymeshassociation/polymesh-sdk/types'; import { when } from 'jest-when'; import { MAX_CONTENT_HASH_LENGTH } from '~/assets/assets.consts'; import { AssetsService } from '~/assets/assets.service'; import { AssetDocumentDto } from '~/assets/dto/asset-document.dto'; import { AppNotFoundError } from '~/common/errors'; +import { TransactionPermissionsDto } from '~/identities/dto/transaction-permissions.dto'; import { POLYMESH_API } from '~/polymesh/polymesh.consts'; import { PolymeshModule } from '~/polymesh/polymesh.module'; import { PolymeshService } from '~/polymesh/polymesh.service'; @@ -778,4 +787,80 @@ describe('AssetsService', () => { ); }); }); + + describe('createPermissionGroup', () => { + describe('createPermissionGroup', () => { + let findSpy: jest.SpyInstance; + let mockAsset: MockAsset; + let mockPermissionGroup: CustomPermissionGroup; + let mockTransaction: MockTransaction; + + beforeEach(() => { + findSpy = jest.spyOn(service, 'findOne'); + mockAsset = new MockAsset(); + mockPermissionGroup = createMock(); + const transaction = { + blockHash: '0x1', + txHash: '0x2', + blockNumber: new BigNumber(1), + tag: TxTags.externalAgents.CreateGroup, + }; + mockTransaction = new MockTransaction(transaction); + mockTransactionsService.submit.mockResolvedValue({ + transactions: [mockTransaction], + result: mockPermissionGroup, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + findSpy.mockResolvedValue(mockAsset as any); + }); + + it('should create a permission group with the given transaction group permissions', async () => { + const result = await service.createPermissionGroup(assetId, { + signer, + transactionGroups: [TxGroup.Distribution], + }); + + expect(result).toEqual({ + result: mockPermissionGroup, + transactions: [mockTransaction], + }); + + expect(mockTransactionsService.submit).toHaveBeenCalledWith( + mockAsset.permissions.createGroup, + expect.objectContaining({ + permissions: { + transactionGroups: [TxGroup.Distribution], + }, + }), + expect.objectContaining({ signer }) + ); + }); + + it('should create a permission group with the given transaction permissions', async () => { + const transactions = new TransactionPermissionsDto({ + values: [TxTags.asset.RegisterUniqueTicker], + type: PermissionType.Include, + exceptions: [TxTags.asset.AcceptTickerTransfer], + }); + + const result = await service.createPermissionGroup(assetId, { signer, transactions }); + + expect(result).toEqual({ + result: mockPermissionGroup, + transactions: [mockTransaction], + }); + + expect(mockTransactionsService.submit).toHaveBeenCalledWith( + mockAsset.permissions.createGroup, + expect.objectContaining({ + permissions: { + transactions, + }, + }), + expect.objectContaining({ signer }) + ); + }); + }); + }); }); diff --git a/src/assets/assets.service.ts b/src/assets/assets.service.ts index 231a5692..612ddc34 100644 --- a/src/assets/assets.service.ts +++ b/src/assets/assets.service.ts @@ -4,16 +4,20 @@ import { Asset, AssetDocument, AuthorizationRequest, + CreateGroupParams, + CustomPermissionGroup, FungibleAsset, HistoricAgentOperation, Identity, IdentityBalance, NftCollection, ResultSet, + TransactionPermissions, } from '@polymeshassociation/polymesh-sdk/types'; import { ControllerTransferDto } from '~/assets/dto/controller-transfer.dto'; import { CreateAssetDto } from '~/assets/dto/create-asset.dto'; +import { CreatePermissionGroupDto } from '~/assets/dto/create-permission-group.dto'; import { IssueDto } from '~/assets/dto/issue.dto'; import { LinkTickerDto } from '~/assets/dto/link-ticker.dto'; import { RedeemTokensDto } from '~/assets/dto/redeem-tokens.dto'; @@ -252,4 +256,39 @@ export class AssetsService { const { unlinkTicker } = await this.findOne(assetInput); return this.transactionsService.submit(unlinkTicker, {}, options); } + + public async createPermissionGroup( + assetId: string, + params: CreatePermissionGroupDto + ): ServiceReturn { + const { options, args } = extractTxOptions(params); + + const { + permissions: { createGroup }, + } = await this.findOne(assetId); + + const toCreateGroupParams = ( + input: CreatePermissionGroupDto + ): CreateGroupParams['permissions'] => { + const { transactions, transactionGroups } = input; + + let permissions = {} as CreateGroupParams['permissions']; + + if (transactions) { + permissions = { + transactions: transactions.toTransactionPermissions() as TransactionPermissions, + }; + } else if (transactionGroups) { + permissions = { transactionGroups }; + } + + return permissions; + }; + + return this.transactionsService.submit( + createGroup, + { permissions: toCreateGroupParams(args) }, + options + ); + } } diff --git a/src/assets/dto/create-permission-group.dto.ts b/src/assets/dto/create-permission-group.dto.ts new file mode 100644 index 00000000..6f5a57a2 --- /dev/null +++ b/src/assets/dto/create-permission-group.dto.ts @@ -0,0 +1,39 @@ +/* istanbul ignore file */ + +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { TxGroup } from '@polymeshassociation/polymesh-sdk/types'; +import { Type } from 'class-transformer'; +import { IsArray, IsEnum, ValidateNested } from 'class-validator'; + +import { IncompatibleWith } from '~/common/decorators'; +import { TransactionBaseDto } from '~/common/dto/transaction-base-dto'; +import { TransactionPermissionsDto } from '~/identities/dto/transaction-permissions.dto'; + +export class CreatePermissionGroupDto extends TransactionBaseDto { + @ApiPropertyOptional({ + description: + 'Transactions that the `external agent` has permission to execute. This value should not be passed along with the `transactionGroups`.', + type: TransactionPermissionsDto, + nullable: true, + }) + @ValidateNested() + @IncompatibleWith(['transactionGroups'], { + message: 'Cannot specify both transactions and transactionGroups', + }) + @Type(() => TransactionPermissionsDto) + readonly transactions?: TransactionPermissionsDto; + + @ApiPropertyOptional({ + description: + 'Transaction Groups that `external agent` has permission to execute. This value should not be passed along with the `transactions`.', + isArray: true, + enum: TxGroup, + example: [TxGroup.Distribution], + }) + @IncompatibleWith(['transactions'], { + message: 'Cannot specify both transactions and transactionGroups', + }) + @IsArray() + @IsEnum(TxGroup, { each: true }) + readonly transactionGroups?: TxGroup[]; +} diff --git a/src/assets/models/created-custom-permission-group.model.ts b/src/assets/models/created-custom-permission-group.model.ts new file mode 100644 index 00000000..356256ad --- /dev/null +++ b/src/assets/models/created-custom-permission-group.model.ts @@ -0,0 +1,21 @@ +/* istanbul ignore file */ + +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { BigNumber } from 'bignumber.js'; + +import { TransactionQueueModel } from '~/common/models/transaction-queue.model'; + +export class CreatedCustomPermissionGroupModel extends TransactionQueueModel { + @ApiPropertyOptional({ + description: 'The newly created ID', + example: '1', + }) + readonly id: BigNumber; + + constructor(model: CreatedCustomPermissionGroupModel) { + const { transactions, details, ...rest } = model; + super({ transactions, details }); + + Object.assign(this, rest); + } +} diff --git a/src/common/decorators/swagger.ts b/src/common/decorators/swagger.ts index a2fcfab6..070a68c0 100644 --- a/src/common/decorators/swagger.ts +++ b/src/common/decorators/swagger.ts @@ -8,6 +8,7 @@ import { ApiOkResponse, ApiProperty, ApiPropertyOptions, + ApiUnauthorizedResponse, ApiUnprocessableEntityResponse, getSchemaPath, OmitType, @@ -178,7 +179,8 @@ export const ApiPropertyOneOf = ({ type SupportedHttpStatusCodes = | HttpStatus.NOT_FOUND | HttpStatus.BAD_REQUEST - | HttpStatus.UNPROCESSABLE_ENTITY; + | HttpStatus.UNPROCESSABLE_ENTITY + | HttpStatus.UNAUTHORIZED; /** * A helper that combines responses for SDK Errors like `BadRequestException`, `NotFoundException`, `UnprocessableEntityException` @@ -203,6 +205,9 @@ export function ApiTransactionFailedResponse( case HttpStatus.BAD_REQUEST: decorators.push(ApiBadRequestResponse({ description })); break; + case HttpStatus.UNAUTHORIZED: + decorators.push(ApiUnauthorizedResponse({ description })); + break; case HttpStatus.UNPROCESSABLE_ENTITY: decorators.push(ApiUnprocessableEntityResponse({ description })); break; diff --git a/src/common/decorators/validation.ts b/src/common/decorators/validation.ts index 15bbfa94..a6255bff 100644 --- a/src/common/decorators/validation.ts +++ b/src/common/decorators/validation.ts @@ -13,8 +13,11 @@ import { MaxLength, maxLength, registerDecorator, + ValidateIf, ValidationArguments, ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, } from 'class-validator'; import { ASSET_ID_LENGTH, MAX_TICKER_LENGTH } from '~/assets/assets.consts'; @@ -170,3 +173,79 @@ export function IsTxTagOrModuleName(validationOptions?: ValidationOptions) { }); }; } + +@ValidatorConstraint({ async: false }) +class IsNotSiblingOfConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments) { + if (value !== undefined && value !== null) { + return this.getFailedConstraints(args).length === 0; + } + return true; + } + + defaultMessage(args: ValidationArguments) { + return `${ + args.property + } cannot exist alongside the following defined properties: ${this.getFailedConstraints( + args + ).join(', ')}`; + } + + getFailedConstraints(args: ValidationArguments) { + return args.constraints.filter(prop => { + const value = args.object[prop as keyof typeof args.object]; + return value !== undefined && value !== null; + }); + } +} + +function IsNotSiblingOf(props: string[], validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: props, + validator: IsNotSiblingOfConstraint, + }); + }; +} + +function incompatibleSiblingsNotPresent(incompatibleSiblings: string[]) { + return function (o: object, v: unknown) { + return Boolean( + v !== undefined || // Validate if prop has value + incompatibleSiblings.every(prop => o[prop as keyof typeof o] === undefined) // Validate if all incompatible siblings are not defined + ); + }; +} + +/** + * Validates that a property cannot be used together with certain other properties. + * If any of the incompatible sibling properties are defined, this property must be undefined. + * + * @param incompatibleSiblings - Array of property names that cannot be used together with the decorated property + * @param validationOptions - Optional validation options to customize the validation behavior and error messages + * + * @example + * class Example { + * @IncompatibleWith(['bar']) + * foo: string; + * + * bar: string; + * } + */ +export function IncompatibleWith( + incompatibleSiblings: string[], + validationOptions?: ValidationOptions +) { + const notSibling = IsNotSiblingOf(incompatibleSiblings, { + message: `Property cannot be used together with: ${incompatibleSiblings.join(', ')}`, + ...validationOptions, + }); + const validateIf = ValidateIf(incompatibleSiblingsNotPresent(incompatibleSiblings)); + return function (target: object, key: string) { + notSibling(target, key); + validateIf(target, key); + }; +} diff --git a/src/test-utils/mocks.ts b/src/test-utils/mocks.ts index 82045a69..c41f6398 100644 --- a/src/test-utils/mocks.ts +++ b/src/test-utils/mocks.ts @@ -255,6 +255,10 @@ export class MockAsset { getOne: jest.fn(), }; + public permissions = { + createGroup: jest.fn(), + }; + public toHuman = jest.fn().mockImplementation(() => this.ticker); } diff --git a/src/test-utils/service-mocks.ts b/src/test-utils/service-mocks.ts index 8facf777..c91ebab5 100644 --- a/src/test-utils/service-mocks.ts +++ b/src/test-utils/service-mocks.ts @@ -45,6 +45,7 @@ export class MockAssetService { removePreApproval = jest.fn(); linkTickerToAsset = jest.fn(); unlinkTickerFromAsset = jest.fn(); + createPermissionGroup = jest.fn(); } export class MockTransactionsService {