feat(syncing-server): send websocket event to shared vault members upon items change in shared vault (#961)

This commit is contained in:
Karol Sójko 2023-12-08 13:09:35 +01:00 committed by GitHub
parent d15d51eae6
commit 6dbb87708f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 256 additions and 1 deletions

View file

@ -161,6 +161,7 @@ import { SyncResponse20200115 } from '../Domain/Item/SyncResponse/SyncResponse20
import { SyncResponse } from '@standardnotes/grpc'
import { SyncResponseGRPCMapper } from '../Mapping/gRPC/SyncResponseGRPCMapper'
import { AccountDeletionVerificationRequestedEventHandler } from '../Domain/Handler/AccountDeletionVerificationRequestedEventHandler'
import { SendEventToClients } from '../Domain/UseCase/Syncing/SendEventToClients/SendEventToClients'
export class ContainerConfigLoader {
private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@ -561,6 +562,15 @@ export class ContainerConfigLoader {
container.get<Logger>(TYPES.Sync_Logger),
),
)
container
.bind<SendEventToClients>(TYPES.Sync_SendEventToClients)
.toConstantValue(
new SendEventToClients(
container.get<SharedVaultUserRepositoryInterface>(TYPES.Sync_SharedVaultUserRepository),
container.get<SendEventToClient>(TYPES.Sync_SendEventToClient),
container.get<Logger>(TYPES.Sync_Logger),
),
)
container
.bind<AddNotificationForUser>(TYPES.Sync_AddNotificationForUser)
.toConstantValue(
@ -607,6 +617,7 @@ export class ContainerConfigLoader {
container.get<SaveNewItem>(TYPES.Sync_SaveNewItem),
container.get<UpdateExistingItem>(TYPES.Sync_UpdateExistingItem),
container.get<SendEventToClient>(TYPES.Sync_SendEventToClient),
container.get<SendEventToClients>(TYPES.Sync_SendEventToClients),
container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
container.get<Logger>(TYPES.Sync_Logger),
),

View file

@ -77,6 +77,7 @@ const TYPES = {
Sync_UpdateStorageQuotaUsedInSharedVault: Symbol.for('Sync_UpdateStorageQuotaUsedInSharedVault'),
Sync_AddNotificationsForUsers: Symbol.for('Sync_AddNotificationsForUsers'),
Sync_SendEventToClient: Symbol.for('Sync_SendEventToClient'),
Sync_SendEventToClients: Symbol.for('Sync_SendEventToClients'),
Sync_RemoveItemsFromSharedVault: Symbol.for('Sync_RemoveItemsFromSharedVault'),
Sync_DesignateSurvivor: Symbol.for('Sync_DesignateSurvivor'),
Sync_RemoveUserFromSharedVaults: Symbol.for('Sync_RemoveUserFromSharedVaults'),

View file

@ -11,6 +11,8 @@ import { Item } from '../../../Item/Item'
import { SendEventToClient } from '../SendEventToClient/SendEventToClient'
import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
import { ItemsChangedOnServerEvent } from '@standardnotes/domain-events'
import { SendEventToClients } from '../SendEventToClients/SendEventToClients'
import { SharedVaultAssociation } from '../../../SharedVault/SharedVaultAssociation'
describe('SaveItems', () => {
let itemSaveValidator: ItemSaveValidatorInterface
@ -22,6 +24,7 @@ describe('SaveItems', () => {
let itemHash1: ItemHash
let savedItem: Item
let sendEventToClient: SendEventToClient
let sendEventToClients: SendEventToClients
let domainEventFactory: DomainEventFactoryInterface
const createUseCase = () =>
@ -32,6 +35,7 @@ describe('SaveItems', () => {
saveNewItem,
updateExistingItem,
sendEventToClient,
sendEventToClients,
domainEventFactory,
logger,
)
@ -40,6 +44,9 @@ describe('SaveItems', () => {
sendEventToClient = {} as jest.Mocked<SendEventToClient>
sendEventToClient.execute = jest.fn().mockReturnValue(Result.ok())
sendEventToClients = {} as jest.Mocked<SendEventToClients>
sendEventToClients.execute = jest.fn().mockReturnValue(Result.ok())
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createItemsChangedOnServerEvent = jest
.fn()
@ -243,6 +250,51 @@ describe('SaveItems', () => {
performingUserUuid: '00000000-0000-0000-0000-000000000000',
})
expect(sendEventToClient.execute).toHaveBeenCalled()
expect(sendEventToClients.execute).not.toHaveBeenCalled()
})
it('should update existing shared vault items', async () => {
savedItem = Item.create({
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
sharedVaultAssociation: SharedVaultAssociation.create({
sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
lastEditedBy: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
}).getValue(),
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
}).getValue()
const useCase = createUseCase()
itemRepository.findByUuid = jest.fn().mockResolvedValue(savedItem)
updateExistingItem.execute = jest.fn().mockResolvedValue(Result.ok(savedItem))
const result = await useCase.execute({
itemHashes: [itemHash1],
userUuid: '00000000-0000-0000-0000-000000000000',
apiVersion: '1',
readOnlyAccess: false,
sessionUuid: 'session-uuid',
snjsVersion: '2.200.0',
})
expect(result.isFailed()).toBeFalsy()
expect(updateExistingItem.execute).toHaveBeenCalledWith({
itemHash: itemHash1,
existingItem: savedItem,
sessionUuid: 'session-uuid',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
})
expect(sendEventToClient.execute).toHaveBeenCalled()
expect(sendEventToClients.execute).toHaveBeenCalled()
})
it('should mark items as conflicts if updating existing item fails', async () => {

View file

@ -13,6 +13,7 @@ import { UpdateExistingItem } from '../UpdateExistingItem/UpdateExistingItem'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { SendEventToClient } from '../SendEventToClient/SendEventToClient'
import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
import { SendEventToClients } from '../SendEventToClients/SendEventToClients'
export class SaveItems implements UseCaseInterface<SaveItemsResult> {
private readonly SYNC_TOKEN_VERSION = 2
@ -24,6 +25,7 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
private saveNewItem: SaveNewItem,
private updateExistingItem: UpdateExistingItem,
private sendEventToClient: SendEventToClient,
private sendEventToClients: SendEventToClients,
private domainEventFactory: DomainEventFactoryInterface,
private logger: Logger,
) {}
@ -167,7 +169,31 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
})
/* istanbul ignore next */
if (result.isFailed()) {
this.logger.error(`[${dto.userUuid}] Sending items changed event to client failed. Error: ${result.getError()}`)
this.logger.error(`Sending items changed event to client failed. Error: ${result.getError()}`, {
userId: dto.userUuid,
})
}
const sharedVaultUuidsMap = new Map<string, boolean>()
for (const item of savedItems) {
if (item.isAssociatedWithASharedVault()) {
sharedVaultUuidsMap.set((item.sharedVaultUuid as Uuid).value, true)
}
}
const sharedVaultUuids = Array.from(sharedVaultUuidsMap.keys())
for (const sharedVaultUuid of sharedVaultUuids) {
const result = await this.sendEventToClients.execute({
sharedVaultUuid,
event: itemsChangedEvent,
originatingUserUuid: dto.userUuid,
})
/* istanbul ignore next */
if (result.isFailed()) {
this.logger.error(`Sending items changed event to clients failed. Error: ${result.getError()}`, {
userId: dto.userUuid,
sharedVaultUuid,
})
}
}
}

View file

@ -0,0 +1,108 @@
import { Logger } from 'winston'
import { SharedVaultUserRepositoryInterface } from '../../../SharedVault/User/SharedVaultUserRepositoryInterface'
import { SendEventToClient } from '../SendEventToClient/SendEventToClient'
import { SendEventToClients } from './SendEventToClients'
import { Result, SharedVaultUser, SharedVaultUserPermission, Timestamps, Uuid } from '@standardnotes/domain-core'
import { DomainEventInterface } from '@standardnotes/domain-events'
describe('SendEventToClients', () => {
let sharedVaultUserRepository: SharedVaultUserRepositoryInterface
let sendEventToClient: SendEventToClient
let logger: Logger
const createUseCase = () => new SendEventToClients(sharedVaultUserRepository, sendEventToClient, logger)
beforeEach(() => {
const sharedVaultUser = SharedVaultUser.create({
permission: SharedVaultUserPermission.create('read').getValue(),
sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123456789, 123456789).getValue(),
userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
isDesignatedSurvivor: false,
}).getValue()
sharedVaultUserRepository = {} as jest.Mocked<SharedVaultUserRepositoryInterface>
sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockReturnValue([sharedVaultUser])
sendEventToClient = {} as jest.Mocked<SendEventToClient>
sendEventToClient.execute = jest.fn().mockReturnValue(Result.ok())
logger = {} as jest.Mocked<Logger>
logger.error = jest.fn()
})
it('should send event to all users', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
event: {
type: 'test',
} as jest.Mocked<DomainEventInterface>,
originatingUserUuid: '00000000-0000-0000-0000-000000000003',
})
expect(result.isFailed()).toBeFalsy()
expect(sendEventToClient.execute).toHaveBeenCalledTimes(1)
})
it('should send event to all users except the originating one', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
event: {
type: 'test',
} as jest.Mocked<DomainEventInterface>,
originatingUserUuid: '00000000-0000-0000-0000-000000000001',
})
expect(result.isFailed()).toBeFalsy()
expect(sendEventToClient.execute).toHaveBeenCalledTimes(0)
})
it('should return error if shared vault uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
sharedVaultUuid: 'invalid',
event: {
type: 'test',
} as jest.Mocked<DomainEventInterface>,
originatingUserUuid: '00000000-0000-0000-0000-000000000001',
})
expect(result.isFailed()).toBeTruthy()
})
it('should return error if originating user uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
event: {
type: 'test',
} as jest.Mocked<DomainEventInterface>,
originatingUserUuid: 'invalid',
})
expect(result.isFailed()).toBeTruthy()
})
it('should log error if sending event to client failed', async () => {
sendEventToClient.execute = jest.fn().mockReturnValue(Result.fail('test error'))
const useCase = createUseCase()
const result = await useCase.execute({
sharedVaultUuid: '00000000-0000-0000-0000-000000000000',
event: {
type: 'test',
} as jest.Mocked<DomainEventInterface>,
originatingUserUuid: '00000000-0000-0000-0000-000000000003',
})
expect(result.isFailed()).toBeFalsy()
expect(logger.error).toHaveBeenCalledTimes(1)
})
})

View file

@ -0,0 +1,50 @@
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { Logger } from 'winston'
import { SendEventToClientsDTO } from './SendEventToClientsDTO'
import { SendEventToClient } from '../SendEventToClient/SendEventToClient'
import { SharedVaultUserRepositoryInterface } from '../../../SharedVault/User/SharedVaultUserRepositoryInterface'
export class SendEventToClients implements UseCaseInterface<void> {
constructor(
private sharedVaultUserRepository: SharedVaultUserRepositoryInterface,
private sendEventToClient: SendEventToClient,
private logger: Logger,
) {}
async execute(dto: SendEventToClientsDTO): Promise<Result<void>> {
const sharedVaultUuidOrError = Uuid.create(dto.sharedVaultUuid)
if (sharedVaultUuidOrError.isFailed()) {
return Result.fail(sharedVaultUuidOrError.getError())
}
const sharedVaultUuid = sharedVaultUuidOrError.getValue()
const originatingUserUuidOrError = Uuid.create(dto.originatingUserUuid)
if (originatingUserUuidOrError.isFailed()) {
return Result.fail(originatingUserUuidOrError.getError())
}
const originatingUserUuid = originatingUserUuidOrError.getValue()
const sharedVaultUsers = await this.sharedVaultUserRepository.findBySharedVaultUuid(sharedVaultUuid)
for (const sharedVaultUser of sharedVaultUsers) {
if (originatingUserUuid.equals(sharedVaultUser.props.userUuid)) {
continue
}
const result = await this.sendEventToClient.execute({
event: dto.event,
userUuid: sharedVaultUser.props.userUuid.value,
})
if (result.isFailed()) {
this.logger.error(`Failed to send event to client: ${result.getError()}`, {
userId: sharedVaultUser.props.userUuid.value,
sharedVaultUuid: sharedVaultUuid.value,
})
}
}
return Result.ok()
}
}

View file

@ -0,0 +1,7 @@
import { DomainEventInterface } from '@standardnotes/domain-events'
export interface SendEventToClientsDTO {
sharedVaultUuid: string
event: DomainEventInterface
originatingUserUuid: string
}