Kaynağa Gözat

feat: getting shared vault users and removing shared vault user (#642)

* feat: getting shared vault users.

Co-authored-by: Mo <mo@standardnotes.com>

* feat: removing shared vault user.

Co-authored-by: Mo <mo@standardnotes.com>

---------

Co-authored-by: Mo <mo@standardnotes.com>
Karol Sójko 1 yıl önce
ebeveyn
işleme
e905128d45

+ 1 - 1
packages/domain-core/src/Domain/Common/RoleNameCollection.ts

@@ -28,7 +28,7 @@ export class RoleNameCollection extends ValueObject<RoleNameCollectionProps> {
     return false
   }
 
-  equals(roleNameCollection: RoleNameCollection): boolean {
+  override equals(roleNameCollection: RoleNameCollection): boolean {
     if (this.props.value.length !== roleNameCollection.value.length) {
       return false
     }

+ 21 - 0
packages/domain-core/src/Domain/Common/Uuid.spec.ts

@@ -13,4 +13,25 @@ describe('Uuid', () => {
 
     expect(valueOrError.isFailed()).toBeTruthy()
   })
+
+  it('should check equality between two value objects', () => {
+    const uuid1 = Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc1d').getValue()
+    const uuid2 = Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc1d').getValue()
+
+    expect(uuid1.equals(uuid2)).toBeTruthy()
+  })
+
+  it('should check inequality between two value objects', () => {
+    const uuid1 = Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc1d').getValue()
+    const uuid2 = Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc1e').getValue()
+
+    expect(uuid1.equals(uuid2)).toBeFalsy()
+  })
+
+  it('should check inequality between two value objects of different types', () => {
+    const uuid1 = Uuid.create('84c0f8e8-544a-4c7e-9adf-26209303bc1d').getValue()
+
+    expect(uuid1.equals(null as unknown as Uuid)).toBeFalsy()
+    expect(uuid1.equals(undefined as unknown as Uuid)).toBeFalsy()
+  })
 })

+ 8 - 0
packages/domain-core/src/Domain/Core/ValueObject.ts

@@ -7,4 +7,12 @@ export abstract class ValueObject<T extends ValueObjectProps> {
   constructor(props: T) {
     this.props = Object.freeze(props)
   }
+
+  public equals(vo?: ValueObject<T>): boolean {
+    if (vo === null || vo === undefined) {
+      return false
+    }
+
+    return JSON.stringify(this.props) === JSON.stringify(vo.props)
+  }
 }

+ 8 - 2
packages/syncing-server/src/Domain/UseCase/CreateSharedVaultFileValetToken/CreateSharedVaultFileValetToken.ts

@@ -41,7 +41,9 @@ export class CreateSharedVaultFileValetToken implements UseCaseInterface<string>
     }
 
     if (
-      sharedVaultUser.props.permission.value === SharedVaultUserPermission.PERMISSIONS.Read &&
+      sharedVaultUser.props.permission.equals(
+        SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Read).getValue(),
+      ) &&
       dto.operation !== ValetTokenOperation.Read
     ) {
       return Result.fail('User does not have permission to perform this operation')
@@ -72,7 +74,11 @@ export class CreateSharedVaultFileValetToken implements UseCaseInterface<string>
           return Result.fail('Shared vault target user not found')
         }
 
-        if (toSharedVaultUser.props.permission.value === SharedVaultUserPermission.PERMISSIONS.Read) {
+        if (
+          toSharedVaultUser.props.permission.equals(
+            SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Read).getValue(),
+          )
+        ) {
           return Result.fail('User does not have permission to perform this operation')
         }
       }

+ 26 - 25
packages/syncing-server/src/Domain/UseCase/DeleteSharedVault/DeleteSharedVault.spec.ts

@@ -1,21 +1,19 @@
-import { DomainEventPublisherInterface, NotificationRequestedEvent } from '@standardnotes/domain-events'
-import { Uuid, Timestamps } from '@standardnotes/domain-core'
+import { Uuid, Timestamps, Result } from '@standardnotes/domain-core'
 
 import { SharedVaultRepositoryInterface } from '../../SharedVault/SharedVaultRepositoryInterface'
 import { SharedVaultInviteRepositoryInterface } from '../../SharedVault/User/Invite/SharedVaultInviteRepositoryInterface'
 import { SharedVaultUserRepositoryInterface } from '../../SharedVault/User/SharedVaultUserRepositoryInterface'
 import { DeleteSharedVault } from './DeleteSharedVault'
-import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
 import { SharedVault } from '../../SharedVault/SharedVault'
 import { SharedVaultUser } from '../../SharedVault/User/SharedVaultUser'
 import { SharedVaultUserPermission } from '../../SharedVault/User/SharedVaultUserPermission'
+import { RemoveUserFromSharedVault } from '../RemoveUserFromSharedVault/RemoveUserFromSharedVault'
 
 describe('DeleteSharedVault', () => {
   let sharedVaultRepository: SharedVaultRepositoryInterface
   let sharedVaultUserRepository: SharedVaultUserRepositoryInterface
   let sharedVaultInviteRepository: SharedVaultInviteRepositoryInterface
-  let domainEventPublisher: DomainEventPublisherInterface
-  let domainEventFactory: DomainEventFactoryInterface
+  let removeUserFromSharedVault: RemoveUserFromSharedVault
   let sharedVault: SharedVault
   let sharedVaultUser: SharedVaultUser
 
@@ -24,8 +22,7 @@ describe('DeleteSharedVault', () => {
       sharedVaultRepository,
       sharedVaultUserRepository,
       sharedVaultInviteRepository,
-      domainEventPublisher,
-      domainEventFactory,
+      removeUserFromSharedVault,
     )
 
   beforeEach(() => {
@@ -47,18 +44,12 @@ describe('DeleteSharedVault', () => {
     }).getValue()
     sharedVaultUserRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>
     sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockResolvedValue([sharedVaultUser])
-    sharedVaultUserRepository.removeBySharedVaultUuid = jest.fn()
 
     sharedVaultInviteRepository = {} as jest.Mocked<SharedVaultInviteRepositoryInterface>
     sharedVaultInviteRepository.removeBySharedVaultUuid = jest.fn()
 
-    domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
-    domainEventPublisher.publish = jest.fn()
-
-    domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
-    domainEventFactory.createNotificationRequestedEvent = jest
-      .fn()
-      .mockReturnValue({} as jest.Mocked<NotificationRequestedEvent>)
+    removeUserFromSharedVault = {} as jest.Mocked<RemoveUserFromSharedVault>
+    removeUserFromSharedVault.execute = jest.fn().mockReturnValue(Result.ok())
   })
 
   it('should remove shared vault', async () => {
@@ -71,9 +62,8 @@ describe('DeleteSharedVault', () => {
 
     expect(result.isFailed()).toBeFalsy()
     expect(sharedVaultRepository.remove).toHaveBeenCalled()
-    expect(sharedVaultUserRepository.removeBySharedVaultUuid).toHaveBeenCalled()
     expect(sharedVaultInviteRepository.removeBySharedVaultUuid).toHaveBeenCalled()
-    expect(domainEventPublisher.publish).toHaveBeenCalled()
+    expect(removeUserFromSharedVault.execute).toHaveBeenCalled()
   })
 
   it('should return error when shared vault does not exist', async () => {
@@ -87,9 +77,8 @@ describe('DeleteSharedVault', () => {
 
     expect(result.isFailed()).toBeTruthy()
     expect(sharedVaultRepository.remove).not.toHaveBeenCalled()
-    expect(sharedVaultUserRepository.removeBySharedVaultUuid).not.toHaveBeenCalled()
     expect(sharedVaultInviteRepository.removeBySharedVaultUuid).not.toHaveBeenCalled()
-    expect(domainEventPublisher.publish).not.toHaveBeenCalled()
+    expect(removeUserFromSharedVault.execute).not.toHaveBeenCalled()
   })
 
   it('should return error when shared vault uuid is invalid', async () => {
@@ -102,9 +91,8 @@ describe('DeleteSharedVault', () => {
 
     expect(result.isFailed()).toBeTruthy()
     expect(sharedVaultRepository.remove).not.toHaveBeenCalled()
-    expect(sharedVaultUserRepository.removeBySharedVaultUuid).not.toHaveBeenCalled()
     expect(sharedVaultInviteRepository.removeBySharedVaultUuid).not.toHaveBeenCalled()
-    expect(domainEventPublisher.publish).not.toHaveBeenCalled()
+    expect(removeUserFromSharedVault.execute).not.toHaveBeenCalled()
   })
 
   it('should return error when originator uuid is invalid', async () => {
@@ -117,9 +105,8 @@ describe('DeleteSharedVault', () => {
 
     expect(result.isFailed()).toBeTruthy()
     expect(sharedVaultRepository.remove).not.toHaveBeenCalled()
-    expect(sharedVaultUserRepository.removeBySharedVaultUuid).not.toHaveBeenCalled()
     expect(sharedVaultInviteRepository.removeBySharedVaultUuid).not.toHaveBeenCalled()
-    expect(domainEventPublisher.publish).not.toHaveBeenCalled()
+    expect(removeUserFromSharedVault.execute).not.toHaveBeenCalled()
   })
 
   it('should return error when originator of the delete request is not the owner of the shared vault', async () => {
@@ -139,8 +126,22 @@ describe('DeleteSharedVault', () => {
 
     expect(result.isFailed()).toBeTruthy()
     expect(sharedVaultRepository.remove).not.toHaveBeenCalled()
-    expect(sharedVaultUserRepository.removeBySharedVaultUuid).not.toHaveBeenCalled()
     expect(sharedVaultInviteRepository.removeBySharedVaultUuid).not.toHaveBeenCalled()
-    expect(domainEventPublisher.publish).not.toHaveBeenCalled()
+    expect(removeUserFromSharedVault.execute).not.toHaveBeenCalled()
+  })
+
+  it('should return error if removing user from shared vault fails', async () => {
+    removeUserFromSharedVault.execute = jest.fn().mockReturnValue(Result.fail('failed'))
+    const useCase = createUseCase()
+
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      originatorUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.isFailed()).toBeTruthy()
+    expect(sharedVaultRepository.remove).not.toHaveBeenCalled()
+    expect(sharedVaultInviteRepository.removeBySharedVaultUuid).not.toHaveBeenCalled()
+    expect(removeUserFromSharedVault.execute).toHaveBeenCalled()
   })
 })

+ 13 - 17
packages/syncing-server/src/Domain/UseCase/DeleteSharedVault/DeleteSharedVault.ts

@@ -1,18 +1,17 @@
-import { NotificationType, Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+
 import { DeleteSharedVaultDTO } from './DeleteSharedVaultDTO'
 import { SharedVaultRepositoryInterface } from '../../SharedVault/SharedVaultRepositoryInterface'
 import { SharedVaultUserRepositoryInterface } from '../../SharedVault/User/SharedVaultUserRepositoryInterface'
-import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
-import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
 import { SharedVaultInviteRepositoryInterface } from '../../SharedVault/User/Invite/SharedVaultInviteRepositoryInterface'
+import { RemoveUserFromSharedVault } from '../RemoveUserFromSharedVault/RemoveUserFromSharedVault'
 
 export class DeleteSharedVault implements UseCaseInterface<void> {
   constructor(
     private sharedVaultRepository: SharedVaultRepositoryInterface,
     private sharedVaultUserRepository: SharedVaultUserRepositoryInterface,
     private sharedVaultInviteRepository: SharedVaultInviteRepositoryInterface,
-    private domainEventPublisher: DomainEventPublisherInterface,
-    private domainEventFactory: DomainEventFactoryInterface,
+    private removeUserFromSharedVault: RemoveUserFromSharedVault,
   ) {}
 
   async execute(dto: DeleteSharedVaultDTO): Promise<Result<void>> {
@@ -39,22 +38,19 @@ export class DeleteSharedVault implements UseCaseInterface<void> {
 
     const sharedVaultUsers = await this.sharedVaultUserRepository.findBySharedVaultUuid(sharedVaultUuid)
     for (const sharedVaultUser of sharedVaultUsers) {
-      await this.domainEventPublisher.publish(
-        this.domainEventFactory.createNotificationRequestedEvent({
-          payload: JSON.stringify({
-            sharedVaultUuid: sharedVault.id.toString(),
-            version: '1.0',
-          }),
-          userUuid: sharedVaultUser.props.userUuid.value,
-          type: NotificationType.TYPES.RemovedFromSharedVault,
-        }),
-      )
+      const result = await this.removeUserFromSharedVault.execute({
+        originatorUuid: originatorUuid.value,
+        sharedVaultUuid: sharedVaultUuid.value,
+        userUuid: sharedVaultUser.props.userUuid.value,
+      })
+
+      if (result.isFailed()) {
+        return Result.fail(result.getError())
+      }
     }
 
     await this.sharedVaultInviteRepository.removeBySharedVaultUuid(sharedVaultUuid)
 
-    await this.sharedVaultUserRepository.removeBySharedVaultUuid(sharedVaultUuid)
-
     await this.sharedVaultRepository.remove(sharedVault)
 
     return Result.ok()

+ 103 - 0
packages/syncing-server/src/Domain/UseCase/GetSharedVaultUsers/GetSharedVaultUsers.spec.ts

@@ -0,0 +1,103 @@
+import { Uuid, Timestamps } from '@standardnotes/domain-core'
+import { SharedVault } from '../../SharedVault/SharedVault'
+import { SharedVaultRepositoryInterface } from '../../SharedVault/SharedVaultRepositoryInterface'
+import { SharedVaultUser } from '../../SharedVault/User/SharedVaultUser'
+import { SharedVaultUserPermission } from '../../SharedVault/User/SharedVaultUserPermission'
+import { SharedVaultUserRepositoryInterface } from '../../SharedVault/User/SharedVaultUserRepositoryInterface'
+import { GetSharedVaultUsers } from './GetSharedVaultUsers'
+
+describe('GetSharedVaultUsers', () => {
+  let sharedVault: SharedVault
+  let sharedVaultUser: SharedVaultUser
+  let sharedVaultUsersRepository: SharedVaultUserRepositoryInterface
+  let sharedVaultRepository: SharedVaultRepositoryInterface
+
+  const createUseCase = () => new GetSharedVaultUsers(sharedVaultUsersRepository, sharedVaultRepository)
+
+  beforeEach(() => {
+    sharedVault = SharedVault.create({
+      fileUploadBytesLimit: 100,
+      fileUploadBytesUsed: 2,
+      userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      timestamps: Timestamps.create(123, 123).getValue(),
+    }).getValue()
+
+    sharedVaultUser = SharedVaultUser.create({
+      permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Read).getValue(),
+      sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      timestamps: Timestamps.create(123, 123).getValue(),
+    }).getValue()
+
+    sharedVaultRepository = {} as jest.Mocked<SharedVaultRepositoryInterface>
+    sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
+
+    sharedVaultUsersRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>
+    sharedVaultUsersRepository.findBySharedVaultUuid = jest.fn().mockResolvedValue([sharedVaultUser])
+  })
+
+  it('returns shared vault users', async () => {
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      originatorUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.isFailed()).toBe(false)
+    expect(result.getValue()).toEqual([sharedVaultUser])
+  })
+
+  it('returns error when shared vault is not found', async () => {
+    sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(null)
+
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      originatorUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Shared vault not found')
+  })
+
+  it('returns error when originator is not the owner of the shared vault', async () => {
+    sharedVault = SharedVault.create({
+      fileUploadBytesLimit: 100,
+      fileUploadBytesUsed: 2,
+      userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
+      timestamps: Timestamps.create(123, 123).getValue(),
+    }).getValue()
+    sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
+
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      originatorUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Only the owner can get shared vault users')
+  })
+
+  it('returns error when shared vault uuid is invalid', async () => {
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      sharedVaultUuid: 'invalid',
+      originatorUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Given value is not a valid uuid: invalid')
+  })
+
+  it('returns error when originator uuid is invalid', async () => {
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      originatorUuid: 'invalid',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Given value is not a valid uuid: invalid')
+  })
+})

+ 40 - 0
packages/syncing-server/src/Domain/UseCase/GetSharedVaultUsers/GetSharedVaultUsers.ts

@@ -0,0 +1,40 @@
+import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+import { SharedVaultUser } from '../../SharedVault/User/SharedVaultUser'
+import { GetSharedVaultUsersDTO } from './GetSharedVaultUsersDTO'
+import { SharedVaultUserRepositoryInterface } from '../../SharedVault/User/SharedVaultUserRepositoryInterface'
+import { SharedVaultRepositoryInterface } from '../../SharedVault/SharedVaultRepositoryInterface'
+
+export class GetSharedVaultUsers implements UseCaseInterface<SharedVaultUser[]> {
+  constructor(
+    private sharedVaultUsersRepository: SharedVaultUserRepositoryInterface,
+    private sharedVaultRepository: SharedVaultRepositoryInterface,
+  ) {}
+
+  async execute(dto: GetSharedVaultUsersDTO): Promise<Result<SharedVaultUser[]>> {
+    const sharedVaultUuidOrError = Uuid.create(dto.sharedVaultUuid)
+    if (sharedVaultUuidOrError.isFailed()) {
+      return Result.fail(sharedVaultUuidOrError.getError())
+    }
+    const sharedVaultUuid = sharedVaultUuidOrError.getValue()
+
+    const originatorUuidOrError = Uuid.create(dto.originatorUuid)
+    if (originatorUuidOrError.isFailed()) {
+      return Result.fail(originatorUuidOrError.getError())
+    }
+    const originatorUuid = originatorUuidOrError.getValue()
+
+    const sharedVault = await this.sharedVaultRepository.findByUuid(sharedVaultUuid)
+    if (!sharedVault) {
+      return Result.fail('Shared vault not found')
+    }
+
+    const isOriginatorTheOwnerOfTheSharedVault = sharedVault.props.userUuid.equals(originatorUuid)
+    if (!isOriginatorTheOwnerOfTheSharedVault) {
+      return Result.fail('Only the owner can get shared vault users')
+    }
+
+    const sharedVaultUsers = await this.sharedVaultUsersRepository.findBySharedVaultUuid(sharedVaultUuid)
+
+    return Result.ok(sharedVaultUsers)
+  }
+}

+ 4 - 0
packages/syncing-server/src/Domain/UseCase/GetSharedVaultUsers/GetSharedVaultUsersDTO.ts

@@ -0,0 +1,4 @@
+export interface GetSharedVaultUsersDTO {
+  sharedVaultUuid: string
+  originatorUuid: string
+}

+ 165 - 0
packages/syncing-server/src/Domain/UseCase/RemoveUserFromSharedVault/RemoveUserFromSharedVault.spec.ts

@@ -0,0 +1,165 @@
+import { Uuid, Timestamps } from '@standardnotes/domain-core'
+import { DomainEventPublisherInterface, NotificationRequestedEvent } from '@standardnotes/domain-events'
+
+import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
+import { SharedVault } from '../../SharedVault/SharedVault'
+import { SharedVaultRepositoryInterface } from '../../SharedVault/SharedVaultRepositoryInterface'
+import { SharedVaultUser } from '../../SharedVault/User/SharedVaultUser'
+import { SharedVaultUserPermission } from '../../SharedVault/User/SharedVaultUserPermission'
+import { SharedVaultUserRepositoryInterface } from '../../SharedVault/User/SharedVaultUserRepositoryInterface'
+import { RemoveUserFromSharedVault } from './RemoveUserFromSharedVault'
+
+describe('RemoveUserFromSharedVault', () => {
+  let sharedVaultRepository: SharedVaultRepositoryInterface
+  let sharedVaultUserRepository: SharedVaultUserRepositoryInterface
+  let domainEventPublisher: DomainEventPublisherInterface
+  let domainEventFactory: DomainEventFactoryInterface
+  let sharedVault: SharedVault
+  let sharedVaultUser: SharedVaultUser
+
+  const createUseCase = () =>
+    new RemoveUserFromSharedVault(
+      sharedVaultUserRepository,
+      sharedVaultRepository,
+      domainEventFactory,
+      domainEventPublisher,
+    )
+
+  beforeEach(() => {
+    sharedVault = SharedVault.create({
+      fileUploadBytesLimit: 100,
+      fileUploadBytesUsed: 2,
+      userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      timestamps: Timestamps.create(123, 123).getValue(),
+    }).getValue()
+    sharedVaultRepository = {} as jest.Mocked<SharedVaultRepositoryInterface>
+    sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
+    sharedVaultRepository.remove = jest.fn()
+
+    sharedVaultUser = SharedVaultUser.create({
+      permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Read).getValue(),
+      sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      timestamps: Timestamps.create(123, 123).getValue(),
+    }).getValue()
+    sharedVaultUserRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>
+    sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)
+    sharedVaultUserRepository.remove = jest.fn()
+
+    domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
+    domainEventPublisher.publish = jest.fn()
+
+    domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
+    domainEventFactory.createNotificationRequestedEvent = jest
+      .fn()
+      .mockReturnValue({} as jest.Mocked<NotificationRequestedEvent>)
+  })
+
+  it('should remove user from shared vault', async () => {
+    const useCase = createUseCase()
+    await useCase.execute({
+      originatorUuid: '00000000-0000-0000-0000-000000000000',
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000001',
+    })
+
+    expect(sharedVaultUserRepository.remove).toHaveBeenCalledWith(sharedVaultUser)
+    expect(domainEventPublisher.publish).toHaveBeenCalled()
+  })
+
+  it('should return error when shared vault is not found', async () => {
+    sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(null)
+
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      originatorUuid: '00000000-0000-0000-0000-000000000000',
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000001',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Shared vault not found')
+  })
+
+  it('should return error when shared vault user is not found', async () => {
+    sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(null)
+
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      originatorUuid: '00000000-0000-0000-0000-000000000000',
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000001',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('User is not a member of the shared vault')
+  })
+
+  it('should return error when user is not owner of shared vault', async () => {
+    sharedVault = SharedVault.create({
+      fileUploadBytesLimit: 100,
+      fileUploadBytesUsed: 2,
+      userUuid: Uuid.create('00000000-0000-0000-0000-000000000002').getValue(),
+      timestamps: Timestamps.create(123, 123).getValue(),
+    }).getValue()
+    sharedVaultRepository.findByUuid = jest.fn().mockResolvedValue(sharedVault)
+
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      originatorUuid: '00000000-0000-0000-0000-000000000000',
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000001',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Only owner can remove users from shared vault')
+  })
+
+  it('should return error when user is owner of shared vault', async () => {
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      originatorUuid: '00000000-0000-0000-0000-000000000000',
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Owner cannot be removed from shared vault')
+  })
+
+  it('should return error if shared vault uuid is invalid', async () => {
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      originatorUuid: '00000000-0000-0000-0000-000000000000',
+      sharedVaultUuid: 'invalid',
+      userUuid: '00000000-0000-0000-0000-000000000001',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Given value is not a valid uuid: invalid')
+  })
+
+  it('should return error if user uuid is invalid', async () => {
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      originatorUuid: '00000000-0000-0000-0000-000000000000',
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: 'invalid',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Given value is not a valid uuid: invalid')
+  })
+
+  it('should return error if originator uuid is invalid', async () => {
+    const useCase = createUseCase()
+    const result = await useCase.execute({
+      originatorUuid: 'invalid',
+      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: '00000000-0000-0000-0000-000000000001',
+    })
+
+    expect(result.isFailed()).toBe(true)
+    expect(result.getError()).toBe('Given value is not a valid uuid: invalid')
+  })
+})

+ 74 - 0
packages/syncing-server/src/Domain/UseCase/RemoveUserFromSharedVault/RemoveUserFromSharedVault.ts

@@ -0,0 +1,74 @@
+import { NotificationType, Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
+
+import { RemoveUserFromSharedVaultDTO } from './RemoveUserFromSharedVaultDTO'
+import { SharedVaultRepositoryInterface } from '../../SharedVault/SharedVaultRepositoryInterface'
+import { SharedVaultUserRepositoryInterface } from '../../SharedVault/User/SharedVaultUserRepositoryInterface'
+import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
+import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
+
+export class RemoveUserFromSharedVault implements UseCaseInterface<void> {
+  constructor(
+    private sharedVaultUsersRepository: SharedVaultUserRepositoryInterface,
+    private sharedVaultRepository: SharedVaultRepositoryInterface,
+    private domainEventFactory: DomainEventFactoryInterface,
+    private domainEventPublisher: DomainEventPublisherInterface,
+  ) {}
+
+  async execute(dto: RemoveUserFromSharedVaultDTO): Promise<Result<void>> {
+    const sharedVaultUuidOrError = Uuid.create(dto.sharedVaultUuid)
+    if (sharedVaultUuidOrError.isFailed()) {
+      return Result.fail(sharedVaultUuidOrError.getError())
+    }
+    const sharedVaultUuid = sharedVaultUuidOrError.getValue()
+
+    const originatorUuidOrError = Uuid.create(dto.originatorUuid)
+    if (originatorUuidOrError.isFailed()) {
+      return Result.fail(originatorUuidOrError.getError())
+    }
+    const originatorUuid = originatorUuidOrError.getValue()
+
+    const userUuidOrError = Uuid.create(dto.userUuid)
+    if (userUuidOrError.isFailed()) {
+      return Result.fail(userUuidOrError.getError())
+    }
+    const userUuid = userUuidOrError.getValue()
+
+    const sharedVault = await this.sharedVaultRepository.findByUuid(sharedVaultUuid)
+    if (!sharedVault) {
+      return Result.fail('Shared vault not found')
+    }
+
+    const originatorIsOwner = sharedVault.props.userUuid.equals(originatorUuid)
+    if (!originatorIsOwner) {
+      return Result.fail('Only owner can remove users from shared vault')
+    }
+
+    const removingOwner = sharedVault.props.userUuid.equals(userUuid)
+    if (removingOwner) {
+      return Result.fail('Owner cannot be removed from shared vault')
+    }
+
+    const sharedVaultUser = await this.sharedVaultUsersRepository.findByUserUuidAndSharedVaultUuid({
+      userUuid,
+      sharedVaultUuid,
+    })
+    if (!sharedVaultUser) {
+      return Result.fail('User is not a member of the shared vault')
+    }
+
+    await this.sharedVaultUsersRepository.remove(sharedVaultUser)
+
+    await this.domainEventPublisher.publish(
+      this.domainEventFactory.createNotificationRequestedEvent({
+        type: NotificationType.TYPES.RemovedFromSharedVault,
+        userUuid: sharedVaultUser.props.userUuid.value,
+        payload: JSON.stringify({
+          sharedVaultUuid: sharedVault.id.toString(),
+          version: '1.0',
+        }),
+      }),
+    )
+
+    return Result.ok()
+  }
+}

+ 5 - 0
packages/syncing-server/src/Domain/UseCase/RemoveUserFromSharedVault/RemoveUserFromSharedVaultDTO.ts

@@ -0,0 +1,5 @@
+export interface RemoveUserFromSharedVaultDTO {
+  sharedVaultUuid: string
+  originatorUuid: string
+  userUuid: string
+}