feat: add mechanism for determining if a user should use the primary or secondary items database (#700)

* feat(domain-core): introduce new role for users transitioning to new mechanisms

* feat: add mechanism for determining if a user should use the primary or secondary items database

* fix: add transition mode enabled switch in docker entrypoint

* fix(syncing-server): mapping roles from middleware

* fix: mongodb item repository binding

* fix: item backups service binding

* fix: passing transition mode enabled variable to docker setup
This commit is contained in:
Karol Sójko
2023-08-18 16:45:10 +02:00
committed by GitHub
parent e00d9d2ca0
commit 302b624504
58 changed files with 1041 additions and 414 deletions

View File

@@ -24,6 +24,7 @@ services:
DB_TYPE: "${DB_TYPE}"
CACHE_TYPE: "${CACHE_TYPE}"
SECONDARY_DB_ENABLED: "${SECONDARY_DB_ENABLED}"
TRANSITION_MODE_ENABLED: "${TRANSITION_MODE_ENABLED}"
container_name: server-ci
ports:
- 3123:3000

View File

@@ -66,6 +66,9 @@ fi
if [ -z "$SECONDARY_DB_ENABLED" ]; then
export SECONDARY_DB_ENABLED=false
fi
if [ -z "$TRANSITION_MODE_ENABLED" ]; then
export TRANSITION_MODE_ENABLED=false
fi
export DB_MIGRATIONS_PATH="dist/migrations/*.js"
#########

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddTransitionRole1692348191367 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'INSERT INTO `roles` (uuid, name, version) VALUES ("e7381dc5-3d67-49e9-b7bd-f2407b2f726e", "TRANSITION_USER", 1)',
)
}
public async down(): Promise<void> {
return
}
}

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddTransitionRole1692348280258 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'INSERT INTO `roles` (uuid, name, version) VALUES ("e7381dc5-3d67-49e9-b7bd-f2407b2f726e", "TRANSITION_USER", 1)',
)
}
public async down(): Promise<void> {
return
}
}

View File

@@ -560,6 +560,9 @@ export class ContainerConfigLoader {
container
.bind(TYPES.Auth_READONLY_USERS)
.toConstantValue(env.get('READONLY_USERS', true) ? env.get('READONLY_USERS', true).split(',') : [])
container
.bind(TYPES.Auth_TRANSITION_MODE_ENABLED)
.toConstantValue(env.get('TRANSITION_MODE_ENABLED', true) === 'true')
if (isConfiguredForInMemoryCache) {
container

View File

@@ -101,6 +101,7 @@ const TYPES = {
Auth_U2F_EXPECTED_ORIGIN: Symbol.for('Auth_U2F_EXPECTED_ORIGIN'),
Auth_U2F_REQUIRE_USER_VERIFICATION: Symbol.for('Auth_U2F_REQUIRE_USER_VERIFICATION'),
Auth_READONLY_USERS: Symbol.for('Auth_READONLY_USERS'),
Auth_TRANSITION_MODE_ENABLED: Symbol.for('Auth_TRANSITION_MODE_ENABLED'),
// use cases
Auth_AuthenticateUser: Symbol.for('Auth_AuthenticateUser'),
Auth_AuthenticateRequest: Symbol.for('Auth_AuthenticateRequest'),

View File

@@ -11,6 +11,7 @@ import { Register } from './Register'
import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
import { AuthResponseFactory20200115 } from '../Auth/AuthResponseFactory20200115'
import { Session } from '../Session/Session'
import { RoleName } from '@standardnotes/domain-core'
describe('Register', () => {
let userRepository: UserRepositoryInterface
@@ -20,9 +21,19 @@ describe('Register', () => {
let user: User
let crypter: CrypterInterface
let timer: TimerInterface
let transitionModeEnabled = false
const createUseCase = () =>
new Register(userRepository, roleRepository, authResponseFactory, crypter, false, settingService, timer)
new Register(
userRepository,
roleRepository,
authResponseFactory,
crypter,
false,
settingService,
timer,
transitionModeEnabled,
)
beforeEach(() => {
userRepository = {} as jest.Mocked<UserRepositoryInterface>
@@ -75,6 +86,7 @@ describe('Register', () => {
updatedWithUserAgent: 'Mozilla',
uuid: expect.any(String),
version: '004',
roles: Promise.resolve([]),
createdAt: new Date(1),
updatedAt: new Date(1),
})
@@ -118,6 +130,48 @@ describe('Register', () => {
})
})
it('should register a new user with default role and transition role', async () => {
transitionModeEnabled = true
const role = new Role()
role.name = RoleName.NAMES.CoreUser
const transitionRole = new Role()
transitionRole.name = RoleName.NAMES.TransitionUser
roleRepository.findOneByName = jest.fn().mockReturnValueOnce(role).mockReturnValueOnce(transitionRole)
expect(
await createUseCase().execute({
email: 'test@test.te',
password: 'asdzxc',
updatedWithUserAgent: 'Mozilla',
apiVersion: '20200115',
ephemeralSession: false,
version: '004',
pwCost: 11,
pwSalt: 'qweqwe',
pwNonce: undefined,
}),
).toEqual({ success: true, authResponse: { foo: 'bar' } })
expect(userRepository.save).toHaveBeenCalledWith({
email: 'test@test.te',
encryptedPassword: expect.any(String),
encryptedServerKey: 'test',
serverEncryptionVersion: 1,
pwCost: 11,
pwNonce: undefined,
pwSalt: 'qweqwe',
updatedWithUserAgent: 'Mozilla',
uuid: expect.any(String),
version: '004',
createdAt: new Date(1),
updatedAt: new Date(1),
roles: Promise.resolve([role, transitionRole]),
})
})
it('should fail to register if username is invalid', async () => {
expect(
await createUseCase().execute({
@@ -195,6 +249,7 @@ describe('Register', () => {
true,
settingService,
timer,
transitionModeEnabled,
).execute({
email: 'test@test.te',
password: 'asdzxc',

View File

@@ -1,8 +1,9 @@
import * as bcrypt from 'bcryptjs'
import { RoleName, Username } from '@standardnotes/domain-core'
import { v4 as uuidv4 } from 'uuid'
import { inject, injectable } from 'inversify'
import { TimerInterface } from '@standardnotes/time'
import TYPES from '../../Bootstrap/Types'
import { User } from '../User/User'
import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
@@ -11,7 +12,6 @@ import { RegisterResponse } from './RegisterResponse'
import { UseCaseInterface } from './UseCaseInterface'
import { RoleRepositoryInterface } from '../Role/RoleRepositoryInterface'
import { CrypterInterface } from '../Encryption/CrypterInterface'
import { TimerInterface } from '@standardnotes/time'
import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
import { AuthResponseFactory20200115 } from '../Auth/AuthResponseFactory20200115'
import { AuthResponse20200115 } from '../Auth/AuthResponse20200115'
@@ -27,6 +27,7 @@ export class Register implements UseCaseInterface {
@inject(TYPES.Auth_DISABLE_USER_REGISTRATION) private disableUserRegistration: boolean,
@inject(TYPES.Auth_SettingService) private settingService: SettingServiceInterface,
@inject(TYPES.Auth_Timer) private timer: TimerInterface,
@inject(TYPES.Auth_TRANSITION_MODE_ENABLED) private transitionModeEnabled: boolean,
) {}
async execute(dto: RegisterDTO): Promise<RegisterResponse> {
@@ -72,10 +73,18 @@ export class Register implements UseCaseInterface {
user.encryptedServerKey = await this.crypter.generateEncryptedUserServerKey()
user.serverEncryptionVersion = User.DEFAULT_ENCRYPTION_VERSION
const roles = []
const defaultRole = await this.roleRepository.findOneByName(RoleName.NAMES.CoreUser)
if (defaultRole) {
user.roles = Promise.resolve([defaultRole])
roles.push(defaultRole)
}
if (this.transitionModeEnabled) {
const transitionRole = await this.roleRepository.findOneByName(RoleName.NAMES.TransitionUser)
if (transitionRole) {
roles.push(transitionRole)
}
}
user.roles = Promise.resolve(roles)
Object.assign(user, registrationFields)

View File

@@ -21,25 +21,36 @@ describe('RoleName', () => {
const plusUserRole = RoleName.create(RoleName.NAMES.PlusUser).getValue()
const coreUser = RoleName.create(RoleName.NAMES.CoreUser).getValue()
const internalTeamUser = RoleName.create(RoleName.NAMES.InternalTeamUser).getValue()
const transitionUser = RoleName.create(RoleName.NAMES.TransitionUser).getValue()
expect(internalTeamUser.hasMoreOrEqualPowerTo(proUserRole)).toBeTruthy()
expect(internalTeamUser.hasMoreOrEqualPowerTo(proUserRole)).toBeTruthy()
expect(internalTeamUser.hasMoreOrEqualPowerTo(plusUserRole)).toBeTruthy()
expect(internalTeamUser.hasMoreOrEqualPowerTo(coreUser)).toBeTruthy()
expect(internalTeamUser.hasMoreOrEqualPowerTo(transitionUser)).toBeTruthy()
expect(proUserRole.hasMoreOrEqualPowerTo(internalTeamUser)).toBeFalsy()
expect(proUserRole.hasMoreOrEqualPowerTo(proUserRole)).toBeTruthy()
expect(proUserRole.hasMoreOrEqualPowerTo(plusUserRole)).toBeTruthy()
expect(proUserRole.hasMoreOrEqualPowerTo(coreUser)).toBeTruthy()
expect(proUserRole.hasMoreOrEqualPowerTo(transitionUser)).toBeTruthy()
expect(plusUserRole.hasMoreOrEqualPowerTo(internalTeamUser)).toBeFalsy()
expect(plusUserRole.hasMoreOrEqualPowerTo(proUserRole)).toBeFalsy()
expect(plusUserRole.hasMoreOrEqualPowerTo(plusUserRole)).toBeTruthy()
expect(plusUserRole.hasMoreOrEqualPowerTo(coreUser)).toBeTruthy()
expect(plusUserRole.hasMoreOrEqualPowerTo(transitionUser)).toBeTruthy()
expect(coreUser.hasMoreOrEqualPowerTo(internalTeamUser)).toBeFalsy()
expect(coreUser.hasMoreOrEqualPowerTo(proUserRole)).toBeFalsy()
expect(coreUser.hasMoreOrEqualPowerTo(plusUserRole)).toBeFalsy()
expect(coreUser.hasMoreOrEqualPowerTo(coreUser)).toBeTruthy()
expect(coreUser.hasMoreOrEqualPowerTo(transitionUser)).toBeTruthy()
expect(transitionUser.hasMoreOrEqualPowerTo(internalTeamUser)).toBeFalsy()
expect(transitionUser.hasMoreOrEqualPowerTo(proUserRole)).toBeFalsy()
expect(transitionUser.hasMoreOrEqualPowerTo(plusUserRole)).toBeFalsy()
expect(transitionUser.hasMoreOrEqualPowerTo(coreUser)).toBeTruthy()
expect(transitionUser.hasMoreOrEqualPowerTo(transitionUser)).toBeTruthy()
})
})

View File

@@ -8,6 +8,7 @@ export class RoleName extends ValueObject<RoleNameProps> {
PlusUser: 'PLUS_USER',
ProUser: 'PRO_USER',
InternalTeamUser: 'INTERNAL_TEAM_USER',
TransitionUser: 'TRANSITION_USER',
}
get value(): string {
@@ -19,11 +20,19 @@ export class RoleName extends ValueObject<RoleNameProps> {
case RoleName.NAMES.InternalTeamUser:
return true
case RoleName.NAMES.ProUser:
return [RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser, RoleName.NAMES.ProUser].includes(roleName.value)
return [
RoleName.NAMES.CoreUser,
RoleName.NAMES.PlusUser,
RoleName.NAMES.ProUser,
RoleName.NAMES.TransitionUser,
].includes(roleName.value)
case RoleName.NAMES.PlusUser:
return [RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser].includes(roleName.value)
return [RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser, RoleName.NAMES.TransitionUser].includes(
roleName.value,
)
case RoleName.NAMES.CoreUser:
return [RoleName.NAMES.CoreUser].includes(roleName.value)
case RoleName.NAMES.TransitionUser:
return [RoleName.NAMES.CoreUser, RoleName.NAMES.TransitionUser].includes(roleName.value)
/*istanbul ignore next*/
default:
throw new Error(`Invalid role name: ${this.value}`)

View File

@@ -3,32 +3,24 @@ import { RoleNameCollection } from './RoleNameCollection'
describe('RoleNameCollection', () => {
it('should create a value object', () => {
const role1 = RoleName.create(RoleName.NAMES.ProUser).getValue()
const valueOrError = RoleNameCollection.create([role1])
const valueOrError = RoleNameCollection.create([RoleName.NAMES.ProUser])
expect(valueOrError.isFailed()).toBeFalsy()
expect(valueOrError.getValue().value).toEqual([role1])
expect(valueOrError.getValue().value[0].value).toEqual('PRO_USER')
})
it('should tell if collections are not equal', () => {
const roles1 = [
RoleName.create(RoleName.NAMES.ProUser).getValue(),
RoleName.create(RoleName.NAMES.PlusUser).getValue(),
]
const roles1 = [RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser]
let roles2 = RoleNameCollection.create([
RoleName.create(RoleName.NAMES.ProUser).getValue(),
RoleName.create(RoleName.NAMES.CoreUser).getValue(),
]).getValue()
let roles2 = RoleNameCollection.create([RoleName.NAMES.ProUser, RoleName.NAMES.CoreUser]).getValue()
let valueOrError = RoleNameCollection.create(roles1)
expect(valueOrError.getValue().equals(roles2)).toBeFalsy()
roles2 = RoleNameCollection.create([
RoleName.create(RoleName.NAMES.ProUser).getValue(),
RoleName.create(RoleName.NAMES.PlusUser).getValue(),
RoleName.create(RoleName.NAMES.CoreUser).getValue(),
RoleName.NAMES.ProUser,
RoleName.NAMES.PlusUser,
RoleName.NAMES.CoreUser,
]).getValue()
valueOrError = RoleNameCollection.create(roles1)
@@ -36,42 +28,30 @@ describe('RoleNameCollection', () => {
})
it('should tell if collections are equal', () => {
const roles1 = [
RoleName.create(RoleName.NAMES.ProUser).getValue(),
RoleName.create(RoleName.NAMES.PlusUser).getValue(),
]
const roles1 = [RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser]
const roles2 = RoleNameCollection.create([
RoleName.create(RoleName.NAMES.ProUser).getValue(),
RoleName.create(RoleName.NAMES.PlusUser).getValue(),
]).getValue()
const roles2 = RoleNameCollection.create([RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser]).getValue()
const valueOrError = RoleNameCollection.create(roles1)
expect(valueOrError.getValue().equals(roles2)).toBeTruthy()
})
it('should tell if collection includes element', () => {
const roles1 = [
RoleName.create(RoleName.NAMES.ProUser).getValue(),
RoleName.create(RoleName.NAMES.PlusUser).getValue(),
]
const roles1 = [RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser]
const valueOrError = RoleNameCollection.create(roles1)
expect(valueOrError.getValue().includes(RoleName.create(RoleName.NAMES.ProUser).getValue())).toBeTruthy()
})
it('should tell if collection does not includes element', () => {
const roles1 = [
RoleName.create(RoleName.NAMES.ProUser).getValue(),
RoleName.create(RoleName.NAMES.PlusUser).getValue(),
]
const roles1 = [RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser]
const valueOrError = RoleNameCollection.create(roles1)
expect(valueOrError.getValue().includes(RoleName.create(RoleName.NAMES.CoreUser).getValue())).toBeFalsy()
})
it('should tell if collection has a role with more or equal power to', () => {
let roles = [RoleName.create(RoleName.NAMES.CoreUser).getValue()]
let roles = [RoleName.NAMES.CoreUser]
let valueOrError = RoleNameCollection.create(roles)
let roleNames = valueOrError.getValue()
@@ -83,7 +63,7 @@ describe('RoleNameCollection', () => {
roleNames.hasARoleNameWithMoreOrEqualPowerTo(RoleName.create(RoleName.NAMES.CoreUser).getValue()),
).toBeTruthy()
roles = [RoleName.create(RoleName.NAMES.CoreUser).getValue(), RoleName.create(RoleName.NAMES.PlusUser).getValue()]
roles = [RoleName.NAMES.CoreUser, RoleName.NAMES.PlusUser]
valueOrError = RoleNameCollection.create(roles)
roleNames = valueOrError.getValue()
@@ -95,7 +75,7 @@ describe('RoleNameCollection', () => {
roleNames.hasARoleNameWithMoreOrEqualPowerTo(RoleName.create(RoleName.NAMES.CoreUser).getValue()),
).toBeTruthy()
roles = [RoleName.create(RoleName.NAMES.ProUser).getValue(), RoleName.create(RoleName.NAMES.PlusUser).getValue()]
roles = [RoleName.NAMES.ProUser, RoleName.NAMES.PlusUser]
valueOrError = RoleNameCollection.create(roles)
roleNames = valueOrError.getValue()
@@ -109,4 +89,11 @@ describe('RoleNameCollection', () => {
roleNames.hasARoleNameWithMoreOrEqualPowerTo(RoleName.create(RoleName.NAMES.CoreUser).getValue()),
).toBeTruthy()
})
it('should fail to create a collection if a role name is invalid', () => {
const valueOrError = RoleNameCollection.create(['invalid-role-name'])
expect(valueOrError.isFailed()).toBeTruthy()
expect(valueOrError.getError()).toEqual('Invalid role name: invalid-role-name')
})
})

View File

@@ -46,7 +46,16 @@ export class RoleNameCollection extends ValueObject<RoleNameCollectionProps> {
super(props)
}
static create(roleName: RoleName[]): Result<RoleNameCollection> {
return Result.ok<RoleNameCollection>(new RoleNameCollection({ value: roleName }))
static create(roleNameStrings: string[]): Result<RoleNameCollection> {
const roleNames: RoleName[] = []
for (const roleNameString of roleNameStrings) {
const roleNameOrError = RoleName.create(roleNameString)
if (roleNameOrError.isFailed()) {
return Result.fail<RoleNameCollection>(roleNameOrError.getError())
}
roleNames.push(roleNameOrError.getValue())
}
return Result.ok<RoleNameCollection>(new RoleNameCollection({ value: roleNames }))
}
}

View File

@@ -16,3 +16,5 @@ MONGO_PORT=27017
MONGO_USERNAME=standardnotes
MONGO_PASSWORD=standardnotes
MONGO_DATABASE=standardnotes
TRANSITION_MODE_ENABLED=false

View File

@@ -39,7 +39,7 @@ import { SyncItems } from '../Domain/UseCase/Syncing/SyncItems/SyncItems'
import { InversifyExpressAuthMiddleware } from '../Infra/InversifyExpressUtils/Middleware/InversifyExpressAuthMiddleware'
import { S3Client } from '@aws-sdk/client-s3'
import { SQSClient, SQSClientConfig } from '@aws-sdk/client-sqs'
import { ContentDecoder } from '@standardnotes/common'
import { ContentDecoder, ContentDecoderInterface } from '@standardnotes/common'
import {
DomainEventMessageHandlerInterface,
DomainEventHandlerInterface,
@@ -153,6 +153,9 @@ import { AddNotificationsForUsers } from '../Domain/UseCase/Messaging/AddNotific
import { MongoDBItem } from '../Infra/TypeORM/MongoDBItem'
import { MongoDBItemRepository } from '../Infra/TypeORM/MongoDBItemRepository'
import { MongoDBItemPersistenceMapper } from '../Mapping/Persistence/MongoDB/MongoDBItemPersistenceMapper'
import { Logger } from 'winston'
import { ItemRepositoryResolverInterface } from '../Domain/Item/ItemRepositoryResolverInterface'
import { TypeORMItemRepositoryResolver } from '../Infra/TypeORM/TypeORMItemRepositoryResolver'
export class ContainerConfigLoader {
private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@@ -284,6 +287,18 @@ export class ContainerConfigLoader {
})
}
container
.bind(TYPES.Sync_EMAIL_ATTACHMENT_MAX_BYTE_SIZE)
.toConstantValue(
env.get('EMAIL_ATTACHMENT_MAX_BYTE_SIZE', true) ? +env.get('EMAIL_ATTACHMENT_MAX_BYTE_SIZE', true) : 10485760,
)
container.bind(TYPES.Sync_NEW_RELIC_ENABLED).toConstantValue(env.get('NEW_RELIC_ENABLED', true))
container
.bind(TYPES.Sync_FILE_UPLOAD_PATH)
.toConstantValue(
env.get('FILE_UPLOAD_PATH', true) ? env.get('FILE_UPLOAD_PATH', true) : this.DEFAULT_FILE_UPLOAD_PATH,
)
// Mapping
container
.bind<MapperInterface<Item, TypeORMItem>>(TYPES.Sync_ItemPersistenceMapper)
@@ -374,25 +389,37 @@ export class ContainerConfigLoader {
.toConstantValue(new MongoDBItemPersistenceMapper())
container
.bind<MongoRepository<MongoDBItem>>(TYPES.Sync_MongoItemRepository)
.bind<MongoRepository<MongoDBItem>>(TYPES.Sync_ORMMongoItemRepository)
.toConstantValue(appDataSource.getMongoRepository(MongoDBItem))
container
.bind<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository)
.toConstantValue(
new MongoDBItemRepository(
container.get<MongoRepository<MongoDBItem>>(TYPES.Sync_ORMMongoItemRepository),
container.get<MapperInterface<Item, MongoDBItem>>(TYPES.Sync_MongoDBItemPersistenceMapper),
container.get<Logger>(TYPES.Sync_Logger),
),
)
}
// Repositories
container
.bind<ItemRepositoryInterface>(TYPES.Sync_ItemRepository)
.bind<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository)
.toConstantValue(
isSecondaryDatabaseEnabled
? new MongoDBItemRepository(
container.get(TYPES.Sync_MongoItemRepository),
container.get(TYPES.Sync_MongoDBItemPersistenceMapper),
container.get(TYPES.Sync_Logger),
)
: new TypeORMItemRepository(
container.get(TYPES.Sync_ORMItemRepository),
container.get(TYPES.Sync_ItemPersistenceMapper),
container.get(TYPES.Sync_Logger),
),
new TypeORMItemRepository(
container.get<Repository<TypeORMItem>>(TYPES.Sync_ORMItemRepository),
container.get<MapperInterface<Item, TypeORMItem>>(TYPES.Sync_ItemPersistenceMapper),
container.get<Logger>(TYPES.Sync_Logger),
),
)
container
.bind<ItemRepositoryResolverInterface>(TYPES.Sync_ItemRepositoryResolver)
.toConstantValue(
new TypeORMItemRepositoryResolver(
container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
isSecondaryDatabaseEnabled ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository) : null,
),
)
container
.bind<SharedVaultRepositoryInterface>(TYPES.Sync_SharedVaultRepository)
@@ -444,10 +471,7 @@ export class ContainerConfigLoader {
container
.bind<ItemTransferCalculatorInterface>(TYPES.Sync_ItemTransferCalculator)
.toDynamicValue((context: interfaces.Context) => {
return new ItemTransferCalculator(
context.container.get(TYPES.Sync_ItemRepository),
context.container.get(TYPES.Sync_Logger),
)
return new ItemTransferCalculator(context.container.get<Logger>(TYPES.Sync_Logger))
})
// Middleware
@@ -525,7 +549,7 @@ export class ContainerConfigLoader {
.bind<GetItems>(TYPES.Sync_GetItems)
.toConstantValue(
new GetItems(
container.get(TYPES.Sync_ItemRepository),
container.get(TYPES.Sync_ItemRepositoryResolver),
container.get(TYPES.Sync_SharedVaultUserRepository),
container.get(TYPES.Sync_CONTENT_SIZE_TRANSFER_LIMIT),
container.get(TYPES.Sync_ItemTransferCalculator),
@@ -537,7 +561,7 @@ export class ContainerConfigLoader {
.bind<SaveNewItem>(TYPES.Sync_SaveNewItem)
.toConstantValue(
new SaveNewItem(
container.get(TYPES.Sync_ItemRepository),
container.get(TYPES.Sync_ItemRepositoryResolver),
container.get(TYPES.Sync_Timer),
container.get(TYPES.Sync_DomainEventPublisher),
container.get(TYPES.Sync_DomainEventFactory),
@@ -563,7 +587,7 @@ export class ContainerConfigLoader {
.bind<UpdateExistingItem>(TYPES.Sync_UpdateExistingItem)
.toConstantValue(
new UpdateExistingItem(
container.get(TYPES.Sync_ItemRepository),
container.get(TYPES.Sync_ItemRepositoryResolver),
container.get(TYPES.Sync_Timer),
container.get(TYPES.Sync_DomainEventPublisher),
container.get(TYPES.Sync_DomainEventFactory),
@@ -578,7 +602,7 @@ export class ContainerConfigLoader {
.toConstantValue(
new SaveItems(
container.get(TYPES.Sync_ItemSaveValidator),
container.get(TYPES.Sync_ItemRepository),
container.get(TYPES.Sync_ItemRepositoryResolver),
container.get(TYPES.Sync_Timer),
container.get(TYPES.Sync_SaveNewItem),
container.get(TYPES.Sync_UpdateExistingItem),
@@ -607,7 +631,7 @@ export class ContainerConfigLoader {
.bind<SyncItems>(TYPES.Sync_SyncItems)
.toConstantValue(
new SyncItems(
container.get(TYPES.Sync_ItemRepository),
container.get(TYPES.Sync_ItemRepositoryResolver),
container.get(TYPES.Sync_GetItems),
container.get(TYPES.Sync_SaveItems),
container.get(TYPES.Sync_GetSharedVaults),
@@ -617,10 +641,10 @@ export class ContainerConfigLoader {
),
)
container.bind<CheckIntegrity>(TYPES.Sync_CheckIntegrity).toDynamicValue((context: interfaces.Context) => {
return new CheckIntegrity(context.container.get(TYPES.Sync_ItemRepository))
return new CheckIntegrity(context.container.get(TYPES.Sync_ItemRepositoryResolver))
})
container.bind<GetItem>(TYPES.Sync_GetItem).toDynamicValue((context: interfaces.Context) => {
return new GetItem(context.container.get(TYPES.Sync_ItemRepository))
return new GetItem(context.container.get(TYPES.Sync_ItemRepositoryResolver))
})
container
.bind<InviteUserToSharedVault>(TYPES.Sync_InviteUserToSharedVault)
@@ -778,48 +802,56 @@ export class ContainerConfigLoader {
)
})
// env vars
container
.bind(TYPES.Sync_EMAIL_ATTACHMENT_MAX_BYTE_SIZE)
.bind<ItemBackupServiceInterface>(TYPES.Sync_ItemBackupService)
.toConstantValue(
env.get('EMAIL_ATTACHMENT_MAX_BYTE_SIZE', true) ? +env.get('EMAIL_ATTACHMENT_MAX_BYTE_SIZE', true) : 10485760,
)
container.bind(TYPES.Sync_NEW_RELIC_ENABLED).toConstantValue(env.get('NEW_RELIC_ENABLED', true))
container
.bind(TYPES.Sync_FILE_UPLOAD_PATH)
.toConstantValue(
env.get('FILE_UPLOAD_PATH', true) ? env.get('FILE_UPLOAD_PATH', true) : this.DEFAULT_FILE_UPLOAD_PATH,
env.get('S3_AWS_REGION', true)
? new S3ItemBackupService(
container.get(TYPES.Sync_S3_BACKUP_BUCKET_NAME),
container.get(TYPES.Sync_ItemBackupMapper),
container.get(TYPES.Sync_ItemHttpMapper),
container.get(TYPES.Sync_Logger),
container.get(TYPES.Sync_S3),
)
: new FSItemBackupService(
container.get(TYPES.Sync_FILE_UPLOAD_PATH),
container.get(TYPES.Sync_ItemBackupMapper),
container.get(TYPES.Sync_Logger),
),
)
// Handlers
container
.bind<DuplicateItemSyncedEventHandler>(TYPES.Sync_DuplicateItemSyncedEventHandler)
.toDynamicValue((context: interfaces.Context) => {
return new DuplicateItemSyncedEventHandler(
context.container.get(TYPES.Sync_ItemRepository),
context.container.get(TYPES.Sync_DomainEventFactory),
context.container.get(TYPES.Sync_DomainEventPublisher),
context.container.get(TYPES.Sync_Logger),
)
})
.toConstantValue(
new DuplicateItemSyncedEventHandler(
container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
isSecondaryDatabaseEnabled ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository) : null,
container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
container.get<Logger>(TYPES.Sync_Logger),
),
)
container
.bind<AccountDeletionRequestedEventHandler>(TYPES.Sync_AccountDeletionRequestedEventHandler)
.toDynamicValue((context: interfaces.Context) => {
return new AccountDeletionRequestedEventHandler(
context.container.get(TYPES.Sync_ItemRepository),
context.container.get(TYPES.Sync_Logger),
)
})
.toConstantValue(
new AccountDeletionRequestedEventHandler(
container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
isSecondaryDatabaseEnabled ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository) : null,
container.get<Logger>(TYPES.Sync_Logger),
),
)
container
.bind<ItemRevisionCreationRequestedEventHandler>(TYPES.Sync_ItemRevisionCreationRequestedEventHandler)
.toDynamicValue((context: interfaces.Context) => {
return new ItemRevisionCreationRequestedEventHandler(
context.container.get(TYPES.Sync_ItemRepository),
context.container.get(TYPES.Sync_ItemBackupService),
context.container.get(TYPES.Sync_DomainEventFactory),
context.container.get(TYPES.Sync_DomainEventPublisher),
)
})
.toConstantValue(
new ItemRevisionCreationRequestedEventHandler(
container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
isSecondaryDatabaseEnabled ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository) : null,
container.get<ItemBackupServiceInterface>(TYPES.Sync_ItemBackupService),
container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
),
)
container
.bind<SharedVaultFileUploadedEventHandler>(TYPES.Sync_SharedVaultFileUploadedEventHandler)
.toConstantValue(
@@ -844,38 +876,17 @@ export class ContainerConfigLoader {
container.bind<AxiosInstance>(TYPES.Sync_HTTPClient).toDynamicValue(() => axios.create())
container
.bind<ExtensionsHttpServiceInterface>(TYPES.Sync_ExtensionsHttpService)
.toDynamicValue((context: interfaces.Context) => {
return new ExtensionsHttpService(
context.container.get(TYPES.Sync_HTTPClient),
context.container.get(TYPES.Sync_ItemRepository),
context.container.get(TYPES.Sync_ContentDecoder),
context.container.get(TYPES.Sync_DomainEventPublisher),
context.container.get(TYPES.Sync_DomainEventFactory),
context.container.get(TYPES.Sync_Logger),
)
})
container
.bind<ItemBackupServiceInterface>(TYPES.Sync_ItemBackupService)
.toDynamicValue((context: interfaces.Context) => {
const env: Env = context.container.get(TYPES.Sync_Env)
if (env.get('S3_AWS_REGION', true)) {
return new S3ItemBackupService(
context.container.get(TYPES.Sync_S3_BACKUP_BUCKET_NAME),
context.container.get(TYPES.Sync_ItemBackupMapper),
context.container.get(TYPES.Sync_ItemHttpMapper),
context.container.get(TYPES.Sync_Logger),
context.container.get(TYPES.Sync_S3),
)
} else {
return new FSItemBackupService(
context.container.get(TYPES.Sync_FILE_UPLOAD_PATH),
context.container.get(TYPES.Sync_ItemBackupMapper),
context.container.get(TYPES.Sync_Logger),
)
}
})
.toConstantValue(
new ExtensionsHttpService(
container.get<AxiosInstance>(TYPES.Sync_HTTPClient),
container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
isSecondaryDatabaseEnabled ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository) : null,
container.get<ContentDecoderInterface>(TYPES.Sync_ContentDecoder),
container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
container.get<Logger>(TYPES.Sync_Logger),
),
)
const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
['DUPLICATE_ITEM_SYNCED', container.get(TYPES.Sync_DuplicateItemSyncedEventHandler)],
@@ -904,19 +915,22 @@ export class ContainerConfigLoader {
container
.bind<EmailBackupRequestedEventHandler>(TYPES.Sync_EmailBackupRequestedEventHandler)
.toDynamicValue((context: interfaces.Context) => {
return new EmailBackupRequestedEventHandler(
context.container.get(TYPES.Sync_ItemRepository),
context.container.get(TYPES.Sync_AuthHttpService),
context.container.get(TYPES.Sync_ItemBackupService),
context.container.get(TYPES.Sync_DomainEventPublisher),
context.container.get(TYPES.Sync_DomainEventFactory),
context.container.get(TYPES.Sync_EMAIL_ATTACHMENT_MAX_BYTE_SIZE),
context.container.get(TYPES.Sync_ItemTransferCalculator),
context.container.get(TYPES.Sync_S3_BACKUP_BUCKET_NAME),
context.container.get(TYPES.Sync_Logger),
)
})
.toConstantValue(
new EmailBackupRequestedEventHandler(
container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
isSecondaryDatabaseEnabled
? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository)
: null,
container.get<AuthHttpServiceInterface>(TYPES.Sync_AuthHttpService),
container.get<ItemBackupServiceInterface>(TYPES.Sync_ItemBackupService),
container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
container.get<number>(TYPES.Sync_EMAIL_ATTACHMENT_MAX_BYTE_SIZE),
container.get<ItemTransferCalculatorInterface>(TYPES.Sync_ItemTransferCalculator),
container.get<string>(TYPES.Sync_S3_BACKUP_BUCKET_NAME),
container.get<Logger>(TYPES.Sync_Logger),
),
)
eventHandlers.set('EMAIL_BACKUP_REQUESTED', container.get(TYPES.Sync_EmailBackupRequestedEventHandler))
}

View File

@@ -7,7 +7,9 @@ const TYPES = {
Sync_S3: Symbol.for('Sync_S3'),
Sync_Env: Symbol.for('Sync_Env'),
// Repositories
Sync_ItemRepository: Symbol.for('Sync_ItemRepository'),
Sync_ItemRepositoryResolver: Symbol.for('Sync_ItemRepositoryResolver'),
Sync_MySQLItemRepository: Symbol.for('Sync_MySQLItemRepository'),
Sync_MongoDBItemRepository: Symbol.for('Sync_MongoDBItemRepository'),
Sync_SharedVaultRepository: Symbol.for('Sync_SharedVaultRepository'),
Sync_SharedVaultInviteRepository: Symbol.for('Sync_SharedVaultInviteRepository'),
Sync_SharedVaultUserRepository: Symbol.for('Sync_SharedVaultUserRepository'),
@@ -23,7 +25,7 @@ const TYPES = {
Sync_ORMNotificationRepository: Symbol.for('Sync_ORMNotificationRepository'),
Sync_ORMMessageRepository: Symbol.for('Sync_ORMMessageRepository'),
// Mongo
Sync_MongoItemRepository: Symbol.for('Sync_MongoItemRepository'),
Sync_ORMMongoItemRepository: Symbol.for('Sync_ORMMongoItemRepository'),
// Middleware
Sync_AuthMiddleware: Symbol.for('Sync_AuthMiddleware'),
// env vars

View File

@@ -13,7 +13,8 @@ import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId } from '@standardn
describe('ExtensionsHttpService', () => {
let httpClient: AxiosInstance
let itemRepository: ItemRepositoryInterface
let primaryItemRepository: ItemRepositoryInterface
let secondaryItemRepository: ItemRepositoryInterface | null
let contentDecoder: ContentDecoderInterface
let domainEventPublisher: DomainEventPublisherInterface
let domainEventFactory: DomainEventFactoryInterface
@@ -24,7 +25,8 @@ describe('ExtensionsHttpService', () => {
const createService = () =>
new ExtensionsHttpService(
httpClient,
itemRepository,
primaryItemRepository,
secondaryItemRepository,
contentDecoder,
domainEventPublisher,
domainEventFactory,
@@ -54,8 +56,8 @@ describe('ExtensionsHttpService', () => {
authParams = {} as jest.Mocked<KeyParamsData>
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(item)
primaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
primaryItemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(item)
logger = {} as jest.Mocked<Logger>
logger.error = jest.fn()
@@ -191,6 +193,31 @@ describe('ExtensionsHttpService', () => {
expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalled()
})
it('should publish a failed backup event if the extension is in the secondary repository', async () => {
primaryItemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(null)
secondaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
secondaryItemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(item)
httpClient.request = jest.fn().mockImplementation(() => {
throw new Error('Could not reach the extensions server')
})
await createService().sendItemsToExtensionsServer({
userUuid: '1-2-3',
extensionId: '2-3-4',
extensionsServerUrl: '',
forceMute: false,
items: [item],
backupFilename: 'backup-file',
authParams,
})
expect(domainEventPublisher.publish).toHaveBeenCalled()
expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalled()
secondaryItemRepository = null
})
it('should publish a failed Dropbox backup event if request was sent and extensions server responded not ok', async () => {
contentDecoder.decode = jest.fn().mockReturnValue({ name: 'Dropbox' })
@@ -273,7 +300,7 @@ describe('ExtensionsHttpService', () => {
})
it('should throw an error if the extension to post to is not found', async () => {
itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(null)
primaryItemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(null)
httpClient.request = jest.fn().mockImplementation(() => {
throw new Error('Could not reach the extensions server')
@@ -299,7 +326,7 @@ describe('ExtensionsHttpService', () => {
it('should throw an error if the extension to post to has no content', async () => {
item = {} as jest.Mocked<Item>
itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(item)
primaryItemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(item)
httpClient.request = jest.fn().mockImplementation(() => {
throw new Error('Could not reach the extensions server')

View File

@@ -17,7 +17,8 @@ import { getBody as oneDriveBody, getSubject as oneDriveSubject } from '../Email
export class ExtensionsHttpService implements ExtensionsHttpServiceInterface {
constructor(
private httpClient: AxiosInstance,
private itemRepository: ItemRepositoryInterface,
private primaryItemRepository: ItemRepositoryInterface,
private secondaryItemRepository: ItemRepositoryInterface | null,
private contentDecoder: ContentDecoderInterface,
private domainEventPublisher: DomainEventPublisherInterface,
private domainEventFactory: DomainEventFactoryInterface,
@@ -139,9 +140,14 @@ export class ExtensionsHttpService implements ExtensionsHttpServiceInterface {
userUuid: string,
email: string,
): Promise<DomainEventInterface> {
const extension = await this.itemRepository.findByUuidAndUserUuid(extensionId, userUuid)
let extension = await this.primaryItemRepository.findByUuidAndUserUuid(extensionId, userUuid)
if (extension === null || !extension.props.content) {
throw Error(`Could not find extensions with id ${extensionId}`)
if (this.secondaryItemRepository) {
extension = await this.secondaryItemRepository.findByUuidAndUserUuid(extensionId, userUuid)
}
if (extension === null || !extension.props.content) {
throw Error(`Could not find extensions with id ${extensionId}`)
}
}
const content = this.contentDecoder.decode(extension.props.content)

View File

@@ -8,12 +8,14 @@ import { AccountDeletionRequestedEventHandler } from './AccountDeletionRequested
import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId } from '@standardnotes/domain-core'
describe('AccountDeletionRequestedEventHandler', () => {
let itemRepository: ItemRepositoryInterface
let primaryItemRepository: ItemRepositoryInterface
let secondaryItemRepository: ItemRepositoryInterface | null
let logger: Logger
let event: AccountDeletionRequestedEvent
let item: Item
const createHandler = () => new AccountDeletionRequestedEventHandler(itemRepository, logger)
const createHandler = () =>
new AccountDeletionRequestedEventHandler(primaryItemRepository, secondaryItemRepository, logger)
beforeEach(() => {
item = Item.create(
@@ -33,9 +35,9 @@ describe('AccountDeletionRequestedEventHandler', () => {
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findAll = jest.fn().mockReturnValue([item])
itemRepository.deleteByUserUuid = jest.fn()
primaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
primaryItemRepository.findAll = jest.fn().mockReturnValue([item])
primaryItemRepository.deleteByUserUuid = jest.fn()
logger = {} as jest.Mocked<Logger>
logger.info = jest.fn()
@@ -52,6 +54,17 @@ describe('AccountDeletionRequestedEventHandler', () => {
it('should remove all items for a user', async () => {
await createHandler().handle(event)
expect(itemRepository.deleteByUserUuid).toHaveBeenCalledWith('2-3-4')
expect(primaryItemRepository.deleteByUserUuid).toHaveBeenCalledWith('2-3-4')
})
it('should remove all items for a user from secondary repository', async () => {
secondaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
secondaryItemRepository.deleteByUserUuid = jest.fn()
await createHandler().handle(event)
expect(secondaryItemRepository.deleteByUserUuid).toHaveBeenCalledWith('2-3-4')
secondaryItemRepository = null
})
})

View File

@@ -3,10 +3,17 @@ import { Logger } from 'winston'
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
export class AccountDeletionRequestedEventHandler implements DomainEventHandlerInterface {
constructor(private itemRepository: ItemRepositoryInterface, private logger: Logger) {}
constructor(
private primaryItemRepository: ItemRepositoryInterface,
private secondaryItemRepository: ItemRepositoryInterface | null,
private logger: Logger,
) {}
async handle(event: AccountDeletionRequestedEvent): Promise<void> {
await this.itemRepository.deleteByUserUuid(event.payload.userUuid)
await this.primaryItemRepository.deleteByUserUuid(event.payload.userUuid)
if (this.secondaryItemRepository) {
await this.secondaryItemRepository.deleteByUserUuid(event.payload.userUuid)
}
this.logger.info(`Finished account cleanup for user: ${event.payload.userUuid}`)
}

View File

@@ -13,7 +13,8 @@ import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterfac
import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId } from '@standardnotes/domain-core'
describe('DuplicateItemSyncedEventHandler', () => {
let itemRepository: ItemRepositoryInterface
let primaryItemRepository: ItemRepositoryInterface
let secondaryItemRepository: ItemRepositoryInterface | null
let logger: Logger
let duplicateItem: Item
let originalItem: Item
@@ -22,7 +23,13 @@ describe('DuplicateItemSyncedEventHandler', () => {
let domainEventPublisher: DomainEventPublisherInterface
const createHandler = () =>
new DuplicateItemSyncedEventHandler(itemRepository, domainEventFactory, domainEventPublisher, logger)
new DuplicateItemSyncedEventHandler(
primaryItemRepository,
secondaryItemRepository,
domainEventFactory,
domainEventPublisher,
logger,
)
beforeEach(() => {
originalItem = Item.create(
@@ -59,8 +66,8 @@ describe('DuplicateItemSyncedEventHandler', () => {
new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
).getValue()
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findByUuidAndUserUuid = jest
primaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
primaryItemRepository.findByUuidAndUserUuid = jest
.fn()
.mockReturnValueOnce(duplicateItem)
.mockReturnValueOnce(originalItem)
@@ -90,8 +97,22 @@ describe('DuplicateItemSyncedEventHandler', () => {
expect(domainEventPublisher.publish).toHaveBeenCalled()
})
it('should copy revisions from original item to the duplicate item in the secondary repository', async () => {
secondaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
secondaryItemRepository.findByUuidAndUserUuid = jest
.fn()
.mockReturnValueOnce(duplicateItem)
.mockReturnValueOnce(originalItem)
await createHandler().handle(event)
expect(domainEventPublisher.publish).toHaveBeenCalledTimes(2)
secondaryItemRepository = null
})
it('should not copy revisions if original item does not exist', async () => {
itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValueOnce(duplicateItem).mockReturnValueOnce(null)
primaryItemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValueOnce(duplicateItem).mockReturnValueOnce(null)
await createHandler().handle(event)
@@ -99,7 +120,7 @@ describe('DuplicateItemSyncedEventHandler', () => {
})
it('should not copy revisions if duplicate item does not exist', async () => {
itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValueOnce(null).mockReturnValueOnce(originalItem)
primaryItemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValueOnce(null).mockReturnValueOnce(originalItem)
await createHandler().handle(event)

View File

@@ -9,14 +9,26 @@ import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
export class DuplicateItemSyncedEventHandler implements DomainEventHandlerInterface {
constructor(
private itemRepository: ItemRepositoryInterface,
private primaryItemRepository: ItemRepositoryInterface,
private secondaryItemRepository: ItemRepositoryInterface | null,
private domainEventFactory: DomainEventFactoryInterface,
private domainEventPublisher: DomainEventPublisherInterface,
private logger: Logger,
) {}
async handle(event: DuplicateItemSyncedEvent): Promise<void> {
const item = await this.itemRepository.findByUuidAndUserUuid(event.payload.itemUuid, event.payload.userUuid)
await this.requestRevisionsCopy(event, this.primaryItemRepository)
if (this.secondaryItemRepository) {
await this.requestRevisionsCopy(event, this.secondaryItemRepository)
}
}
private async requestRevisionsCopy(
event: DuplicateItemSyncedEvent,
itemRepository: ItemRepositoryInterface,
): Promise<void> {
const item = await itemRepository.findByUuidAndUserUuid(event.payload.itemUuid, event.payload.userUuid)
if (item === null) {
this.logger.warn(`Could not find item with uuid ${event.payload.itemUuid}`)
@@ -30,7 +42,7 @@ export class DuplicateItemSyncedEventHandler implements DomainEventHandlerInterf
return
}
const existingOriginalItem = await this.itemRepository.findByUuidAndUserUuid(
const existingOriginalItem = await itemRepository.findByUuidAndUserUuid(
item.props.duplicateOf.value,
event.payload.userUuid,
)

View File

@@ -13,9 +13,11 @@ import { ItemBackupServiceInterface } from '../Item/ItemBackupServiceInterface'
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
import { EmailBackupRequestedEventHandler } from './EmailBackupRequestedEventHandler'
import { ItemTransferCalculatorInterface } from '../Item/ItemTransferCalculatorInterface'
import { ItemContentSizeDescriptor } from '../Item/ItemContentSizeDescriptor'
describe('EmailBackupRequestedEventHandler', () => {
let itemRepository: ItemRepositoryInterface
let primaryItemRepository: ItemRepositoryInterface
let secondaryItemRepository: ItemRepositoryInterface | null
let authHttpService: AuthHttpServiceInterface
let itemBackupService: ItemBackupServiceInterface
let domainEventPublisher: DomainEventPublisherInterface
@@ -28,7 +30,8 @@ describe('EmailBackupRequestedEventHandler', () => {
const createHandler = () =>
new EmailBackupRequestedEventHandler(
itemRepository,
primaryItemRepository,
secondaryItemRepository,
authHttpService,
itemBackupService,
domainEventPublisher,
@@ -42,8 +45,11 @@ describe('EmailBackupRequestedEventHandler', () => {
beforeEach(() => {
item = {} as jest.Mocked<Item>
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findAll = jest.fn().mockReturnValue([item])
primaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
primaryItemRepository.findAll = jest.fn().mockReturnValue([item])
primaryItemRepository.findContentSizeForComputingTransferLimit = jest
.fn()
.mockResolvedValue([ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue()])
authHttpService = {} as jest.Mocked<AuthHttpServiceInterface>
authHttpService.getUserKeyParams = jest.fn().mockReturnValue({ identifier: 'test@test.com' })
@@ -81,6 +87,21 @@ describe('EmailBackupRequestedEventHandler', () => {
expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalled()
})
it('should inform that backup attachment for email was created in the secondary repository', async () => {
secondaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
secondaryItemRepository.findAll = jest.fn().mockReturnValue([item])
secondaryItemRepository.findContentSizeForComputingTransferLimit = jest
.fn()
.mockResolvedValue([ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue()])
await createHandler().handle(event)
expect(domainEventPublisher.publish).toHaveBeenCalledTimes(2)
expect(domainEventFactory.createEmailRequestedEvent).toHaveBeenCalledTimes(2)
secondaryItemRepository = null
})
it('should inform that multipart backup attachment for email was created', async () => {
itemBackupService.backup = jest
.fn()

View File

@@ -16,7 +16,8 @@ import { getBody, getSubject } from '../Email/EmailBackupAttachmentCreated'
export class EmailBackupRequestedEventHandler implements DomainEventHandlerInterface {
constructor(
private itemRepository: ItemRepositoryInterface,
private primaryItemRepository: ItemRepositoryInterface,
private secondaryItemRepository: ItemRepositoryInterface | null,
private authHttpService: AuthHttpServiceInterface,
private itemBackupService: ItemBackupServiceInterface,
private domainEventPublisher: DomainEventPublisherInterface,
@@ -28,6 +29,17 @@ export class EmailBackupRequestedEventHandler implements DomainEventHandlerInter
) {}
async handle(event: EmailBackupRequestedEvent): Promise<void> {
await this.requestEmailWithBackupFile(event, this.primaryItemRepository)
if (this.secondaryItemRepository) {
await this.requestEmailWithBackupFile(event, this.secondaryItemRepository)
}
}
private async requestEmailWithBackupFile(
event: EmailBackupRequestedEvent,
itemRepository: ItemRepositoryInterface,
): Promise<void> {
let authParams: KeyParamsData
try {
authParams = await this.authHttpService.getUserKeyParams({
@@ -46,14 +58,15 @@ export class EmailBackupRequestedEventHandler implements DomainEventHandlerInter
sortOrder: 'ASC',
deleted: false,
}
const itemContentSizeDescriptors = await itemRepository.findContentSizeForComputingTransferLimit(itemQuery)
const itemUuidBundles = await this.itemTransferCalculator.computeItemUuidBundlesToFetch(
itemQuery,
itemContentSizeDescriptors,
this.emailAttachmentMaxByteSize,
)
const backupFileNames: string[] = []
for (const itemUuidBundle of itemUuidBundles) {
const items = await this.itemRepository.findAll({
const items = await itemRepository.findAll({
uuids: itemUuidBundle,
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',

View File

@@ -14,7 +14,8 @@ import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterfac
import { Uuid, ContentType, Dates, Timestamps, UniqueEntityId } from '@standardnotes/domain-core'
describe('ItemRevisionCreationRequestedEventHandler', () => {
let itemRepository: ItemRepositoryInterface
let primaryItemRepository: ItemRepositoryInterface
let secondaryItemRepository: ItemRepositoryInterface | null
let event: ItemRevisionCreationRequestedEvent
let item: Item
let itemBackupService: ItemBackupServiceInterface
@@ -23,7 +24,8 @@ describe('ItemRevisionCreationRequestedEventHandler', () => {
const createHandler = () =>
new ItemRevisionCreationRequestedEventHandler(
itemRepository,
primaryItemRepository,
secondaryItemRepository,
itemBackupService,
domainEventFactory,
domainEventPublisher,
@@ -47,8 +49,8 @@ describe('ItemRevisionCreationRequestedEventHandler', () => {
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findByUuid = jest.fn().mockReturnValue(item)
primaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
primaryItemRepository.findByUuid = jest.fn().mockReturnValue(item)
event = {} as jest.Mocked<ItemRevisionCreationRequestedEvent>
event.createdAt = new Date(1)
@@ -80,8 +82,20 @@ describe('ItemRevisionCreationRequestedEventHandler', () => {
expect(domainEventFactory.createItemDumpedEvent).toHaveBeenCalled()
})
it('should create a revision for an item in the secondary repository', async () => {
secondaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
secondaryItemRepository.findByUuid = jest.fn().mockReturnValue(item)
await createHandler().handle(event)
expect(domainEventPublisher.publish).toHaveBeenCalled()
expect(domainEventFactory.createItemDumpedEvent).toHaveBeenCalled()
secondaryItemRepository = null
})
it('should not create a revision for an item that does not exist', async () => {
itemRepository.findByUuid = jest.fn().mockReturnValue(null)
primaryItemRepository.findByUuid = jest.fn().mockReturnValue(null)
await createHandler().handle(event)

View File

@@ -11,20 +11,32 @@ import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
export class ItemRevisionCreationRequestedEventHandler implements DomainEventHandlerInterface {
constructor(
private itemRepository: ItemRepositoryInterface,
private primaryItemRepository: ItemRepositoryInterface,
private secondaryItemRepository: ItemRepositoryInterface | null,
private itemBackupService: ItemBackupServiceInterface,
private domainEventFactory: DomainEventFactoryInterface,
private domainEventPublisher: DomainEventPublisherInterface,
) {}
async handle(event: ItemRevisionCreationRequestedEvent): Promise<void> {
await this.createItemDump(event, this.primaryItemRepository)
if (this.secondaryItemRepository) {
await this.createItemDump(event, this.secondaryItemRepository)
}
}
private async createItemDump(
event: ItemRevisionCreationRequestedEvent,
itemRepository: ItemRepositoryInterface,
): Promise<void> {
const itemUuidOrError = Uuid.create(event.payload.itemUuid)
if (itemUuidOrError.isFailed()) {
return
}
const itemUuid = itemUuidOrError.getValue()
const item = await this.itemRepository.findByUuid(itemUuid)
const item = await itemRepository.findByUuid(itemUuid)
if (item === null) {
return
}

View File

@@ -0,0 +1,15 @@
import { ItemContentSizeDescriptor } from './ItemContentSizeDescriptor'
describe('ItemContentSizeDescriptor', () => {
it('should create a value object', () => {
const valueOrError = ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20)
expect(valueOrError.isFailed()).toBeFalsy()
})
it('should return error if shared vault uuid is not valid', () => {
const valueOrError = ItemContentSizeDescriptor.create('invalid', 20)
expect(valueOrError.isFailed()).toBeTruthy()
})
})

View File

@@ -0,0 +1,24 @@
import { Result, Uuid, ValueObject } from '@standardnotes/domain-core'
import { ItemContentSizeDescriptorProps } from './ItemContentSizeDescriptorProps'
export class ItemContentSizeDescriptor extends ValueObject<ItemContentSizeDescriptorProps> {
private constructor(props: ItemContentSizeDescriptorProps) {
super(props)
}
static create(itemUuidString: string, contentSize: number | null): Result<ItemContentSizeDescriptor> {
const uuidOrError = Uuid.create(itemUuidString)
if (uuidOrError.isFailed()) {
return Result.fail<ItemContentSizeDescriptor>(uuidOrError.getError())
}
const uuid = uuidOrError.getValue()
return Result.ok<ItemContentSizeDescriptor>(
new ItemContentSizeDescriptor({
uuid,
contentSize,
}),
)
}
}

View File

@@ -0,0 +1,6 @@
import { Uuid } from '@standardnotes/domain-core'
export interface ItemContentSizeDescriptorProps {
uuid: Uuid
contentSize: number | null
}

View File

@@ -3,14 +3,13 @@ import { Uuid } from '@standardnotes/domain-core'
import { Item } from './Item'
import { ItemQuery } from './ItemQuery'
import { ExtendedIntegrityPayload } from './ExtendedIntegrityPayload'
import { ItemContentSizeDescriptor } from './ItemContentSizeDescriptor'
export interface ItemRepositoryInterface {
deleteByUserUuid(userUuid: string): Promise<void>
findAll(query: ItemQuery): Promise<Item[]>
countAll(query: ItemQuery): Promise<number>
findContentSizeForComputingTransferLimit(
query: ItemQuery,
): Promise<Array<{ uuid: string; contentSize: number | null }>>
findContentSizeForComputingTransferLimit(query: ItemQuery): Promise<Array<ItemContentSizeDescriptor>>
findDatesForComputingIntegrityHash(userUuid: string): Promise<Array<{ updated_at_timestamp: number }>>
findItemsForComputingIntegrityPayloads(userUuid: string): Promise<ExtendedIntegrityPayload[]>
findByUuidAndUserUuid(uuid: string, userUuid: string): Promise<Item | null>

View File

@@ -0,0 +1,7 @@
import { RoleNameCollection } from '@standardnotes/domain-core'
import { ItemRepositoryInterface } from './ItemRepositoryInterface'
export interface ItemRepositoryResolverInterface {
resolve(roleNames: RoleNameCollection): ItemRepositoryInterface
}

View File

@@ -1,201 +1,143 @@
import 'reflect-metadata'
import { Logger } from 'winston'
import { ItemQuery } from './ItemQuery'
import { ItemRepositoryInterface } from './ItemRepositoryInterface'
import { ItemTransferCalculator } from './ItemTransferCalculator'
import { ItemContentSizeDescriptor } from './ItemContentSizeDescriptor'
describe('ItemTransferCalculator', () => {
let itemRepository: ItemRepositoryInterface
let logger: Logger
const createCalculator = () => new ItemTransferCalculator(itemRepository, logger)
const createCalculator = () => new ItemTransferCalculator(logger)
beforeEach(() => {
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([])
logger = {} as jest.Mocked<Logger>
logger.warn = jest.fn()
})
describe('fetching uuids', () => {
it('should compute uuids to fetch based on transfer limit - one item overlaping limit', async () => {
const query = {} as jest.Mocked<ItemQuery>
itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
{
uuid: '1-2-3',
contentSize: 20,
},
{
uuid: '2-3-4',
contentSize: 20,
},
{
uuid: '3-4-5',
contentSize: 20,
},
const itemContentSizeDescriptors = [
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', 20).getValue(),
]
const result = await createCalculator().computeItemUuidsToFetch(itemContentSizeDescriptors, 50)
expect(result).toEqual([
'00000000-0000-0000-0000-000000000000',
'00000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000002',
])
const result = await createCalculator().computeItemUuidsToFetch(query, 50)
expect(result).toEqual(['1-2-3', '2-3-4', '3-4-5'])
})
it('should compute uuids to fetch based on transfer limit - exact limit fit', async () => {
const query = {} as jest.Mocked<ItemQuery>
itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
{
uuid: '1-2-3',
contentSize: 20,
},
{
uuid: '2-3-4',
contentSize: 20,
},
{
uuid: '3-4-5',
contentSize: 20,
},
])
const itemContentSizeDescriptors = [
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', 20).getValue(),
]
const result = await createCalculator().computeItemUuidsToFetch(query, 40)
const result = await createCalculator().computeItemUuidsToFetch(itemContentSizeDescriptors, 40)
expect(result).toEqual(['1-2-3', '2-3-4'])
expect(result).toEqual(['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'])
})
it('should compute uuids to fetch based on transfer limit - content size not defined on an item', async () => {
const query = {} as jest.Mocked<ItemQuery>
itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
{
uuid: '1-2-3',
contentSize: 20,
},
{
uuid: '2-3-4',
contentSize: 20,
},
{
uuid: '3-4-5',
},
const itemContentSizeDescriptors = [
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', null).getValue(),
]
const result = await createCalculator().computeItemUuidsToFetch(itemContentSizeDescriptors, 50)
expect(result).toEqual([
'00000000-0000-0000-0000-000000000000',
'00000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000002',
])
const result = await createCalculator().computeItemUuidsToFetch(query, 50)
expect(result).toEqual(['1-2-3', '2-3-4', '3-4-5'])
})
it('should compute uuids to fetch based on transfer limit - first item over the limit', async () => {
const query = {} as jest.Mocked<ItemQuery>
itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
{
uuid: '1-2-3',
contentSize: 50,
},
{
uuid: '2-3-4',
contentSize: 20,
},
{
uuid: '3-4-5',
contentSize: 20,
},
])
const itemContentSizeDescriptors = [
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 50).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', 20).getValue(),
]
const result = await createCalculator().computeItemUuidsToFetch(query, 40)
const result = await createCalculator().computeItemUuidsToFetch(itemContentSizeDescriptors, 40)
expect(result).toEqual(['1-2-3', '2-3-4'])
expect(result).toEqual(['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'])
})
})
describe('fetching bundles', () => {
it('should compute uuid bundles to fetch based on transfer limit - one item overlaping limit', async () => {
const query = {} as jest.Mocked<ItemQuery>
itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
{
uuid: '1-2-3',
contentSize: 20,
},
{
uuid: '2-3-4',
contentSize: 20,
},
{
uuid: '3-4-5',
contentSize: 20,
},
const itemContentSizeDescriptors = [
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', 20).getValue(),
]
const result = await createCalculator().computeItemUuidBundlesToFetch(itemContentSizeDescriptors, 50)
expect(result).toEqual([
[
'00000000-0000-0000-0000-000000000000',
'00000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000002',
],
])
const result = await createCalculator().computeItemUuidBundlesToFetch(query, 50)
expect(result).toEqual([['1-2-3', '2-3-4', '3-4-5']])
})
it('should compute uuid bundles to fetch based on transfer limit - exact limit fit', async () => {
const query = {} as jest.Mocked<ItemQuery>
itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
{
uuid: '1-2-3',
contentSize: 20,
},
{
uuid: '2-3-4',
contentSize: 20,
},
{
uuid: '3-4-5',
contentSize: 20,
},
const itemContentSizeDescriptors = [
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', 20).getValue(),
]
const result = await createCalculator().computeItemUuidBundlesToFetch(itemContentSizeDescriptors, 40)
expect(result).toEqual([
['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
['00000000-0000-0000-0000-000000000002'],
])
const result = await createCalculator().computeItemUuidBundlesToFetch(query, 40)
expect(result).toEqual([['1-2-3', '2-3-4'], ['3-4-5']])
})
it('should compute uuid bundles to fetch based on transfer limit - content size not defined on an item', async () => {
const query = {} as jest.Mocked<ItemQuery>
itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
{
uuid: '1-2-3',
contentSize: 20,
},
{
uuid: '2-3-4',
contentSize: 20,
},
{
uuid: '3-4-5',
},
const itemContentSizeDescriptors = [
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', null).getValue(),
]
const result = await createCalculator().computeItemUuidBundlesToFetch(itemContentSizeDescriptors, 50)
expect(result).toEqual([
[
'00000000-0000-0000-0000-000000000000',
'00000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000002',
],
])
const result = await createCalculator().computeItemUuidBundlesToFetch(query, 50)
expect(result).toEqual([['1-2-3', '2-3-4', '3-4-5']])
})
it('should compute uuid bundles to fetch based on transfer limit - first item over the limit', async () => {
const query = {} as jest.Mocked<ItemQuery>
itemRepository.findContentSizeForComputingTransferLimit = jest.fn().mockReturnValue([
{
uuid: '1-2-3',
contentSize: 50,
},
{
uuid: '2-3-4',
contentSize: 20,
},
{
uuid: '3-4-5',
contentSize: 20,
},
const itemContentSizeDescriptors = [
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 50).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000001', 20).getValue(),
ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000002', 20).getValue(),
]
const result = await createCalculator().computeItemUuidBundlesToFetch(itemContentSizeDescriptors, 40)
expect(result).toEqual([
['00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'],
['00000000-0000-0000-0000-000000000002'],
])
const result = await createCalculator().computeItemUuidBundlesToFetch(query, 40)
expect(result).toEqual([['1-2-3', '2-3-4'], ['3-4-5']])
})
})
})

View File

@@ -1,27 +1,28 @@
import { Logger } from 'winston'
import { ItemTransferCalculatorInterface } from './ItemTransferCalculatorInterface'
import { ItemQuery } from './ItemQuery'
import { ItemRepositoryInterface } from './ItemRepositoryInterface'
import { ItemContentSizeDescriptor } from './ItemContentSizeDescriptor'
export class ItemTransferCalculator implements ItemTransferCalculatorInterface {
constructor(private itemRepository: ItemRepositoryInterface, private logger: Logger) {}
constructor(private logger: Logger) {}
async computeItemUuidsToFetch(itemQuery: ItemQuery, bytesTransferLimit: number): Promise<Array<string>> {
async computeItemUuidsToFetch(
itemContentSizeDescriptors: ItemContentSizeDescriptor[],
bytesTransferLimit: number,
): Promise<Array<string>> {
const itemUuidsToFetch = []
const itemContentSizes = await this.itemRepository.findContentSizeForComputingTransferLimit(itemQuery)
let totalContentSizeInBytes = 0
for (const itemContentSize of itemContentSizes) {
const contentSize = itemContentSize.contentSize ?? 0
for (const itemContentSize of itemContentSizeDescriptors) {
const contentSize = itemContentSize.props.contentSize ?? 0
itemUuidsToFetch.push(itemContentSize.uuid)
itemUuidsToFetch.push(itemContentSize.props.uuid.value)
totalContentSizeInBytes += contentSize
const transferLimitBreached = this.isTransferLimitBreached({
totalContentSizeInBytes,
bytesTransferLimit,
itemUuidsToFetch,
itemContentSizes,
itemContentSizeDescriptors,
})
if (transferLimitBreached) {
@@ -32,22 +33,24 @@ export class ItemTransferCalculator implements ItemTransferCalculatorInterface {
return itemUuidsToFetch
}
async computeItemUuidBundlesToFetch(itemQuery: ItemQuery, bytesTransferLimit: number): Promise<Array<Array<string>>> {
async computeItemUuidBundlesToFetch(
itemContentSizeDescriptors: ItemContentSizeDescriptor[],
bytesTransferLimit: number,
): Promise<Array<Array<string>>> {
let itemUuidsToFetch = []
const itemContentSizes = await this.itemRepository.findContentSizeForComputingTransferLimit(itemQuery)
let totalContentSizeInBytes = 0
const bundles = []
for (const itemContentSize of itemContentSizes) {
const contentSize = itemContentSize.contentSize ?? 0
for (const itemContentSize of itemContentSizeDescriptors) {
const contentSize = itemContentSize.props.contentSize ?? 0
itemUuidsToFetch.push(itemContentSize.uuid)
itemUuidsToFetch.push(itemContentSize.props.uuid.value)
totalContentSizeInBytes += contentSize
const transferLimitBreached = this.isTransferLimitBreached({
totalContentSizeInBytes,
bytesTransferLimit,
itemUuidsToFetch,
itemContentSizes,
itemContentSizeDescriptors,
})
if (transferLimitBreached) {
@@ -68,11 +71,11 @@ export class ItemTransferCalculator implements ItemTransferCalculatorInterface {
totalContentSizeInBytes: number
bytesTransferLimit: number
itemUuidsToFetch: Array<string>
itemContentSizes: Array<{ uuid: string; contentSize: number | null }>
itemContentSizeDescriptors: ItemContentSizeDescriptor[]
}): boolean {
const transferLimitBreached = dto.totalContentSizeInBytes >= dto.bytesTransferLimit
const transferLimitBreachedAtFirstItem =
transferLimitBreached && dto.itemUuidsToFetch.length === 1 && dto.itemContentSizes.length > 1
transferLimitBreached && dto.itemUuidsToFetch.length === 1 && dto.itemContentSizeDescriptors.length > 1
if (transferLimitBreachedAtFirstItem) {
this.logger.warn(

View File

@@ -1,6 +1,12 @@
import { ItemQuery } from './ItemQuery'
import { ItemContentSizeDescriptor } from './ItemContentSizeDescriptor'
export interface ItemTransferCalculatorInterface {
computeItemUuidsToFetch(itemQuery: ItemQuery, bytesTransferLimit: number): Promise<Array<string>>
computeItemUuidBundlesToFetch(itemQuery: ItemQuery, bytesTransferLimit: number): Promise<Array<Array<string>>>
computeItemUuidsToFetch(
itemContentSizeDescriptors: ItemContentSizeDescriptor[],
bytesTransferLimit: number,
): Promise<Array<string>>
computeItemUuidBundlesToFetch(
itemContentSizeDescriptors: ItemContentSizeDescriptor[],
bytesTransferLimit: number,
): Promise<Array<Array<string>>>
}

View File

@@ -1,15 +1,17 @@
import 'reflect-metadata'
import { ContentType } from '@standardnotes/domain-core'
import { ContentType, RoleName } from '@standardnotes/domain-core'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { CheckIntegrity } from './CheckIntegrity'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
describe('CheckIntegrity', () => {
let itemRepository: ItemRepositoryInterface
let itemRepositoryResolver: ItemRepositoryResolverInterface
const createUseCase = () => new CheckIntegrity(itemRepository)
const createUseCase = () => new CheckIntegrity(itemRepositoryResolver)
beforeEach(() => {
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
@@ -40,6 +42,9 @@ describe('CheckIntegrity', () => {
content_type: ContentType.TYPES.File,
},
])
itemRepositoryResolver = {} as jest.Mocked<ItemRepositoryResolverInterface>
itemRepositoryResolver.resolve = jest.fn().mockReturnValue(itemRepository)
})
it('should return an empty result if there are no integrity mismatches', async () => {
@@ -63,6 +68,7 @@ describe('CheckIntegrity', () => {
updated_at_timestamp: 5,
},
],
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.getValue()).toEqual([])
})
@@ -88,6 +94,7 @@ describe('CheckIntegrity', () => {
updated_at_timestamp: 5,
},
],
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.getValue()).toEqual([
{
@@ -114,6 +121,7 @@ describe('CheckIntegrity', () => {
updated_at_timestamp: 5,
},
],
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.getValue()).toEqual([
{
@@ -122,4 +130,27 @@ describe('CheckIntegrity', () => {
},
])
})
it('should return error if the role names are invalid', async () => {
const result = await createUseCase().execute({
userUuid: '1-2-3',
integrityPayloads: [
{
uuid: '1-2-3',
updated_at_timestamp: 1,
},
{
uuid: '2-3-4',
updated_at_timestamp: 2,
},
{
uuid: '5-6-7',
updated_at_timestamp: 5,
},
],
roleNames: ['invalid-role-name'],
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Invalid role name: invalid-role-name')
})
})

View File

@@ -1,15 +1,22 @@
import { IntegrityPayload } from '@standardnotes/responses'
import { ContentType, Result, UseCaseInterface } from '@standardnotes/domain-core'
import { ContentType, Result, RoleNameCollection, UseCaseInterface } from '@standardnotes/domain-core'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { CheckIntegrityDTO } from './CheckIntegrityDTO'
import { ExtendedIntegrityPayload } from '../../../Item/ExtendedIntegrityPayload'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
export class CheckIntegrity implements UseCaseInterface<IntegrityPayload[]> {
constructor(private itemRepository: ItemRepositoryInterface) {}
constructor(private itemRepositoryResolver: ItemRepositoryResolverInterface) {}
async execute(dto: CheckIntegrityDTO): Promise<Result<IntegrityPayload[]>> {
const serverItemIntegrityPayloads = await this.itemRepository.findItemsForComputingIntegrityPayloads(dto.userUuid)
const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
if (roleNamesOrError.isFailed()) {
return Result.fail(roleNamesOrError.getError())
}
const roleNames = roleNamesOrError.getValue()
const itemRepository = this.itemRepositoryResolver.resolve(roleNames)
const serverItemIntegrityPayloads = await itemRepository.findItemsForComputingIntegrityPayloads(dto.userUuid)
const serverItemIntegrityPayloadsMap = new Map<string, ExtendedIntegrityPayload>()
for (const serverItemIntegrityPayload of serverItemIntegrityPayloads) {

View File

@@ -2,5 +2,6 @@ import { IntegrityPayload } from '@standardnotes/responses'
export type CheckIntegrityDTO = {
userUuid: string
roleNames: string[]
integrityPayloads: IntegrityPayload[]
}

View File

@@ -3,26 +3,43 @@ import { Item } from '../../../Item/Item'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { GetItem } from './GetItem'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
import { RoleName } from '@standardnotes/domain-core'
describe('GetItem', () => {
let itemRepository: ItemRepositoryInterface
let itemRepositoryResolver: ItemRepositoryResolverInterface
const createUseCase = () => new GetItem(itemRepository)
const createUseCase = () => new GetItem(itemRepositoryResolver)
beforeEach(() => {
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(null)
itemRepositoryResolver = {} as jest.Mocked<ItemRepositoryResolverInterface>
itemRepositoryResolver.resolve = jest.fn().mockReturnValue(itemRepository)
})
it('should fail if item is not found', async () => {
const result = await createUseCase().execute({
userUuid: '1-2-3',
itemUuid: '2-3-4',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Could not find item with uuid 2-3-4')
})
it('should fail if the role names are invalid', async () => {
const result = await createUseCase().execute({
userUuid: '1-2-3',
itemUuid: '2-3-4',
roleNames: ['invalid-role-name'],
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Invalid role name: invalid-role-name')
})
it('should succeed if item is found', async () => {
const item = {} as jest.Mocked<Item>
itemRepository.findByUuidAndUserUuid = jest.fn().mockReturnValue(item)
@@ -30,6 +47,7 @@ describe('GetItem', () => {
const result = await createUseCase().execute({
userUuid: '1-2-3',
itemUuid: '2-3-4',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.getValue()).toEqual(item)

View File

@@ -1,14 +1,21 @@
import { Result, UseCaseInterface } from '@standardnotes/domain-core'
import { Result, RoleNameCollection, UseCaseInterface } from '@standardnotes/domain-core'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { GetItemDTO } from './GetItemDTO'
import { Item } from '../../../Item/Item'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
export class GetItem implements UseCaseInterface<Item> {
constructor(private itemRepository: ItemRepositoryInterface) {}
constructor(private itemRepositoryResolver: ItemRepositoryResolverInterface) {}
async execute(dto: GetItemDTO): Promise<Result<Item>> {
const item = await this.itemRepository.findByUuidAndUserUuid(dto.itemUuid, dto.userUuid)
const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
if (roleNamesOrError.isFailed()) {
return Result.fail(roleNamesOrError.getError())
}
const roleNames = roleNamesOrError.getValue()
const itemRepository = this.itemRepositoryResolver.resolve(roleNames)
const item = await itemRepository.findByUuidAndUserUuid(dto.itemUuid, dto.userUuid)
if (item === null) {
return Result.fail(`Could not find item with uuid ${dto.itemUuid}`)

View File

@@ -1,4 +1,5 @@
export type GetItemDTO = {
userUuid: string
itemUuid: string
roleNames: string[]
}

View File

@@ -3,11 +3,14 @@ import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { ItemTransferCalculatorInterface } from '../../../Item/ItemTransferCalculatorInterface'
import { GetItems } from './GetItems'
import { Item } from '../../../Item/Item'
import { ContentType, Dates, Timestamps, Uuid } from '@standardnotes/domain-core'
import { ContentType, Dates, RoleName, Timestamps, Uuid } from '@standardnotes/domain-core'
import { SharedVaultUserRepositoryInterface } from '../../../SharedVault/User/SharedVaultUserRepositoryInterface'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
import { ItemContentSizeDescriptor } from '../../../Item/ItemContentSizeDescriptor'
describe('GetItems', () => {
let itemRepository: ItemRepositoryInterface
let itemRepositoryResolver: ItemRepositoryResolverInterface
const contentSizeTransferLimit = 100
let itemTransferCalculator: ItemTransferCalculatorInterface
let timer: TimerInterface
@@ -17,7 +20,7 @@ describe('GetItems', () => {
const createUseCase = () =>
new GetItems(
itemRepository,
itemRepositoryResolver,
sharedVaultUserRepository,
contentSizeTransferLimit,
itemTransferCalculator,
@@ -43,6 +46,12 @@ describe('GetItems', () => {
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findAll = jest.fn().mockResolvedValue([item])
itemRepository.countAll = jest.fn().mockResolvedValue(1)
itemRepository.findContentSizeForComputingTransferLimit = jest
.fn()
.mockResolvedValue([ItemContentSizeDescriptor.create('00000000-0000-0000-0000-000000000000', 20).getValue()])
itemRepositoryResolver = {} as jest.Mocked<ItemRepositoryResolverInterface>
itemRepositoryResolver.resolve = jest.fn().mockReturnValue(itemRepository)
itemTransferCalculator = {} as jest.Mocked<ItemTransferCalculatorInterface>
itemTransferCalculator.computeItemUuidsToFetch = jest.fn().mockResolvedValue(['item-uuid'])
@@ -60,6 +69,7 @@ describe('GetItems', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
cursorToken: undefined,
contentType: undefined,
limit: 10,
@@ -80,6 +90,7 @@ describe('GetItems', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
cursorToken: undefined,
contentType: undefined,
limit: undefined,
@@ -98,6 +109,7 @@ describe('GetItems', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
cursorToken: 'MjowLjAwMDEyMw==',
contentType: undefined,
limit: undefined,
@@ -119,6 +131,7 @@ describe('GetItems', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
syncToken,
contentType: undefined,
limit: undefined,
@@ -140,6 +153,7 @@ describe('GetItems', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
syncToken,
contentType: undefined,
limit: undefined,
@@ -154,6 +168,7 @@ describe('GetItems', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
cursorToken: undefined,
contentType: undefined,
limit: 200,
@@ -172,6 +187,7 @@ describe('GetItems', () => {
const result = await useCase.execute({
userUuid: 'invalid',
roleNames: [RoleName.NAMES.CoreUser],
cursorToken: undefined,
contentType: undefined,
limit: undefined,
@@ -181,6 +197,21 @@ describe('GetItems', () => {
expect(result.getError()).toEqual('Given value is not a valid uuid: invalid')
})
it('should return error for invalid role names', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: ['invalid'],
cursorToken: undefined,
contentType: undefined,
limit: undefined,
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Invalid role name: invalid')
})
it('should filter shared vault uuids user wants to sync with the ones it has access to', async () => {
sharedVaultUserRepository.findByUserUuid = jest.fn().mockResolvedValue([
{
@@ -194,6 +225,7 @@ describe('GetItems', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
cursorToken: undefined,
contentType: undefined,
limit: undefined,

View File

@@ -1,20 +1,20 @@
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { Result, RoleNameCollection, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { Time, TimerInterface } from '@standardnotes/time'
import { Item } from '../../../Item/Item'
import { GetItemsResult } from './GetItemsResult'
import { ItemQuery } from '../../../Item/ItemQuery'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { ItemTransferCalculatorInterface } from '../../../Item/ItemTransferCalculatorInterface'
import { GetItemsDTO } from './GetItemsDTO'
import { SharedVaultUserRepositoryInterface } from '../../../SharedVault/User/SharedVaultUserRepositoryInterface'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
export class GetItems implements UseCaseInterface<GetItemsResult> {
private readonly DEFAULT_ITEMS_LIMIT = 150
private readonly SYNC_TOKEN_VERSION = 2
constructor(
private itemRepository: ItemRepositoryInterface,
private itemRepositoryResolver: ItemRepositoryResolverInterface,
private sharedVaultUserRepository: SharedVaultUserRepositoryInterface,
private contentSizeTransferLimit: number,
private itemTransferCalculator: ItemTransferCalculatorInterface,
@@ -35,6 +35,12 @@ export class GetItems implements UseCaseInterface<GetItemsResult> {
}
const userUuid = userUuidOrError.getValue()
const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
if (roleNamesOrError.isFailed()) {
return Result.fail(roleNamesOrError.getError())
}
const roleNames = roleNamesOrError.getValue()
const syncTimeComparison = dto.cursorToken ? '>=' : '>'
const limit = dto.limit === undefined || dto.limit < 1 ? this.DEFAULT_ITEMS_LIMIT : dto.limit
const upperBoundLimit = limit < this.maxItemsSyncLimit ? limit : this.maxItemsSyncLimit
@@ -59,19 +65,22 @@ export class GetItems implements UseCaseInterface<GetItemsResult> {
exclusiveSharedVaultUuids,
}
const itemRepository = this.itemRepositoryResolver.resolve(roleNames)
const itemContentSizeDescriptors = await itemRepository.findContentSizeForComputingTransferLimit(itemQuery)
const itemUuidsToFetch = await this.itemTransferCalculator.computeItemUuidsToFetch(
itemQuery,
itemContentSizeDescriptors,
this.contentSizeTransferLimit,
)
let items: Array<Item> = []
if (itemUuidsToFetch.length > 0) {
items = await this.itemRepository.findAll({
items = await itemRepository.findAll({
uuids: itemUuidsToFetch,
sortBy: 'updated_at_timestamp',
sortOrder: 'ASC',
})
}
const totalItemsCount = await this.itemRepository.countAll(itemQuery)
const totalItemsCount = await itemRepository.countAll(itemQuery)
let cursorToken = undefined
if (totalItemsCount > upperBoundLimit) {

View File

@@ -1,5 +1,6 @@
export interface GetItemsDTO {
userUuid: string
roleNames: string[]
syncToken?: string | null
cursorToken?: string | null
limit?: number

View File

@@ -5,13 +5,15 @@ import { SaveItems } from './SaveItems'
import { SaveNewItem } from '../SaveNewItem/SaveNewItem'
import { UpdateExistingItem } from '../UpdateExistingItem/UpdateExistingItem'
import { Logger } from 'winston'
import { ContentType, Dates, Result, Timestamps, Uuid } from '@standardnotes/domain-core'
import { ContentType, Dates, Result, RoleName, Timestamps, Uuid } from '@standardnotes/domain-core'
import { ItemHash } from '../../../Item/ItemHash'
import { Item } from '../../../Item/Item'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
describe('SaveItems', () => {
let itemSaveValidator: ItemSaveValidatorInterface
let itemRepository: ItemRepositoryInterface
let itemRepositoryResolver: ItemRepositoryResolverInterface
let timer: TimerInterface
let saveNewItem: SaveNewItem
let updateExistingItem: UpdateExistingItem
@@ -20,7 +22,7 @@ describe('SaveItems', () => {
let savedItem: Item
const createUseCase = () =>
new SaveItems(itemSaveValidator, itemRepository, timer, saveNewItem, updateExistingItem, logger)
new SaveItems(itemSaveValidator, itemRepositoryResolver, timer, saveNewItem, updateExistingItem, logger)
beforeEach(() => {
itemSaveValidator = {} as jest.Mocked<ItemSaveValidatorInterface>
@@ -29,6 +31,9 @@ describe('SaveItems', () => {
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findByUuid = jest.fn().mockResolvedValue(null)
itemRepositoryResolver = {} as jest.Mocked<ItemRepositoryResolverInterface>
itemRepositoryResolver.resolve = jest.fn().mockReturnValue(itemRepository)
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123)
@@ -83,6 +88,7 @@ describe('SaveItems', () => {
readOnlyAccess: false,
sessionUuid: 'session-uuid',
snjsVersion: '2.200.0',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
@@ -91,6 +97,7 @@ describe('SaveItems', () => {
itemHash: itemHash1,
userUuid: 'user-uuid',
sessionUuid: 'session-uuid',
roleNames: ['CORE_USER'],
})
})
@@ -106,6 +113,7 @@ describe('SaveItems', () => {
readOnlyAccess: false,
sessionUuid: 'session-uuid',
snjsVersion: '2.200.0',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
@@ -129,6 +137,7 @@ describe('SaveItems', () => {
readOnlyAccess: false,
sessionUuid: 'session-uuid',
snjsVersion: '2.200.0',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
@@ -150,6 +159,7 @@ describe('SaveItems', () => {
readOnlyAccess: true,
sessionUuid: 'session-uuid',
snjsVersion: '2.200.0',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
@@ -172,6 +182,7 @@ describe('SaveItems', () => {
readOnlyAccess: false,
sessionUuid: 'session-uuid',
snjsVersion: '2.200.0',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
@@ -190,6 +201,7 @@ describe('SaveItems', () => {
readOnlyAccess: false,
sessionUuid: 'session-uuid',
snjsVersion: '2.200.0',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
@@ -208,6 +220,7 @@ describe('SaveItems', () => {
readOnlyAccess: false,
sessionUuid: 'session-uuid',
snjsVersion: '2.200.0',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
@@ -216,6 +229,7 @@ describe('SaveItems', () => {
existingItem: savedItem,
sessionUuid: 'session-uuid',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: ['CORE_USER'],
})
})
@@ -232,6 +246,7 @@ describe('SaveItems', () => {
readOnlyAccess: false,
sessionUuid: 'session-uuid',
snjsVersion: '2.200.0',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
@@ -256,6 +271,7 @@ describe('SaveItems', () => {
readOnlyAccess: false,
sessionUuid: 'session-uuid',
snjsVersion: '2.200.0',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
@@ -301,9 +317,27 @@ describe('SaveItems', () => {
readOnlyAccess: false,
sessionUuid: 'session-uuid',
snjsVersion: '2.200.0',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue().syncToken).toEqual('MjowLjAwMDE2')
})
it('should fail if the role names are invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
itemHashes: [itemHash1],
userUuid: 'user-uuid',
apiVersion: '2',
readOnlyAccess: false,
sessionUuid: 'session-uuid',
snjsVersion: '2.200.0',
roleNames: ['invalid-role-name'],
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Invalid role name: invalid-role-name')
})
})

View File

@@ -1,4 +1,4 @@
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { Result, RoleNameCollection, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { SaveItemsResult } from './SaveItemsResult'
import { SaveItemsDTO } from './SaveItemsDTO'
@@ -7,17 +7,17 @@ import { ItemConflict } from '../../../Item/ItemConflict'
import { ConflictType } from '@standardnotes/responses'
import { Time, TimerInterface } from '@standardnotes/time'
import { Logger } from 'winston'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { ItemSaveValidatorInterface } from '../../../Item/SaveValidator/ItemSaveValidatorInterface'
import { SaveNewItem } from '../SaveNewItem/SaveNewItem'
import { UpdateExistingItem } from '../UpdateExistingItem/UpdateExistingItem'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
export class SaveItems implements UseCaseInterface<SaveItemsResult> {
private readonly SYNC_TOKEN_VERSION = 2
constructor(
private itemSaveValidator: ItemSaveValidatorInterface,
private itemRepository: ItemRepositoryInterface,
private itemRepositoryResolver: ItemRepositoryResolverInterface,
private timer: TimerInterface,
private saveNewItem: SaveNewItem,
private updateExistingItem: UpdateExistingItem,
@@ -28,6 +28,12 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
const savedItems: Array<Item> = []
const conflicts: Array<ItemConflict> = []
const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
if (roleNamesOrError.isFailed()) {
return Result.fail(roleNamesOrError.getError())
}
const roleNames = roleNamesOrError.getValue()
const lastUpdatedTimestamp = this.timer.getTimestampInMicroseconds()
for (const itemHash of dto.itemHashes) {
@@ -42,7 +48,8 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
}
const itemUuid = itemUuidOrError.getValue()
const existingItem = await this.itemRepository.findByUuid(itemUuid)
const itemRepository = this.itemRepositoryResolver.resolve(roleNames)
const existingItem = await itemRepository.findByUuid(itemUuid)
if (dto.readOnlyAccess) {
conflicts.push({
@@ -78,6 +85,7 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
itemHash,
sessionUuid: dto.sessionUuid,
performingUserUuid: dto.userUuid,
roleNames: dto.roleNames,
})
if (udpatedItemOrError.isFailed()) {
this.logger.error(
@@ -100,6 +108,7 @@ export class SaveItems implements UseCaseInterface<SaveItemsResult> {
userUuid: dto.userUuid,
itemHash,
sessionUuid: dto.sessionUuid,
roleNames: dto.roleNames,
})
if (newItemOrError.isFailed()) {
this.logger.error(

View File

@@ -7,4 +7,5 @@ export interface SaveItemsDTO {
readOnlyAccess: boolean
sessionUuid: string | null
snjsVersion: string
roleNames: string[]
}

View File

@@ -4,20 +4,22 @@ import { SaveNewItem } from './SaveNewItem'
import { DomainEventInterface, DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
import { ItemHash } from '../../../Item/ItemHash'
import { ContentType, Dates, Result, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { ContentType, Dates, Result, RoleName, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { Item } from '../../../Item/Item'
import { SharedVaultAssociation } from '../../../SharedVault/SharedVaultAssociation'
import { KeySystemAssociation } from '../../../KeySystem/KeySystemAssociation'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
describe('SaveNewItem', () => {
let itemRepository: ItemRepositoryInterface
let itemRepositoryResolver: ItemRepositoryResolverInterface
let timer: TimerInterface
let domainEventPublisher: DomainEventPublisherInterface
let domainEventFactory: DomainEventFactoryInterface
let itemHash1: ItemHash
let item1: Item
const createUseCase = () => new SaveNewItem(itemRepository, timer, domainEventPublisher, domainEventFactory)
const createUseCase = () => new SaveNewItem(itemRepositoryResolver, timer, domainEventPublisher, domainEventFactory)
beforeEach(() => {
const timeHelper = new Timer()
@@ -62,6 +64,9 @@ describe('SaveNewItem', () => {
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.save = jest.fn()
itemRepositoryResolver = {} as jest.Mocked<ItemRepositoryResolverInterface>
itemRepositoryResolver.resolve = jest.fn().mockReturnValue(itemRepository)
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123456789)
timer.convertMicrosecondsToDate = jest.fn().mockReturnValue(new Date(123456789))
@@ -85,6 +90,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
@@ -106,6 +112,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
@@ -125,6 +132,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
@@ -142,6 +150,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
@@ -161,6 +170,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
@@ -180,6 +190,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
@@ -196,6 +207,20 @@ describe('SaveNewItem', () => {
userUuid: '00000000-0000-0000-0000-00000000000',
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
})
it('returns a failure if the role names are invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
roleNames: ['invalid'],
})
expect(result.isFailed()).toBeTruthy()
@@ -206,6 +231,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-00000000000',
itemHash: itemHash1,
})
@@ -223,6 +249,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
@@ -240,6 +267,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
@@ -257,6 +285,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
@@ -276,6 +305,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
@@ -297,6 +327,7 @@ describe('SaveNewItem', () => {
userUuid: '00000000-0000-0000-0000-00000000000',
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
@@ -313,6 +344,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
@@ -339,6 +371,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
@@ -360,6 +393,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
@@ -378,6 +412,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})
@@ -400,6 +435,7 @@ describe('SaveNewItem', () => {
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
sessionUuid: '00000000-0000-0000-0000-000000000001',
itemHash: itemHash1,
})

View File

@@ -2,6 +2,7 @@ import {
ContentType,
Dates,
Result,
RoleNameCollection,
Timestamps,
UniqueEntityId,
UseCaseInterface,
@@ -13,14 +14,14 @@ import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { Item } from '../../../Item/Item'
import { SaveNewItemDTO } from './SaveNewItemDTO'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
import { SharedVaultAssociation } from '../../../SharedVault/SharedVaultAssociation'
import { KeySystemAssociation } from '../../../KeySystem/KeySystemAssociation'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
export class SaveNewItem implements UseCaseInterface<Item> {
constructor(
private itemRepository: ItemRepositoryInterface,
private itemRepositoryResolver: ItemRepositoryResolverInterface,
private timer: TimerInterface,
private domainEventPublisher: DomainEventPublisherInterface,
private domainEventFactory: DomainEventFactoryInterface,
@@ -47,6 +48,12 @@ export class SaveNewItem implements UseCaseInterface<Item> {
}
const userUuid = userUuidOrError.getValue()
const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
if (roleNamesOrError.isFailed()) {
return Result.fail(roleNamesOrError.getError())
}
const roleNames = roleNamesOrError.getValue()
const contentTypeOrError = ContentType.create(dto.itemHash.props.content_type)
if (contentTypeOrError.isFailed()) {
return Result.fail(contentTypeOrError.getError())
@@ -135,7 +142,9 @@ export class SaveNewItem implements UseCaseInterface<Item> {
newItem.setKeySystemAssociation(keySystemAssociationOrError.getValue())
}
await this.itemRepository.save(newItem)
const itemRepository = this.itemRepositoryResolver.resolve(roleNames)
await itemRepository.save(newItem)
if (contentType.value !== null && [ContentType.TYPES.Note, ContentType.TYPES.File].includes(contentType.value)) {
await this.domainEventPublisher.publish(

View File

@@ -2,6 +2,7 @@ import { ItemHash } from '../../../Item/ItemHash'
export interface SaveNewItemDTO {
userUuid: string
roleNames: string[]
itemHash: ItemHash
sessionUuid: string | null
}

View File

@@ -5,7 +5,7 @@ import { Item } from '../../../Item/Item'
import { ItemHash } from '../../../Item/ItemHash'
import { SyncItems } from './SyncItems'
import { ContentType, Dates, Result, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { ContentType, Dates, Result, RoleName, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { GetItems } from '../GetItems/GetItems'
import { SaveItems } from '../SaveItems/SaveItems'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
@@ -13,11 +13,13 @@ import { GetSharedVaults } from '../../SharedVaults/GetSharedVaults/GetSharedVau
import { GetMessagesSentToUser } from '../../Messaging/GetMessagesSentToUser/GetMessagesSentToUser'
import { GetUserNotifications } from '../../Messaging/GetUserNotifications/GetUserNotifications'
import { GetSharedVaultInvitesSentToUser } from '../../SharedVaults/GetSharedVaultInvitesSentToUser/GetSharedVaultInvitesSentToUser'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
describe('SyncItems', () => {
let getItemsUseCase: GetItems
let saveItemsUseCase: SaveItems
let itemRepository: ItemRepositoryInterface
let itemRepositoryResolver: ItemRepositoryResolverInterface
let item1: Item
let item2: Item
let item3: Item
@@ -29,7 +31,7 @@ describe('SyncItems', () => {
const createUseCase = () =>
new SyncItems(
itemRepository,
itemRepositoryResolver,
getItemsUseCase,
saveItemsUseCase,
getSharedVaultsUseCase,
@@ -122,6 +124,9 @@ describe('SyncItems', () => {
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.findAll = jest.fn().mockReturnValue([item3, item1])
itemRepositoryResolver = {} as jest.Mocked<ItemRepositoryResolverInterface>
itemRepositoryResolver.resolve = jest.fn().mockReturnValue(itemRepository)
getSharedVaultsUseCase = {} as jest.Mocked<GetSharedVaults>
getSharedVaultsUseCase.execute = jest.fn().mockReturnValue(Result.ok([]))
@@ -148,6 +153,7 @@ describe('SyncItems', () => {
apiVersion: ApiVersion.v20200115,
sessionUuid: null,
snjsVersion: '1.2.3',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.getValue()).toEqual({
conflicts: [],
@@ -167,12 +173,14 @@ describe('SyncItems', () => {
limit: 10,
syncToken: 'foo',
userUuid: '1-2-3',
roleNames: ['CORE_USER'],
})
expect(saveItemsUseCase.execute).toHaveBeenCalledWith({
itemHashes: [itemHash],
userUuid: '1-2-3',
apiVersion: '20200115',
snjsVersion: '1.2.3',
roleNames: [RoleName.NAMES.CoreUser],
readOnlyAccess: false,
sessionUuid: null,
})
@@ -189,6 +197,7 @@ describe('SyncItems', () => {
contentType: 'Note',
apiVersion: ApiVersion.v20200115,
snjsVersion: '1.2.3',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.getValue()).toEqual({
conflicts: [],
@@ -214,6 +223,7 @@ describe('SyncItems', () => {
contentType: 'Note',
apiVersion: ApiVersion.v20200115,
snjsVersion: '1.2.3',
roleNames: [RoleName.NAMES.CoreUser],
sharedVaultUuids: ['00000000-0000-0000-0000-000000000000'],
})
expect(result.getValue()).toEqual({
@@ -266,6 +276,7 @@ describe('SyncItems', () => {
contentType: 'Note',
apiVersion: ApiVersion.v20200115,
snjsVersion: '1.2.3',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.getValue()).toEqual({
@@ -305,6 +316,7 @@ describe('SyncItems', () => {
contentType: 'Note',
apiVersion: ApiVersion.v20200115,
snjsVersion: '1.2.3',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
@@ -325,6 +337,24 @@ describe('SyncItems', () => {
contentType: 'Note',
apiVersion: ApiVersion.v20200115,
snjsVersion: '1.2.3',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
})
it('should return error if role names are invalid', async () => {
const result = await createUseCase().execute({
userUuid: '1-2-3',
itemHashes: [itemHash],
computeIntegrityHash: false,
limit: 10,
readOnlyAccess: false,
sessionUuid: '2-3-4',
contentType: 'Note',
apiVersion: ApiVersion.v20200115,
snjsVersion: '1.2.3',
roleNames: ['invalid'],
})
expect(result.isFailed()).toBeTruthy()
@@ -345,6 +375,7 @@ describe('SyncItems', () => {
contentType: 'Note',
apiVersion: ApiVersion.v20200115,
snjsVersion: '1.2.3',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
@@ -365,6 +396,7 @@ describe('SyncItems', () => {
contentType: 'Note',
apiVersion: ApiVersion.v20200115,
snjsVersion: '1.2.3',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
@@ -385,6 +417,7 @@ describe('SyncItems', () => {
contentType: 'Note',
apiVersion: ApiVersion.v20200115,
snjsVersion: '1.2.3',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
@@ -405,6 +438,7 @@ describe('SyncItems', () => {
contentType: 'Note',
apiVersion: ApiVersion.v20200115,
snjsVersion: '1.2.3',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()

View File

@@ -1,20 +1,20 @@
import { ContentType, Result, UseCaseInterface } from '@standardnotes/domain-core'
import { ContentType, Result, RoleNameCollection, UseCaseInterface } from '@standardnotes/domain-core'
import { Item } from '../../../Item/Item'
import { ItemConflict } from '../../../Item/ItemConflict'
import { SyncItemsDTO } from './SyncItemsDTO'
import { SyncItemsResponse } from './SyncItemsResponse'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { GetItems } from '../GetItems/GetItems'
import { SaveItems } from '../SaveItems/SaveItems'
import { GetSharedVaults } from '../../SharedVaults/GetSharedVaults/GetSharedVaults'
import { GetSharedVaultInvitesSentToUser } from '../../SharedVaults/GetSharedVaultInvitesSentToUser/GetSharedVaultInvitesSentToUser'
import { GetMessagesSentToUser } from '../../Messaging/GetMessagesSentToUser/GetMessagesSentToUser'
import { GetUserNotifications } from '../../Messaging/GetUserNotifications/GetUserNotifications'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
constructor(
private itemRepository: ItemRepositoryInterface,
private itemRepositoryResolver: ItemRepositoryResolverInterface,
private getItemsUseCase: GetItems,
private saveItemsUseCase: SaveItems,
private getSharedVaultsUseCase: GetSharedVaults,
@@ -24,6 +24,12 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
) {}
async execute(dto: SyncItemsDTO): Promise<Result<SyncItemsResponse>> {
const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
if (roleNamesOrError.isFailed()) {
return Result.fail(roleNamesOrError.getError())
}
const roleNames = roleNamesOrError.getValue()
const getItemsResultOrError = await this.getItemsUseCase.execute({
userUuid: dto.userUuid,
syncToken: dto.syncToken,
@@ -31,6 +37,7 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
limit: dto.limit,
contentType: dto.contentType,
sharedVaultUuids: dto.sharedVaultUuids,
roleNames: dto.roleNames,
})
if (getItemsResultOrError.isFailed()) {
return Result.fail(getItemsResultOrError.getError())
@@ -44,6 +51,7 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
readOnlyAccess: dto.readOnlyAccess,
sessionUuid: dto.sessionUuid,
snjsVersion: dto.snjsVersion,
roleNames: dto.roleNames,
})
if (saveItemsResultOrError.isFailed()) {
return Result.fail(saveItemsResultOrError.getError())
@@ -53,7 +61,7 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
let retrievedItems = this.filterOutSyncConflictsForConsecutiveSyncs(getItemsResult.items, saveItemsResult.conflicts)
const isSharedVaultExclusiveSync = dto.sharedVaultUuids && dto.sharedVaultUuids.length > 0
if (this.isFirstSync(dto) && !isSharedVaultExclusiveSync) {
retrievedItems = await this.frontLoadKeysItemsToTop(dto.userUuid, retrievedItems)
retrievedItems = await this.frontLoadKeysItemsToTop(dto.userUuid, roleNames, retrievedItems)
}
const sharedVaultsOrError = await this.getSharedVaultsUseCase.execute({
@@ -125,8 +133,13 @@ export class SyncItems implements UseCaseInterface<SyncItemsResponse> {
return retrievedItems.filter((item: Item) => syncConflictIds.indexOf(item.id.toString()) === -1)
}
private async frontLoadKeysItemsToTop(userUuid: string, retrievedItems: Array<Item>): Promise<Array<Item>> {
const itemsKeys = await this.itemRepository.findAll({
private async frontLoadKeysItemsToTop(
userUuid: string,
roleNames: RoleNameCollection,
retrievedItems: Array<Item>,
): Promise<Array<Item>> {
const itemRepository = this.itemRepositoryResolver.resolve(roleNames)
const itemsKeys = await itemRepository.findAll({
userUuid,
contentType: ContentType.TYPES.ItemsKey,
sortBy: 'updated_at_timestamp',

View File

@@ -2,6 +2,7 @@ import { ItemHash } from '../../../Item/ItemHash'
export type SyncItemsDTO = {
userUuid: string
roleNames: string[]
itemHashes: Array<ItemHash>
computeIntegrityHash: boolean
limit: number

View File

@@ -13,6 +13,7 @@ import {
UniqueEntityId,
Result,
NotificationPayload,
RoleName,
} from '@standardnotes/domain-core'
import { SharedVaultAssociation } from '../../../SharedVault/SharedVaultAssociation'
import { KeySystemAssociation } from '../../../KeySystem/KeySystemAssociation'
@@ -20,9 +21,11 @@ import { DetermineSharedVaultOperationOnItem } from '../../SharedVaults/Determin
import { AddNotificationForUser } from '../../Messaging/AddNotificationForUser/AddNotificationForUser'
import { RemoveNotificationsForUser } from '../../Messaging/RemoveNotificationsForUser/RemoveNotificationsForUser'
import { SharedVaultOperationOnItem } from '../../../SharedVault/SharedVaultOperationOnItem'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
describe('UpdateExistingItem', () => {
let itemRepository: ItemRepositoryInterface
let itemRepositoryResolver: ItemRepositoryResolverInterface
let timer: TimerInterface
let domainEventPublisher: DomainEventPublisherInterface
let domainEventFactory: DomainEventFactoryInterface
@@ -34,7 +37,7 @@ describe('UpdateExistingItem', () => {
const createUseCase = () =>
new UpdateExistingItem(
itemRepository,
itemRepositoryResolver,
timer,
domainEventPublisher,
domainEventFactory,
@@ -88,6 +91,9 @@ describe('UpdateExistingItem', () => {
itemRepository = {} as jest.Mocked<ItemRepositoryInterface>
itemRepository.save = jest.fn()
itemRepositoryResolver = {} as jest.Mocked<ItemRepositoryResolverInterface>
itemRepositoryResolver.resolve = jest.fn().mockReturnValue(itemRepository)
timer = {} as jest.Mocked<TimerInterface>
timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123456789)
timer.convertMicrosecondsToDate = jest.fn().mockReturnValue(new Date(123456789))
@@ -134,6 +140,7 @@ describe('UpdateExistingItem', () => {
itemHash: itemHash1,
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
@@ -148,6 +155,21 @@ describe('UpdateExistingItem', () => {
itemHash: itemHash1,
sessionUuid: 'invalid-uuid',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
})
it('should return error if role names are invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
existingItem: item1,
itemHash: itemHash1,
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: ['invalid-role'],
})
expect(result.isFailed()).toBeTruthy()
@@ -164,6 +186,7 @@ describe('UpdateExistingItem', () => {
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
@@ -180,6 +203,7 @@ describe('UpdateExistingItem', () => {
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
@@ -203,6 +227,7 @@ describe('UpdateExistingItem', () => {
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
@@ -221,6 +246,7 @@ describe('UpdateExistingItem', () => {
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
@@ -238,6 +264,7 @@ describe('UpdateExistingItem', () => {
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
@@ -256,6 +283,7 @@ describe('UpdateExistingItem', () => {
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
@@ -278,6 +306,7 @@ describe('UpdateExistingItem', () => {
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
@@ -302,6 +331,7 @@ describe('UpdateExistingItem', () => {
}).getValue(),
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
@@ -316,6 +346,7 @@ describe('UpdateExistingItem', () => {
itemHash: itemHash1,
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: 'invalid-uuid',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
})
@@ -334,6 +365,7 @@ describe('UpdateExistingItem', () => {
itemHash,
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
expect(item1.props.sharedVaultAssociation).not.toBeUndefined()
@@ -363,6 +395,7 @@ describe('UpdateExistingItem', () => {
itemHash,
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
@@ -389,6 +422,7 @@ describe('UpdateExistingItem', () => {
itemHash,
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
mock.mockRestore()
@@ -409,6 +443,7 @@ describe('UpdateExistingItem', () => {
itemHash,
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
})
@@ -440,6 +475,7 @@ describe('UpdateExistingItem', () => {
itemHash,
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
})
@@ -474,6 +510,7 @@ describe('UpdateExistingItem', () => {
itemHash,
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
@@ -495,6 +532,7 @@ describe('UpdateExistingItem', () => {
itemHash,
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
})
@@ -514,6 +552,7 @@ describe('UpdateExistingItem', () => {
itemHash,
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
expect(item1.props.keySystemAssociation).not.toBeUndefined()
@@ -540,6 +579,7 @@ describe('UpdateExistingItem', () => {
itemHash,
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeFalsy()
@@ -561,6 +601,7 @@ describe('UpdateExistingItem', () => {
itemHash,
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
})
@@ -583,6 +624,7 @@ describe('UpdateExistingItem', () => {
itemHash,
sessionUuid: '00000000-0000-0000-0000-000000000000',
performingUserUuid: '00000000-0000-0000-0000-000000000000',
roleNames: [RoleName.NAMES.CoreUser],
})
expect(result.isFailed()).toBeTruthy()
mock.mockRestore()

View File

@@ -4,6 +4,7 @@ import {
NotificationPayload,
NotificationType,
Result,
RoleNameCollection,
Timestamps,
UniqueEntityId,
UseCaseInterface,
@@ -15,7 +16,6 @@ import { TimerInterface } from '@standardnotes/time'
import { Item } from '../../../Item/Item'
import { UpdateExistingItemDTO } from './UpdateExistingItemDTO'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface'
import { SharedVaultAssociation } from '../../../SharedVault/SharedVaultAssociation'
import { KeySystemAssociation } from '../../../KeySystem/KeySystemAssociation'
@@ -23,10 +23,11 @@ import { DetermineSharedVaultOperationOnItem } from '../../SharedVaults/Determin
import { SharedVaultOperationOnItem } from '../../../SharedVault/SharedVaultOperationOnItem'
import { AddNotificationForUser } from '../../Messaging/AddNotificationForUser/AddNotificationForUser'
import { RemoveNotificationsForUser } from '../../Messaging/RemoveNotificationsForUser/RemoveNotificationsForUser'
import { ItemRepositoryResolverInterface } from '../../../Item/ItemRepositoryResolverInterface'
export class UpdateExistingItem implements UseCaseInterface<Item> {
constructor(
private itemRepository: ItemRepositoryInterface,
private itemRepositoryResolver: ItemRepositoryResolverInterface,
private timer: TimerInterface,
private domainEventPublisher: DomainEventPublisherInterface,
private domainEventFactory: DomainEventFactoryInterface,
@@ -53,6 +54,12 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
}
const userUuid = userUuidOrError.getValue()
const roleNamesOrError = RoleNameCollection.create(dto.roleNames)
if (roleNamesOrError.isFailed()) {
return Result.fail(roleNamesOrError.getError())
}
const roleNames = roleNamesOrError.getValue()
if (dto.itemHash.props.content) {
dto.existingItem.props.content = dto.itemHash.props.content
}
@@ -190,7 +197,9 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
dto.existingItem.props.itemsKeyId = null
}
await this.itemRepository.save(dto.existingItem)
const itemRepository = this.itemRepositoryResolver.resolve(roleNames)
await itemRepository.save(dto.existingItem)
if (secondsFromLastUpdate >= this.revisionFrequency) {
if (

View File

@@ -6,4 +6,5 @@ export interface UpdateExistingItemDTO {
itemHash: ItemHash
sessionUuid: string | null
performingUserUuid: string
roleNames: string[]
}

View File

@@ -1,6 +1,8 @@
import { ControllerContainerInterface, MapperInterface, Validator } from '@standardnotes/domain-core'
import { BaseHttpController, results } from 'inversify-express-utils'
import { Request, Response } from 'express'
import { HttpStatusCode } from '@standardnotes/responses'
import { Role } from '@standardnotes/security'
import { Item } from '../../../Domain/Item/Item'
import { SyncResponseFactoryResolverInterface } from '../../../Domain/Item/SyncResponse/SyncResponseFactoryResolverInterface'
@@ -8,7 +10,6 @@ import { CheckIntegrity } from '../../../Domain/UseCase/Syncing/CheckIntegrity/C
import { GetItem } from '../../../Domain/UseCase/Syncing/GetItem/GetItem'
import { ApiVersion } from '../../../Domain/Api/ApiVersion'
import { SyncItems } from '../../../Domain/UseCase/Syncing/SyncItems/SyncItems'
import { HttpStatusCode } from '@standardnotes/responses'
import { ItemHttpRepresentation } from '../../../Mapping/Http/ItemHttpRepresentation'
import { ItemHash } from '../../../Domain/Item/ItemHash'
@@ -59,6 +60,7 @@ export class BaseItemsController extends BaseHttpController {
const syncResult = await this.syncItems.execute({
userUuid: response.locals.user.uuid,
roleNames: response.locals.roles.map((role: Role) => role.name),
itemHashes,
computeIntegrityHash: request.body.compute_integrity === true,
syncToken: request.body.sync_token,
@@ -91,6 +93,7 @@ export class BaseItemsController extends BaseHttpController {
const result = await this.checkIntegrity.execute({
userUuid: response.locals.user.uuid,
integrityPayloads,
roleNames: response.locals.roles.map((role: Role) => role.name),
})
if (result.isFailed()) {
@@ -106,6 +109,7 @@ export class BaseItemsController extends BaseHttpController {
const result = await this.getItem.execute({
userUuid: response.locals.user.uuid,
itemUuid: request.params.uuid,
roleNames: response.locals.roles.map((role: Role) => role.name),
})
if (result.isFailed()) {

View File

@@ -8,6 +8,7 @@ import { Item } from '../../Domain/Item/Item'
import { ItemQuery } from '../../Domain/Item/ItemQuery'
import { ItemRepositoryInterface } from '../../Domain/Item/ItemRepositoryInterface'
import { MongoDBItem } from './MongoDBItem'
import { ItemContentSizeDescriptor } from '../../Domain/Item/ItemContentSizeDescriptor'
export class MongoDBItemRepository implements ItemRepositoryInterface {
constructor(
@@ -44,23 +45,30 @@ export class MongoDBItemRepository implements ItemRepositoryInterface {
return this.mongoRepository.count((options as FindManyOptions<MongoDBItem>).where)
}
async findContentSizeForComputingTransferLimit(
query: ItemQuery,
): Promise<{ uuid: string; contentSize: number | null }[]> {
async findContentSizeForComputingTransferLimit(query: ItemQuery): Promise<ItemContentSizeDescriptor[]> {
const options = this.createFindOptions(query)
const rawItems = await this.mongoRepository.find({
select: ['uuid', 'contentSize'],
...options,
})
const items = rawItems.map((item) => {
return {
uuid: item._id.toHexString(),
contentSize: item.contentSize,
}
})
const itemContentSizeDescriptors: ItemContentSizeDescriptor[] = []
return items
for (const rawItem of rawItems) {
const itemContentSizeDescriptorOrError = ItemContentSizeDescriptor.create(
rawItem._id.toHexString(),
rawItem.contentSize,
)
if (itemContentSizeDescriptorOrError.isFailed()) {
this.logger.error(
`Failed to create ItemContentSizeDescriptor for item ${rawItem._id.toHexString()}: ${itemContentSizeDescriptorOrError.getError()}`,
)
continue
}
itemContentSizeDescriptors.push(itemContentSizeDescriptorOrError.getValue())
}
return itemContentSizeDescriptors
}
async findDatesForComputingIntegrityHash(userUuid: string): Promise<{ updated_at_timestamp: number }[]> {

View File

@@ -7,6 +7,7 @@ import { ItemQuery } from '../../Domain/Item/ItemQuery'
import { ItemRepositoryInterface } from '../../Domain/Item/ItemRepositoryInterface'
import { ExtendedIntegrityPayload } from '../../Domain/Item/ExtendedIntegrityPayload'
import { TypeORMItem } from './TypeORMItem'
import { ItemContentSizeDescriptor } from '../../Domain/Item/ItemContentSizeDescriptor'
export class TypeORMItemRepository implements ItemRepositoryInterface {
constructor(
@@ -38,16 +39,28 @@ export class TypeORMItemRepository implements ItemRepositoryInterface {
.execute()
}
async findContentSizeForComputingTransferLimit(
query: ItemQuery,
): Promise<{ uuid: string; contentSize: number | null }[]> {
async findContentSizeForComputingTransferLimit(query: ItemQuery): Promise<ItemContentSizeDescriptor[]> {
const queryBuilder = this.createFindAllQueryBuilder(query)
queryBuilder.select('item.uuid', 'uuid')
queryBuilder.addSelect('item.content_size', 'contentSize')
const items = await queryBuilder.getRawMany()
return items
const itemContentSizeDescriptors: ItemContentSizeDescriptor[] = []
for (const item of items) {
const ItemContentSizeDescriptorOrError = ItemContentSizeDescriptor.create(item.uuid, item.contentSize)
if (ItemContentSizeDescriptorOrError.isFailed()) {
this.logger.error(
`Failed to create ItemContentSizeDescriptor for item ${
item.uuid
}: ${ItemContentSizeDescriptorOrError.getError()}`,
)
continue
}
itemContentSizeDescriptors.push(ItemContentSizeDescriptorOrError.getValue())
}
return itemContentSizeDescriptors
}
async deleteByUserUuid(userUuid: string): Promise<void> {

View File

@@ -0,0 +1,25 @@
import { RoleName, RoleNameCollection } from '@standardnotes/domain-core'
import { ItemRepositoryInterface } from '../../Domain/Item/ItemRepositoryInterface'
import { ItemRepositoryResolverInterface } from '../../Domain/Item/ItemRepositoryResolverInterface'
export class TypeORMItemRepositoryResolver implements ItemRepositoryResolverInterface {
constructor(
private mysqlItemRepository: ItemRepositoryInterface,
private mongoDbItemRepository: ItemRepositoryInterface | null,
) {}
resolve(roleNames: RoleNameCollection): ItemRepositoryInterface {
if (!this.mongoDbItemRepository) {
return this.mysqlItemRepository
}
const transitionRoleName = RoleName.create(RoleName.NAMES.TransitionUser).getValue()
if (roleNames.includes(transitionRoleName)) {
return this.mongoDbItemRepository
}
return this.mysqlItemRepository
}
}