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
This commit is contained in:
Karol Sójko
2023-09-21 12:26:08 +02:00
committed by GitHub
parent e2696fcd1a
commit 230c96dcf1
48 changed files with 847 additions and 3 deletions

View File

@@ -3,6 +3,8 @@ module.exports = {
testEnvironment: 'node',
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.ts$',
testTimeout: 20000,
coverageReporters: ['text-summary'],
reporters: ['summary'],
coverageThreshold: {
global: {
branches: 100,

View File

@@ -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<void> {
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,
)
}
}

View File

@@ -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 {

Binary file not shown.

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddDesignatedSurvivor1695283870612 implements MigrationInterface {
name = 'AddDesignatedSurvivor1695283870612'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE `auth_shared_vault_users` ADD `is_designated_survivor` tinyint NOT NULL DEFAULT 0',
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `auth_shared_vault_users` DROP COLUMN `is_designated_survivor`')
}
}

View File

@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddDesignatedSurvivor1695283961201 implements MigrationInterface {
name = 'AddDesignatedSurvivor1695283961201'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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") ',
)
}
}

View File

@@ -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<SharedVaultUserRepositoryInterface>(TYPES.Auth_SharedVaultUserRepository),
),
)
container
.bind<DesignateSurvivor>(TYPES.Auth_DesignateSurvivor)
.toConstantValue(
new DesignateSurvivor(
container.get<SharedVaultUserRepositoryInterface>(TYPES.Auth_SharedVaultUserRepository),
container.get<TimerInterface>(TYPES.Auth_Timer),
),
)
// Controller
container
@@ -1122,6 +1132,16 @@ export class ContainerConfigLoader {
container.get<winston.Logger>(TYPES.Auth_Logger),
),
)
container
.bind<UserDesignatedAsSurvivorInSharedVaultEventHandler>(
TYPES.Auth_UserDesignatedAsSurvivorInSharedVaultEventHandler,
)
.toConstantValue(
new UserDesignatedAsSurvivorInSharedVaultEventHandler(
container.get<DesignateSurvivor>(TYPES.Auth_DesignateSurvivor),
container.get<winston.Logger>(TYPES.Auth_Logger),
),
)
const eventHandlers: Map<string, DomainEventHandlerInterface> = 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) {

View File

@@ -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'),

View File

@@ -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<void> {
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()}`,
)
}
}
}

View File

@@ -3,6 +3,7 @@ import { SharedVaultUser, Uuid } from '@standardnotes/domain-core'
export interface SharedVaultUserRepositoryInterface {
findByUserUuidAndSharedVaultUuid(dto: { userUuid: Uuid; sharedVaultUuid: Uuid }): Promise<SharedVaultUser | null>
findByUserUuid(userUuid: Uuid): Promise<SharedVaultUser[]>
findDesignatedSurvivorBySharedVaultUuid(sharedVaultUuid: Uuid): Promise<SharedVaultUser | null>
save(sharedVaultUser: SharedVaultUser): Promise<void>
remove(sharedVault: SharedVaultUser): Promise<void>
}

View File

@@ -43,6 +43,7 @@ export class AddSharedVaultUser implements UseCaseInterface<void> {
sharedVaultUuid,
permission,
timestamps,
isDesignatedSurvivor: false,
})
if (sharedVaultUserOrError.isFailed()) {
return Result.fail(sharedVaultUserOrError.getError())

View File

@@ -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(),
])
})

View File

@@ -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<TimerInterface>
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<SharedVaultUserRepositoryInterface>
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)
})
})

View File

@@ -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<void> {
constructor(
private sharedVaultUserRepository: SharedVaultUserRepositoryInterface,
private timer: TimerInterface,
) {}
async execute(dto: DesignateSurvivorDTO): Promise<Result<void>> {
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()
}
}

View File

@@ -0,0 +1,5 @@
export interface DesignateSurvivorDTO {
sharedVaultUuid: string
userUuid: string
timestamp: number
}

View File

@@ -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',

View File

@@ -10,6 +10,24 @@ export class TypeORMSharedVaultUserRepository implements SharedVaultUserReposito
private mapper: MapperInterface<SharedVaultUser, TypeORMSharedVaultUser>,
) {}
async findDesignatedSurvivorBySharedVaultUuid(sharedVaultUuid: Uuid): Promise<SharedVaultUser | null> {
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<SharedVaultUser[]> {
const persistence = await this.ormRepository
.createQueryBuilder('shared_vault_user')

View File

@@ -41,6 +41,7 @@ export class SharedVaultUserPersistenceMapper implements MapperInterface<SharedV
sharedVaultUuid,
permission,
timestamps,
isDesignatedSurvivor: !!projection.isDesignatedSurvivor,
},
new UniqueEntityId(projection.uuid),
)
@@ -61,6 +62,7 @@ export class SharedVaultUserPersistenceMapper implements MapperInterface<SharedV
typeorm.permission = domain.props.permission.value
typeorm.createdAtTimestamp = domain.props.timestamps.createdAt
typeorm.updatedAtTimestamp = domain.props.timestamps.updatedAt
typeorm.isDesignatedSurvivor = !!domain.props.isDesignatedSurvivor
return typeorm
}

View File

@@ -10,6 +10,7 @@ describe('SharedVaultUser', () => {
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()

View File

@@ -6,5 +6,6 @@ export interface SharedVaultUserProps {
sharedVaultUuid: Uuid
userUuid: Uuid
permission: SharedVaultUserPermission
isDesignatedSurvivor: boolean
timestamps: Timestamps
}

View File

@@ -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
}

View File

@@ -0,0 +1,5 @@
export interface UserDesignatedAsSurvivorInSharedVaultEventPayload {
userUuid: string
sharedVaultUuid: string
timestamp: number
}

View File

@@ -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'

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddDesignatedSurvivor1695284084365 implements MigrationInterface {
name = 'AddDesignatedSurvivor1695284084365'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `shared_vault_users` ADD `is_designated_survivor` tinyint NOT NULL DEFAULT 0')
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `shared_vault_users` DROP COLUMN `is_designated_survivor`')
}
}

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddDesignatedSurvivor1695284084365 implements MigrationInterface {
name = 'AddDesignatedSurvivor1695284084365'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `shared_vault_users` ADD `is_designated_survivor` tinyint NOT NULL DEFAULT 0')
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `shared_vault_users` DROP COLUMN `is_designated_survivor`')
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class AddDesignatedSurvivor1695284249461 implements MigrationInterface {
name = 'AddDesignatedSurvivor1695284249461'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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") ')
}
}

View File

@@ -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<ItemRepositoryInterface>(TYPES.Sync_SQLItemRepository),
),
)
container
.bind<DesignateSurvivor>(TYPES.Sync_DesignateSurvivor)
.toConstantValue(
new DesignateSurvivor(
container.get<SharedVaultUserRepositoryInterface>(TYPES.Sync_SharedVaultUserRepository),
container.get<TimerInterface>(TYPES.Sync_Timer),
container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
),
)
// Services
container

View File

@@ -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'),

View File

@@ -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',

View File

@@ -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
}

View File

@@ -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<DetermineSharedVaultOperationOnItem>
@@ -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({

View File

@@ -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<SharedVaultUserRepositoryInterface>

View File

@@ -63,6 +63,7 @@ export class AddUserToSharedVault implements UseCaseInterface<SharedVaultUser> {
sharedVaultUuid,
permission,
timestamps,
isDesignatedSurvivor: false,
})
if (sharedVaultUserOrError.isFailed()) {
return Result.fail(sharedVaultUserOrError.getError())

View File

@@ -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<SharedVaultUserRepositoryInterface>
@@ -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(),
)

View File

@@ -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<SharedVaultUserRepositoryInterface>
sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockResolvedValue([sharedVaultUser])

View File

@@ -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<TimerInterface>
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<SharedVaultUserRepositoryInterface>
sharedVaultUserRepository.findBySharedVaultUuid = jest.fn().mockReturnValue([])
sharedVaultUserRepository.save = jest.fn()
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createUserDesignatedAsSurvivorInSharedVaultEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<DomainEventInterface>)
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
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)
})
})

View File

@@ -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<void> {
constructor(
private sharedVaultUserRepository: SharedVaultUserRepositoryInterface,
private timer: TimerInterface,
private domainEventFactory: DomainEventFactoryInterface,
private domainEventPublisher: DomainEventPublisherInterface,
) {}
async execute(dto: DesignateSurvivorDTO): Promise<Result<void>> {
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()
}
}

View File

@@ -0,0 +1,5 @@
export interface DesignateSurvivorDTO {
sharedVaultUuid: string
userUuid: string
originatorUuid: string
}

View File

@@ -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<SharedVaultRepositoryInterface>

View File

@@ -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<SharedVaultUserRepositoryInterface>
sharedVaultUserRepository.findByUserUuid = jest.fn().mockResolvedValue([sharedVaultUser])

View File

@@ -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<SharedVaultUserRepositoryInterface>

View File

@@ -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<SharedVaultUserRepositoryInterface>
sharedVaultUserRepository.findByUserUuidAndSharedVaultUuid = jest.fn().mockResolvedValue(sharedVaultUser)

View File

@@ -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<SharedVaultUser, SharedVaultUserHttpRepresentation>,
) {
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<results.JsonResult> {
return super.removeUserFromSharedVault(request, response)
}
@httpPost('/:userUuid/designate-survivor')
override async designateSurvivor(request: Request, response: Response): Promise<results.JsonResult> {
return super.designateSurvivor(request, response)
}
}

View File

@@ -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<SharedVaultUser, SharedVaultUserHttpRepresentation>,
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<results.JsonResult> {
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,
})
}
}

View File

@@ -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',

View File

@@ -13,6 +13,7 @@ export class SharedVaultUserHttpMapper implements MapperInterface<SharedVaultUse
user_uuid: domain.props.userUuid.value,
permission: domain.props.permission.value,
shared_vault_uuid: domain.props.sharedVaultUuid.value,
is_designated_survivor: domain.props.isDesignatedSurvivor,
created_at_timestamp: domain.props.timestamps.createdAt,
updated_at_timestamp: domain.props.timestamps.updatedAt,
}

View File

@@ -3,6 +3,7 @@ export interface SharedVaultUserHttpRepresentation {
shared_vault_uuid: string
user_uuid: string
permission: string
is_designated_survivor: boolean
created_at_timestamp: number
updated_at_timestamp: number
}

View File

@@ -41,6 +41,7 @@ export class SharedVaultUserPersistenceMapper implements MapperInterface<SharedV
sharedVaultUuid,
permission,
timestamps,
isDesignatedSurvivor: !!projection.isDesignatedSurvivor,
},
new UniqueEntityId(projection.uuid),
)
@@ -61,6 +62,7 @@ export class SharedVaultUserPersistenceMapper implements MapperInterface<SharedV
typeorm.permission = domain.props.permission.value
typeorm.createdAtTimestamp = domain.props.timestamps.createdAt
typeorm.updatedAtTimestamp = domain.props.timestamps.updatedAt
typeorm.isDesignatedSurvivor = !!domain.props.isDesignatedSurvivor
return typeorm
}