diff --git a/src/domain/accounts/address-books/address-books.repository.interface.ts b/src/domain/accounts/address-books/address-books.repository.interface.ts index 840ab97cdf..7e9c30aca3 100644 --- a/src/domain/accounts/address-books/address-books.repository.interface.ts +++ b/src/domain/accounts/address-books/address-books.repository.interface.ts @@ -24,6 +24,19 @@ export interface IAddressBooksRepository { chainId: string; createAddressBookItemDto: CreateAddressBookItemDto; }): Promise; + + deleteAddressBook(args: { + authPayload: AuthPayload; + address: `0x${string}`; + chainId: string; + }): Promise; + + deleteAddressBookItem(args: { + authPayload: AuthPayload; + address: `0x${string}`; + chainId: string; + addressBookItemId: number; + }): Promise; } @Module({ diff --git a/src/domain/accounts/address-books/address-books.repository.ts b/src/domain/accounts/address-books/address-books.repository.ts index a55369095d..265d7f05e4 100644 --- a/src/domain/accounts/address-books/address-books.repository.ts +++ b/src/domain/accounts/address-books/address-books.repository.ts @@ -86,6 +86,56 @@ export class AddressBooksRepository implements IAddressBooksRepository { }); } + async deleteAddressBook(args: { + authPayload: AuthPayload; + address: `0x${string}`; + chainId: string; + }): Promise { + if (!args.authPayload.isForSigner(args.address)) { + throw new UnauthorizedException(); + } + await this.checkAddressBooksIsEnabled({ + authPayload: args.authPayload, + address: args.address, + }); + const account = await this.accountsRepository.getAccount({ + authPayload: args.authPayload, + address: args.address, + }); + const addressBook = await this.datasource.getAddressBook({ + account, + chainId: args.chainId, + }); + return this.datasource.deleteAddressBook(addressBook); + } + + async deleteAddressBookItem(args: { + authPayload: AuthPayload; + address: `0x${string}`; + chainId: string; + addressBookItemId: number; + }): Promise { + if (!args.authPayload.isForSigner(args.address)) { + throw new UnauthorizedException(); + } + await this.checkAddressBooksIsEnabled({ + authPayload: args.authPayload, + address: args.address, + }); + const account = await this.accountsRepository.getAccount({ + authPayload: args.authPayload, + address: args.address, + }); + const addressBook = await this.datasource.getAddressBook({ + account, + chainId: args.chainId, + }); + return this.datasource.deleteAddressBookItem({ + addressBook, + id: args.addressBookItemId, + }); + } + // TODO: Extract this functionality in AccountsRepository['checkIsEnabled(DataType, Account)'] private async checkAddressBooksIsEnabled(args: { authPayload: AuthPayload; diff --git a/src/routes/accounts/address-books/address-books.controller.spec.ts b/src/routes/accounts/address-books/address-books.controller.spec.ts index 19c066eafe..7e2d651138 100644 --- a/src/routes/accounts/address-books/address-books.controller.spec.ts +++ b/src/routes/accounts/address-books/address-books.controller.spec.ts @@ -44,6 +44,8 @@ import { accountDataTypeBuilder } from '@/domain/accounts/entities/__tests__/acc import { AccountDataTypeNames } from '@/domain/accounts/entities/account-data-type.entity'; import { accountDataSettingBuilder } from '@/domain/accounts/entities/__tests__/account-data-setting.builder'; import { createAddressBookItemDtoBuilder } from '@/domain/accounts/address-books/entities/__tests__/create-address-book-item.dto.builder'; +import { AddressBookNotFoundError } from '@/domain/accounts/address-books/errors/address-book-not-found.error'; +import { DB_MAX_SAFE_INTEGER } from '@/domain/common/constants'; describe('AddressBooksController', () => { let app: INestApplication; @@ -104,6 +106,8 @@ describe('AddressBooksController', () => { const protectedEndpoints = [ AddressBooksController.prototype.getAddressBook, AddressBooksController.prototype.createAddressBookItem, + AddressBooksController.prototype.deleteAddressBook, + AddressBooksController.prototype.deleteAddressBookItem, ]; protectedEndpoints.forEach((fn) => checkGuardIsApplied(AuthGuard, fn)); }); @@ -153,6 +157,40 @@ describe('AddressBooksController', () => { }); }); + it('should return a 404 if the AddressBook does not exist', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + const account = accountBuilder().build(); + const accountDataTypes = [ + accountDataTypeBuilder() + .with('name', AccountDataTypeNames.AddressBook) + .with('is_active', true) + .build(), + ]; + accountsRepository.getAccount.mockResolvedValue(account); + accountsRepository.getDataTypes.mockResolvedValue(accountDataTypes); + accountsRepository.getAccountDataSettings.mockResolvedValue([ + accountDataSettingBuilder() + .with('account_id', account.id) + .with('account_data_type_id', accountDataTypes[0].id) + .with('enabled', true) + .build(), + ]); + addressBooksDatasource.getAddressBook.mockImplementation(() => { + throw new AddressBookNotFoundError(); + }); + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}/address-books/${chainId}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(404); + }); + it('should fail if the authPayload does not match the URL address', async () => { const address = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); @@ -345,4 +383,343 @@ describe('AddressBooksController', () => { }); }); }); + + describe('DELETE /accounts/:address/address-books/:chainId', () => { + it('should delete an AddressBook', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + const account = accountBuilder().build(); + const accountDataTypes = [ + accountDataTypeBuilder() + .with('name', AccountDataTypeNames.AddressBook) + .with('is_active', true) + .build(), + ]; + accountsRepository.getAccount.mockResolvedValue(account); + accountsRepository.getDataTypes.mockResolvedValue(accountDataTypes); + accountsRepository.getAccountDataSettings.mockResolvedValue([ + accountDataSettingBuilder() + .with('account_id', account.id) + .with('account_data_type_id', accountDataTypes[0].id) + .with('enabled', true) + .build(), + ]); + const addressBook = addressBookBuilder().build(); + addressBooksDatasource.getAddressBook.mockResolvedValue(addressBook); + + await request(app.getHttpServer()) + .delete(`/v1/accounts/${address}/address-books/${chainId}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(200); + + expect(addressBooksDatasource.deleteAddressBook).toHaveBeenCalledWith( + addressBook, + ); + }); + + it('should return a 404 if the AddressBook does not exist', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + const account = accountBuilder().build(); + const accountDataTypes = [ + accountDataTypeBuilder() + .with('name', AccountDataTypeNames.AddressBook) + .with('is_active', true) + .build(), + ]; + accountsRepository.getAccount.mockResolvedValue(account); + accountsRepository.getDataTypes.mockResolvedValue(accountDataTypes); + accountsRepository.getAccountDataSettings.mockResolvedValue([ + accountDataSettingBuilder() + .with('account_id', account.id) + .with('account_data_type_id', accountDataTypes[0].id) + .with('enabled', true) + .build(), + ]); + addressBooksDatasource.getAddressBook.mockImplementation(() => { + throw new AddressBookNotFoundError(); + }); + + await request(app.getHttpServer()) + .delete(`/v1/accounts/${address}/address-books/${chainId}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(404); + + expect(addressBooksDatasource.deleteAddressBook).not.toHaveBeenCalled(); + }); + + it('should fail if the authPayload does not match the URL address', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chainId) + .with('signer_address', getAddress(faker.finance.ethereumAddress())) // Different address + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + await request(app.getHttpServer()) + .delete(`/v1/accounts/${address}/address-books/${chainId}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(401); + + expect(addressBooksDatasource.deleteAddressBook).not.toHaveBeenCalled(); + }); + + it('should fail if the account does not have the AddressBooks data setting enabled', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + const account = accountBuilder().build(); + const accountDataTypes = [ + accountDataTypeBuilder() + .with('name', AccountDataTypeNames.AddressBook) + .with('is_active', true) + .build(), + ]; + accountsRepository.getAccount.mockResolvedValue(account); + accountsRepository.getDataTypes.mockResolvedValue(accountDataTypes); + accountsRepository.getAccountDataSettings.mockResolvedValue([ + accountDataSettingBuilder() + .with('account_id', account.id) + .with('account_data_type_id', accountDataTypes[0].id) + .with('enabled', false) // AddressBooks setting is not enabled + .build(), + ]); + + await request(app.getHttpServer()) + .delete(`/v1/accounts/${address}/address-books/${chainId}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(410); + + expect(addressBooksDatasource.deleteAddressBook).not.toHaveBeenCalled(); + }); + + it('should not propagate a database error', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + accountsRepository.getAccount.mockRejectedValue( + new Error('Database error'), + ); + + await request(app.getHttpServer()) + .delete(`/v1/accounts/${address}/address-books/${chainId}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(500) + .expect({ + code: 500, + message: 'Internal server error', + }); + + expect(addressBooksDatasource.deleteAddressBook).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /accounts/:address/address-books/:chainId/:addressBookItemId', () => { + it('should delete an AddressBookItem', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const addressBookItemId = faker.number.int({ + min: 1, + max: DB_MAX_SAFE_INTEGER, + }); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + const account = accountBuilder().build(); + const accountDataTypes = [ + accountDataTypeBuilder() + .with('name', AccountDataTypeNames.AddressBook) + .with('is_active', true) + .build(), + ]; + accountsRepository.getAccount.mockResolvedValue(account); + accountsRepository.getDataTypes.mockResolvedValue(accountDataTypes); + accountsRepository.getAccountDataSettings.mockResolvedValue([ + accountDataSettingBuilder() + .with('account_id', account.id) + .with('account_data_type_id', accountDataTypes[0].id) + .with('enabled', true) + .build(), + ]); + const addressBook = addressBookBuilder().build(); + addressBooksDatasource.getAddressBook.mockResolvedValue(addressBook); + + await request(app.getHttpServer()) + .delete( + `/v1/accounts/${address}/address-books/${chainId}/${addressBookItemId}`, + ) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(200); + + expect(addressBooksDatasource.deleteAddressBookItem).toHaveBeenCalledWith( + { + addressBook, + id: addressBookItemId, + }, + ); + }); + + it('should return a 404 if the AddressBook does not exist', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const addressBookItemId = faker.number.int({ + min: 1, + max: DB_MAX_SAFE_INTEGER, + }); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + const account = accountBuilder().build(); + const accountDataTypes = [ + accountDataTypeBuilder() + .with('name', AccountDataTypeNames.AddressBook) + .with('is_active', true) + .build(), + ]; + accountsRepository.getAccount.mockResolvedValue(account); + accountsRepository.getDataTypes.mockResolvedValue(accountDataTypes); + accountsRepository.getAccountDataSettings.mockResolvedValue([ + accountDataSettingBuilder() + .with('account_id', account.id) + .with('account_data_type_id', accountDataTypes[0].id) + .with('enabled', true) + .build(), + ]); + addressBooksDatasource.getAddressBook.mockImplementation(() => { + throw new AddressBookNotFoundError(); + }); + + await request(app.getHttpServer()) + .delete( + `/v1/accounts/${address}/address-books/${chainId}/${addressBookItemId}`, + ) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(404); + + expect( + addressBooksDatasource.deleteAddressBookItem, + ).not.toHaveBeenCalled(); + }); + + it('should fail if the authPayload does not match the URL address', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const addressBookItemId = faker.number.int({ + min: 1, + max: DB_MAX_SAFE_INTEGER, + }); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chainId) + .with('signer_address', getAddress(faker.finance.ethereumAddress())) // Different address + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + await request(app.getHttpServer()) + .delete( + `/v1/accounts/${address}/address-books/${chainId}/${addressBookItemId}`, + ) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(401); + + expect( + addressBooksDatasource.deleteAddressBookItem, + ).not.toHaveBeenCalled(); + }); + + it('should fail if the account does not have the AddressBooks data setting enabled', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const addressBookItemId = faker.number.int({ + min: 1, + max: DB_MAX_SAFE_INTEGER, + }); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + const account = accountBuilder().build(); + const accountDataTypes = [ + accountDataTypeBuilder() + .with('name', AccountDataTypeNames.AddressBook) + .with('is_active', true) + .build(), + ]; + accountsRepository.getAccount.mockResolvedValue(account); + accountsRepository.getDataTypes.mockResolvedValue(accountDataTypes); + accountsRepository.getAccountDataSettings.mockResolvedValue([ + accountDataSettingBuilder() + .with('account_id', account.id) + .with('account_data_type_id', accountDataTypes[0].id) + .with('enabled', false) // AddressBooks setting is not enabled + .build(), + ]); + + await request(app.getHttpServer()) + .delete( + `/v1/accounts/${address}/address-books/${chainId}/${addressBookItemId}`, + ) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(410); + + expect( + addressBooksDatasource.deleteAddressBookItem, + ).not.toHaveBeenCalled(); + }); + + it('should not propagate a database error', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const addressBookItemId = faker.number.int({ + min: 1, + max: DB_MAX_SAFE_INTEGER, + }); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + accountsRepository.getAccount.mockRejectedValue( + new Error('Database error'), + ); + + await request(app.getHttpServer()) + .delete( + `/v1/accounts/${address}/address-books/${chainId}/${addressBookItemId}`, + ) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(500) + .expect({ + code: 500, + message: 'Internal server error', + }); + + expect( + addressBooksDatasource.deleteAddressBookItem, + ).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/routes/accounts/address-books/address-books.controller.ts b/src/routes/accounts/address-books/address-books.controller.ts index 4214b23d4e..df434e5d6b 100644 --- a/src/routes/accounts/address-books/address-books.controller.ts +++ b/src/routes/accounts/address-books/address-books.controller.ts @@ -11,7 +11,17 @@ import { AuthGuard } from '@/routes/auth/guards/auth.guard'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + DefaultValuePipe, + Delete, + Get, + Param, + ParseIntPipe, + Post, + UseGuards, +} from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; @ApiTags('accounts') @@ -51,4 +61,35 @@ export class AddressBooksController { createAddressBookItemDto, }); } + + @Delete(':address/address-books/:chainId') + @UseGuards(AuthGuard) + async deleteAddressBook( + @Auth() authPayload: AuthPayload, + @Param('address', new ValidationPipe(AddressSchema)) address: `0x${string}`, + @Param('chainId', new ValidationPipe(NumericStringSchema)) chainId: string, + ): Promise { + return this.service.deleteAddressBook({ + authPayload, + address, + chainId, + }); + } + + @Delete(':address/address-books/:chainId/:addressBookItemId') + @UseGuards(AuthGuard) + async deleteAddressBookItem( + @Auth() authPayload: AuthPayload, + @Param('address', new ValidationPipe(AddressSchema)) address: `0x${string}`, + @Param('chainId', new ValidationPipe(NumericStringSchema)) chainId: string, + @Param('addressBookItemId', new DefaultValuePipe(0), ParseIntPipe) + addressBookItemId: number, + ): Promise { + return this.service.deleteAddressBookItem({ + authPayload, + address, + chainId, + addressBookItemId, + }); + } } diff --git a/src/routes/accounts/address-books/address-books.service.ts b/src/routes/accounts/address-books/address-books.service.ts index e15fbfa288..a5e572ffe2 100644 --- a/src/routes/accounts/address-books/address-books.service.ts +++ b/src/routes/accounts/address-books/address-books.service.ts @@ -36,6 +36,23 @@ export class AddressBooksService { return this.mapAddressBookItem(domainAddressBookItem); } + async deleteAddressBook(args: { + authPayload: AuthPayload; + address: `0x${string}`; + chainId: string; + }): Promise { + await this.repository.deleteAddressBook(args); + } + + async deleteAddressBookItem(args: { + authPayload: AuthPayload; + address: `0x${string}`; + chainId: string; + addressBookItemId: number; + }): Promise { + await this.repository.deleteAddressBookItem(args); + } + private mapAddressBook(domainAddressBook: DomainAddressBook): AddressBook { return { id: domainAddressBook.id.toString(),