瀏覽代碼

feat(syncing-server): filtering items by shared vault permissions (#670)

Co-authored-by: Mo <mo@standardnotes.com>
Karol Sójko 1 年之前
父節點
當前提交
5f7e768e64

+ 15 - 0
packages/syncing-server/src/Bootstrap/Container.ts

@@ -150,6 +150,8 @@ import { MessageHttpMapper } from '../Mapping/Http/MessageHttpMapper'
 import { GetUserNotifications } from '../Domain/UseCase/Messaging/GetUserNotifications/GetUserNotifications'
 import { NotificationHttpMapper } from '../Mapping/Http/NotificationHttpMapper'
 import { NotificationHttpRepresentation } from '../Mapping/Http/NotificationHttpRepresentation'
+import { DetermineSharedVaultOperationOnItem } from '../Domain/UseCase/SharedVaults/DetermineSharedVaultOperationOnItem/DetermineSharedVaultOperationOnItem'
+import { SharedVaultFilter } from '../Domain/Item/SaveRule/SharedVaultFilter'
 
 export class ContainerConfigLoader {
   private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@@ -494,12 +496,24 @@ export class ContainerConfigLoader {
       .bind<TokenEncoderInterface<SharedVaultValetTokenData>>(TYPES.Sync_SharedVaultValetTokenEncoder)
       .toConstantValue(new TokenEncoder<SharedVaultValetTokenData>(container.get(TYPES.Sync_VALET_TOKEN_SECRET)))
 
+    container
+      .bind<DetermineSharedVaultOperationOnItem>(TYPES.Sync_DetermineSharedVaultOperationOnItem)
+      .toConstantValue(new DetermineSharedVaultOperationOnItem())
+
     container.bind<OwnershipFilter>(TYPES.Sync_OwnershipFilter).toConstantValue(new OwnershipFilter())
     container
       .bind<TimeDifferenceFilter>(TYPES.Sync_TimeDifferenceFilter)
       .toConstantValue(new TimeDifferenceFilter(container.get(TYPES.Sync_Timer)))
     container.bind<ContentTypeFilter>(TYPES.Sync_ContentTypeFilter).toConstantValue(new ContentTypeFilter())
     container.bind<ContentFilter>(TYPES.Sync_ContentFilter).toConstantValue(new ContentFilter())
+    container
+      .bind<SharedVaultFilter>(TYPES.Sync_SharedVaultFilter)
+      .toConstantValue(
+        new SharedVaultFilter(
+          container.get(TYPES.Sync_DetermineSharedVaultOperationOnItem),
+          container.get(TYPES.Sync_SharedVaultUserRepository),
+        ),
+      )
     container
       .bind<ItemSaveValidatorInterface>(TYPES.Sync_ItemSaveValidator)
       .toConstantValue(
@@ -508,6 +522,7 @@ export class ContainerConfigLoader {
           container.get(TYPES.Sync_TimeDifferenceFilter),
           container.get(TYPES.Sync_ContentTypeFilter),
           container.get(TYPES.Sync_ContentFilter),
+          container.get(TYPES.Sync_SharedVaultFilter),
         ]),
       )
 

+ 2 - 0
packages/syncing-server/src/Bootstrap/Types.ts

@@ -76,6 +76,7 @@ const TYPES = {
   Sync_GetItems: Symbol.for('Sync_GetItems'),
   Sync_SaveItems: Symbol.for('Sync_SaveItems'),
   Sync_GetUserNotifications: Symbol.for('Sync_GetUserNotifications'),
+  Sync_DetermineSharedVaultOperationOnItem: Symbol.for('Sync_DetermineSharedVaultOperationOnItem'),
   // Handlers
   Sync_AccountDeletionRequestedEventHandler: Symbol.for('Sync_AccountDeletionRequestedEventHandler'),
   Sync_DuplicateItemSyncedEventHandler: Symbol.for('Sync_DuplicateItemSyncedEventHandler'),
@@ -98,6 +99,7 @@ const TYPES = {
   Sync_ItemBackupService: Symbol.for('Sync_ItemBackupService'),
   Sync_ItemSaveValidator: Symbol.for('Sync_ItemSaveValidator'),
   Sync_OwnershipFilter: Symbol.for('Sync_OwnershipFilter'),
+  Sync_SharedVaultFilter: Symbol.for('Sync_SharedVaultFilter'),
   Sync_TimeDifferenceFilter: Symbol.for('Sync_TimeDifferenceFilter'),
   Sync_ContentTypeFilter: Symbol.for('Sync_ContentTypeFilter'),
   Sync_ContentFilter: Symbol.for('Sync_ContentFilter'),

+ 6 - 3
packages/syncing-server/src/Domain/Item/Item.ts

@@ -20,13 +20,16 @@ export class Item extends Aggregate<ItemProps> {
     return this.props.sharedVaultAssociation.props.sharedVaultUuid
   }
 
+  isAssociatedWithASharedVault(): boolean {
+    return this.sharedVaultUuid !== null
+  }
+
   isAssociatedWithSharedVault(sharedVaultUuid: Uuid): boolean {
-    const associatedSharedVaultUuid = this.sharedVaultUuid
-    if (!associatedSharedVaultUuid) {
+    if (!this.isAssociatedWithASharedVault()) {
       return false
     }
 
-    return associatedSharedVaultUuid.equals(sharedVaultUuid)
+    return (this.sharedVaultUuid as Uuid).equals(sharedVaultUuid)
   }
 
   isAssociatedWithKeySystem(keySystemIdentifier: string): boolean {

+ 25 - 0
packages/syncing-server/src/Domain/Item/SaveRule/OwnershipFilter.spec.ts

@@ -59,6 +59,31 @@ describe('OwnershipFilter', () => {
     })
   })
 
+  it('should deffer to the shared vault filter if the item hash represents a shared vault item or existing item is a shared vault item', async () => {
+    const itemHash = ItemHash.create({
+      uuid: '2-3-4',
+      content_type: ContentType.TYPES.Note,
+      user_uuid: '00000000-0000-0000-0000-000000000000',
+      content: 'foobar',
+      created_at: '2020-01-01T00:00:00.000Z',
+      updated_at: '2020-01-01T00:00:00.000Z',
+      created_at_timestamp: 123,
+      updated_at_timestamp: 123,
+      key_system_identifier: null,
+      shared_vault_uuid: '00000000-0000-0000-0000-000000000000',
+    }).getValue()
+    const result = await createFilter().check({
+      userUuid: '00000000-0000-0000-0000-000000000001',
+      apiVersion: ApiVersion.v20200115,
+      itemHash,
+      existingItem,
+    })
+
+    expect(result).toEqual({
+      passed: true,
+    })
+  })
+
   it('should leave items belonging to the same user', async () => {
     const result = await createFilter().check({
       userUuid: '00000000-0000-0000-0000-000000000000',

+ 8 - 0
packages/syncing-server/src/Domain/Item/SaveRule/OwnershipFilter.ts

@@ -6,6 +6,14 @@ import { Uuid } from '@standardnotes/domain-core'
 
 export class OwnershipFilter implements ItemSaveRuleInterface {
   async check(dto: ItemSaveValidationDTO): Promise<ItemSaveRuleResult> {
+    const deferToSharedVaultFilter =
+      dto.existingItem?.isAssociatedWithASharedVault() || dto.itemHash.representsASharedVaultItem()
+    if (deferToSharedVaultFilter) {
+      return {
+        passed: true,
+      }
+    }
+
     const userUuidOrError = Uuid.create(dto.userUuid)
     if (userUuidOrError.isFailed()) {
       return {

+ 825 - 0
packages/syncing-server/src/Domain/Item/SaveRule/SharedVaultFilter.spec.ts

@@ -0,0 +1,825 @@
+import { ContentType, Dates, Result, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
+import { SharedVaultUser } from '../../SharedVault/User/SharedVaultUser'
+import { SharedVaultUserPermission } from '../../SharedVault/User/SharedVaultUserPermission'
+import { SharedVaultUserRepositoryInterface } from '../../SharedVault/User/SharedVaultUserRepositoryInterface'
+import { DetermineSharedVaultOperationOnItem } from '../../UseCase/SharedVaults/DetermineSharedVaultOperationOnItem/DetermineSharedVaultOperationOnItem'
+import { SharedVaultFilter } from './SharedVaultFilter'
+import { ItemHash } from '../ItemHash'
+import { Item } from '../Item'
+import { SharedVaultOperationOnItem } from '../../SharedVault/SharedVaultOperationOnItem'
+import { SharedVaultAssociation } from '../../SharedVault/SharedVaultAssociation'
+
+describe('SharedVaultFilter', () => {
+  let determineSharedVaultOperationOnItem: DetermineSharedVaultOperationOnItem
+  let sharedVaultUserRepository: SharedVaultUserRepositoryInterface
+  let sharedVaultUser: SharedVaultUser
+  let itemHash: ItemHash
+  let existingItem: Item
+
+  const createFilter = () => new SharedVaultFilter(determineSharedVaultOperationOnItem, sharedVaultUserRepository)
+
+  beforeEach(() => {
+    existingItem = Item.create(
+      {
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+        updatedWithSession: null,
+        content: 'foobar',
+        contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+        encItemKey: null,
+        authHash: null,
+        itemsKeyId: null,
+        duplicateOf: null,
+        deleted: false,
+        dates: Dates.create(new Date(1616164633241311), new Date(1616164633241311)).getValue(),
+        timestamps: Timestamps.create(1616164633241311, 1616164633241311).getValue(),
+        sharedVaultAssociation: SharedVaultAssociation.create({
+          itemUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+          lastEditedBy: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+          sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+          timestamps: Timestamps.create(123, 123).getValue(),
+        }).getValue(),
+      },
+      new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
+    ).getValue()
+
+    itemHash = ItemHash.create({
+      uuid: '2-3-4',
+      content_type: ContentType.TYPES.Note,
+      user_uuid: '00000000-0000-0000-0000-000000000000',
+      content: 'foobar',
+      created_at: '2020-01-01T00:00:00.000Z',
+      updated_at: '2020-01-01T00:00:00.000Z',
+      created_at_timestamp: 123,
+      updated_at_timestamp: 123,
+      key_system_identifier: 'key-system-identifier',
+      shared_vault_uuid: '00000000-0000-0000-0000-000000000000',
+    }).getValue()
+
+    sharedVaultUser = SharedVaultUser.create({
+      permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Write).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()
+
+    determineSharedVaultOperationOnItem = {} as jest.Mocked<DetermineSharedVaultOperationOnItem>
+    determineSharedVaultOperationOnItem.execute = jest.fn()
+
+    sharedVaultUserRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>
+    sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest
+      .fn()
+      .mockResolvedValueOnce(sharedVaultUser)
+      .mockResolvedValueOnce(null)
+  })
+
+  it('should return as passed if the item hash does not represent a shared vault item', async () => {
+    itemHash = ItemHash.create({
+      ...itemHash.props,
+      shared_vault_uuid: null,
+    }).getValue()
+
+    const filter = createFilter()
+    const result = await filter.check({
+      apiVersion: '001',
+      existingItem: existingItem,
+      itemHash: itemHash,
+      userUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.passed).toBe(true)
+  })
+
+  it('should return as passed if the item is not a shared vault item', async () => {
+    existingItem = Item.create({
+      ...existingItem.props,
+      sharedVaultAssociation: undefined,
+    }).getValue()
+
+    const filter = createFilter()
+    const result = await filter.check({
+      apiVersion: '001',
+      existingItem: existingItem,
+      itemHash: itemHash,
+      userUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.passed).toBe(true)
+  })
+
+  it('should return as not passed if the operation could not be determined', async () => {
+    determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(Result.fail('error'))
+
+    const filter = createFilter()
+    const result = await filter.check({
+      apiVersion: '001',
+      existingItem: existingItem,
+      itemHash: itemHash,
+      userUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.passed).toBe(false)
+  })
+
+  it('should return as not passed if the item is a shared vault item without a dedicated key system association', async () => {
+    determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+      Result.ok(
+        SharedVaultOperationOnItem.create({
+          userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+          sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+          type: SharedVaultOperationOnItem.TYPES.AddToSharedVault,
+          incomingItemHash: itemHash,
+        }).getValue(),
+      ),
+    )
+
+    itemHash = ItemHash.create({
+      uuid: '2-3-4',
+      content_type: ContentType.TYPES.Note,
+      user_uuid: '00000000-0000-0000-0000-000000000000',
+      content: 'foobar',
+      created_at: '2020-01-01T00:00:00.000Z',
+      updated_at: '2020-01-01T00:00:00.000Z',
+      created_at_timestamp: 123,
+      updated_at_timestamp: 123,
+      key_system_identifier: null,
+      shared_vault_uuid: '00000000-0000-0000-0000-000000000000',
+    }).getValue()
+
+    const filter = createFilter()
+    const result = await filter.check({
+      apiVersion: '001',
+      existingItem: existingItem,
+      itemHash: itemHash,
+      userUuid: '00000000-0000-0000-0000-000000000000',
+    })
+
+    expect(result.passed).toBe(false)
+  })
+
+  describe('when the shared vault operation on item is: move to other shared vault', () => {
+    beforeEach(() => {
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            targetSharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.MoveToOtherSharedVault,
+            incomingItemHash: itemHash,
+          }).getValue(),
+        ),
+      )
+
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest
+        .fn()
+        .mockResolvedValueOnce(sharedVaultUser)
+        .mockResolvedValueOnce(sharedVaultUser)
+    })
+
+    it('should return as not passed if the user is not a member of the shared vault', async () => {
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(null)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+
+    it('should return as not passed if the user is not a member of the target shared vault', async () => {
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest
+        .fn()
+        .mockResolvedValue(sharedVaultUser)
+        .mockResolvedValue(null)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+
+    it('should return as passed if the user is a member of both shared vaults', async () => {
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(true)
+    })
+
+    it('should return as not passed if the user is not a member of the target shared vault', async () => {
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest
+        .fn()
+        .mockReturnValueOnce(sharedVaultUser)
+        .mockReturnValueOnce(null)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+
+    it('should return as not passed if the item is deleted', async () => {
+      existingItem = Item.create(
+        {
+          userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+          updatedWithSession: null,
+          content: 'foobar',
+          contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+          encItemKey: null,
+          authHash: null,
+          itemsKeyId: null,
+          duplicateOf: null,
+          deleted: true,
+          dates: Dates.create(new Date(1616164633241311), new Date(1616164633241311)).getValue(),
+          timestamps: Timestamps.create(1616164633241311, 1616164633241311).getValue(),
+          sharedVaultAssociation: SharedVaultAssociation.create({
+            itemUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            lastEditedBy: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            timestamps: Timestamps.create(123, 123).getValue(),
+          }).getValue(),
+        },
+        new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
+      ).getValue()
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            targetSharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.MoveToOtherSharedVault,
+            incomingItemHash: itemHash,
+            existingItem,
+          }).getValue(),
+        ),
+      )
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+
+    it('should return as not passed if the item is being deleted', async () => {
+      itemHash = ItemHash.create({
+        uuid: '2-3-4',
+        content_type: ContentType.TYPES.Note,
+        user_uuid: '00000000-0000-0000-0000-000000000000',
+        content: 'foobar',
+        created_at: '2020-01-01T00:00:00.000Z',
+        updated_at: '2020-01-01T00:00:00.000Z',
+        created_at_timestamp: 123,
+        updated_at_timestamp: 123,
+        key_system_identifier: null,
+        shared_vault_uuid: '00000000-0000-0000-0000-000000000000',
+        deleted: true,
+      }).getValue()
+
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            targetSharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.MoveToOtherSharedVault,
+            incomingItemHash: itemHash,
+            existingItem,
+          }).getValue(),
+        ),
+      )
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+
+    it('should return as not passed if the user has insufficient permissions to write key system items key', async () => {
+      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()
+
+      itemHash = ItemHash.create({
+        ...itemHash.props,
+        content_type: ContentType.TYPES.KeySystemItemsKey,
+      }).getValue()
+
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            targetSharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.MoveToOtherSharedVault,
+            incomingItemHash: itemHash,
+          }).getValue(),
+        ),
+      )
+
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+  })
+
+  describe('when the shared vault operation on item is: add to shared vault', () => {
+    beforeEach(() => {
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.AddToSharedVault,
+            incomingItemHash: itemHash,
+            existingItem,
+          }).getValue(),
+        ),
+      )
+    })
+
+    it('should return as not passed if the user is not a member of the shared vault', async () => {
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(null)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+
+    it('should return as passed if the user is a member of the shared vault', async () => {
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(true)
+    })
+
+    it('should return as not passed if the item is deleted', async () => {
+      existingItem = Item.create(
+        {
+          userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+          updatedWithSession: null,
+          content: 'foobar',
+          contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+          encItemKey: null,
+          authHash: null,
+          itemsKeyId: null,
+          duplicateOf: null,
+          deleted: true,
+          dates: Dates.create(new Date(1616164633241311), new Date(1616164633241311)).getValue(),
+          timestamps: Timestamps.create(1616164633241311, 1616164633241311).getValue(),
+          sharedVaultAssociation: SharedVaultAssociation.create({
+            itemUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            lastEditedBy: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            timestamps: Timestamps.create(123, 123).getValue(),
+          }).getValue(),
+        },
+        new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
+      ).getValue()
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.AddToSharedVault,
+            incomingItemHash: itemHash,
+            existingItem,
+          }).getValue(),
+        ),
+      )
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+
+    it('should return as not passed if the user is not the owner of the item', async () => {
+      existingItem = Item.create({
+        ...existingItem.props,
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
+      }).getValue()
+
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000002').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.AddToSharedVault,
+            incomingItemHash: itemHash,
+            existingItem,
+          }).getValue(),
+        ),
+      )
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000001',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+
+    it('should return as not passed if the user has insufficient permissions to write key system items key', async () => {
+      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()
+
+      itemHash = ItemHash.create({
+        ...itemHash.props,
+        content_type: ContentType.TYPES.KeySystemItemsKey,
+      }).getValue()
+
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.AddToSharedVault,
+            incomingItemHash: itemHash,
+            existingItem,
+          }).getValue(),
+        ),
+      )
+
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+  })
+
+  describe('when the shared vault operation on item is: remove from shared vault', () => {
+    beforeEach(() => {
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.RemoveFromSharedVault,
+            incomingItemHash: itemHash,
+            existingItem,
+          }).getValue(),
+        ),
+      )
+    })
+
+    it('should return as not passed if the user is not a member of the shared vault', async () => {
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(null)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+
+    it('should return as passed if the user is a member of the shared vault', async () => {
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(true)
+    })
+
+    it('should return as not passed if the item is deleted', async () => {
+      existingItem = Item.create(
+        {
+          userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+          updatedWithSession: null,
+          content: 'foobar',
+          contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
+          encItemKey: null,
+          authHash: null,
+          itemsKeyId: null,
+          duplicateOf: null,
+          deleted: true,
+          dates: Dates.create(new Date(1616164633241311), new Date(1616164633241311)).getValue(),
+          timestamps: Timestamps.create(1616164633241311, 1616164633241311).getValue(),
+          sharedVaultAssociation: SharedVaultAssociation.create({
+            itemUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            lastEditedBy: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            timestamps: Timestamps.create(123, 123).getValue(),
+          }).getValue(),
+        },
+        new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
+      ).getValue()
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.RemoveFromSharedVault,
+            incomingItemHash: itemHash,
+            existingItem,
+          }).getValue(),
+        ),
+      )
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+
+    it('should return as not passed if the user is not the owner of the item', async () => {
+      existingItem = Item.create({
+        ...existingItem.props,
+        userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
+      }).getValue()
+
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000002').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.RemoveFromSharedVault,
+            incomingItemHash: itemHash,
+            existingItem,
+          }).getValue(),
+        ),
+      )
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000001',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+
+    it('should return as not passed if the user has insufficient permissions to write key system items key', async () => {
+      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()
+
+      itemHash = ItemHash.create({
+        ...itemHash.props,
+        content_type: ContentType.TYPES.KeySystemItemsKey,
+      }).getValue()
+
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.RemoveFromSharedVault,
+            incomingItemHash: itemHash,
+            existingItem,
+          }).getValue(),
+        ),
+      )
+
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+  })
+
+  describe('when the shared vault operation on item is: save to shared vault', () => {
+    beforeEach(() => {
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.SaveToSharedVault,
+            incomingItemHash: itemHash,
+            existingItem,
+          }).getValue(),
+        ),
+      )
+    })
+
+    it('should return as not passed if the user is not a member of the shared vault', async () => {
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(null)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+
+    it('should return as passed if the user is a member of the shared vault', async () => {
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(true)
+    })
+
+    it('should return as not passed if the user has insufficient permissions', async () => {
+      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.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+  })
+
+  describe('when the shared vault operation on item is: create to shared vault', () => {
+    beforeEach(() => {
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.CreateToSharedVault,
+            incomingItemHash: itemHash,
+            existingItem,
+          }).getValue(),
+        ),
+      )
+    })
+
+    it('should return as not passed if the user is not a member of the shared vault', async () => {
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(null)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+
+    it('should return as passed if the user is a member of the shared vault', async () => {
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(true)
+    })
+
+    it('should return as not passed if the user has insufficient permissions to write key system items key', async () => {
+      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()
+
+      itemHash = ItemHash.create({
+        ...itemHash.props,
+        content_type: ContentType.TYPES.KeySystemItemsKey,
+      }).getValue()
+
+      determineSharedVaultOperationOnItem.execute = jest.fn().mockReturnValue(
+        Result.ok(
+          SharedVaultOperationOnItem.create({
+            userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+            type: SharedVaultOperationOnItem.TYPES.CreateToSharedVault,
+            incomingItemHash: itemHash,
+          }).getValue(),
+        ),
+      )
+
+      sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)
+
+      const filter = createFilter()
+      const result = await filter.check({
+        apiVersion: '001',
+        existingItem: existingItem,
+        itemHash: itemHash,
+        userUuid: '00000000-0000-0000-0000-000000000000',
+      })
+
+      expect(result.passed).toBe(false)
+    })
+  })
+})

+ 231 - 0
packages/syncing-server/src/Domain/Item/SaveRule/SharedVaultFilter.ts

@@ -0,0 +1,231 @@
+import { ConflictType } from '@standardnotes/responses'
+import { ContentType, Result, Uuid } from '@standardnotes/domain-core'
+
+import { ItemSaveValidationDTO } from '../SaveValidator/ItemSaveValidationDTO'
+import { ItemSaveRuleResult } from './ItemSaveRuleResult'
+import { ItemSaveRuleInterface } from './ItemSaveRuleInterface'
+import { DetermineSharedVaultOperationOnItem } from '../../UseCase/SharedVaults/DetermineSharedVaultOperationOnItem/DetermineSharedVaultOperationOnItem'
+import { SharedVaultOperationOnItem } from '../../SharedVault/SharedVaultOperationOnItem'
+import { SharedVaultUserPermission } from '../../SharedVault/User/SharedVaultUserPermission'
+import { SharedVaultUserRepositoryInterface } from '../../SharedVault/User/SharedVaultUserRepositoryInterface'
+
+export class SharedVaultFilter implements ItemSaveRuleInterface {
+  constructor(
+    private determineSharedVaultOperationOnItem: DetermineSharedVaultOperationOnItem,
+    private sharedVaultUserRepository: SharedVaultUserRepositoryInterface,
+  ) {}
+
+  async check(dto: ItemSaveValidationDTO): Promise<ItemSaveRuleResult> {
+    if (!dto.itemHash.representsASharedVaultItem() || !dto.existingItem?.isAssociatedWithASharedVault()) {
+      return {
+        passed: true,
+      }
+    }
+
+    const operationOrError = await this.determineSharedVaultOperationOnItem.execute({
+      userUuid: dto.userUuid,
+      itemHash: dto.itemHash,
+      existingItem: dto.existingItem,
+    })
+    if (operationOrError.isFailed()) {
+      return {
+        passed: false,
+        conflict: {
+          unsavedItem: dto.itemHash,
+          type: ConflictType.SharedVaultInvalidState,
+        },
+      }
+    }
+    const operation = operationOrError.getValue()
+
+    if (dto.itemHash.representsASharedVaultItem() && !dto.itemHash.hasDedicatedKeySystemAssociation()) {
+      return this.buildFailResult(operation, ConflictType.SharedVaultInvalidState)
+    }
+
+    const sharedVaultPermission = await this.getSharedVaultUserPermission(
+      operation.props.userUuid,
+      operation.props.sharedVaultUuid,
+    )
+
+    if (!sharedVaultPermission) {
+      return this.buildFailResult(operation, ConflictType.SharedVaultNotMemberError)
+    }
+
+    let targetSharedVaultPermission: SharedVaultUserPermission | null = null
+    if (operation.props.targetSharedVaultUuid) {
+      targetSharedVaultPermission = await this.getSharedVaultUserPermission(
+        operation.props.userUuid,
+        operation.props.targetSharedVaultUuid,
+      )
+
+      if (!targetSharedVaultPermission) {
+        return this.buildFailResult(operation, ConflictType.SharedVaultNotMemberError)
+      }
+    }
+
+    const resultOrError = await this.getResultForOperation(
+      operation,
+      sharedVaultPermission,
+      targetSharedVaultPermission,
+    )
+    /* istanbul ignore next */
+    if (resultOrError.isFailed()) {
+      return this.buildFailResult(operation, ConflictType.SharedVaultInvalidState)
+    }
+
+    return resultOrError.getValue()
+  }
+
+  private async getResultForOperation(
+    operation: SharedVaultOperationOnItem,
+    sharedVaultPermission: SharedVaultUserPermission,
+    targetSharedVaultPermission: SharedVaultUserPermission | null,
+  ): Promise<Result<ItemSaveRuleResult>> {
+    switch (operation.props.type) {
+      case SharedVaultOperationOnItem.TYPES.AddToSharedVault:
+      case SharedVaultOperationOnItem.TYPES.RemoveFromSharedVault:
+        return Result.ok(await this.handleAddOrRemoveToSharedVaultOperation(operation, sharedVaultPermission))
+      case SharedVaultOperationOnItem.TYPES.MoveToOtherSharedVault:
+        return Result.ok(
+          await this.handleMoveToOtherSharedVaultOperation(
+            operation,
+            sharedVaultPermission,
+            targetSharedVaultPermission as SharedVaultUserPermission,
+          ),
+        )
+      case SharedVaultOperationOnItem.TYPES.SaveToSharedVault:
+      case SharedVaultOperationOnItem.TYPES.CreateToSharedVault:
+        return Result.ok(await this.handleSaveOrCreateToSharedVaultOperation(operation, sharedVaultPermission))
+      /* istanbul ignore next */
+      default:
+        return Result.fail(`Unsupported sharedVault operation: ${operation}`)
+    }
+  }
+
+  private isAuthorizedToSaveContentType(contentType: string | null, permission: SharedVaultUserPermission): boolean {
+    if (contentType === ContentType.TYPES.KeySystemItemsKey) {
+      return permission.value === SharedVaultUserPermission.PERMISSIONS.Admin
+    }
+
+    return true
+  }
+
+  private async handleAddOrRemoveToSharedVaultOperation(
+    operation: SharedVaultOperationOnItem,
+    sharedVaultPermission: SharedVaultUserPermission,
+  ): Promise<ItemSaveRuleResult> {
+    if (this.isItemDeletedOrBeingDeleted(operation)) {
+      return this.buildFailResult(operation, ConflictType.SharedVaultInvalidState)
+    }
+
+    if (!this.isOwnerOfTheItem(operation)) {
+      return this.buildFailResult(operation, ConflictType.SharedVaultInsufficientPermissionsError)
+    }
+
+    if (!this.hasSufficientPermissionsToWriteInVault(operation, sharedVaultPermission)) {
+      return this.buildFailResult(operation, ConflictType.SharedVaultInsufficientPermissionsError)
+    }
+
+    return this.buildSuccessValue()
+  }
+
+  private async handleMoveToOtherSharedVaultOperation(
+    operation: SharedVaultOperationOnItem,
+    sourceSharedVaultPermission: SharedVaultUserPermission,
+    targetSharedVaultPermission: SharedVaultUserPermission,
+  ): Promise<ItemSaveRuleResult> {
+    if (this.isItemDeletedOrBeingDeleted(operation)) {
+      return this.buildFailResult(operation, ConflictType.SharedVaultInvalidState)
+    }
+
+    for (const permission of [sourceSharedVaultPermission, targetSharedVaultPermission]) {
+      if (!this.hasSufficientPermissionsToWriteInVault(operation, permission)) {
+        return this.buildFailResult(operation, ConflictType.SharedVaultInsufficientPermissionsError)
+      }
+    }
+
+    return this.buildSuccessValue()
+  }
+
+  private async handleSaveOrCreateToSharedVaultOperation(
+    operation: SharedVaultOperationOnItem,
+    sharedVaultPermission: SharedVaultUserPermission,
+  ): Promise<ItemSaveRuleResult> {
+    if (!this.hasSufficientPermissionsToWriteInVault(operation, sharedVaultPermission)) {
+      return this.buildFailResult(operation, ConflictType.SharedVaultInsufficientPermissionsError)
+    }
+
+    return this.buildSuccessValue()
+  }
+
+  private isItemDeletedOrBeingDeleted(operation: SharedVaultOperationOnItem): boolean {
+    if (operation.props.existingItem?.props.deleted || operation.props.incomingItemHash.props.deleted) {
+      return true
+    }
+
+    return false
+  }
+
+  private isOwnerOfTheItem(operation: SharedVaultOperationOnItem): boolean {
+    if (operation.props.userUuid.equals(operation.props.existingItem?.props.userUuid)) {
+      return true
+    }
+
+    return false
+  }
+
+  private hasSufficientPermissionsToWriteInVault(
+    operation: SharedVaultOperationOnItem,
+    sharedVaultPermission: SharedVaultUserPermission,
+  ): boolean {
+    if (
+      !this.isAuthorizedToSaveContentType(operation.props.incomingItemHash.props.content_type, sharedVaultPermission)
+    ) {
+      return false
+    }
+
+    if (sharedVaultPermission.value === SharedVaultUserPermission.PERMISSIONS.Read) {
+      return false
+    }
+
+    return true
+  }
+
+  private async getSharedVaultUserPermission(
+    userUuid: Uuid,
+    sharedVaultUuid: Uuid,
+  ): Promise<SharedVaultUserPermission | null> {
+    const sharedVaultUser = await this.sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid({
+      userUuid,
+      sharedVaultUuid,
+    })
+
+    if (sharedVaultUser) {
+      return sharedVaultUser.props.permission
+    }
+
+    return null
+  }
+
+  private buildFailResult(operation: SharedVaultOperationOnItem, type: ConflictType): ItemSaveRuleResult {
+    const includeServerItem = [
+      ConflictType.SharedVaultInvalidState,
+      ConflictType.SharedVaultInsufficientPermissionsError,
+    ].includes(type)
+
+    return {
+      passed: false,
+      conflict: {
+        unsavedItem: operation.props.incomingItemHash,
+        serverItem: includeServerItem ? operation.props.existingItem : undefined,
+        type,
+      },
+    }
+  }
+
+  private buildSuccessValue(): ItemSaveRuleResult {
+    return {
+      passed: true,
+    }
+  }
+}

+ 9 - 45
packages/syncing-server/src/Domain/SharedVault/SharedVaultOperationOnItem.spec.ts

@@ -1,4 +1,4 @@
-import { ContentType } from '@standardnotes/domain-core'
+import { ContentType, Uuid } from '@standardnotes/domain-core'
 
 import { ItemHash } from '../Item/ItemHash'
 import { SharedVaultOperationOnItem } from './SharedVaultOperationOnItem'
@@ -24,58 +24,22 @@ describe('SharedVaultOperationOnItem', () => {
   it('should create a value object', () => {
     const valueOrError = SharedVaultOperationOnItem.create({
       incomingItemHash: itemHash,
-      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
-      targetSharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      targetSharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       type: SharedVaultOperationOnItem.TYPES.AddToSharedVault,
-      userUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
     })
 
     expect(valueOrError.isFailed()).toBeFalsy()
   })
 
-  it('should return error if user uuid is not valid', () => {
-    const valueOrError = SharedVaultOperationOnItem.create({
-      incomingItemHash: itemHash,
-      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
-      targetSharedVaultUuid: '00000000-0000-0000-0000-000000000000',
-      type: SharedVaultOperationOnItem.TYPES.AddToSharedVault,
-      userUuid: 'invalid',
-    })
-
-    expect(valueOrError.isFailed()).toBeTruthy()
-  })
-
-  it('should return error if shared vault uuid is not valid', () => {
-    const valueOrError = SharedVaultOperationOnItem.create({
-      incomingItemHash: itemHash,
-      sharedVaultUuid: 'invalid',
-      targetSharedVaultUuid: '00000000-0000-0000-0000-000000000000',
-      type: SharedVaultOperationOnItem.TYPES.AddToSharedVault,
-      userUuid: '00000000-0000-0000-0000-000000000000',
-    })
-
-    expect(valueOrError.isFailed()).toBeTruthy()
-  })
-
   it('should return error if shared vault operation type is invalid', () => {
     const valueOrError = SharedVaultOperationOnItem.create({
       incomingItemHash: itemHash,
-      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
-      targetSharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
+      targetSharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       type: 'invalid',
-      userUuid: '00000000-0000-0000-0000-000000000000',
-    })
-
-    expect(valueOrError.isFailed()).toBeTruthy()
-  })
-
-  it('should return error if target shared vault uuid is not valid', () => {
-    const valueOrError = SharedVaultOperationOnItem.create({
-      incomingItemHash: itemHash,
-      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
-      targetSharedVaultUuid: 'invalid',
-      type: SharedVaultOperationOnItem.TYPES.AddToSharedVault,
-      userUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
     })
 
     expect(valueOrError.isFailed()).toBeTruthy()
@@ -84,10 +48,10 @@ describe('SharedVaultOperationOnItem', () => {
   it('should return error if operation type is move to other shared vault and target shared vault uuid is not provided', () => {
     const valueOrError = SharedVaultOperationOnItem.create({
       incomingItemHash: itemHash,
-      sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
+      sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
       targetSharedVaultUuid: undefined,
       type: SharedVaultOperationOnItem.TYPES.MoveToOtherSharedVault,
-      userUuid: '00000000-0000-0000-0000-000000000000',
+      userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
     })
 
     expect(valueOrError.isFailed()).toBeTruthy()

+ 1 - 18
packages/syncing-server/src/Domain/SharedVault/SharedVaultOperationOnItem.ts

@@ -1,4 +1,4 @@
-import { ValueObject, Result, Uuid } from '@standardnotes/domain-core'
+import { ValueObject, Result } from '@standardnotes/domain-core'
 
 import { SharedVaultOperationOnItemProps } from './SharedVaultOperationOnItemProps'
 
@@ -16,28 +16,11 @@ export class SharedVaultOperationOnItem extends ValueObject<SharedVaultOperation
   }
 
   static create(props: SharedVaultOperationOnItemProps): Result<SharedVaultOperationOnItem> {
-    const userUuidOrError = Uuid.create(props.userUuid)
-    if (userUuidOrError.isFailed()) {
-      return Result.fail<SharedVaultOperationOnItem>(userUuidOrError.getError())
-    }
-
     const isValidType = Object.values(this.TYPES).includes(props.type)
     if (!isValidType) {
       return Result.fail<SharedVaultOperationOnItem>(`Invalid shared vault operation type: ${props.type}`)
     }
 
-    const sharedVaultUuidOrError = Uuid.create(props.sharedVaultUuid)
-    if (sharedVaultUuidOrError.isFailed()) {
-      return Result.fail<SharedVaultOperationOnItem>(sharedVaultUuidOrError.getError())
-    }
-
-    if (props.targetSharedVaultUuid) {
-      const targetSharedVaultUuidOrError = Uuid.create(props.targetSharedVaultUuid)
-      if (targetSharedVaultUuidOrError.isFailed()) {
-        return Result.fail<SharedVaultOperationOnItem>(targetSharedVaultUuidOrError.getError())
-      }
-    }
-
     if (props.type === this.TYPES.MoveToOtherSharedVault && !props.targetSharedVaultUuid) {
       return Result.fail<SharedVaultOperationOnItem>('Missing target shared vault uuid')
     }

+ 5 - 3
packages/syncing-server/src/Domain/SharedVault/SharedVaultOperationOnItemProps.ts

@@ -1,11 +1,13 @@
+import { Uuid } from '@standardnotes/domain-core'
+
 import { Item } from '../Item/Item'
 import { ItemHash } from '../Item/ItemHash'
 
 export interface SharedVaultOperationOnItemProps {
   incomingItemHash: ItemHash
-  userUuid: string
+  userUuid: Uuid
   type: string
-  sharedVaultUuid: string
-  targetSharedVaultUuid?: string
+  sharedVaultUuid: Uuid
+  targetSharedVaultUuid?: Uuid
   existingItem?: Item
 }

+ 6 - 6
packages/syncing-server/src/Domain/UseCase/SharedVaults/DetermineSharedVaultOperationOnItem/DetermineSharedVaultOperationOnItem.spec.ts

@@ -80,8 +80,8 @@ describe('DetermineSharedVaultOperationOnItem', () => {
 
     expect(result.isFailed()).toBeFalsy()
     expect(result.getValue().props.type).toEqual(SharedVaultOperationOnItem.TYPES.MoveToOtherSharedVault)
-    expect(result.getValue().props.sharedVaultUuid).toEqual('00000000-0000-0000-0000-000000000000')
-    expect(result.getValue().props.targetSharedVaultUuid).toEqual('00000000-0000-0000-0000-000000000001')
+    expect(result.getValue().props.sharedVaultUuid.value).toEqual('00000000-0000-0000-0000-000000000000')
+    expect(result.getValue().props.targetSharedVaultUuid?.value).toEqual('00000000-0000-0000-0000-000000000001')
   })
 
   it('should return an operation representing removing from shared vault', async () => {
@@ -108,7 +108,7 @@ describe('DetermineSharedVaultOperationOnItem', () => {
 
     expect(result.isFailed()).toBeFalsy()
     expect(result.getValue().props.type).toEqual(SharedVaultOperationOnItem.TYPES.RemoveFromSharedVault)
-    expect(result.getValue().props.sharedVaultUuid).toEqual('00000000-0000-0000-0000-000000000000')
+    expect(result.getValue().props.sharedVaultUuid.value).toEqual('00000000-0000-0000-0000-000000000000')
   })
 
   it('should return an operation representing adding to shared vault', async () => {
@@ -125,7 +125,7 @@ describe('DetermineSharedVaultOperationOnItem', () => {
 
     expect(result.isFailed()).toBeFalsy()
     expect(result.getValue().props.type).toEqual(SharedVaultOperationOnItem.TYPES.AddToSharedVault)
-    expect(result.getValue().props.sharedVaultUuid).toEqual('00000000-0000-0000-0000-000000000001')
+    expect(result.getValue().props.sharedVaultUuid.value).toEqual('00000000-0000-0000-0000-000000000001')
   })
 
   it('should return an operation representing saving to shared vault', async () => {
@@ -152,7 +152,7 @@ describe('DetermineSharedVaultOperationOnItem', () => {
 
     expect(result.isFailed()).toBeFalsy()
     expect(result.getValue().props.type).toEqual(SharedVaultOperationOnItem.TYPES.SaveToSharedVault)
-    expect(result.getValue().props.sharedVaultUuid).toEqual('00000000-0000-0000-0000-000000000000')
+    expect(result.getValue().props.sharedVaultUuid.value).toEqual('00000000-0000-0000-0000-000000000000')
   })
 
   it('should return an operation representing creating to shared vault', async () => {
@@ -169,7 +169,7 @@ describe('DetermineSharedVaultOperationOnItem', () => {
 
     expect(result.isFailed()).toBeFalsy()
     expect(result.getValue().props.type).toEqual(SharedVaultOperationOnItem.TYPES.CreateToSharedVault)
-    expect(result.getValue().props.sharedVaultUuid).toEqual('00000000-0000-0000-0000-000000000001')
+    expect(result.getValue().props.sharedVaultUuid.value).toEqual('00000000-0000-0000-0000-000000000001')
   })
 
   it('should return an error if both existing and incoming item hash do not have shared vault uuid', async () => {

+ 11 - 11
packages/syncing-server/src/Domain/UseCase/SharedVaults/DetermineSharedVaultOperationOnItem/DetermineSharedVaultOperationOnItem.ts

@@ -39,41 +39,41 @@ export class DetermineSharedVaultOperationOnItem implements UseCaseInterface<Sha
     if (isMovingToOtherSharedVault) {
       operationOrError = SharedVaultOperationOnItem.create({
         existingItem: dto.existingItem as Item,
-        sharedVaultUuid: (existingItemSharedVaultUuid as Uuid).value,
-        targetSharedVaultUuid: targetItemSharedVaultUuid.value,
+        sharedVaultUuid: existingItemSharedVaultUuid as Uuid,
+        targetSharedVaultUuid: targetItemSharedVaultUuid,
         incomingItemHash: dto.itemHash,
-        userUuid: userUuid.value,
+        userUuid: userUuid,
         type: SharedVaultOperationOnItem.TYPES.MoveToOtherSharedVault,
       })
     } else if (isRemovingFromSharedVault) {
       operationOrError = SharedVaultOperationOnItem.create({
         existingItem: dto.existingItem as Item,
-        sharedVaultUuid: (existingItemSharedVaultUuid as Uuid).value,
+        sharedVaultUuid: existingItemSharedVaultUuid as Uuid,
         incomingItemHash: dto.itemHash,
-        userUuid: userUuid.value,
+        userUuid: userUuid,
         type: SharedVaultOperationOnItem.TYPES.RemoveFromSharedVault,
       })
     } else if (isAddingToSharedVault) {
       operationOrError = SharedVaultOperationOnItem.create({
         existingItem: dto.existingItem as Item,
-        sharedVaultUuid: targetItemSharedVaultUuid.value,
+        sharedVaultUuid: targetItemSharedVaultUuid,
         incomingItemHash: dto.itemHash,
-        userUuid: userUuid.value,
+        userUuid: userUuid,
         type: SharedVaultOperationOnItem.TYPES.AddToSharedVault,
       })
     } else if (isSavingToSharedVault) {
       operationOrError = SharedVaultOperationOnItem.create({
         existingItem: dto.existingItem as Item,
-        sharedVaultUuid: (existingItemSharedVaultUuid as Uuid).value,
+        sharedVaultUuid: existingItemSharedVaultUuid as Uuid,
         incomingItemHash: dto.itemHash,
-        userUuid: userUuid.value,
+        userUuid: userUuid,
         type: SharedVaultOperationOnItem.TYPES.SaveToSharedVault,
       })
     } else {
       operationOrError = SharedVaultOperationOnItem.create({
-        sharedVaultUuid: (targetItemSharedVaultUuid as Uuid).value,
+        sharedVaultUuid: targetItemSharedVaultUuid as Uuid,
         incomingItemHash: dto.itemHash,
-        userUuid: userUuid.value,
+        userUuid: userUuid,
         type: SharedVaultOperationOnItem.TYPES.CreateToSharedVault,
       })
     }