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 3761bb747..000000000 Binary files a/packages/auth/database.sqlite and /dev/null differ 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