瀏覽代碼

fix(auth): prevent from loop disabling of email settings (#858)

Karol Sójko 1 年之前
父節點
當前提交
bd71422fab

+ 14 - 2
packages/auth/src/Bootstrap/Container.ts

@@ -273,6 +273,7 @@ import { UserAddedToSharedVaultEventHandler } from '../Domain/Handler/UserAddedT
 import { UserRemovedFromSharedVaultEventHandler } from '../Domain/Handler/UserRemovedFromSharedVaultEventHandler'
 import { DesignateSurvivor } from '../Domain/UseCase/DesignateSurvivor/DesignateSurvivor'
 import { UserDesignatedAsSurvivorInSharedVaultEventHandler } from '../Domain/Handler/UserDesignatedAsSurvivorInSharedVaultEventHandler'
+import { DisableEmailSettingBasedOnEmailSubscription } from '../Domain/UseCase/DisableEmailSettingBasedOnEmailSubscription/DisableEmailSettingBasedOnEmailSubscription'
 
 export class ContainerConfigLoader {
   constructor(private mode: 'server' | 'worker' = 'server') {}
@@ -965,6 +966,15 @@ export class ContainerConfigLoader {
           container.get<TimerInterface>(TYPES.Auth_Timer),
         ),
       )
+    container
+      .bind<DisableEmailSettingBasedOnEmailSubscription>(TYPES.Auth_DisableEmailSettingBasedOnEmailSubscription)
+      .toConstantValue(
+        new DisableEmailSettingBasedOnEmailSubscription(
+          container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository),
+          container.get<SettingRepositoryInterface>(TYPES.Auth_SettingRepository),
+          container.get<SettingFactoryInterface>(TYPES.Auth_SettingFactory),
+        ),
+      )
 
     // Controller
     container
@@ -1102,8 +1112,10 @@ export class ContainerConfigLoader {
       .bind<EmailSubscriptionUnsubscribedEventHandler>(TYPES.Auth_EmailSubscriptionUnsubscribedEventHandler)
       .toConstantValue(
         new EmailSubscriptionUnsubscribedEventHandler(
-          container.get(TYPES.Auth_UserRepository),
-          container.get(TYPES.Auth_SettingService),
+          container.get<DisableEmailSettingBasedOnEmailSubscription>(
+            TYPES.Auth_DisableEmailSettingBasedOnEmailSubscription,
+          ),
+          container.get<winston.Logger>(TYPES.Auth_Logger),
         ),
       )
     container

+ 1 - 0
packages/auth/src/Bootstrap/Types.ts

@@ -161,6 +161,7 @@ const TYPES = {
   Auth_AddSharedVaultUser: Symbol.for('Auth_AddSharedVaultUser'),
   Auth_RemoveSharedVaultUser: Symbol.for('Auth_RemoveSharedVaultUser'),
   Auth_DesignateSurvivor: Symbol.for('Auth_DesignateSurvivor'),
+  Auth_DisableEmailSettingBasedOnEmailSubscription: Symbol.for('Auth_DisableEmailSettingBasedOnEmailSubscription'),
   // Handlers
   Auth_UserRegisteredEventHandler: Symbol.for('Auth_UserRegisteredEventHandler'),
   Auth_AccountDeletionRequestedEventHandler: Symbol.for('Auth_AccountDeletionRequestedEventHandler'),

+ 0 - 117
packages/auth/src/Domain/Handler/EmailSubscriptionUnsubscribedEventHandler.spec.ts

@@ -1,117 +0,0 @@
-import { EmailLevel } from '@standardnotes/domain-core'
-import { EmailSubscriptionUnsubscribedEvent } from '@standardnotes/domain-events'
-
-import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
-import { User } from '../User/User'
-import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
-import { EmailSubscriptionUnsubscribedEventHandler } from './EmailSubscriptionUnsubscribedEventHandler'
-
-describe('EmailSubscriptionUnsubscribedEventHandler', () => {
-  let userRepository: UserRepositoryInterface
-  let settingsService: SettingServiceInterface
-  let event: EmailSubscriptionUnsubscribedEvent
-
-  const createHandler = () => new EmailSubscriptionUnsubscribedEventHandler(userRepository, settingsService)
-
-  beforeEach(() => {
-    userRepository = {} as jest.Mocked<UserRepositoryInterface>
-    userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue({} as jest.Mocked<User>)
-
-    settingsService = {} as jest.Mocked<SettingServiceInterface>
-    settingsService.createOrReplace = jest.fn()
-
-    event = {
-      payload: {
-        userEmail: 'test@test.te',
-        level: EmailLevel.LEVELS.Marketing,
-      },
-    } as jest.Mocked<EmailSubscriptionUnsubscribedEvent>
-  })
-
-  it('should not do anything if username is invalid', async () => {
-    event.payload.userEmail = ''
-
-    await createHandler().handle(event)
-
-    expect(settingsService.createOrReplace).not.toHaveBeenCalled()
-  })
-
-  it('should not do anything if user is not found', async () => {
-    userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(null)
-
-    await createHandler().handle(event)
-
-    expect(settingsService.createOrReplace).not.toHaveBeenCalled()
-  })
-
-  it('should update user marketing email settings', async () => {
-    await createHandler().handle(event)
-
-    expect(settingsService.createOrReplace).toHaveBeenCalledWith({
-      user: {},
-      props: {
-        name: 'MUTE_MARKETING_EMAILS',
-        unencryptedValue: 'muted',
-        sensitive: false,
-      },
-    })
-  })
-
-  it('should update user sign in email settings', async () => {
-    event.payload.level = EmailLevel.LEVELS.SignIn
-
-    await createHandler().handle(event)
-
-    expect(settingsService.createOrReplace).toHaveBeenCalledWith({
-      user: {},
-      props: {
-        name: 'MUTE_SIGN_IN_EMAILS',
-        unencryptedValue: 'muted',
-        sensitive: false,
-      },
-    })
-  })
-
-  it('should update user email backup email settings', async () => {
-    event.payload.level = EmailLevel.LEVELS.FailedEmailBackup
-
-    await createHandler().handle(event)
-
-    expect(settingsService.createOrReplace).toHaveBeenCalledWith({
-      user: {},
-      props: {
-        name: 'MUTE_FAILED_BACKUPS_EMAILS',
-        unencryptedValue: 'muted',
-        sensitive: false,
-      },
-    })
-  })
-
-  it('should update user email backup email settings', async () => {
-    event.payload.level = EmailLevel.LEVELS.FailedCloudBackup
-
-    await createHandler().handle(event)
-
-    expect(settingsService.createOrReplace).toHaveBeenCalledWith({
-      user: {},
-      props: {
-        name: 'MUTE_FAILED_CLOUD_BACKUPS_EMAILS',
-        unencryptedValue: 'muted',
-        sensitive: false,
-      },
-    })
-  })
-
-  it('should throw error for unrecognized level', async () => {
-    event.payload.level = 'foobar'
-
-    let caughtError = null
-    try {
-      await createHandler().handle(event)
-    } catch (error) {
-      caughtError = error
-    }
-
-    expect(caughtError).not.toBeNull()
-  })
-})

+ 9 - 38
packages/auth/src/Domain/Handler/EmailSubscriptionUnsubscribedEventHandler.ts

@@ -1,50 +1,21 @@
-import { EmailLevel, Username } from '@standardnotes/domain-core'
 import { DomainEventHandlerInterface, EmailSubscriptionUnsubscribedEvent } from '@standardnotes/domain-events'
-import { SettingName } from '@standardnotes/settings'
-
-import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
-import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
+import { Logger } from 'winston'
+import { DisableEmailSettingBasedOnEmailSubscription } from '../UseCase/DisableEmailSettingBasedOnEmailSubscription/DisableEmailSettingBasedOnEmailSubscription'
 
 export class EmailSubscriptionUnsubscribedEventHandler implements DomainEventHandlerInterface {
   constructor(
-    private userRepository: UserRepositoryInterface,
-    private settingsService: SettingServiceInterface,
+    private disableEmailSettingBasedOnEmailSubscription: DisableEmailSettingBasedOnEmailSubscription,
+    private logger: Logger,
   ) {}
 
   async handle(event: EmailSubscriptionUnsubscribedEvent): Promise<void> {
-    const usernameOrError = Username.create(event.payload.userEmail)
-    if (usernameOrError.isFailed()) {
-      return
-    }
-    const username = usernameOrError.getValue()
-
-    const user = await this.userRepository.findOneByUsernameOrEmail(username)
-    if (user === null) {
-      return
-    }
-
-    await this.settingsService.createOrReplace({
-      user,
-      props: {
-        name: this.getSettingNameFromLevel(event.payload.level),
-        unencryptedValue: 'muted',
-        sensitive: false,
-      },
+    const result = await this.disableEmailSettingBasedOnEmailSubscription.execute({
+      userEmail: event.payload.userEmail,
+      level: event.payload.level,
     })
-  }
 
-  private getSettingNameFromLevel(level: string): string {
-    switch (level) {
-      case EmailLevel.LEVELS.FailedCloudBackup:
-        return SettingName.NAMES.MuteFailedCloudBackupsEmails
-      case EmailLevel.LEVELS.FailedEmailBackup:
-        return SettingName.NAMES.MuteFailedBackupsEmails
-      case EmailLevel.LEVELS.Marketing:
-        return SettingName.NAMES.MuteMarketingEmails
-      case EmailLevel.LEVELS.SignIn:
-        return SettingName.NAMES.MuteSignInEmails
-      default:
-        throw new Error(`Unknown level: ${level}`)
+    if (result.isFailed()) {
+      this.logger.error(`Failed to disable email setting for user ${event.payload.userEmail}: ${result.getError()}`)
     }
   }
 }

+ 96 - 0
packages/auth/src/Domain/UseCase/DisableEmailSettingBasedOnEmailSubscription/DisableEmailSettingBasedOnEmailSubscription.spec.ts

@@ -0,0 +1,96 @@
+import { EmailLevel } from '@standardnotes/domain-core'
+import { Setting } from '../../Setting/Setting'
+import { SettingFactoryInterface } from '../../Setting/SettingFactoryInterface'
+import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
+import { User } from '../../User/User'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
+import { DisableEmailSettingBasedOnEmailSubscription } from './DisableEmailSettingBasedOnEmailSubscription'
+
+describe('DisableEmailSettingBasedOnEmailSubscription', () => {
+  let userRepository: UserRepositoryInterface
+  let settingRepository: SettingRepositoryInterface
+  let factory: SettingFactoryInterface
+  let user: User
+
+  const createUseCase = () =>
+    new DisableEmailSettingBasedOnEmailSubscription(userRepository, settingRepository, factory)
+
+  beforeEach(() => {
+    user = {} as jest.Mocked<User>
+    user.uuid = 'userUuid'
+
+    userRepository = {} as jest.Mocked<UserRepositoryInterface>
+    userRepository.findOneByUsernameOrEmail = jest.fn().mockResolvedValue(user)
+
+    settingRepository = {} as jest.Mocked<SettingRepositoryInterface>
+    settingRepository.findLastByNameAndUserUuid = jest.fn().mockResolvedValue({} as jest.Mocked<Setting>)
+    settingRepository.save = jest.fn()
+
+    factory = {} as jest.Mocked<SettingFactoryInterface>
+    factory.create = jest.fn().mockResolvedValue({} as jest.Mocked<Setting>)
+    factory.createReplacement = jest.fn().mockResolvedValue({} as jest.Mocked<Setting>)
+  })
+
+  it('should fail if the username is empty', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userEmail: '',
+      level: EmailLevel.LEVELS.Marketing,
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('should fail if the user is not found', async () => {
+    userRepository.findOneByUsernameOrEmail = jest.fn().mockResolvedValue(null)
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userEmail: 'test@test.te',
+      level: EmailLevel.LEVELS.Marketing,
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('should fail if the setting name cannot be determined', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userEmail: 'test@test.te',
+      level: 'invalid',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+  })
+
+  it('should create a new setting if it does not exist', async () => {
+    settingRepository.findLastByNameAndUserUuid = jest.fn().mockResolvedValue(null)
+
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userEmail: 'test@test.te',
+      level: EmailLevel.LEVELS.Marketing,
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(factory.create).toHaveBeenCalled()
+    expect(factory.createReplacement).not.toHaveBeenCalled()
+  })
+
+  it('should replace the setting if it exists', async () => {
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      userEmail: 'test@test.te',
+      level: EmailLevel.LEVELS.Marketing,
+    })
+
+    expect(result.isFailed()).toBeFalsy()
+    expect(factory.create).not.toHaveBeenCalled()
+    expect(factory.createReplacement).toHaveBeenCalled()
+  })
+})

+ 72 - 0
packages/auth/src/Domain/UseCase/DisableEmailSettingBasedOnEmailSubscription/DisableEmailSettingBasedOnEmailSubscription.ts

@@ -0,0 +1,72 @@
+import { EmailLevel, Result, UseCaseInterface, Username } from '@standardnotes/domain-core'
+import { SettingName } from '@standardnotes/settings'
+
+import { DisableEmailSettingBasedOnEmailSubscriptionDTO } from './DisableEmailSettingBasedOnEmailSubscriptionDTO'
+import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
+import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface'
+import { SettingFactoryInterface } from '../../Setting/SettingFactoryInterface'
+
+export class DisableEmailSettingBasedOnEmailSubscription implements UseCaseInterface<void> {
+  constructor(
+    private userRepository: UserRepositoryInterface,
+    private settingRepository: SettingRepositoryInterface,
+    private factory: SettingFactoryInterface,
+  ) {}
+
+  async execute(dto: DisableEmailSettingBasedOnEmailSubscriptionDTO): Promise<Result<void>> {
+    const usernameOrError = Username.create(dto.userEmail)
+    if (usernameOrError.isFailed()) {
+      return Result.fail(usernameOrError.getError())
+    }
+    const username = usernameOrError.getValue()
+
+    const user = await this.userRepository.findOneByUsernameOrEmail(username)
+    if (user === null) {
+      return Result.fail(`User not found for email ${dto.userEmail}`)
+    }
+
+    const settingNameOrError = this.getSettingNameFromLevel(dto.level)
+    if (settingNameOrError.isFailed()) {
+      return Result.fail(settingNameOrError.getError())
+    }
+    const settingName = settingNameOrError.getValue()
+
+    let setting = await this.settingRepository.findLastByNameAndUserUuid(settingName, user.uuid)
+    if (!setting) {
+      setting = await this.factory.create(
+        {
+          name: settingName,
+          unencryptedValue: 'muted',
+          sensitive: false,
+        },
+        user,
+      )
+    } else {
+      setting = await this.factory.createReplacement(setting, {
+        name: settingName,
+        unencryptedValue: 'muted',
+        sensitive: false,
+      })
+    }
+
+    await this.settingRepository.save(setting)
+
+    return Result.ok()
+  }
+
+  private getSettingNameFromLevel(level: string): Result<string> {
+    /* istanbul ignore next */
+    switch (level) {
+      case EmailLevel.LEVELS.FailedCloudBackup:
+        return Result.ok(SettingName.NAMES.MuteFailedCloudBackupsEmails)
+      case EmailLevel.LEVELS.FailedEmailBackup:
+        return Result.ok(SettingName.NAMES.MuteFailedBackupsEmails)
+      case EmailLevel.LEVELS.Marketing:
+        return Result.ok(SettingName.NAMES.MuteMarketingEmails)
+      case EmailLevel.LEVELS.SignIn:
+        return Result.ok(SettingName.NAMES.MuteSignInEmails)
+      default:
+        return Result.fail(`Unknown level: ${level}`)
+    }
+  }
+}

+ 4 - 0
packages/auth/src/Domain/UseCase/DisableEmailSettingBasedOnEmailSubscription/DisableEmailSettingBasedOnEmailSubscriptionDTO.ts

@@ -0,0 +1,4 @@
+export interface DisableEmailSettingBasedOnEmailSubscriptionDTO {
+  userEmail: string
+  level: string
+}