From 230c96dcf1d8faed9ce8fe288549226da70317e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Thu, 21 Sep 2023 12:26:08 +0200 Subject: [PATCH] feat: add designating a survivor in shared vault (#841) * feat: add designating a survivor in shared vault * add designated survivor property to http representation * fix: specs * fix: more specs * fix: another spec fix * fix: yet another spec fix --- jest.config.js | 2 + .../v1/SharedVaultUsersController.ts | 17 +- .../src/Service/Resolver/EndpointResolver.ts | 4 + packages/auth/database.sqlite | Bin 352256 -> 0 bytes .../1695283870612-add-designated-survivor.ts | 15 ++ .../1695283961201-add-designated-survivor.ts | 43 +++++ packages/auth/src/Bootstrap/Container.ts | 24 +++ packages/auth/src/Bootstrap/Types.ts | 4 + ...atedAsSurvivorInSharedVaultEventHandler.ts | 26 +++ .../SharedVaultUserRepositoryInterface.ts | 1 + .../AddSharedVaultUser/AddSharedVaultUser.ts | 1 + .../CreateCrossServiceToken.spec.ts | 1 + .../DesignateSurvivor.spec.ts | 156 +++++++++++++++++ .../DesignateSurvivor/DesignateSurvivor.ts | 66 ++++++++ .../DesignateSurvivor/DesignateSurvivorDTO.ts | 5 + .../Infra/TypeORM/TypeORMSharedVaultUser.ts | 7 + .../TypeORMSharedVaultUserRepository.ts | 18 ++ .../SharedVaultUserPersistenceMapper.ts | 2 + .../SharedVault/SharedVaultUser.spec.ts | 1 + .../SharedVault/SharedVaultUserProps.ts | 1 + ...rDesignatedAsSurvivorInSharedVaultEvent.ts | 7 + ...atedAsSurvivorInSharedVaultEventPayload.ts | 5 + packages/domain-events/src/Domain/index.ts | 2 + .../1695284084365-add-designated-survivor.ts | 13 ++ .../1695284084365-add-designated-survivor.ts | 13 ++ .../1695284249461-add-designated-survivor.ts | 39 +++++ .../syncing-server/src/Bootstrap/Container.ts | 11 ++ .../syncing-server/src/Bootstrap/Types.ts | 1 + .../src/Domain/Event/DomainEventFactory.ts | 20 +++ .../Event/DomainEventFactoryInterface.ts | 6 + .../Item/SaveRule/SharedVaultFilter.spec.ts | 6 + .../AddNotificationsForUsers.spec.ts | 1 + .../AddUserToSharedVault.ts | 1 + .../CreateSharedVaultFileValetToken.spec.ts | 13 ++ .../DeleteSharedVault.spec.ts | 1 + .../DesignateSurvivor.spec.ts | 158 ++++++++++++++++++ .../DesignateSurvivor/DesignateSurvivor.ts | 97 +++++++++++ .../DesignateSurvivor/DesignateSurvivorDTO.ts | 5 + .../GetSharedVaultUsers.spec.ts | 1 + .../GetSharedVaults/GetSharedVaults.spec.ts | 1 + .../InviteUserToSharedVault.spec.ts | 1 + .../RemoveUserFromSharedVault.spec.ts | 1 + .../AnnotatedSharedVaultUsersController.ts | 16 +- .../Base/BaseSharedVaultUsersController.ts | 26 +++ .../Infra/TypeORM/TypeORMSharedVaultUser.ts | 7 + .../Mapping/Http/SharedVaultUserHttpMapper.ts | 1 + .../Http/SharedVaultUserHttpRepresentation.ts | 1 + .../SharedVaultUserPersistenceMapper.ts | 2 + 48 files changed, 847 insertions(+), 3 deletions(-) delete mode 100644 packages/auth/database.sqlite create mode 100644 packages/auth/migrations/mysql/1695283870612-add-designated-survivor.ts create mode 100644 packages/auth/migrations/sqlite/1695283961201-add-designated-survivor.ts create mode 100644 packages/auth/src/Domain/Handler/UserDesignatedAsSurvivorInSharedVaultEventHandler.ts create mode 100644 packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivor.spec.ts create mode 100644 packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivor.ts create mode 100644 packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivorDTO.ts create mode 100644 packages/domain-events/src/Domain/Event/UserDesignatedAsSurvivorInSharedVaultEvent.ts create mode 100644 packages/domain-events/src/Domain/Event/UserDesignatedAsSurvivorInSharedVaultEventPayload.ts create mode 100644 packages/syncing-server/migrations/mysql-legacy/1695284084365-add-designated-survivor.ts create mode 100644 packages/syncing-server/migrations/mysql/1695284084365-add-designated-survivor.ts create mode 100644 packages/syncing-server/migrations/sqlite/1695284249461-add-designated-survivor.ts create mode 100644 packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor.spec.ts create mode 100644 packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor.ts create mode 100644 packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivorDTO.ts diff --git a/jest.config.js b/jest.config.js index 7478c95e3..e6b8ff0e8 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,6 +3,8 @@ module.exports = { testEnvironment: 'node', testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.ts$', testTimeout: 20000, + coverageReporters: ['text-summary'], + reporters: ['summary'], coverageThreshold: { global: { branches: 100, diff --git a/packages/api-gateway/src/Controller/v1/SharedVaultUsersController.ts b/packages/api-gateway/src/Controller/v1/SharedVaultUsersController.ts index a2e2df24a..b44f0252a 100644 --- a/packages/api-gateway/src/Controller/v1/SharedVaultUsersController.ts +++ b/packages/api-gateway/src/Controller/v1/SharedVaultUsersController.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express' import { inject } from 'inversify' -import { BaseHttpController, controller, httpDelete, httpGet } from 'inversify-express-utils' +import { BaseHttpController, controller, httpDelete, httpGet, httpPost } from 'inversify-express-utils' import { TYPES } from '../../Bootstrap/Types' import { ServiceProxyInterface } from '../../Service/Http/ServiceProxyInterface' import { EndpointResolverInterface } from '../../Service/Resolver/EndpointResolverInterface' @@ -42,4 +42,19 @@ export class SharedVaultUsersController extends BaseHttpController { request.body, ) } + + @httpPost('/:userUuid/designate-survivor') + async designateSurvivor(request: Request, response: Response): Promise { + await this.httpService.callSyncingServer( + request, + response, + this.endpointResolver.resolveEndpointOrMethodIdentifier( + 'POST', + 'shared-vaults/:sharedVaultUuid/users/:userUuid/designate-survivor', + request.params.sharedVaultUuid, + request.params.userUuid, + ), + request.body, + ) + } } diff --git a/packages/api-gateway/src/Service/Resolver/EndpointResolver.ts b/packages/api-gateway/src/Service/Resolver/EndpointResolver.ts index 52c76fe6c..61db387d3 100644 --- a/packages/api-gateway/src/Service/Resolver/EndpointResolver.ts +++ b/packages/api-gateway/src/Service/Resolver/EndpointResolver.ts @@ -89,6 +89,10 @@ export class EndpointResolver implements EndpointResolverInterface { // Shared Vault Users Controller ['[GET]:shared-vaults/:sharedVaultUuid/users', 'sync.shared-vault-users.get-users'], ['[DELETE]:shared-vaults/:sharedVaultUuid/users/:userUuid', 'sync.shared-vault-users.remove-user'], + [ + '[POST]:shared-vaults/:sharedVaultUuid/users/:userUuid/designate-survivor', + 'sync.shared-vault-users.designate-survivor', + ], ]) resolveEndpointOrMethodIdentifier(method: string, endpoint: string, ...params: string[]): string { diff --git a/packages/auth/database.sqlite b/packages/auth/database.sqlite deleted file mode 100644 index 3761bb74789f2a7c6090ffc78692108e775b8c3e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 352256 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 diff --git a/packages/auth/migrations/mysql/1695283870612-add-designated-survivor.ts b/packages/auth/migrations/mysql/1695283870612-add-designated-survivor.ts new file mode 100644 index 000000000..7b1532d20 --- /dev/null +++ b/packages/auth/migrations/mysql/1695283870612-add-designated-survivor.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddDesignatedSurvivor1695283870612 implements MigrationInterface { + name = 'AddDesignatedSurvivor1695283870612' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE `auth_shared_vault_users` ADD `is_designated_survivor` tinyint NOT NULL DEFAULT 0', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `auth_shared_vault_users` DROP COLUMN `is_designated_survivor`') + } +} diff --git a/packages/auth/migrations/sqlite/1695283961201-add-designated-survivor.ts b/packages/auth/migrations/sqlite/1695283961201-add-designated-survivor.ts new file mode 100644 index 000000000..6847e730b --- /dev/null +++ b/packages/auth/migrations/sqlite/1695283961201-add-designated-survivor.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddDesignatedSurvivor1695283961201 implements MigrationInterface { + name = 'AddDesignatedSurvivor1695283961201' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX "user_uuid_on_auth_shared_vault_users"') + await queryRunner.query('DROP INDEX "shared_vault_uuid_on_auth_shared_vault_users"') + await queryRunner.query( + 'CREATE TABLE "temporary_auth_shared_vault_users" ("uuid" varchar PRIMARY KEY NOT NULL, "shared_vault_uuid" varchar(36) NOT NULL, "user_uuid" varchar(36) NOT NULL, "permission" varchar(24) NOT NULL, "created_at_timestamp" bigint NOT NULL, "updated_at_timestamp" bigint NOT NULL, "is_designated_survivor" boolean NOT NULL DEFAULT (0))', + ) + await queryRunner.query( + 'INSERT INTO "temporary_auth_shared_vault_users"("uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp") SELECT "uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp" FROM "auth_shared_vault_users"', + ) + await queryRunner.query('DROP TABLE "auth_shared_vault_users"') + await queryRunner.query('ALTER TABLE "temporary_auth_shared_vault_users" RENAME TO "auth_shared_vault_users"') + await queryRunner.query( + 'CREATE INDEX "user_uuid_on_auth_shared_vault_users" ON "auth_shared_vault_users" ("user_uuid") ', + ) + await queryRunner.query( + 'CREATE INDEX "shared_vault_uuid_on_auth_shared_vault_users" ON "auth_shared_vault_users" ("shared_vault_uuid") ', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX "shared_vault_uuid_on_auth_shared_vault_users"') + await queryRunner.query('DROP INDEX "user_uuid_on_auth_shared_vault_users"') + await queryRunner.query('ALTER TABLE "auth_shared_vault_users" RENAME TO "temporary_auth_shared_vault_users"') + await queryRunner.query( + 'CREATE TABLE "auth_shared_vault_users" ("uuid" varchar PRIMARY KEY NOT NULL, "shared_vault_uuid" varchar(36) NOT NULL, "user_uuid" varchar(36) NOT NULL, "permission" varchar(24) NOT NULL, "created_at_timestamp" bigint NOT NULL, "updated_at_timestamp" bigint NOT NULL)', + ) + await queryRunner.query( + 'INSERT INTO "auth_shared_vault_users"("uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp") SELECT "uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp" FROM "temporary_auth_shared_vault_users"', + ) + await queryRunner.query('DROP TABLE "temporary_auth_shared_vault_users"') + await queryRunner.query( + 'CREATE INDEX "shared_vault_uuid_on_auth_shared_vault_users" ON "auth_shared_vault_users" ("shared_vault_uuid") ', + ) + await queryRunner.query( + 'CREATE INDEX "user_uuid_on_auth_shared_vault_users" ON "auth_shared_vault_users" ("user_uuid") ', + ) + } +} diff --git a/packages/auth/src/Bootstrap/Container.ts b/packages/auth/src/Bootstrap/Container.ts index 3055ba05b..08b39e53f 100644 --- a/packages/auth/src/Bootstrap/Container.ts +++ b/packages/auth/src/Bootstrap/Container.ts @@ -271,6 +271,8 @@ import { AddSharedVaultUser } from '../Domain/UseCase/AddSharedVaultUser/AddShar import { RemoveSharedVaultUser } from '../Domain/UseCase/RemoveSharedVaultUser/RemoveSharedVaultUser' import { UserAddedToSharedVaultEventHandler } from '../Domain/Handler/UserAddedToSharedVaultEventHandler' import { UserRemovedFromSharedVaultEventHandler } from '../Domain/Handler/UserRemovedFromSharedVaultEventHandler' +import { DesignateSurvivor } from '../Domain/UseCase/DesignateSurvivor/DesignateSurvivor' +import { UserDesignatedAsSurvivorInSharedVaultEventHandler } from '../Domain/Handler/UserDesignatedAsSurvivorInSharedVaultEventHandler' export class ContainerConfigLoader { constructor(private mode: 'server' | 'worker' = 'server') {} @@ -957,6 +959,14 @@ export class ContainerConfigLoader { container.get(TYPES.Auth_SharedVaultUserRepository), ), ) + container + .bind(TYPES.Auth_DesignateSurvivor) + .toConstantValue( + new DesignateSurvivor( + container.get(TYPES.Auth_SharedVaultUserRepository), + container.get(TYPES.Auth_Timer), + ), + ) // Controller container @@ -1122,6 +1132,16 @@ export class ContainerConfigLoader { container.get(TYPES.Auth_Logger), ), ) + container + .bind( + TYPES.Auth_UserDesignatedAsSurvivorInSharedVaultEventHandler, + ) + .toConstantValue( + new UserDesignatedAsSurvivorInSharedVaultEventHandler( + container.get(TYPES.Auth_DesignateSurvivor), + container.get(TYPES.Auth_Logger), + ), + ) const eventHandlers: Map = new Map([ ['USER_REGISTERED', container.get(TYPES.Auth_UserRegisteredEventHandler)], @@ -1156,6 +1176,10 @@ export class ContainerConfigLoader { ['TRANSITION_STATUS_UPDATED', container.get(TYPES.Auth_TransitionStatusUpdatedEventHandler)], ['USER_ADDED_TO_SHARED_VAULT', container.get(TYPES.Auth_UserAddedToSharedVaultEventHandler)], ['USER_REMOVED_FROM_SHARED_VAULT', container.get(TYPES.Auth_UserRemovedFromSharedVaultEventHandler)], + [ + 'USER_DESIGNATED_AS_SURVIVOR_IN_SHARED_VAULT', + container.get(TYPES.Auth_UserDesignatedAsSurvivorInSharedVaultEventHandler), + ], ]) if (isConfiguredForHomeServer) { diff --git a/packages/auth/src/Bootstrap/Types.ts b/packages/auth/src/Bootstrap/Types.ts index e2ca762b1..f6c6c997e 100644 --- a/packages/auth/src/Bootstrap/Types.ts +++ b/packages/auth/src/Bootstrap/Types.ts @@ -161,6 +161,7 @@ const TYPES = { Auth_UpdateTransitionStatus: Symbol.for('Auth_UpdateTransitionStatus'), Auth_AddSharedVaultUser: Symbol.for('Auth_AddSharedVaultUser'), Auth_RemoveSharedVaultUser: Symbol.for('Auth_RemoveSharedVaultUser'), + Auth_DesignateSurvivor: Symbol.for('Auth_DesignateSurvivor'), // Handlers Auth_UserRegisteredEventHandler: Symbol.for('Auth_UserRegisteredEventHandler'), Auth_AccountDeletionRequestedEventHandler: Symbol.for('Auth_AccountDeletionRequestedEventHandler'), @@ -192,6 +193,9 @@ const TYPES = { Auth_TransitionStatusUpdatedEventHandler: Symbol.for('Auth_TransitionStatusUpdatedEventHandler'), Auth_UserAddedToSharedVaultEventHandler: Symbol.for('Auth_UserAddedToSharedVaultEventHandler'), Auth_UserRemovedFromSharedVaultEventHandler: Symbol.for('Auth_UserRemovedFromSharedVaultEventHandler'), + Auth_UserDesignatedAsSurvivorInSharedVaultEventHandler: Symbol.for( + 'Auth_UserDesignatedAsSurvivorInSharedVaultEventHandler', + ), // Services Auth_DeviceDetector: Symbol.for('Auth_DeviceDetector'), Auth_SessionService: Symbol.for('Auth_SessionService'), diff --git a/packages/auth/src/Domain/Handler/UserDesignatedAsSurvivorInSharedVaultEventHandler.ts b/packages/auth/src/Domain/Handler/UserDesignatedAsSurvivorInSharedVaultEventHandler.ts new file mode 100644 index 000000000..b3e3d17e7 --- /dev/null +++ b/packages/auth/src/Domain/Handler/UserDesignatedAsSurvivorInSharedVaultEventHandler.ts @@ -0,0 +1,26 @@ +import { DomainEventHandlerInterface, UserDesignatedAsSurvivorInSharedVaultEvent } from '@standardnotes/domain-events' +import { Logger } from 'winston' +import { DesignateSurvivor } from '../UseCase/DesignateSurvivor/DesignateSurvivor' + +export class UserDesignatedAsSurvivorInSharedVaultEventHandler implements DomainEventHandlerInterface { + constructor( + private designateSurvivorUseCase: DesignateSurvivor, + private logger: Logger, + ) {} + + async handle(event: UserDesignatedAsSurvivorInSharedVaultEvent): Promise { + const result = await this.designateSurvivorUseCase.execute({ + sharedVaultUuid: event.payload.sharedVaultUuid, + userUuid: event.payload.userUuid, + timestamp: event.payload.timestamp, + }) + + if (result.isFailed()) { + this.logger.error( + `Failed designate survivor for user ${event.payload.userUuid} and shared vault ${ + event.payload.sharedVaultUuid + }: ${result.getError()}`, + ) + } + } +} diff --git a/packages/auth/src/Domain/SharedVault/SharedVaultUserRepositoryInterface.ts b/packages/auth/src/Domain/SharedVault/SharedVaultUserRepositoryInterface.ts index 2fe53754f..afdcf1bcb 100644 --- a/packages/auth/src/Domain/SharedVault/SharedVaultUserRepositoryInterface.ts +++ b/packages/auth/src/Domain/SharedVault/SharedVaultUserRepositoryInterface.ts @@ -3,6 +3,7 @@ import { SharedVaultUser, Uuid } from '@standardnotes/domain-core' export interface SharedVaultUserRepositoryInterface { findByUserUuidAndSharedVaultUuid(dto: { userUuid: Uuid; sharedVaultUuid: Uuid }): Promise findByUserUuid(userUuid: Uuid): Promise + findDesignatedSurvivorBySharedVaultUuid(sharedVaultUuid: Uuid): Promise save(sharedVaultUser: SharedVaultUser): Promise remove(sharedVault: SharedVaultUser): Promise } diff --git a/packages/auth/src/Domain/UseCase/AddSharedVaultUser/AddSharedVaultUser.ts b/packages/auth/src/Domain/UseCase/AddSharedVaultUser/AddSharedVaultUser.ts index 1959809c5..dbf33db07 100644 --- a/packages/auth/src/Domain/UseCase/AddSharedVaultUser/AddSharedVaultUser.ts +++ b/packages/auth/src/Domain/UseCase/AddSharedVaultUser/AddSharedVaultUser.ts @@ -43,6 +43,7 @@ export class AddSharedVaultUser implements UseCaseInterface { sharedVaultUuid, permission, timestamps, + isDesignatedSurvivor: false, }) if (sharedVaultUserOrError.isFailed()) { return Result.fail(sharedVaultUserOrError.getError()) diff --git a/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts b/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts index 62b12de9c..c566723c1 100644 --- a/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts +++ b/packages/auth/src/Domain/UseCase/CreateCrossServiceToken/CreateCrossServiceToken.spec.ts @@ -90,6 +90,7 @@ describe('CreateCrossServiceToken', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123456789, 123456789).getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), + isDesignatedSurvivor: false, }).getValue(), ]) }) diff --git a/packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivor.spec.ts b/packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivor.spec.ts new file mode 100644 index 000000000..09a21a8c9 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivor.spec.ts @@ -0,0 +1,156 @@ +import { SharedVaultUser, SharedVaultUserPermission, Timestamps, Uuid } from '@standardnotes/domain-core' + +import { DesignateSurvivor } from './DesignateSurvivor' +import { TimerInterface } from '@standardnotes/time' +import { SharedVaultUserRepositoryInterface } from '../../SharedVault/SharedVaultUserRepositoryInterface' + +describe('DesignateSurvivor', () => { + let sharedVaultUserRepository: SharedVaultUserRepositoryInterface + let sharedVaultUser: SharedVaultUser + let timer: TimerInterface + + const createUseCase = () => new DesignateSurvivor(sharedVaultUserRepository, timer) + + beforeEach(() => { + timer = {} as jest.Mocked + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123) + + sharedVaultUser = SharedVaultUser.create({ + permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Write).getValue(), + sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), + userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), + timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, + }).getValue() + + sharedVaultUserRepository = {} as jest.Mocked + sharedVaultUserRepository.findDesignatedSurvivorBySharedVaultUuid = jest.fn().mockReturnValue(null) + sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockReturnValue(sharedVaultUser) + sharedVaultUserRepository.save = jest.fn() + }) + + it('should fail if shared vault uuid is invalid', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ + sharedVaultUuid: 'invalid', + userUuid: '00000000-0000-0000-0000-000000000000', + timestamp: 123, + }) + + expect(result.isFailed()).toBe(true) + }) + + it('should fail if user uuid is invalid', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ + sharedVaultUuid: '00000000-0000-0000-0000-000000000000', + userUuid: 'invalid', + timestamp: 123, + }) + + expect(result.isFailed()).toBe(true) + }) + + it('should fail if shared vault user is not found', async () => { + sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockReturnValue(null) + + const useCase = createUseCase() + + const result = await useCase.execute({ + sharedVaultUuid: '00000000-0000-0000-0000-000000000000', + userUuid: '00000000-0000-0000-0000-000000000000', + timestamp: 123, + }) + + expect(result.isFailed()).toBe(true) + }) + + it('should designate a survivor if the user is a member', async () => { + sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockReturnValue(sharedVaultUser) + + const useCase = createUseCase() + + const result = await useCase.execute({ + sharedVaultUuid: '00000000-0000-0000-0000-000000000000', + userUuid: '00000000-0000-0000-0000-000000000000', + timestamp: 123, + }) + + expect(result.isFailed()).toBe(false) + expect(sharedVaultUser.props.isDesignatedSurvivor).toBe(true) + expect(sharedVaultUserRepository.save).toBeCalledTimes(1) + }) + + it('should designate a survivor if the user is a member and there is already a survivor', async () => { + sharedVaultUserRepository.findDesignatedSurvivorBySharedVaultUuid = jest.fn().mockReturnValue( + SharedVaultUser.create({ + permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Write).getValue(), + sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), + userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(), + timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: true, + }).getValue(), + ) + sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockReturnValue(sharedVaultUser) + + const useCase = createUseCase() + + const result = await useCase.execute({ + sharedVaultUuid: '00000000-0000-0000-0000-000000000000', + userUuid: '00000000-0000-0000-0000-000000000000', + timestamp: 123, + }) + + expect(result.isFailed()).toBe(false) + expect(sharedVaultUser.props.isDesignatedSurvivor).toBe(true) + expect(sharedVaultUserRepository.save).toBeCalledTimes(2) + }) + + it('should fail if the timestamp is older than the existing survivor', async () => { + sharedVaultUserRepository.findDesignatedSurvivorBySharedVaultUuid = jest.fn().mockReturnValue( + SharedVaultUser.create({ + permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Write).getValue(), + sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), + userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(), + timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: true, + }).getValue(), + ) + sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockReturnValue(sharedVaultUser) + + const useCase = createUseCase() + + const result = await useCase.execute({ + sharedVaultUuid: '00000000-0000-0000-0000-000000000000', + userUuid: '00000000-0000-0000-0000-000000000000', + timestamp: 122, + }) + + expect(result.isFailed()).toBe(true) + }) + + it('should do nothing if the user is already a survivor', async () => { + sharedVaultUserRepository.findDesignatedSurvivorBySharedVaultUuid = jest.fn().mockReturnValue( + SharedVaultUser.create({ + permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Write).getValue(), + sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), + userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), + timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: true, + }).getValue(), + ) + sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockReturnValue(sharedVaultUser) + + const useCase = createUseCase() + + const result = await useCase.execute({ + sharedVaultUuid: '00000000-0000-0000-0000-000000000000', + userUuid: '00000000-0000-0000-0000-000000000000', + timestamp: 200, + }) + + expect(result.isFailed()).toBe(false) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivor.ts b/packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivor.ts new file mode 100644 index 000000000..00d64365b --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivor.ts @@ -0,0 +1,66 @@ +import { TimerInterface } from '@standardnotes/time' +import { Result, Timestamps, UseCaseInterface, Uuid } from '@standardnotes/domain-core' + +import { DesignateSurvivorDTO } from './DesignateSurvivorDTO' +import { SharedVaultUserRepositoryInterface } from '../../SharedVault/SharedVaultUserRepositoryInterface' + +export class DesignateSurvivor implements UseCaseInterface { + constructor( + private sharedVaultUserRepository: SharedVaultUserRepositoryInterface, + private timer: TimerInterface, + ) {} + + async execute(dto: DesignateSurvivorDTO): Promise> { + const sharedVaultUuidOrError = Uuid.create(dto.sharedVaultUuid) + if (sharedVaultUuidOrError.isFailed()) { + return Result.fail(sharedVaultUuidOrError.getError()) + } + const sharedVaultUuid = sharedVaultUuidOrError.getValue() + + const userUuidOrError = Uuid.create(dto.userUuid) + if (userUuidOrError.isFailed()) { + return Result.fail(userUuidOrError.getError()) + } + const userUuid = userUuidOrError.getValue() + + const existingSurvivor = + await this.sharedVaultUserRepository.findDesignatedSurvivorBySharedVaultUuid(sharedVaultUuid) + + if (existingSurvivor) { + if (existingSurvivor.props.timestamps.updatedAt > dto.timestamp) { + return Result.fail( + 'Cannot designate survivor to a previous version of the shared vault. Most probably a race condition.', + ) + } + if (existingSurvivor.props.userUuid.value === userUuid.value) { + return Result.ok() + } + + existingSurvivor.props.isDesignatedSurvivor = false + existingSurvivor.props.timestamps = Timestamps.create( + existingSurvivor.props.timestamps.createdAt, + this.timer.getTimestampInMicroseconds(), + ).getValue() + + await this.sharedVaultUserRepository.save(existingSurvivor) + } + + const toBeDesignatedAsASurvivor = await this.sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid({ + userUuid, + sharedVaultUuid, + }) + if (!toBeDesignatedAsASurvivor) { + return Result.fail('User is not a member of the shared vault') + } + + toBeDesignatedAsASurvivor.props.isDesignatedSurvivor = true + toBeDesignatedAsASurvivor.props.timestamps = Timestamps.create( + toBeDesignatedAsASurvivor.props.timestamps.createdAt, + this.timer.getTimestampInMicroseconds(), + ).getValue() + + await this.sharedVaultUserRepository.save(toBeDesignatedAsASurvivor) + + return Result.ok() + } +} diff --git a/packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivorDTO.ts b/packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivorDTO.ts new file mode 100644 index 000000000..9bb4ae22e --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DesignateSurvivor/DesignateSurvivorDTO.ts @@ -0,0 +1,5 @@ +export interface DesignateSurvivorDTO { + sharedVaultUuid: string + userUuid: string + timestamp: number +} diff --git a/packages/auth/src/Infra/TypeORM/TypeORMSharedVaultUser.ts b/packages/auth/src/Infra/TypeORM/TypeORMSharedVaultUser.ts index e1b149e87..ff25e4729 100644 --- a/packages/auth/src/Infra/TypeORM/TypeORMSharedVaultUser.ts +++ b/packages/auth/src/Infra/TypeORM/TypeORMSharedVaultUser.ts @@ -26,6 +26,13 @@ export class TypeORMSharedVaultUser { }) declare permission: string + @Column({ + name: 'is_designated_survivor', + type: 'boolean', + default: false, + }) + declare isDesignatedSurvivor: boolean + @Column({ name: 'created_at_timestamp', type: 'bigint', diff --git a/packages/auth/src/Infra/TypeORM/TypeORMSharedVaultUserRepository.ts b/packages/auth/src/Infra/TypeORM/TypeORMSharedVaultUserRepository.ts index 1d8503eca..4aa671e6c 100644 --- a/packages/auth/src/Infra/TypeORM/TypeORMSharedVaultUserRepository.ts +++ b/packages/auth/src/Infra/TypeORM/TypeORMSharedVaultUserRepository.ts @@ -10,6 +10,24 @@ export class TypeORMSharedVaultUserRepository implements SharedVaultUserReposito private mapper: MapperInterface, ) {} + async findDesignatedSurvivorBySharedVaultUuid(sharedVaultUuid: Uuid): Promise { + const persistence = await this.ormRepository + .createQueryBuilder('shared_vault_user') + .where('shared_vault_user.shared_vault_uuid = :sharedVaultUuid', { + sharedVaultUuid: sharedVaultUuid.value, + }) + .andWhere('shared_vault_user.is_designated_survivor = :isDesignatedSurvivor', { + isDesignatedSurvivor: true, + }) + .getOne() + + if (persistence === null) { + return null + } + + return this.mapper.toDomain(persistence) + } + async findByUserUuid(userUuid: Uuid): Promise { const persistence = await this.ormRepository .createQueryBuilder('shared_vault_user') diff --git a/packages/auth/src/Mapping/SharedVaultUserPersistenceMapper.ts b/packages/auth/src/Mapping/SharedVaultUserPersistenceMapper.ts index 21ff1da88..4a22ac1b1 100644 --- a/packages/auth/src/Mapping/SharedVaultUserPersistenceMapper.ts +++ b/packages/auth/src/Mapping/SharedVaultUserPersistenceMapper.ts @@ -41,6 +41,7 @@ export class SharedVaultUserPersistenceMapper implements MapperInterface { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123456789, 123456789).getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), + isDesignatedSurvivor: false, }) expect(entityOrError.isFailed()).toBeFalsy() diff --git a/packages/domain-core/src/Domain/SharedVault/SharedVaultUserProps.ts b/packages/domain-core/src/Domain/SharedVault/SharedVaultUserProps.ts index 4e6dc4214..6dbd7358c 100644 --- a/packages/domain-core/src/Domain/SharedVault/SharedVaultUserProps.ts +++ b/packages/domain-core/src/Domain/SharedVault/SharedVaultUserProps.ts @@ -6,5 +6,6 @@ export interface SharedVaultUserProps { sharedVaultUuid: Uuid userUuid: Uuid permission: SharedVaultUserPermission + isDesignatedSurvivor: boolean timestamps: Timestamps } diff --git a/packages/domain-events/src/Domain/Event/UserDesignatedAsSurvivorInSharedVaultEvent.ts b/packages/domain-events/src/Domain/Event/UserDesignatedAsSurvivorInSharedVaultEvent.ts new file mode 100644 index 000000000..f0cdcbcf1 --- /dev/null +++ b/packages/domain-events/src/Domain/Event/UserDesignatedAsSurvivorInSharedVaultEvent.ts @@ -0,0 +1,7 @@ +import { DomainEventInterface } from './DomainEventInterface' +import { UserDesignatedAsSurvivorInSharedVaultEventPayload } from './UserDesignatedAsSurvivorInSharedVaultEventPayload' + +export interface UserDesignatedAsSurvivorInSharedVaultEvent extends DomainEventInterface { + type: 'USER_DESIGNATED_AS_SURVIVOR_IN_SHARED_VAULT' + payload: UserDesignatedAsSurvivorInSharedVaultEventPayload +} diff --git a/packages/domain-events/src/Domain/Event/UserDesignatedAsSurvivorInSharedVaultEventPayload.ts b/packages/domain-events/src/Domain/Event/UserDesignatedAsSurvivorInSharedVaultEventPayload.ts new file mode 100644 index 000000000..960d5c137 --- /dev/null +++ b/packages/domain-events/src/Domain/Event/UserDesignatedAsSurvivorInSharedVaultEventPayload.ts @@ -0,0 +1,5 @@ +export interface UserDesignatedAsSurvivorInSharedVaultEventPayload { + userUuid: string + sharedVaultUuid: string + timestamp: number +} diff --git a/packages/domain-events/src/Domain/index.ts b/packages/domain-events/src/Domain/index.ts index 8c860c740..cb1a00e7a 100644 --- a/packages/domain-events/src/Domain/index.ts +++ b/packages/domain-events/src/Domain/index.ts @@ -104,6 +104,8 @@ export * from './Event/TransitionStatusUpdatedEvent' export * from './Event/TransitionStatusUpdatedEventPayload' export * from './Event/UserAddedToSharedVaultEvent' export * from './Event/UserAddedToSharedVaultEventPayload' +export * from './Event/UserDesignatedAsSurvivorInSharedVaultEvent' +export * from './Event/UserDesignatedAsSurvivorInSharedVaultEventPayload' export * from './Event/UserDisabledSessionUserAgentLoggingEvent' export * from './Event/UserDisabledSessionUserAgentLoggingEventPayload' export * from './Event/UserEmailChangedEvent' diff --git a/packages/syncing-server/migrations/mysql-legacy/1695284084365-add-designated-survivor.ts b/packages/syncing-server/migrations/mysql-legacy/1695284084365-add-designated-survivor.ts new file mode 100644 index 000000000..05c0ab9bc --- /dev/null +++ b/packages/syncing-server/migrations/mysql-legacy/1695284084365-add-designated-survivor.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddDesignatedSurvivor1695284084365 implements MigrationInterface { + name = 'AddDesignatedSurvivor1695284084365' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `shared_vault_users` ADD `is_designated_survivor` tinyint NOT NULL DEFAULT 0') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `shared_vault_users` DROP COLUMN `is_designated_survivor`') + } +} diff --git a/packages/syncing-server/migrations/mysql/1695284084365-add-designated-survivor.ts b/packages/syncing-server/migrations/mysql/1695284084365-add-designated-survivor.ts new file mode 100644 index 000000000..05c0ab9bc --- /dev/null +++ b/packages/syncing-server/migrations/mysql/1695284084365-add-designated-survivor.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddDesignatedSurvivor1695284084365 implements MigrationInterface { + name = 'AddDesignatedSurvivor1695284084365' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `shared_vault_users` ADD `is_designated_survivor` tinyint NOT NULL DEFAULT 0') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `shared_vault_users` DROP COLUMN `is_designated_survivor`') + } +} diff --git a/packages/syncing-server/migrations/sqlite/1695284249461-add-designated-survivor.ts b/packages/syncing-server/migrations/sqlite/1695284249461-add-designated-survivor.ts new file mode 100644 index 000000000..8fadf6392 --- /dev/null +++ b/packages/syncing-server/migrations/sqlite/1695284249461-add-designated-survivor.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddDesignatedSurvivor1695284249461 implements MigrationInterface { + name = 'AddDesignatedSurvivor1695284249461' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX "user_uuid_on_shared_vault_users"') + await queryRunner.query('DROP INDEX "shared_vault_uuid_on_shared_vault_users"') + await queryRunner.query( + 'CREATE TABLE "temporary_shared_vault_users" ("uuid" varchar PRIMARY KEY NOT NULL, "shared_vault_uuid" varchar(36) NOT NULL, "user_uuid" varchar(36) NOT NULL, "permission" varchar(24) NOT NULL, "created_at_timestamp" bigint NOT NULL, "updated_at_timestamp" bigint NOT NULL, "is_designated_survivor" boolean NOT NULL DEFAULT (0))', + ) + await queryRunner.query( + 'INSERT INTO "temporary_shared_vault_users"("uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp") SELECT "uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp" FROM "shared_vault_users"', + ) + await queryRunner.query('DROP TABLE "shared_vault_users"') + await queryRunner.query('ALTER TABLE "temporary_shared_vault_users" RENAME TO "shared_vault_users"') + await queryRunner.query('CREATE INDEX "user_uuid_on_shared_vault_users" ON "shared_vault_users" ("user_uuid") ') + await queryRunner.query( + 'CREATE INDEX "shared_vault_uuid_on_shared_vault_users" ON "shared_vault_users" ("shared_vault_uuid") ', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX "shared_vault_uuid_on_shared_vault_users"') + await queryRunner.query('DROP INDEX "user_uuid_on_shared_vault_users"') + await queryRunner.query('ALTER TABLE "shared_vault_users" RENAME TO "temporary_shared_vault_users"') + await queryRunner.query( + 'CREATE TABLE "shared_vault_users" ("uuid" varchar PRIMARY KEY NOT NULL, "shared_vault_uuid" varchar(36) NOT NULL, "user_uuid" varchar(36) NOT NULL, "permission" varchar(24) NOT NULL, "created_at_timestamp" bigint NOT NULL, "updated_at_timestamp" bigint NOT NULL)', + ) + await queryRunner.query( + 'INSERT INTO "shared_vault_users"("uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp") SELECT "uuid", "shared_vault_uuid", "user_uuid", "permission", "created_at_timestamp", "updated_at_timestamp" FROM "temporary_shared_vault_users"', + ) + await queryRunner.query('DROP TABLE "temporary_shared_vault_users"') + await queryRunner.query( + 'CREATE INDEX "shared_vault_uuid_on_shared_vault_users" ON "shared_vault_users" ("shared_vault_uuid") ', + ) + await queryRunner.query('CREATE INDEX "user_uuid_on_shared_vault_users" ON "shared_vault_users" ("user_uuid") ') + } +} diff --git a/packages/syncing-server/src/Bootstrap/Container.ts b/packages/syncing-server/src/Bootstrap/Container.ts index 8b9fa0896..4e191aacd 100644 --- a/packages/syncing-server/src/Bootstrap/Container.ts +++ b/packages/syncing-server/src/Bootstrap/Container.ts @@ -168,6 +168,7 @@ import { TransitionRequestedEventHandler } from '../Domain/Handler/TransitionReq import { DeleteSharedVaults } from '../Domain/UseCase/SharedVaults/DeleteSharedVaults/DeleteSharedVaults' import { RemoveItemsFromSharedVault } from '../Domain/UseCase/SharedVaults/RemoveItemsFromSharedVault/RemoveItemsFromSharedVault' import { SharedVaultRemovedEventHandler } from '../Domain/Handler/SharedVaultRemovedEventHandler' +import { DesignateSurvivor } from '../Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor' export class ContainerConfigLoader { private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000 @@ -865,6 +866,16 @@ export class ContainerConfigLoader { : container.get(TYPES.Sync_SQLItemRepository), ), ) + container + .bind(TYPES.Sync_DesignateSurvivor) + .toConstantValue( + new DesignateSurvivor( + container.get(TYPES.Sync_SharedVaultUserRepository), + container.get(TYPES.Sync_Timer), + container.get(TYPES.Sync_DomainEventFactory), + container.get(TYPES.Sync_DomainEventPublisher), + ), + ) // Services container diff --git a/packages/syncing-server/src/Bootstrap/Types.ts b/packages/syncing-server/src/Bootstrap/Types.ts index 431e939b7..d8ede1348 100644 --- a/packages/syncing-server/src/Bootstrap/Types.ts +++ b/packages/syncing-server/src/Bootstrap/Types.ts @@ -87,6 +87,7 @@ const TYPES = { ), Sync_SendEventToClient: Symbol.for('Sync_SendEventToClient'), Sync_RemoveItemsFromSharedVault: Symbol.for('Sync_RemoveItemsFromSharedVault'), + Sync_DesignateSurvivor: Symbol.for('Sync_DesignateSurvivor'), // Handlers Sync_AccountDeletionRequestedEventHandler: Symbol.for('Sync_AccountDeletionRequestedEventHandler'), Sync_DuplicateItemSyncedEventHandler: Symbol.for('Sync_DuplicateItemSyncedEventHandler'), diff --git a/packages/syncing-server/src/Domain/Event/DomainEventFactory.ts b/packages/syncing-server/src/Domain/Event/DomainEventFactory.ts index d6292fbbc..7fd338da9 100644 --- a/packages/syncing-server/src/Domain/Event/DomainEventFactory.ts +++ b/packages/syncing-server/src/Domain/Event/DomainEventFactory.ts @@ -12,6 +12,7 @@ import { SharedVaultRemovedEvent, TransitionStatusUpdatedEvent, UserAddedToSharedVaultEvent, + UserDesignatedAsSurvivorInSharedVaultEvent, UserInvitedToSharedVaultEvent, UserRemovedFromSharedVaultEvent, WebSocketMessageRequestedEvent, @@ -22,6 +23,25 @@ import { DomainEventFactoryInterface } from './DomainEventFactoryInterface' export class DomainEventFactory implements DomainEventFactoryInterface { constructor(private timer: TimerInterface) {} + createUserDesignatedAsSurvivorInSharedVaultEvent(dto: { + sharedVaultUuid: string + userUuid: string + timestamp: number + }): UserDesignatedAsSurvivorInSharedVaultEvent { + return { + type: 'USER_DESIGNATED_AS_SURVIVOR_IN_SHARED_VAULT', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: dto.userUuid, + userIdentifierType: 'uuid', + }, + origin: DomainEventService.SyncingServer, + }, + payload: dto, + } + } + createSharedVaultRemovedEvent(dto: { sharedVaultUuid: string }): SharedVaultRemovedEvent { return { type: 'SHARED_VAULT_REMOVED', diff --git a/packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts b/packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts index 9c58b3d30..e33dd0fba 100644 --- a/packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts +++ b/packages/syncing-server/src/Domain/Event/DomainEventFactoryInterface.ts @@ -10,6 +10,7 @@ import { SharedVaultRemovedEvent, TransitionStatusUpdatedEvent, UserAddedToSharedVaultEvent, + UserDesignatedAsSurvivorInSharedVaultEvent, UserInvitedToSharedVaultEvent, UserRemovedFromSharedVaultEvent, WebSocketMessageRequestedEvent, @@ -102,4 +103,9 @@ export interface DomainEventFactoryInterface { userUuid: string }): ItemRemovedFromSharedVaultEvent createSharedVaultRemovedEvent(dto: { sharedVaultUuid: string }): SharedVaultRemovedEvent + createUserDesignatedAsSurvivorInSharedVaultEvent(dto: { + sharedVaultUuid: string + userUuid: string + timestamp: number + }): UserDesignatedAsSurvivorInSharedVaultEvent } diff --git a/packages/syncing-server/src/Domain/Item/SaveRule/SharedVaultFilter.spec.ts b/packages/syncing-server/src/Domain/Item/SaveRule/SharedVaultFilter.spec.ts index 44b086b08..33c0a665b 100644 --- a/packages/syncing-server/src/Domain/Item/SaveRule/SharedVaultFilter.spec.ts +++ b/packages/syncing-server/src/Domain/Item/SaveRule/SharedVaultFilter.spec.ts @@ -65,6 +65,7 @@ describe('SharedVaultFilter', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue() determineSharedVaultOperationOnItem = {} as jest.Mocked @@ -329,6 +330,7 @@ describe('SharedVaultFilter', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue() itemHash = ItemHash.create({ @@ -489,6 +491,7 @@ describe('SharedVaultFilter', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue() itemHash = ItemHash.create({ @@ -649,6 +652,7 @@ describe('SharedVaultFilter', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue() itemHash = ItemHash.create({ @@ -734,6 +738,7 @@ describe('SharedVaultFilter', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue() sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser) @@ -802,6 +807,7 @@ describe('SharedVaultFilter', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue() itemHash = ItemHash.create({ diff --git a/packages/syncing-server/src/Domain/UseCase/Messaging/AddNotificationsForUsers/AddNotificationsForUsers.spec.ts b/packages/syncing-server/src/Domain/UseCase/Messaging/AddNotificationsForUsers/AddNotificationsForUsers.spec.ts index d2dd10985..126c774a1 100644 --- a/packages/syncing-server/src/Domain/UseCase/Messaging/AddNotificationsForUsers/AddNotificationsForUsers.spec.ts +++ b/packages/syncing-server/src/Domain/UseCase/Messaging/AddNotificationsForUsers/AddNotificationsForUsers.spec.ts @@ -25,6 +25,7 @@ describe('AddNotificationsForUsers', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue() sharedVaultUserRepository = {} as jest.Mocked diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/AddUserToSharedVault/AddUserToSharedVault.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/AddUserToSharedVault/AddUserToSharedVault.ts index 865723b55..0c7c24ae8 100644 --- a/packages/syncing-server/src/Domain/UseCase/SharedVaults/AddUserToSharedVault/AddUserToSharedVault.ts +++ b/packages/syncing-server/src/Domain/UseCase/SharedVaults/AddUserToSharedVault/AddUserToSharedVault.ts @@ -63,6 +63,7 @@ export class AddUserToSharedVault implements UseCaseInterface { sharedVaultUuid, permission, timestamps, + isDesignatedSurvivor: false, }) if (sharedVaultUserOrError.isFailed()) { return Result.fail(sharedVaultUserOrError.getError()) diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/CreateSharedVaultFileValetToken/CreateSharedVaultFileValetToken.spec.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/CreateSharedVaultFileValetToken/CreateSharedVaultFileValetToken.spec.ts index 0852e6e1b..d436bca84 100644 --- a/packages/syncing-server/src/Domain/UseCase/SharedVaults/CreateSharedVaultFileValetToken/CreateSharedVaultFileValetToken.spec.ts +++ b/packages/syncing-server/src/Domain/UseCase/SharedVaults/CreateSharedVaultFileValetToken/CreateSharedVaultFileValetToken.spec.ts @@ -31,6 +31,7 @@ describe('CreateSharedVaultFileValetToken', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue() sharedVaultUserRepository = {} as jest.Mocked @@ -115,6 +116,7 @@ describe('CreateSharedVaultFileValetToken', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue() sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser) @@ -140,6 +142,7 @@ describe('CreateSharedVaultFileValetToken', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue(), ) .mockReturnValueOnce( @@ -148,6 +151,7 @@ describe('CreateSharedVaultFileValetToken', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue(), ) }) @@ -203,6 +207,7 @@ describe('CreateSharedVaultFileValetToken', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue(), ) .mockReturnValueOnce(null) @@ -230,6 +235,7 @@ describe('CreateSharedVaultFileValetToken', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue(), ) .mockReturnValueOnce( @@ -238,6 +244,7 @@ describe('CreateSharedVaultFileValetToken', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue(), ) @@ -281,6 +288,7 @@ describe('CreateSharedVaultFileValetToken', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue(), ) .mockReturnValueOnce( @@ -289,6 +297,7 @@ describe('CreateSharedVaultFileValetToken', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue(), ) @@ -315,6 +324,7 @@ describe('CreateSharedVaultFileValetToken', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue(), ) .mockReturnValueOnce( @@ -323,6 +333,7 @@ describe('CreateSharedVaultFileValetToken', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue(), ) @@ -349,6 +360,7 @@ describe('CreateSharedVaultFileValetToken', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue(), ) .mockReturnValueOnce( @@ -357,6 +369,7 @@ describe('CreateSharedVaultFileValetToken', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue(), ) diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/DeleteSharedVault/DeleteSharedVault.spec.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/DeleteSharedVault/DeleteSharedVault.spec.ts index 080662255..072d85fcf 100644 --- a/packages/syncing-server/src/Domain/UseCase/SharedVaults/DeleteSharedVault/DeleteSharedVault.spec.ts +++ b/packages/syncing-server/src/Domain/UseCase/SharedVaults/DeleteSharedVault/DeleteSharedVault.spec.ts @@ -49,6 +49,7 @@ describe('DeleteSharedVault', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue() sharedVaultUserRepository = {} as jest.Mocked sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockResolvedValue([sharedVaultUser]) diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor.spec.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor.spec.ts new file mode 100644 index 000000000..8842844b5 --- /dev/null +++ b/packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor.spec.ts @@ -0,0 +1,158 @@ +import { SharedVaultUser, SharedVaultUserPermission, Timestamps, Uuid } from '@standardnotes/domain-core' + +import { SharedVaultUserRepositoryInterface } from '../../../SharedVault/User/SharedVaultUserRepositoryInterface' +import { DesignateSurvivor } from './DesignateSurvivor' +import { TimerInterface } from '@standardnotes/time' +import { DomainEventInterface, DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface' + +describe('DesignateSurvivor', () => { + let sharedVaultUserRepository: SharedVaultUserRepositoryInterface + let sharedVaultUser: SharedVaultUser + let sharedVaultOwner: SharedVaultUser + let timer: TimerInterface + let domainEventFactory: DomainEventFactoryInterface + let domainEventPublisher: DomainEventPublisherInterface + + const createUseCase = () => + new DesignateSurvivor(sharedVaultUserRepository, timer, domainEventFactory, domainEventPublisher) + + beforeEach(() => { + timer = {} as jest.Mocked + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123) + + sharedVaultOwner = SharedVaultUser.create({ + permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Admin).getValue(), + sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), + userUuid: Uuid.create('00000000-0000-0000-0000-000000000002').getValue(), + timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, + }).getValue() + + sharedVaultUser = SharedVaultUser.create({ + permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Write).getValue(), + sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), + userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), + timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, + }).getValue() + + sharedVaultUserRepository = {} as jest.Mocked + sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockReturnValue([]) + sharedVaultUserRepository.save = jest.fn() + + domainEventFactory = {} as jest.Mocked + domainEventFactory.createUserDesignatedAsSurvivorInSharedVaultEvent = jest + .fn() + .mockReturnValue({} as jest.Mocked) + + domainEventPublisher = {} as jest.Mocked + domainEventPublisher.publish = jest.fn() + }) + + it('should fail if shared vault uuid is invalid', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ + sharedVaultUuid: 'invalid', + userUuid: '00000000-0000-0000-0000-000000000000', + originatorUuid: '00000000-0000-0000-0000-000000000002', + }) + + expect(result.isFailed()).toBe(true) + }) + + it('should fail if user uuid is invalid', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ + sharedVaultUuid: '00000000-0000-0000-0000-000000000000', + userUuid: 'invalid', + originatorUuid: '00000000-0000-0000-0000-000000000002', + }) + + expect(result.isFailed()).toBe(true) + }) + + it('should fail if originator uuid is invalid', async () => { + const useCase = createUseCase() + + const result = await useCase.execute({ + sharedVaultUuid: '00000000-0000-0000-0000-000000000000', + userUuid: '00000000-0000-0000-0000-000000000000', + originatorUuid: 'invalid', + }) + + expect(result.isFailed()).toBe(true) + }) + + it('should fail if shared vault user is not found', async () => { + sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockReturnValue([sharedVaultOwner]) + + const useCase = createUseCase() + + const result = await useCase.execute({ + sharedVaultUuid: '00000000-0000-0000-0000-000000000000', + userUuid: '00000000-0000-0000-0000-000000000000', + originatorUuid: '00000000-0000-0000-0000-000000000002', + }) + + expect(result.isFailed()).toBe(true) + }) + + it('should fail if the originator is not the admin of the shared vault', async () => { + sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockReturnValue([sharedVaultOwner, sharedVaultUser]) + + const useCase = createUseCase() + + const result = await useCase.execute({ + sharedVaultUuid: '00000000-0000-0000-0000-000000000000', + userUuid: '00000000-0000-0000-0000-000000000000', + originatorUuid: '00000000-0000-0000-0000-000000000003', + }) + + expect(result.isFailed()).toBe(true) + }) + + it('should designate a survivor if the user is a member', async () => { + sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockReturnValue([sharedVaultOwner, sharedVaultUser]) + + const useCase = createUseCase() + + const result = await useCase.execute({ + sharedVaultUuid: '00000000-0000-0000-0000-000000000000', + userUuid: '00000000-0000-0000-0000-000000000000', + originatorUuid: '00000000-0000-0000-0000-000000000002', + }) + + expect(result.isFailed()).toBe(false) + expect(sharedVaultUser.props.isDesignatedSurvivor).toBe(true) + expect(sharedVaultUserRepository.save).toBeCalledTimes(1) + }) + + it('should designate a survivor if the user is a member and there is already a survivor', async () => { + sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockReturnValue([ + sharedVaultOwner, + sharedVaultUser, + SharedVaultUser.create({ + permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Write).getValue(), + sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), + userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(), + timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: true, + }).getValue(), + ]) + + const useCase = createUseCase() + + const result = await useCase.execute({ + sharedVaultUuid: '00000000-0000-0000-0000-000000000000', + userUuid: '00000000-0000-0000-0000-000000000000', + originatorUuid: '00000000-0000-0000-0000-000000000002', + }) + + expect(result.isFailed()).toBe(false) + expect(sharedVaultUser.props.isDesignatedSurvivor).toBe(true) + expect(sharedVaultUserRepository.save).toBeCalledTimes(2) + }) +}) diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor.ts new file mode 100644 index 000000000..6a96f853a --- /dev/null +++ b/packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor.ts @@ -0,0 +1,97 @@ +import { TimerInterface } from '@standardnotes/time' +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { + Result, + SharedVaultUser, + SharedVaultUserPermission, + Timestamps, + UseCaseInterface, + Uuid, +} from '@standardnotes/domain-core' + +import { SharedVaultUserRepositoryInterface } from '../../../SharedVault/User/SharedVaultUserRepositoryInterface' +import { DesignateSurvivorDTO } from './DesignateSurvivorDTO' +import { DomainEventFactoryInterface } from '../../../Event/DomainEventFactoryInterface' + +export class DesignateSurvivor implements UseCaseInterface { + constructor( + private sharedVaultUserRepository: SharedVaultUserRepositoryInterface, + private timer: TimerInterface, + private domainEventFactory: DomainEventFactoryInterface, + private domainEventPublisher: DomainEventPublisherInterface, + ) {} + + async execute(dto: DesignateSurvivorDTO): Promise> { + const sharedVaultUuidOrError = Uuid.create(dto.sharedVaultUuid) + if (sharedVaultUuidOrError.isFailed()) { + return Result.fail(sharedVaultUuidOrError.getError()) + } + const sharedVaultUuid = sharedVaultUuidOrError.getValue() + + const userUuidOrError = Uuid.create(dto.userUuid) + if (userUuidOrError.isFailed()) { + return Result.fail(userUuidOrError.getError()) + } + const userUuid = userUuidOrError.getValue() + + const originatorUuidOrError = Uuid.create(dto.originatorUuid) + if (originatorUuidOrError.isFailed()) { + return Result.fail(originatorUuidOrError.getError()) + } + const originatorUuid = originatorUuidOrError.getValue() + + const sharedVaultUsers = await this.sharedVaultUserRepository.findBySharedVaultUuid(sharedVaultUuid) + let sharedVaultExistingSurvivor: SharedVaultUser | undefined + let toBeDesignatedAsASurvivor: SharedVaultUser | undefined + let isOriginatorTheOwner = false + for (const sharedVaultUser of sharedVaultUsers) { + if (sharedVaultUser.props.userUuid.equals(userUuid)) { + toBeDesignatedAsASurvivor = sharedVaultUser + } + if (sharedVaultUser.props.isDesignatedSurvivor) { + sharedVaultExistingSurvivor = sharedVaultUser + } + if ( + sharedVaultUser.props.userUuid.equals(originatorUuid) && + sharedVaultUser.props.permission.value === SharedVaultUserPermission.PERMISSIONS.Admin + ) { + isOriginatorTheOwner = true + } + } + + if (!isOriginatorTheOwner) { + return Result.fail('Only the owner can designate a survivor') + } + + if (!toBeDesignatedAsASurvivor) { + return Result.fail('Attempting to designate a survivor for a non-member') + } + + if (sharedVaultExistingSurvivor) { + sharedVaultExistingSurvivor.props.isDesignatedSurvivor = false + sharedVaultExistingSurvivor.props.timestamps = Timestamps.create( + sharedVaultExistingSurvivor.props.timestamps.createdAt, + this.timer.getTimestampInMicroseconds(), + ).getValue() + await this.sharedVaultUserRepository.save(sharedVaultExistingSurvivor) + } + + toBeDesignatedAsASurvivor.props.isDesignatedSurvivor = true + toBeDesignatedAsASurvivor.props.timestamps = Timestamps.create( + toBeDesignatedAsASurvivor.props.timestamps.createdAt, + this.timer.getTimestampInMicroseconds(), + ).getValue() + + await this.sharedVaultUserRepository.save(toBeDesignatedAsASurvivor) + + await this.domainEventPublisher.publish( + this.domainEventFactory.createUserDesignatedAsSurvivorInSharedVaultEvent({ + sharedVaultUuid: sharedVaultUuid.value, + userUuid: userUuid.value, + timestamp: this.timer.getTimestampInMicroseconds(), + }), + ) + + return Result.ok() + } +} diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivorDTO.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivorDTO.ts new file mode 100644 index 000000000..5b89d6106 --- /dev/null +++ b/packages/syncing-server/src/Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivorDTO.ts @@ -0,0 +1,5 @@ +export interface DesignateSurvivorDTO { + sharedVaultUuid: string + userUuid: string + originatorUuid: string +} diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaultUsers/GetSharedVaultUsers.spec.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaultUsers/GetSharedVaultUsers.spec.ts index cd0346328..bd87c714e 100644 --- a/packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaultUsers/GetSharedVaultUsers.spec.ts +++ b/packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaultUsers/GetSharedVaultUsers.spec.ts @@ -25,6 +25,7 @@ describe('GetSharedVaultUsers', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue() sharedVaultRepository = {} as jest.Mocked diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaults/GetSharedVaults.spec.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaults/GetSharedVaults.spec.ts index 70180e606..18cb4be05 100644 --- a/packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaults/GetSharedVaults.spec.ts +++ b/packages/syncing-server/src/Domain/UseCase/SharedVaults/GetSharedVaults/GetSharedVaults.spec.ts @@ -19,6 +19,7 @@ describe('GetSharedVaults', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Admin).getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue() sharedVaultUserRepository = {} as jest.Mocked sharedVaultUserRepository.findByUserUuid = jest.fn().mockResolvedValue([sharedVaultUser]) diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/InviteUserToSharedVault/InviteUserToSharedVault.spec.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/InviteUserToSharedVault/InviteUserToSharedVault.spec.ts index 56ccb5ee5..6adc883f5 100644 --- a/packages/syncing-server/src/Domain/UseCase/SharedVaults/InviteUserToSharedVault/InviteUserToSharedVault.spec.ts +++ b/packages/syncing-server/src/Domain/UseCase/SharedVaults/InviteUserToSharedVault/InviteUserToSharedVault.spec.ts @@ -53,6 +53,7 @@ describe('InviteUserToSharedVault', () => { userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), permission: SharedVaultUserPermission.create(SharedVaultUserPermission.PERMISSIONS.Read).getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue() sharedVaultUserRepository = {} as jest.Mocked diff --git a/packages/syncing-server/src/Domain/UseCase/SharedVaults/RemoveUserFromSharedVault/RemoveUserFromSharedVault.spec.ts b/packages/syncing-server/src/Domain/UseCase/SharedVaults/RemoveUserFromSharedVault/RemoveUserFromSharedVault.spec.ts index f6ab2d6f3..18c643bc0 100644 --- a/packages/syncing-server/src/Domain/UseCase/SharedVaults/RemoveUserFromSharedVault/RemoveUserFromSharedVault.spec.ts +++ b/packages/syncing-server/src/Domain/UseCase/SharedVaults/RemoveUserFromSharedVault/RemoveUserFromSharedVault.spec.ts @@ -51,6 +51,7 @@ describe('RemoveUserFromSharedVault', () => { sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(), timestamps: Timestamps.create(123, 123).getValue(), + isDesignatedSurvivor: false, }).getValue() sharedVaultUserRepository = {} as jest.Mocked sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser) diff --git a/packages/syncing-server/src/Infra/InversifyExpressUtils/AnnotatedSharedVaultUsersController.ts b/packages/syncing-server/src/Infra/InversifyExpressUtils/AnnotatedSharedVaultUsersController.ts index a4fbd3953..42ef1258e 100644 --- a/packages/syncing-server/src/Infra/InversifyExpressUtils/AnnotatedSharedVaultUsersController.ts +++ b/packages/syncing-server/src/Infra/InversifyExpressUtils/AnnotatedSharedVaultUsersController.ts @@ -1,4 +1,4 @@ -import { controller, httpDelete, httpGet, results } from 'inversify-express-utils' +import { controller, httpDelete, httpGet, httpPost, results } from 'inversify-express-utils' import { inject } from 'inversify' import { MapperInterface, SharedVaultUser } from '@standardnotes/domain-core' import { Request, Response } from 'express' @@ -8,16 +8,23 @@ import TYPES from '../../Bootstrap/Types' import { SharedVaultUserHttpRepresentation } from '../../Mapping/Http/SharedVaultUserHttpRepresentation' import { GetSharedVaultUsers } from '../../Domain/UseCase/SharedVaults/GetSharedVaultUsers/GetSharedVaultUsers' import { RemoveUserFromSharedVault } from '../../Domain/UseCase/SharedVaults/RemoveUserFromSharedVault/RemoveUserFromSharedVault' +import { DesignateSurvivor } from '../../Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor' @controller('/shared-vaults/:sharedVaultUuid/users', TYPES.Sync_AuthMiddleware) export class AnnotatedSharedVaultUsersController extends BaseSharedVaultUsersController { constructor( @inject(TYPES.Sync_GetSharedVaultUsers) override getSharedVaultUsersUseCase: GetSharedVaultUsers, @inject(TYPES.Sync_RemoveSharedVaultUser) override removeUserFromSharedVaultUseCase: RemoveUserFromSharedVault, + @inject(TYPES.Sync_DesignateSurvivor) override designateSurvivorUseCase: DesignateSurvivor, @inject(TYPES.Sync_SharedVaultUserHttpMapper) override sharedVaultUserHttpMapper: MapperInterface, ) { - super(getSharedVaultUsersUseCase, removeUserFromSharedVaultUseCase, sharedVaultUserHttpMapper) + super( + getSharedVaultUsersUseCase, + removeUserFromSharedVaultUseCase, + designateSurvivorUseCase, + sharedVaultUserHttpMapper, + ) } @httpGet('/') @@ -29,4 +36,9 @@ export class AnnotatedSharedVaultUsersController extends BaseSharedVaultUsersCon override async removeUserFromSharedVault(request: Request, response: Response): Promise { return super.removeUserFromSharedVault(request, response) } + + @httpPost('/:userUuid/designate-survivor') + override async designateSurvivor(request: Request, response: Response): Promise { + return super.designateSurvivor(request, response) + } } diff --git a/packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseSharedVaultUsersController.ts b/packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseSharedVaultUsersController.ts index f79fce974..410c056f1 100644 --- a/packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseSharedVaultUsersController.ts +++ b/packages/syncing-server/src/Infra/InversifyExpressUtils/Base/BaseSharedVaultUsersController.ts @@ -6,11 +6,13 @@ import { ControllerContainerInterface, MapperInterface, SharedVaultUser } from ' import { SharedVaultUserHttpRepresentation } from '../../../Mapping/Http/SharedVaultUserHttpRepresentation' import { GetSharedVaultUsers } from '../../../Domain/UseCase/SharedVaults/GetSharedVaultUsers/GetSharedVaultUsers' import { RemoveUserFromSharedVault } from '../../../Domain/UseCase/SharedVaults/RemoveUserFromSharedVault/RemoveUserFromSharedVault' +import { DesignateSurvivor } from '../../../Domain/UseCase/SharedVaults/DesignateSurvivor/DesignateSurvivor' export class BaseSharedVaultUsersController extends BaseHttpController { constructor( protected getSharedVaultUsersUseCase: GetSharedVaultUsers, protected removeUserFromSharedVaultUseCase: RemoveUserFromSharedVault, + protected designateSurvivorUseCase: DesignateSurvivor, protected sharedVaultUserHttpMapper: MapperInterface, private controllerContainer?: ControllerContainerInterface, ) { @@ -22,6 +24,7 @@ export class BaseSharedVaultUsersController extends BaseHttpController { 'sync.shared-vault-users.remove-user', this.removeUserFromSharedVault.bind(this), ) + this.controllerContainer.register('sync.shared-vault-users.designate-survivor', this.designateSurvivor.bind(this)) } } @@ -71,4 +74,27 @@ export class BaseSharedVaultUsersController extends BaseHttpController { success: true, }) } + + async designateSurvivor(request: Request, response: Response): Promise { + const result = await this.designateSurvivorUseCase.execute({ + sharedVaultUuid: request.params.sharedVaultUuid, + userUuid: request.params.userUuid, + originatorUuid: response.locals.user.uuid, + }) + + if (result.isFailed()) { + return this.json( + { + error: { + message: result.getError(), + }, + }, + HttpStatusCode.BadRequest, + ) + } + + return this.json({ + success: true, + }) + } } diff --git a/packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultUser.ts b/packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultUser.ts index b442cf11a..d9ef79529 100644 --- a/packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultUser.ts +++ b/packages/syncing-server/src/Infra/TypeORM/TypeORMSharedVaultUser.ts @@ -26,6 +26,13 @@ export class TypeORMSharedVaultUser { }) declare permission: string + @Column({ + name: 'is_designated_survivor', + type: 'boolean', + default: false, + }) + declare isDesignatedSurvivor: boolean + @Column({ name: 'created_at_timestamp', type: 'bigint', diff --git a/packages/syncing-server/src/Mapping/Http/SharedVaultUserHttpMapper.ts b/packages/syncing-server/src/Mapping/Http/SharedVaultUserHttpMapper.ts index 34d84dbbe..9d68f4559 100644 --- a/packages/syncing-server/src/Mapping/Http/SharedVaultUserHttpMapper.ts +++ b/packages/syncing-server/src/Mapping/Http/SharedVaultUserHttpMapper.ts @@ -13,6 +13,7 @@ export class SharedVaultUserHttpMapper implements MapperInterface