diff --git a/migrations/__tests__/00005_notifications.spec.ts b/migrations/__tests__/00005_notifications.spec.ts index 57dd827aaa..50e672ac70 100644 --- a/migrations/__tests__/00005_notifications.spec.ts +++ b/migrations/__tests__/00005_notifications.spec.ts @@ -1,7 +1,7 @@ import { TestDbFactory } from '@/__tests__/db.factory'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; import { DeviceType } from '@/domain/notifications/entities-v2/device-type.entity'; -import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; +import { UUID } from 'crypto'; import { faker } from '@faker-js/faker'; import postgres from 'postgres'; import { getAddress } from 'viem'; @@ -10,7 +10,7 @@ type PushNotificationDevicesRow = { id: number; account_id: number; device_type: 'ANDROID' | 'IOS' | 'WEB'; - device_uuid: Uuid; + device_uuid: UUID; cloud_messaging_token: string; created_at: Date; updated_at: Date; @@ -168,7 +168,7 @@ describe('Migration 00005_notifications', () => { it('should upsert the updated_at timestamp in push_notification_devices', async () => { const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); - const deviceUuid = faker.string.uuid() as Uuid; + const deviceUuid = faker.string.uuid() as UUID; const cloudMessagingToken = faker.string.alphanumeric(); const afterMigration = await migrator.test({ migration: '00005_notifications', @@ -191,7 +191,7 @@ describe('Migration 00005_notifications', () => { }, ]); - const newDeviceUuid = faker.string.uuid() as Uuid; + const newDeviceUuid = faker.string.uuid() as UUID; // Update device with new device_uuid const afterUpdate = await sql< [PushNotificationDevicesRow] @@ -216,7 +216,7 @@ describe('Migration 00005_notifications', () => { it('should only allow an ANDROID, IOS, or WEB as device_type in push_notification_devices', async () => { const deviceType = faker.lorem.word() as DeviceType; - const deviceUuid = faker.string.uuid() as Uuid; + const deviceUuid = faker.string.uuid() as UUID; const cloudMessagingToken = faker.string.alphanumeric(); await migrator.test({ migration: '00005_notifications', @@ -233,7 +233,7 @@ describe('Migration 00005_notifications', () => { it('should not allow a duplicate device_uuid in push_notification_devices', async () => { const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); - const deviceUuid = faker.string.uuid() as Uuid; + const deviceUuid = faker.string.uuid() as UUID; const cloudMessagingToken = faker.string.alphanumeric(); await migrator.test({ migration: '00005_notifications', @@ -277,7 +277,7 @@ describe('Migration 00005_notifications', () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); - const deviceUuid = faker.string.uuid() as Uuid; + const deviceUuid = faker.string.uuid() as UUID; const cloudMessagingToken = faker.string.alphanumeric(); const afterMigration = await migrator.test({ migration: '00005_notifications', @@ -317,7 +317,7 @@ describe('Migration 00005_notifications', () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); - const deviceUuid = faker.string.uuid() as Uuid; + const deviceUuid = faker.string.uuid() as UUID; const cloudMessagingToken = faker.string.alphanumeric(); const afterMigration = await migrator.test({ migration: '00005_notifications', @@ -358,7 +358,7 @@ describe('Migration 00005_notifications', () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); - const deviceUuid = faker.string.uuid() as Uuid; + const deviceUuid = faker.string.uuid() as UUID; const cloudMessagingToken = faker.string.alphanumeric(); const afterMigration = await migrator.test({ migration: '00005_notifications', @@ -398,7 +398,7 @@ describe('Migration 00005_notifications', () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); - const deviceUuid = faker.string.uuid() as Uuid; + const deviceUuid = faker.string.uuid() as UUID; const cloudMessagingToken = faker.string.alphanumeric(); const afterMigration = await migrator.test({ migration: '00005_notifications', @@ -437,7 +437,7 @@ describe('Migration 00005_notifications', () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); - const deviceUuid = faker.string.uuid() as Uuid; + const deviceUuid = faker.string.uuid() as UUID; const cloudMessagingToken = faker.string.alphanumeric(); const afterMigration = await migrator.test({ migration: '00005_notifications', @@ -505,7 +505,7 @@ describe('Migration 00005_notifications', () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); - const deviceUuid = faker.string.uuid() as Uuid; + const deviceUuid = faker.string.uuid() as UUID; const cloudMessagingToken = faker.string.alphanumeric(); const afterMigration = await migrator.test({ migration: '00005_notifications', @@ -550,7 +550,7 @@ describe('Migration 00005_notifications', () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); - const deviceUuid = faker.string.uuid() as Uuid; + const deviceUuid = faker.string.uuid() as UUID; const cloudMessagingToken = faker.string.alphanumeric(); const afterMigration = await migrator.test({ migration: '00005_notifications', @@ -596,7 +596,7 @@ describe('Migration 00005_notifications', () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); - const deviceUuid = faker.string.uuid() as Uuid; + const deviceUuid = faker.string.uuid() as UUID; const cloudMessagingToken = faker.string.alphanumeric(); const afterMigration = await migrator.test({ migration: '00005_notifications', diff --git a/src/app.module.ts b/src/app.module.ts index 123d9d7e81..9cd3f5b655 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -48,6 +48,7 @@ import { AuthModule } from '@/routes/auth/auth.module'; import { TransactionsViewControllerModule } from '@/routes/transactions/transactions-view.controller'; import { DelegatesV2Module } from '@/routes/delegates/v2/delegates.v2.module'; import { AccountsModule } from '@/routes/accounts/accounts.module'; +import { NotificationsModuleV2 } from '@/routes/accounts/notifications/notifications.module.v2'; @Module({}) export class AppModule implements NestModule { @@ -87,7 +88,7 @@ export class AppModule implements NestModule { EstimationsModule, HealthModule, ...(isPushNotificationsEnabled - ? [HooksModuleWithNotifications] + ? [HooksModuleWithNotifications, NotificationsModuleV2] : [HooksModule]), MessagesModule, NotificationsModule, diff --git a/src/datasources/accounts/notifications/notifications.datasource.spec.ts b/src/datasources/accounts/notifications/notifications.datasource.spec.ts index 37843c106c..44536b8fb3 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.spec.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.spec.ts @@ -1,12 +1,12 @@ import { TestDbFactory } from '@/__tests__/db.factory'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; -import { upsertSubscriptionsDtoBuilder } from '@/domain/notifications/entities-v2/__tests__/upsert-subscriptions.dto.entity.builder'; +import { upsertSubscriptionsDtoBuilder } from '@/routes/accounts/notifications/entities/__tests__/upsert-subscriptions.dto.entity.builder'; import { NotificationsDatasource } from '@/datasources/accounts/notifications/notifications.datasource'; import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; -import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; +import { UUID } from 'crypto'; import { ILoggingService } from '@/logging/logging.interface'; import { faker } from '@faker-js/faker'; import postgres from 'postgres'; @@ -62,12 +62,16 @@ describe('NotificationsDatasource', () => { describe('upsertSubscriptions', () => { it('should insert a subscription', async () => { + const account = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('deviceUuid', undefined) + .with('deviceUuid', null) .build(); - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); + await accountsDatasource.createAccount(account); - const actual = await target.upsertSubscriptions(upsertSubscriptionsDto); + const actual = await target.upsertSubscriptions({ + account, + upsertSubscriptionsDto, + }); expect(actual).toStrictEqual({ deviceUuid: expect.any(String) }); @@ -90,7 +94,7 @@ describe('NotificationsDatasource', () => { { id: 1, group_id: null, - address: upsertSubscriptionsDto.account, + address: account, created_at: expect.any(Date), updated_at: expect.any(Date), }, @@ -145,17 +149,23 @@ describe('NotificationsDatasource', () => { }); it('should always update the deviceType/cloudMessagingToken', async () => { + const account = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); const secondSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('account', upsertSubscriptionsDto.account) .with('deviceUuid', upsertSubscriptionsDto.deviceUuid) .build(); - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await target.upsertSubscriptions(upsertSubscriptionsDto); + await accountsDatasource.createAccount(account); + await target.upsertSubscriptions({ + account, + upsertSubscriptionsDto, + }); // Insert should not throw despite it being the same device UUID await expect( - target.upsertSubscriptions(secondSubscriptionsDto), + target.upsertSubscriptions({ + account, + upsertSubscriptionsDto: secondSubscriptionsDto, + }), ).resolves.not.toThrow(); // Device UUID should have updated await expect( @@ -173,6 +183,7 @@ describe('NotificationsDatasource', () => { }); it('should update a subscription, setting only the newly subscribed notification types', async () => { + const account = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('safes', [ { @@ -187,16 +198,22 @@ describe('NotificationsDatasource', () => { const newNotificationTypes = faker.helpers.arrayElements( Object.values(NotificationType), ); - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await target.upsertSubscriptions(upsertSubscriptionsDto); + await accountsDatasource.createAccount(account); await target.upsertSubscriptions({ - ...upsertSubscriptionsDto, - safes: [ - { - ...upsertSubscriptionsDto.safes[0], - notificationTypes: newNotificationTypes, - }, - ], + account, + upsertSubscriptionsDto, + }); + await target.upsertSubscriptions({ + account, + upsertSubscriptionsDto: { + ...upsertSubscriptionsDto, + safes: [ + { + ...upsertSubscriptionsDto.safes[0], + notificationTypes: newNotificationTypes, + }, + ], + }, }); await Promise.all([ @@ -221,15 +238,22 @@ describe('NotificationsDatasource', () => { }); it('should allow multiple subscriptions, varying by device UUID', async () => { + const account = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - const secondDeviceUuid = faker.string.uuid() as Uuid; + const secondDeviceUuid = faker.string.uuid() as UUID; const secondUpsertSubscriptionsDto = { ...upsertSubscriptionsDto, deviceUuid: secondDeviceUuid, }; - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await target.upsertSubscriptions(upsertSubscriptionsDto); - await target.upsertSubscriptions(secondUpsertSubscriptionsDto); + await accountsDatasource.createAccount(account); + await target.upsertSubscriptions({ + account, + upsertSubscriptionsDto, + }); + await target.upsertSubscriptions({ + account, + upsertSubscriptionsDto: secondUpsertSubscriptionsDto, + }); // Ensure correct database structure await Promise.all([ @@ -250,7 +274,7 @@ describe('NotificationsDatasource', () => { { id: 1, group_id: null, - address: upsertSubscriptionsDto.account, + address: account, created_at: expect.any(Date), updated_at: expect.any(Date), }, @@ -343,14 +367,18 @@ describe('NotificationsDatasource', () => { describe('getSafeSubscription', () => { it('should return a subscription for a Safe', async () => { + const account = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await target.upsertSubscriptions(upsertSubscriptionsDto); + await accountsDatasource.createAccount(account); + await target.upsertSubscriptions({ + account, + upsertSubscriptionsDto, + }); const safe = upsertSubscriptionsDto.safes[0]; await expect( target.getSafeSubscription({ - account: upsertSubscriptionsDto.account, + account: account, deviceUuid: upsertSubscriptionsDto.deviceUuid!, chainId: safe.chainId, safeAddress: safe.address, @@ -361,16 +389,22 @@ describe('NotificationsDatasource', () => { describe('getSubscribersWithTokensBySafe', () => { it('should return a list of subscribers with tokens for a Safe', async () => { + const account = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + const secondAccount = getAddress(faker.finance.ethereumAddress()); const secondUpsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('safes', upsertSubscriptionsDto.safes) .build(); - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await accountsDatasource.createAccount( - secondUpsertSubscriptionsDto.account, - ); - await target.upsertSubscriptions(upsertSubscriptionsDto); - await target.upsertSubscriptions(secondUpsertSubscriptionsDto); + await accountsDatasource.createAccount(account); + await accountsDatasource.createAccount(secondAccount); + await target.upsertSubscriptions({ + account, + upsertSubscriptionsDto, + }); + await target.upsertSubscriptions({ + account: secondAccount, + upsertSubscriptionsDto: secondUpsertSubscriptionsDto, + }); const safe = upsertSubscriptionsDto.safes[0]; await expect( @@ -380,12 +414,12 @@ describe('NotificationsDatasource', () => { }), ).resolves.toStrictEqual([ { - subscriber: upsertSubscriptionsDto.account, + subscriber: account, deviceUuid: upsertSubscriptionsDto.deviceUuid!, cloudMessagingToken: upsertSubscriptionsDto.cloudMessagingToken, }, { - subscriber: secondUpsertSubscriptionsDto.account, + subscriber: secondAccount, deviceUuid: secondUpsertSubscriptionsDto.deviceUuid!, cloudMessagingToken: secondUpsertSubscriptionsDto.cloudMessagingToken, }, @@ -395,6 +429,7 @@ describe('NotificationsDatasource', () => { describe('deleteSubscription', () => { it('should delete a subscription and orphaned device', async () => { + const accountAddress = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('safes', [ { @@ -406,14 +441,15 @@ describe('NotificationsDatasource', () => { }, ]) .build(); - const account = await accountsDatasource.createAccount( - upsertSubscriptionsDto.account, - ); - await target.upsertSubscriptions(upsertSubscriptionsDto); + const account = await accountsDatasource.createAccount(accountAddress); + await target.upsertSubscriptions({ + account: accountAddress, + upsertSubscriptionsDto, + }); const safe = upsertSubscriptionsDto.safes[0]; await target.deleteSubscription({ - account: upsertSubscriptionsDto.account, + account: accountAddress, deviceUuid: upsertSubscriptionsDto.deviceUuid!, chainId: safe.chainId, safeAddress: safe.address, @@ -423,11 +459,12 @@ describe('NotificationsDatasource', () => { sql`SELECT * FROM notification_subscriptions WHERE account_id = ${account.id} AND chain_id = ${safe.chainId} AND safe_address = ${safe.address}`, ).resolves.toStrictEqual([]); await expect( - sql`SELECT * FROM push_notification_devices WHERE device_uuid = ${upsertSubscriptionsDto.deviceUuid!}`, + sql`SELECT * FROM push_notification_devices WHERE device_uuid = ${upsertSubscriptionsDto.deviceUuid as UUID}`, ).resolves.toStrictEqual([]); }); it('should not delete subscriptions of other device UUIDs', async () => { + const account = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('safes', [ { @@ -439,18 +476,24 @@ describe('NotificationsDatasource', () => { }, ]) .build(); - const secondDeviceUuid = faker.string.uuid() as Uuid; + const secondDeviceUuid = faker.string.uuid() as UUID; const secondUpsertSubscriptionsDto = { ...upsertSubscriptionsDto, deviceUuid: secondDeviceUuid, }; - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await target.upsertSubscriptions(upsertSubscriptionsDto); - await target.upsertSubscriptions(secondUpsertSubscriptionsDto); + await accountsDatasource.createAccount(account); + await target.upsertSubscriptions({ + account, + upsertSubscriptionsDto, + }); + await target.upsertSubscriptions({ + account, + upsertSubscriptionsDto: secondUpsertSubscriptionsDto, + }); const safe = upsertSubscriptionsDto.safes[0]; await target.deleteSubscription({ - account: upsertSubscriptionsDto.account, + account, deviceUuid: upsertSubscriptionsDto.deviceUuid!, chainId: safe.chainId, safeAddress: safe.address, @@ -473,6 +516,7 @@ describe('NotificationsDatasource', () => { }); it('should not delete devices with other subscriptions', async () => { + const account = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('safes', [ { @@ -491,12 +535,15 @@ describe('NotificationsDatasource', () => { }, ]) .build(); - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await target.upsertSubscriptions(upsertSubscriptionsDto); + await accountsDatasource.createAccount(account); + await target.upsertSubscriptions({ + account, + upsertSubscriptionsDto, + }); const safe = upsertSubscriptionsDto.safes[0]; await target.deleteSubscription({ - account: upsertSubscriptionsDto.account, + account, deviceUuid: upsertSubscriptionsDto.deviceUuid!, chainId: safe.chainId, safeAddress: safe.address, @@ -520,9 +567,13 @@ describe('NotificationsDatasource', () => { describe('deleteDevice', () => { it('should delete all subscriptions of a device', async () => { + const account = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await target.upsertSubscriptions(upsertSubscriptionsDto); + await accountsDatasource.createAccount(account); + await target.upsertSubscriptions({ + account, + upsertSubscriptionsDto, + }); await target.deleteDevice(upsertSubscriptionsDto.deviceUuid!); diff --git a/src/datasources/accounts/notifications/notifications.datasource.ts b/src/datasources/accounts/notifications/notifications.datasource.ts index 147a084b37..c2a1023db0 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.ts @@ -1,5 +1,5 @@ import { NotificationType as DomainNotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; -import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; +import { UUID } from 'crypto'; import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; import { LoggingService, ILoggingService } from '@/logging/logging.interface'; @@ -11,7 +11,7 @@ import { UnprocessableEntityException, } from '@nestjs/common'; import postgres from 'postgres'; -import { UpsertSubscriptionsDto } from '@/domain/notifications/entities-v2/upsert-subscriptions.dto.entity'; +import { UpsertSubscriptionsDto } from '@/routes/accounts/notifications/entities/upsert-subscriptions.dto.entity'; @Injectable() export class NotificationsDatasource implements INotificationsDatasource { @@ -36,19 +36,19 @@ export class NotificationsDatasource implements INotificationsDatasource { * * @returns Device UUID */ - async upsertSubscriptions( - upsertSubscriptionsDto: UpsertSubscriptionsDto, - ): Promise<{ deviceUuid: Uuid }> { - const account = await this.accountsDatasource.getAccount( - upsertSubscriptionsDto.account, - ); - const deviceUuid = upsertSubscriptionsDto.deviceUuid ?? crypto.randomUUID(); + async upsertSubscriptions(args: { + account: `0x${string}`; + upsertSubscriptionsDto: UpsertSubscriptionsDto; + }): Promise<{ deviceUuid: UUID }> { + const account = await this.accountsDatasource.getAccount(args.account); + const deviceUuid = + args.upsertSubscriptionsDto.deviceUuid ?? crypto.randomUUID(); await this.sql.begin(async (sql) => { // Insert (or update the type/cloud messaging token of) a device const [device] = await sql<[{ id: number }]>` INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) - VALUES (${upsertSubscriptionsDto.deviceType}, ${deviceUuid}, ${upsertSubscriptionsDto.cloudMessagingToken}) + VALUES (${args.upsertSubscriptionsDto.deviceType}, ${deviceUuid}, ${args.upsertSubscriptionsDto.cloudMessagingToken}) ON CONFLICT (device_uuid) DO UPDATE SET cloud_messaging_token = EXCLUDED.cloud_messaging_token, @@ -64,7 +64,7 @@ export class NotificationsDatasource implements INotificationsDatasource { // For each Safe, upsert the subscription and overwrite the subscribed-to notification types await Promise.all( - upsertSubscriptionsDto.safes.map(async (safe) => { + args.upsertSubscriptionsDto.safes.map(async (safe) => { try { // 1. Upsert subscription const [subscription] = await sql<[{ id: number }]>` @@ -111,7 +111,7 @@ export class NotificationsDatasource implements INotificationsDatasource { */ async getSafeSubscription(args: { account: `0x${string}`; - deviceUuid: Uuid; + deviceUuid: UUID; chainId: string; safeAddress: `0x${string}`; }): Promise> { @@ -152,14 +152,14 @@ export class NotificationsDatasource implements INotificationsDatasource { }): Promise< Array<{ subscriber: `0x${string}`; - deviceUuid: Uuid; + deviceUuid: UUID; cloudMessagingToken: string; }> > { const subscribers = await this.sql< Array<{ address: `0x${string}`; - device_uuid: Uuid; + device_uuid: UUID; cloud_messaging_token: string; }> >` @@ -194,7 +194,7 @@ export class NotificationsDatasource implements INotificationsDatasource { */ async deleteSubscription(args: { account: `0x${string}`; - deviceUuid: Uuid; + deviceUuid: UUID; chainId: string; safeAddress: `0x${string}`; }): Promise { @@ -243,7 +243,7 @@ export class NotificationsDatasource implements INotificationsDatasource { * * @param deviceUuid Device UUID */ - async deleteDevice(deviceUuid: Uuid): Promise { + async deleteDevice(deviceUuid: UUID): Promise { await this.sql` DELETE FROM push_notification_devices WHERE device_uuid = ${deviceUuid} diff --git a/src/domain/interfaces/notifications.datasource.interface.ts b/src/domain/interfaces/notifications.datasource.interface.ts index 957a47c8ef..db62cba854 100644 --- a/src/domain/interfaces/notifications.datasource.interface.ts +++ b/src/domain/interfaces/notifications.datasource.interface.ts @@ -1,17 +1,20 @@ -import { UpsertSubscriptionsDto } from '@/domain/notifications/entities-v2/upsert-subscriptions.dto.entity'; +import { UpsertSubscriptionsDto } from '@/routes/accounts/notifications/entities/upsert-subscriptions.dto.entity'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; -import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; +import { UUID } from 'crypto'; export const INotificationsDatasource = Symbol('INotificationsDatasource'); export interface INotificationsDatasource { - upsertSubscriptions(upsertSubscriptionsDto: UpsertSubscriptionsDto): Promise<{ - deviceUuid: Uuid; + upsertSubscriptions(args: { + account: `0x${string}`; + upsertSubscriptionsDto: UpsertSubscriptionsDto; + }): Promise<{ + deviceUuid: UUID; }>; getSafeSubscription(args: { account: `0x${string}`; - deviceUuid: Uuid; + deviceUuid: UUID; chainId: string; safeAddress: `0x${string}`; }): Promise>; @@ -22,17 +25,17 @@ export interface INotificationsDatasource { }): Promise< Array<{ subscriber: `0x${string}`; - deviceUuid: Uuid; + deviceUuid: UUID; cloudMessagingToken: string; }> >; deleteSubscription(args: { account: `0x${string}`; - deviceUuid: Uuid; + deviceUuid: UUID; chainId: string; safeAddress: `0x${string}`; }): Promise; - deleteDevice(deviceUuid: Uuid): Promise; + deleteDevice(deviceUuid: UUID): Promise; } diff --git a/src/domain/notifications/entities-v2/upsert-subscriptions.dto.entity.ts b/src/domain/notifications/entities-v2/upsert-subscriptions.dto.entity.ts deleted file mode 100644 index 8abfbf2295..0000000000 --- a/src/domain/notifications/entities-v2/upsert-subscriptions.dto.entity.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { DeviceType } from '@/domain/notifications/entities-v2/device-type.entity'; -import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; -import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; - -export type UpsertSubscriptionsDto = { - account: `0x${string}`; - cloudMessagingToken: string; - safes: Array<{ - chainId: string; - address: `0x${string}`; - notificationTypes: Array; - }>; - deviceType: DeviceType; - deviceUuid?: Uuid; -}; diff --git a/src/domain/notifications/entities-v2/uuid.entity.ts b/src/domain/notifications/entities-v2/uuid.entity.ts deleted file mode 100644 index feddb9c4e2..0000000000 --- a/src/domain/notifications/entities-v2/uuid.entity.ts +++ /dev/null @@ -1 +0,0 @@ -export type Uuid = `${string}-${string}-${string}-${string}-${string}`; diff --git a/src/domain/notifications/notifications.repository.v2.interface.ts b/src/domain/notifications/notifications.repository.v2.interface.ts index 8b31906b6b..019923e668 100644 --- a/src/domain/notifications/notifications.repository.v2.interface.ts +++ b/src/domain/notifications/notifications.repository.v2.interface.ts @@ -1,30 +1,36 @@ -import { UpsertSubscriptionsDto } from '@/domain/notifications/entities-v2/upsert-subscriptions.dto.entity'; +import { UpsertSubscriptionsDto } from '@/routes/accounts/notifications/entities/upsert-subscriptions.dto.entity'; import { FirebaseNotification } from '@/datasources/push-notifications-api/entities/firebase-notification.entity'; import { PushNotificationsApiModule } from '@/datasources/push-notifications-api/push-notifications-api.module'; -import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; +import { UUID } from 'crypto'; import { NotificationsRepositoryV2 } from '@/domain/notifications/notifications.repository.v2'; import { Module } from '@nestjs/common'; import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; import { DelegatesV2RepositoryModule } from '@/domain/delegate/v2/delegates.v2.repository.interface'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; export const INotificationsRepositoryV2 = Symbol('INotificationsRepositoryV2'); export interface INotificationsRepositoryV2 { enqueueNotification(args: { token: string; - deviceUuid: Uuid; + deviceUuid: UUID; notification: FirebaseNotification; }): Promise; - upsertSubscriptions(args: UpsertSubscriptionsDto): Promise<{ - deviceUuid: Uuid; + upsertSubscriptions(args: { + account: `0x${string}`; + authPayload: AuthPayload; + upsertSubscriptionsDto: UpsertSubscriptionsDto; + }): Promise<{ + deviceUuid: UUID; }>; getSafeSubscription(args: { account: `0x${string}`; - deviceUuid: Uuid; + authPayload: AuthPayload; + deviceUuid: UUID; chainId: string; safeAddress: `0x${string}`; }): Promise>; @@ -35,18 +41,23 @@ export interface INotificationsRepositoryV2 { }): Promise< Array<{ subscriber: `0x${string}`; - deviceUuid: Uuid; + deviceUuid: UUID; cloudMessagingToken: string; }> >; deleteSubscription(args: { account: `0x${string}`; + authPayload: AuthPayload; chainId: string; safeAddress: `0x${string}`; }): Promise; - deleteDevice(deviceUuid: Uuid): Promise; + deleteDevice(args: { + account: `0x${string}`; + authPayload: AuthPayload; + deviceUuid: UUID; + }): Promise; } @Module({ diff --git a/src/domain/notifications/notifications.repository.v2.ts b/src/domain/notifications/notifications.repository.v2.ts index 62ef027d57..972e0ea058 100644 --- a/src/domain/notifications/notifications.repository.v2.ts +++ b/src/domain/notifications/notifications.repository.v2.ts @@ -1,8 +1,8 @@ -import { UpsertSubscriptionsDto } from '@/domain/notifications/entities-v2/upsert-subscriptions.dto.entity'; +import { UpsertSubscriptionsDto } from '@/routes/accounts/notifications/entities/upsert-subscriptions.dto.entity'; import { FirebaseNotification } from '@/datasources/push-notifications-api/entities/firebase-notification.entity'; import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; import { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface'; -import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; +import { UUID } from 'crypto'; import { INotificationsRepositoryV2 } from '@/domain/notifications/notifications.repository.v2.interface'; import { Inject, @@ -15,6 +15,7 @@ import { IDelegatesV2Repository } from '@/domain/delegate/v2/delegates.v2.reposi import { ILoggingService, LoggingService } from '@/logging/logging.interface'; import { NotificationType } from '@/domain/notifications/entities-v2/notification.entity'; import { get } from 'lodash'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; @Injectable() export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { @@ -58,9 +59,10 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { private readonly loggingService: ILoggingService, ) {} + // Note: no authentication required as not controller focused async enqueueNotification(args: { token: string; - deviceUuid: Uuid; + deviceUuid: UUID; notification: FirebaseNotification; }): Promise { try { @@ -70,7 +72,13 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { ); } catch (e) { if (this.isTokenUnregistered(e)) { - await this.deleteDevice(args.deviceUuid).catch(() => null); + this.loggingService.info( + `Deleting unregistered token for device ${args.deviceUuid}: ${e}`, + ); + await this.notificationsDatasource + .deleteDevice(args.deviceUuid) + // No need to log as datasource does + .catch(() => null); } else { this.loggingService.info(`Failed to enqueue notification: ${e}`); throw new UnprocessableEntityException(); @@ -87,12 +95,22 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { return isNotFound && isUnregistered; } - async upsertSubscriptions(args: UpsertSubscriptionsDto): Promise<{ - deviceUuid: Uuid; + async upsertSubscriptions(args: { + account: `0x${string}`; + authPayload: AuthPayload; + upsertSubscriptionsDto: UpsertSubscriptionsDto; + }): Promise<{ + deviceUuid: UUID; }> { + // Note: we only check the signature is of the account, not the chain + // to simplify subscription management of Safes across chains + if (!args.authPayload.isForSigner(args.account)) { + throw new UnauthorizedException(); + } + // Only allow owners or delegates to subscribe to notifications // We don't Promise.all getSafe/getDelegates to prevent unnecessary calls - for (const safeToSubscribe of args.safes) { + for (const safeToSubscribe of args.upsertSubscriptionsDto.safes) { const safe = await this.safeRepository .getSafe({ chainId: safeToSubscribe.chainId, @@ -123,25 +141,36 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { throw new UnauthorizedException(); } - return this.notificationsDatasource.upsertSubscriptions(args); + return this.notificationsDatasource.upsertSubscriptions({ + account: args.account, + upsertSubscriptionsDto: args.upsertSubscriptionsDto, + }); } getSafeSubscription(args: { account: `0x${string}`; - deviceUuid: Uuid; + authPayload: AuthPayload; + deviceUuid: UUID; chainId: string; safeAddress: `0x${string}`; }): Promise> { + // Note: we only check the signature is of the account, not the chain + // to simplify subscription retrieval for Safes across chains + if (!args.authPayload.isForSigner(args.account)) { + throw new UnauthorizedException(); + } + return this.notificationsDatasource.getSafeSubscription(args); } + // Note: no authentication required as not controller focused getSubscribersBySafe(args: { chainId: string; safeAddress: `0x${string}`; }): Promise< Array<{ subscriber: `0x${string}`; - deviceUuid: Uuid; + deviceUuid: UUID; cloudMessagingToken: string; }> > { @@ -150,14 +179,31 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { deleteSubscription(args: { account: `0x${string}`; - deviceUuid: Uuid; + authPayload: AuthPayload; + deviceUuid: UUID; chainId: string; safeAddress: `0x${string}`; }): Promise { + // Note: we only check the signature is of the account, not the chain + // to simplify subscription management for Safes across chains + if (!args.authPayload.isForSigner(args.account)) { + throw new UnauthorizedException(); + } + return this.notificationsDatasource.deleteSubscription(args); } - deleteDevice(deviceUuid: Uuid): Promise { - return this.notificationsDatasource.deleteDevice(deviceUuid); + deleteDevice(args: { + account: `0x${string}`; + authPayload: AuthPayload; + deviceUuid: UUID; + }): Promise { + // Note: we only check the signature is of the account, not the chain + // to simplify subscription management for Safes across chains + if (!args.authPayload.isForSigner(args.account)) { + throw new UnauthorizedException(); + } + + return this.notificationsDatasource.deleteDevice(args.deviceUuid); } } diff --git a/src/routes/accounts/notifications/entities/__tests__/upsert-subscriptions-dto.entity.spec.ts b/src/routes/accounts/notifications/entities/__tests__/upsert-subscriptions-dto.entity.spec.ts new file mode 100644 index 0000000000..fa03638c2e --- /dev/null +++ b/src/routes/accounts/notifications/entities/__tests__/upsert-subscriptions-dto.entity.spec.ts @@ -0,0 +1,185 @@ +import { DeviceType } from '@/domain/notifications/entities-v2/device-type.entity'; +import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; +import { upsertSubscriptionsDtoBuilder } from '@/routes/accounts/notifications/entities/__tests__/upsert-subscriptions.dto.entity.builder'; +import { UpsertSubscriptionsDtoSchema } from '@/routes/accounts/notifications/entities/upsert-subscriptions.dto.entity'; +import { faker } from '@faker-js/faker'; +import { UUID } from 'crypto'; +import { getAddress } from 'viem'; + +describe('UpsertSubscriptionsDtoSchema', () => { + it('should validate a valid UpsertSubscriptionsDto', () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + + const result = UpsertSubscriptionsDtoSchema.safeParse( + upsertSubscriptionsDto, + ); + + expect(result.success).toBe(true); + }); + + it.each([ + ['cloudMessagingToken' as const, 'string'], + ['safes' as const, 'array'], + ['deviceType' as const, "'ANDROID' | 'IOS' | 'WEB'"], + ])('should require %s', (key, expected) => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + delete upsertSubscriptionsDto[key]; + + const result = UpsertSubscriptionsDtoSchema.safeParse( + upsertSubscriptionsDto, + ); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected, + message: 'Required', + path: Array.isArray(result.error!.issues[0].path) ? [key] : key, + received: 'undefined', + }, + ]); + }); + + it('should not allow non-deviceType values for deviceType', () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('deviceType', 'not-a-device-type' as DeviceType) + .build(); + + const result = UpsertSubscriptionsDtoSchema.safeParse( + upsertSubscriptionsDto, + ); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_enum_value', + message: + "Invalid enum value. Expected 'ANDROID' | 'IOS' | 'WEB', received 'not-a-device-type'", + options: ['ANDROID', 'IOS', 'WEB'], + path: ['deviceType'], + received: 'not-a-device-type', + }, + ]); + }); + + it('should not allow non-UUID values for deviceUuid', () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('deviceUuid', 'not-a-uuid' as UUID) + .build(); + + const result = UpsertSubscriptionsDtoSchema.safeParse( + upsertSubscriptionsDto, + ); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_string', + message: 'Invalid uuid', + path: ['deviceUuid'], + validation: 'uuid', + }, + ]); + }); + + it('should allow a nullish deviceUuid, defaulting to null', () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + // @ts-expect-error - inferred type doesn't allow optional properties + delete upsertSubscriptionsDto.deviceUuid; + + const result = UpsertSubscriptionsDtoSchema.safeParse( + upsertSubscriptionsDto, + ); + + expect(result.success && result.data.deviceUuid).toBe(null); + }); + + it.each([ + ['chainId' as const, 'string'], + ['address' as const, 'string'], + ['notificationTypes' as const, 'array'], + ])(`should require safes[number].%s`, (key, expected) => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('safes', [ + { + chainId: faker.string.numeric(), + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }, + ]) + .build(); + delete upsertSubscriptionsDto.safes[0][key]; + + const result = UpsertSubscriptionsDtoSchema.safeParse( + upsertSubscriptionsDto, + ); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected, + message: 'Required', + path: ['safes', 0, key], + received: 'undefined', + }, + ]); + }); + + it('should checksum safes[number].address', () => { + const nonChecksummedAddress = faker.finance.ethereumAddress().toLowerCase(); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('safes', [ + { + chainId: faker.string.numeric(), + address: nonChecksummedAddress as `0x${string}`, + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }, + ]) + .build(); + + const result = UpsertSubscriptionsDtoSchema.safeParse( + upsertSubscriptionsDto, + ); + + expect(result.success && result.data.safes[0].address).toBe( + getAddress(nonChecksummedAddress), + ); + }); + + it('should only allow NotificationType values for safes[number].notificationTypes', () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('safes', [ + { + chainId: faker.string.numeric(), + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: ['not-a-notification-type' as NotificationType], + }, + ]) + .build(); + + const result = UpsertSubscriptionsDtoSchema.safeParse( + upsertSubscriptionsDto, + ); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_enum_value', + message: + "Invalid enum value. Expected 'CONFIRMATION_REQUEST' | 'DELETED_MULTISIG_TRANSACTION' | 'EXECUTED_MULTISIG_TRANSACTION' | 'INCOMING_ETHER' | 'INCOMING_TOKEN' | 'MESSAGE_CONFIRMATION_REQUEST' | 'MODULE_TRANSACTION', received 'not-a-notification-type'", + options: [ + 'CONFIRMATION_REQUEST', + 'DELETED_MULTISIG_TRANSACTION', + 'EXECUTED_MULTISIG_TRANSACTION', + 'INCOMING_ETHER', + 'INCOMING_TOKEN', + 'MESSAGE_CONFIRMATION_REQUEST', + 'MODULE_TRANSACTION', + ], + path: ['safes', 0, 'notificationTypes', 0], + received: 'not-a-notification-type', + }, + ]); + }); +}); diff --git a/src/domain/notifications/entities-v2/__tests__/upsert-subscriptions.dto.entity.builder.ts b/src/routes/accounts/notifications/entities/__tests__/upsert-subscriptions.dto.entity.builder.ts similarity index 77% rename from src/domain/notifications/entities-v2/__tests__/upsert-subscriptions.dto.entity.builder.ts rename to src/routes/accounts/notifications/entities/__tests__/upsert-subscriptions.dto.entity.builder.ts index c304be1bd1..8b266ce861 100644 --- a/src/domain/notifications/entities-v2/__tests__/upsert-subscriptions.dto.entity.builder.ts +++ b/src/routes/accounts/notifications/entities/__tests__/upsert-subscriptions.dto.entity.builder.ts @@ -1,17 +1,16 @@ import { faker } from '@faker-js/faker'; import { Builder, IBuilder } from '@/__tests__/builder'; import { getAddress } from 'viem'; -import { UpsertSubscriptionsDto } from '@/domain/notifications/entities-v2/upsert-subscriptions.dto.entity'; +import { UpsertSubscriptionsDto } from '@/routes/accounts/notifications/entities/upsert-subscriptions.dto.entity'; import { DeviceType } from '@/domain/notifications/entities-v2/device-type.entity'; -import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; +import { UUID } from 'crypto'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; export function upsertSubscriptionsDtoBuilder(): IBuilder { return new Builder() - .with('account', getAddress(faker.finance.ethereumAddress())) .with('cloudMessagingToken', faker.string.alphanumeric({ length: 10 })) .with('deviceType', faker.helpers.arrayElement(Object.values(DeviceType))) - .with('deviceUuid', faker.string.uuid() as Uuid) + .with('deviceUuid', faker.string.uuid() as UUID) .with( 'safes', Array.from( diff --git a/src/routes/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts b/src/routes/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts new file mode 100644 index 0000000000..391cab5564 --- /dev/null +++ b/src/routes/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts @@ -0,0 +1,22 @@ +import { DeviceType } from '@/domain/notifications/entities-v2/device-type.entity'; +import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { UuidSchema } from '@/validation/entities/schemas/uuid.schema'; +import { z } from 'zod'; + +export const UpsertSubscriptionsDtoSchema = z.object({ + cloudMessagingToken: z.string(), + safes: z.array( + z.object({ + chainId: z.string(), + address: AddressSchema, + notificationTypes: z.array(z.nativeEnum(NotificationType)), + }), + ), + deviceType: z.nativeEnum(DeviceType), + deviceUuid: UuidSchema.nullish().default(null), +}); + +export type UpsertSubscriptionsDto = z.infer< + typeof UpsertSubscriptionsDtoSchema +>; diff --git a/src/routes/accounts/notifications/notifications.controller.v2.spec.ts b/src/routes/accounts/notifications/notifications.controller.v2.spec.ts new file mode 100644 index 0000000000..7456e69f26 --- /dev/null +++ b/src/routes/accounts/notifications/notifications.controller.v2.spec.ts @@ -0,0 +1,780 @@ +import { TestAppProvider } from '@/__tests__/test-app.provider'; +import { AppModule } from '@/app.module'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import configuration from '@/config/entities/__tests__/configuration'; +import { TestAccountsDataSourceModule } from '@/datasources/accounts/__tests__/test.accounts.datasource.module'; +import { AccountsDatasourceModule } from '@/datasources/accounts/accounts.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; +import { CacheModule } from '@/datasources/cache/cache.module'; +import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.configuration'; +import { + JWT_CONFIGURATION_MODULE, + JwtConfigurationModule, +} from '@/datasources/jwt/configuration/jwt.configuration.module'; +import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; +import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; +import { NetworkModule } from '@/datasources/network/network.module'; +import { + INetworkService, + NetworkService, +} from '@/datasources/network/network.service.interface'; +import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; +import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; +import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; +import { delegateBuilder } from '@/domain/delegate/entities/__tests__/delegate.builder'; +import { pageBuilder } from '@/domain/entities/__tests__/page.builder'; +import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; +import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; +import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; +import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; +import { RequestScopedLoggingModule } from '@/logging/logging.module'; +import { upsertSubscriptionsDtoBuilder } from '@/routes/accounts/notifications/entities/__tests__/upsert-subscriptions.dto.entity.builder'; +import { faker } from '@faker-js/faker'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Server } from 'net'; +import request from 'supertest'; +import { getAddress } from 'viem'; + +describe('Notifications Controller V2 (Unit)', () => { + let app: INestApplication; + let safeConfigUrl: string; + let jwtService: IJwtService; + let networkService: jest.MockedObjectDeep; + let notificationsDatasource: jest.MockedObjectDeep; + + beforeAll(async () => { + const defaultConfiguration = configuration(); + const testConfiguration = (): typeof defaultConfiguration => ({ + ...defaultConfiguration, + features: { + ...defaultConfiguration.features, + auth: true, + accounts: true, + pushNotifications: true, + }, + }); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule.register(testConfiguration)], + }) + .overrideModule(JWT_CONFIGURATION_MODULE) + .useModule(JwtConfigurationModule.register(jwtConfiguration)) + .overrideModule(AccountsDatasourceModule) + .useModule(TestAccountsDataSourceModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) + .overrideModule(CacheModule) + .useModule(TestCacheModule) + .overrideModule(RequestScopedLoggingModule) + .useModule(TestLoggingModule) + .overrideModule(NetworkModule) + .useModule(TestNetworkModule) + .overrideModule(QueuesApiModule) + .useModule(TestQueuesApiModule) + .compile(); + + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); + jwtService = moduleFixture.get(IJwtService); + networkService = moduleFixture.get(NetworkService); + notificationsDatasource = moduleFixture.get(INotificationsDatasource); + + app = await new TestAppProvider().provide(moduleFixture); + await app.init(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /v1/accounts/:address/notifications/devices/register', () => { + it('should upsert subscription(s) for owners', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with( + 'safes', + Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + chainId: chain.chainId, + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }), + ), + ) + .build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('signer_address', signerAddress) + .with('chain_id', chain.chainId) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + for (const safe of upsertSubscriptionsDto.safes) { + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ + data: safeBuilder() + .with('address', safe.address) + .with('owners', [signerAddress]) + .build(), + status: 200, + }); + } + } + if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + data: pageBuilder().with('results', []).build(), + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post(`/v1/accounts/${signerAddress}/notifications/devices/register`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(upsertSubscriptionsDto) + .expect(201); + + expect(notificationsDatasource.upsertSubscriptions).toHaveBeenCalledTimes( + 1, + ); + expect( + notificationsDatasource.upsertSubscriptions, + ).toHaveBeenNthCalledWith(1, { + account: signerAddress, + upsertSubscriptionsDto, + }); + }); + + it('should upsert subscription(s) for delegates', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with( + 'safes', + Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + chainId: chain.chainId, + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }), + ), + ) + .build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('signer_address', signerAddress) + .with('chain_id', chain.chainId) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + networkService.get.mockImplementation(({ url, networkRequest }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + for (const safe of upsertSubscriptionsDto.safes) { + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ + data: safeBuilder().with('address', safe.address).build(), + status: 200, + }); + } + if ( + url === `${chain.transactionService}/api/v2/delegates/` && + networkRequest?.params?.safe === safe.address + ) { + return Promise.resolve({ + data: pageBuilder() + .with('results', [ + delegateBuilder().with('delegate', signerAddress).build(), + ]) + .build(), + status: 200, + }); + } + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + expect(() => jwtService.verify(accessToken)).not.toThrow(); + await request(app.getHttpServer()) + .post(`/v1/accounts/${signerAddress}/notifications/devices/register`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(upsertSubscriptionsDto) + .expect(201); + + expect(notificationsDatasource.upsertSubscriptions).toHaveBeenCalledTimes( + 1, + ); + expect( + notificationsDatasource.upsertSubscriptions, + ).toHaveBeenNthCalledWith(1, { + account: signerAddress, + upsertSubscriptionsDto, + }); + }); + + it('should return 401 if you are not an owner/delegate of any of the Safes you are trying to subscribe to', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with( + 'safes', + Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + chainId: chain.chainId, + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }), + ), + ) + .build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('signer_address', signerAddress) + .with('chain_id', chain.chainId) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + for (const safe of upsertSubscriptionsDto.safes) { + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ + data: safeBuilder().with('address', safe.address).build(), + status: 200, + }); + } + } + if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + data: pageBuilder().with('results', []).build(), + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + expect(() => jwtService.verify(accessToken)).not.toThrow(); + await request(app.getHttpServer()) + .post(`/v1/accounts/${signerAddress}/notifications/devices/register`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(upsertSubscriptionsDto) + .expect(401); + }); + + it('should allow subscription upsertion with a token with the same signer_address from a different chain_id', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with( + 'safes', + Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + chainId: chain.chainId, + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }), + ), + ) + .build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('signer_address', signerAddress) + .with('chain_id', faker.string.numeric({ exclude: chain.chainId })) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + for (const safe of upsertSubscriptionsDto.safes) { + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ + data: safeBuilder() + .with('address', safe.address) + .with('owners', [signerAddress]) + .build(), + status: 200, + }); + } + } + if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + data: pageBuilder().with('results', []).build(), + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + expect(() => jwtService.verify(accessToken)).not.toThrow(); + await request(app.getHttpServer()) + .post(`/v1/accounts/${signerAddress}/notifications/devices/register`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(upsertSubscriptionsDto) + .expect(201); + + expect(notificationsDatasource.upsertSubscriptions).toHaveBeenCalledTimes( + 1, + ); + expect( + notificationsDatasource.upsertSubscriptions, + ).toHaveBeenNthCalledWith(1, { + account: signerAddress, + upsertSubscriptionsDto, + }); + }); + + it('should allow subscription(s) to the same Safe with different devices', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with( + 'safes', + Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + chainId: chain.chainId, + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }), + ), + ) + .build(); + const secondSubscriptionDto = upsertSubscriptionsDtoBuilder() + .with('safes', upsertSubscriptionsDto.safes) + .with('deviceUuid', null) + .build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('signer_address', signerAddress) + .with('chain_id', chain.chainId) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + for (const safe of upsertSubscriptionsDto.safes) { + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ + data: safeBuilder() + .with('address', safe.address) + .with('owners', [signerAddress]) + .build(), + status: 200, + }); + } + } + if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + data: pageBuilder().with('results', []).build(), + status: 200, + }); + } + for (const safe of secondSubscriptionDto.safes) { + if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ + data: safeBuilder() + .with('address', safe.address) + .with('owners', [signerAddress]) + .build(), + status: 200, + }); + } + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + expect(() => jwtService.verify(accessToken)).not.toThrow(); + await request(app.getHttpServer()) + .post(`/v1/accounts/${signerAddress}/notifications/devices/register`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(upsertSubscriptionsDto) + .expect(201); + await request(app.getHttpServer()) + .post(`/v1/accounts/${signerAddress}/notifications/devices/register`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(secondSubscriptionDto) + .expect(201); + + expect(notificationsDatasource.upsertSubscriptions).toHaveBeenCalledTimes( + 2, + ); + expect( + notificationsDatasource.upsertSubscriptions, + ).toHaveBeenNthCalledWith(1, { + account: signerAddress, + upsertSubscriptionsDto, + }); + expect( + notificationsDatasource.upsertSubscriptions, + ).toHaveBeenNthCalledWith(2, { + account: signerAddress, + upsertSubscriptionsDto: secondSubscriptionDto, + }); + }); + + it('should return 422 if the account is invalid', async () => { + const invalidAccount = faker.string.alpha(); + const signerAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with( + 'safes', + Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + chainId, + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }), + ), + ) + .build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('signer_address', signerAddress) + .with('chain_id', chainId) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + expect(() => jwtService.verify(accessToken)).not.toThrow(); + await request(app.getHttpServer()) + .post(`/v1/accounts/${invalidAccount}/notifications/devices/register`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(upsertSubscriptionsDto) + .expect({ + statusCode: 422, + code: 'custom', + message: 'Invalid address', + path: [], + }); + }); + + it('should return 422 if the UpsertSubscriptionsDto is invalid', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const upsertSubscriptionsDto = { invalid: 'upsertSubscriptionsDto' }; + const authPayloadDto = authPayloadDtoBuilder() + .with('signer_address', signerAddress) + .with('chain_id', chainId) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + expect(() => jwtService.verify(accessToken)).not.toThrow(); + await request(app.getHttpServer()) + .post(`/v1/accounts/${signerAddress}/notifications/devices/register`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(upsertSubscriptionsDto) + .expect({ + statusCode: 422, + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['cloudMessagingToken'], + message: 'Required', + }); + }); + + describe('authentication', () => { + it("should return 401 if signer_address of token doesn't match account", async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with( + 'safes', + Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + chainId, + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }), + ), + ) + .build(); + const authPayloadDto = authPayloadDtoBuilder() + // Not signerAddress + .with('chain_id', chainId) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + expect(() => jwtService.verify(accessToken)).not.toThrow(); + await request(app.getHttpServer()) + .post(`/v1/accounts/${signerAddress}/notifications/devices/register`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(upsertSubscriptionsDto) + .expect(401); + }); + + it('should return 403 if no token is present', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with( + 'safes', + Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + chainId, + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }), + ), + ) + .build(); + + await request(app.getHttpServer()) + .post(`/v1/accounts/${signerAddress}/notifications/devices/register`) + .send(upsertSubscriptionsDto) + .expect(403); + }); + + it('should return 403 if token is invalid', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with( + 'safes', + Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + chainId, + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }), + ), + ) + .build(); + const accessToken = faker.string.sample(); + + expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed'); + await request(app.getHttpServer()) + .post(`/v1/accounts/${signerAddress}/notifications/devices/register`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(upsertSubscriptionsDto) + .expect(403); + }); + + it('should return 403 if token is not yet valid', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with( + 'safes', + Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + chainId, + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }), + ), + ) + .build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('signer_address', signerAddress) + .with('chain_id', chainId) + .build(); + const accessToken = jwtService.sign({ + ...authPayloadDto, + nbf: faker.date.future(), + }); + + expect(() => jwtService.verify(accessToken)).toThrow('jwt not active'); + await request(app.getHttpServer()) + .post(`/v1/accounts/${signerAddress}/notifications/devices/register`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(upsertSubscriptionsDto) + .expect(403); + }); + + it('should return 403 if token has expired', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with( + 'safes', + Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + chainId, + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }), + ), + ) + .build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('signer_address', signerAddress) + .with('chain_id', chainId) + .build(); + const accessToken = jwtService.sign({ + ...authPayloadDto, + exp: faker.date.past(), + }); + + expect(() => jwtService.verify(accessToken)).toThrow('jwt expired'); + await request(app.getHttpServer()) + .post(`/v1/accounts/${signerAddress}/notifications/devices/register`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(upsertSubscriptionsDto) + .expect(403); + }); + }); + }); + describe.skip('GET /v1/accounts/:address/notifications/devices/:deviceUuid/chains/:chainId/safes/:safeAddress', () => { + it.todo('should return the subscription for the Safe'); + + it.todo('should return 422 if the account is invalid'); + + it.todo('should return XXX if the device can not be found'); + + it.todo('should return XXX if the safe can not be found'); + + describe('authentication', () => { + it.todo( + 'should allow subscription upsertion with a token with the same signer_address from a different chain_id', + ); + + it.todo( + "should return 401 if signer_address of token doesn't match account", + ); + + it.todo('should return 403 if no token is present'); + + it.todo('should return 403 if token is invalid'); + + it.todo('should return 403 if token is not yet valid'); + + it.todo('should return 403 if token has expired'); + }); + }); + + describe.skip('DELETE /v1/accounts/:address/notifications/devices/:deviceUuid/chains/:chainId/safes/:safeAddress', () => { + it.todo('should delete the subscription for the Safe'); + + it.todo('should not delete subscriptions of other devices'); + + it.todo('should not delete the device if it has other subscriptions'); + + it.todo('should return 422 if the account is invalid'); + + it.todo('should return XXX if the device can not be found'); + + it.todo('should return XXX if the safe can not be found'); + + describe('authentication', () => { + it.todo( + 'should allow subscription upsertion with a token with the same signer_address from a different chain_id', + ); + + it.todo( + "should return 403 if signer_address of token doesn't match account", + ); + + it.todo('should return 403 if no token is present'); + + it.todo('should return 403 if token is invalid'); + + it.todo('should return 403 if token is not yet valid'); + + it.todo('should return 403 if token has expired'); + }); + }); + describe.skip('DELETE /v1/accounts/:address/notifications/devices/:deviceUuid', () => { + it.todo('should delete all subscriptions of the device'); + + it.todo('should return 422 if the account is invalid'); + + it.todo('should return XXX if the device can not be found'); + + describe('authentication', () => { + it.todo( + 'should allow subscription upsertion with a token with the same signer_address from a different chain_id', + ); + + it.todo( + "should return 403 if signer_address of token doesn't match account", + ); + + it.todo('should return 403 if no token is present'); + + it.todo('should return 403 if token is invalid'); + + it.todo('should return 403 if token is not yet valid'); + + it.todo('should return 403 if token has expired'); + }); + }); +}); diff --git a/src/routes/accounts/notifications/notifications.controller.v2.ts b/src/routes/accounts/notifications/notifications.controller.v2.ts new file mode 100644 index 0000000000..9a748f203e --- /dev/null +++ b/src/routes/accounts/notifications/notifications.controller.v2.ts @@ -0,0 +1,99 @@ +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; +import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; +import { + UpsertSubscriptionsDto, + UpsertSubscriptionsDtoSchema, +} from '@/routes/accounts/notifications/entities/upsert-subscriptions.dto.entity'; +import { NotificationsServiceV2 } from '@/routes/accounts/notifications/notifications.service.v2'; +import { Auth } from '@/routes/auth/decorators/auth.decorator'; +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 { UuidSchema } from '@/validation/entities/schemas/uuid.schema'; +import { ValidationPipe } from '@/validation/pipes/validation.pipe'; +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { UUID } from 'crypto'; + +@ApiTags('notifications') +@Controller({ path: 'accounts/:address/notifications/devices', version: '1' }) +export class NotificationsControllerV2 { + constructor(private readonly notificationsService: NotificationsServiceV2) {} + + // TODO: Add ApiOkResponse and HttpCode for each route + + @Post('register') + @UseGuards(AuthGuard) + upsertSubscriptions( + @Body(new ValidationPipe(UpsertSubscriptionsDtoSchema)) + upsertSubscriptionsDto: UpsertSubscriptionsDto, + @Auth() authPayload: AuthPayload, + @Param('address', new ValidationPipe(AddressSchema)) account: `0x${string}`, + ): Promise<{ deviceUuid: UUID }> { + return this.notificationsService.upsertSubscriptions({ + authPayload, + account, + upsertSubscriptionsDto, + }); + } + + @Get(':deviceUuid/chains/:chainId/safes/:safeAddress') + @UseGuards(AuthGuard) + getSafeSubscription( + @Param('address', new ValidationPipe(AddressSchema)) account: `0x${string}`, + @Param('deviceUuid', new ValidationPipe(UuidSchema)) deviceUuid: UUID, + @Param('chainId', new ValidationPipe(NumericStringSchema)) chainId: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, + @Auth() authPayload: AuthPayload, + ): Promise> { + return this.notificationsService.getSafeSubscription({ + authPayload, + account, + deviceUuid, + chainId, + safeAddress, + }); + } + + @Delete(':deviceUuid/chains/:chainId/safes/:safeAddress') + @UseGuards(AuthGuard) + deleteSubscription( + @Param('address', new ValidationPipe(AddressSchema)) account: `0x${string}`, + @Param('deviceUuid', new ValidationPipe(UuidSchema)) deviceUuid: UUID, + @Param('chainId', new ValidationPipe(NumericStringSchema)) chainId: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, + @Auth() authPayload: AuthPayload, + ): Promise { + return this.notificationsService.deleteSubscription({ + authPayload, + account, + deviceUuid, + chainId, + safeAddress, + }); + } + + @Delete(':deviceUuid') + @UseGuards(AuthGuard) + deleteDevice( + @Param('address', new ValidationPipe(AddressSchema)) account: `0x${string}`, + @Param('deviceUuid', new ValidationPipe(UuidSchema)) deviceUuid: UUID, + @Auth() authPayload: AuthPayload, + ): Promise { + return this.notificationsService.deleteDevice({ + account, + authPayload, + deviceUuid, + }); + } +} diff --git a/src/routes/accounts/notifications/notifications.module.v2.ts b/src/routes/accounts/notifications/notifications.module.v2.ts new file mode 100644 index 0000000000..72b8686894 --- /dev/null +++ b/src/routes/accounts/notifications/notifications.module.v2.ts @@ -0,0 +1,12 @@ +import { AuthRepositoryModule } from '@/domain/auth/auth.repository.interface'; +import { NotificationsRepositoryV2Module } from '@/domain/notifications/notifications.repository.v2.interface'; +import { NotificationsControllerV2 } from '@/routes/accounts/notifications/notifications.controller.v2'; +import { NotificationsServiceV2 } from '@/routes/accounts/notifications/notifications.service.v2'; +import { Module } from '@nestjs/common'; + +@Module({ + imports: [NotificationsRepositoryV2Module, AuthRepositoryModule], + controllers: [NotificationsControllerV2], + providers: [NotificationsServiceV2], +}) +export class NotificationsModuleV2 {} diff --git a/src/routes/accounts/notifications/notifications.service.v2.ts b/src/routes/accounts/notifications/notifications.service.v2.ts new file mode 100644 index 0000000000..b4f1361250 --- /dev/null +++ b/src/routes/accounts/notifications/notifications.service.v2.ts @@ -0,0 +1,51 @@ +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; +import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; +import { UpsertSubscriptionsDto } from '@/routes/accounts/notifications/entities/upsert-subscriptions.dto.entity'; +import { INotificationsRepositoryV2 } from '@/domain/notifications/notifications.repository.v2.interface'; +import { Inject, Injectable } from '@nestjs/common'; +import { UUID } from 'crypto'; + +@Injectable() +export class NotificationsServiceV2 { + constructor( + @Inject(INotificationsRepositoryV2) + private readonly notificationsRepository: INotificationsRepositoryV2, + ) {} + + upsertSubscriptions(args: { + authPayload: AuthPayload; + account: `0x${string}`; + upsertSubscriptionsDto: UpsertSubscriptionsDto; + }): Promise<{ + deviceUuid: UUID; + }> { + return this.notificationsRepository.upsertSubscriptions(args); + } + getSafeSubscription(args: { + authPayload: AuthPayload; + account: `0x${string}`; + deviceUuid: UUID; + chainId: string; + safeAddress: `0x${string}`; + }): Promise> { + return this.notificationsRepository.getSafeSubscription(args); + } + + deleteSubscription(args: { + authPayload: AuthPayload; + account: `0x${string}`; + deviceUuid: UUID; + chainId: string; + safeAddress: `0x${string}`; + }): Promise { + return this.notificationsRepository.deleteSubscription(args); + } + + deleteDevice(args: { + account: `0x${string}`; + authPayload: AuthPayload; + deviceUuid: UUID; + }): Promise { + return this.notificationsRepository.deleteDevice(args); + } +} diff --git a/src/routes/hooks/hooks-notifications.spec.ts b/src/routes/hooks/hooks-notifications.spec.ts index c8253eccac..dafe3a15fe 100644 --- a/src/routes/hooks/hooks-notifications.spec.ts +++ b/src/routes/hooks/hooks-notifications.spec.ts @@ -49,7 +49,7 @@ import { confirmationBuilder } from '@/domain/safe/entities/__tests__/multisig-t import { messageBuilder } from '@/domain/messages/entities/__tests__/message.builder'; import { messageCreatedEventBuilder } from '@/routes/hooks/entities/__tests__/message-created.builder'; import { messageConfirmationBuilder } from '@/domain/messages/entities/__tests__/message-confirmation.builder'; -import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; +import { UUID } from 'crypto'; describe('Post Hook Events for Notifications (Unit)', () => { let app: INestApplication; @@ -142,7 +142,7 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), - deviceUuid: faker.string.uuid() as Uuid, + deviceUuid: faker.string.uuid() as UUID, cloudMessagingToken: faker.string.alphanumeric(), }), ); @@ -180,7 +180,7 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), - deviceUuid: faker.string.uuid() as Uuid, + deviceUuid: faker.string.uuid() as UUID, cloudMessagingToken: faker.string.alphanumeric(), }), ); @@ -257,7 +257,7 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), - deviceUuid: faker.string.uuid() as Uuid, + deviceUuid: faker.string.uuid() as UUID, cloudMessagingToken: faker.string.alphanumeric(), }), ); @@ -329,7 +329,7 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), - deviceUuid: faker.string.uuid() as Uuid, + deviceUuid: faker.string.uuid() as UUID, cloudMessagingToken: faker.string.alphanumeric(), }), ); @@ -397,7 +397,7 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), - deviceUuid: faker.string.uuid() as Uuid, + deviceUuid: faker.string.uuid() as UUID, cloudMessagingToken: faker.string.alphanumeric(), }), ); @@ -443,7 +443,7 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), - deviceUuid: faker.string.uuid() as Uuid, + deviceUuid: faker.string.uuid() as UUID, cloudMessagingToken: faker.string.alphanumeric(), }), ); @@ -510,7 +510,7 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), - deviceUuid: faker.string.uuid() as Uuid, + deviceUuid: faker.string.uuid() as UUID, cloudMessagingToken: faker.string.alphanumeric(), }), ); @@ -578,7 +578,7 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), - deviceUuid: faker.string.uuid() as Uuid, + deviceUuid: faker.string.uuid() as UUID, cloudMessagingToken: faker.string.alphanumeric(), }), ); @@ -624,7 +624,7 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), - deviceUuid: faker.string.uuid() as Uuid, + deviceUuid: faker.string.uuid() as UUID, cloudMessagingToken: faker.string.alphanumeric(), }), ); @@ -689,7 +689,7 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), - deviceUuid: faker.string.uuid() as Uuid, + deviceUuid: faker.string.uuid() as UUID, cloudMessagingToken: faker.string.alphanumeric(), }), ); diff --git a/src/validation/entities/schemas/__tests__/uuid.schema.spec.ts b/src/validation/entities/schemas/__tests__/uuid.schema.spec.ts new file mode 100644 index 0000000000..f587b57af9 --- /dev/null +++ b/src/validation/entities/schemas/__tests__/uuid.schema.spec.ts @@ -0,0 +1,21 @@ +import { UuidSchema } from '@/validation/entities/schemas/uuid.schema'; +import { faker } from '@faker-js/faker'; + +describe('UuidSchema', () => { + it('should validate a valid UUID string', () => { + const value = faker.string.uuid(); + + const result = UuidSchema.safeParse(value); + + expect(result.success && result.data).toBe(value); + }); + + it('should not validate a non-UUID string', () => { + // Length of a UUID + const value = faker.string.alphanumeric({ length: 36 }); + + const result = UuidSchema.safeParse(value); + + expect(result.success).toBe(false); + }); +}); diff --git a/src/validation/entities/schemas/uuid.schema.ts b/src/validation/entities/schemas/uuid.schema.ts new file mode 100644 index 0000000000..e06b777b4c --- /dev/null +++ b/src/validation/entities/schemas/uuid.schema.ts @@ -0,0 +1,10 @@ +import { UUID } from 'crypto'; +import { z } from 'zod'; + +export const UuidSchema = z + .string() + .uuid() + // Return type of uuid is string so we need to cast it + .transform((uuid) => { + return uuid as UUID; + });