feat: add verifiying if user has no items before mass deleting spam accounts (#936)

This commit is contained in:
Karol Sójko 2023-11-22 11:00:56 +01:00 committed by GitHub
parent 4d12566b0d
commit c11abe1bd3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 243 additions and 19 deletions

View file

@ -280,6 +280,7 @@ import { TriggerEmailBackupForAllUsers } from '../Domain/UseCase/TriggerEmailBac
import { CSVFileReaderInterface } from '../Domain/CSV/CSVFileReaderInterface'
import { S3CsvFileReader } from '../Infra/S3/S3CsvFileReader'
import { DeleteAccountsFromCSVFile } from '../Domain/UseCase/DeleteAccountsFromCSVFile/DeleteAccountsFromCSVFile'
import { AccountDeletionVerificationPassedEventHandler } from '../Domain/Handler/AccountDeletionVerificationPassedEventHandler'
export class ContainerConfigLoader {
constructor(private mode: 'server' | 'worker' = 'server') {}
@ -1274,7 +1275,9 @@ export class ContainerConfigLoader {
.toConstantValue(
new DeleteAccountsFromCSVFile(
container.get<CSVFileReaderInterface>(TYPES.Auth_CSVFileReader),
container.get<DeleteAccount>(TYPES.Auth_DeleteAccount),
container.get<DomainEventPublisherInterface>(TYPES.Auth_DomainEventPublisher),
container.get<DomainEventFactoryInterface>(TYPES.Auth_DomainEventFactory),
container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository),
container.get<winston.Logger>(TYPES.Auth_Logger),
),
)
@ -1328,6 +1331,14 @@ export class ContainerConfigLoader {
container.get<winston.Logger>(TYPES.Auth_Logger),
),
)
container
.bind<AccountDeletionVerificationPassedEventHandler>(TYPES.Auth_AccountDeletionVerificationPassedEventHandler)
.toConstantValue(
new AccountDeletionVerificationPassedEventHandler(
container.get<DeleteAccount>(TYPES.Auth_DeleteAccount),
container.get<winston.Logger>(TYPES.Auth_Logger),
),
)
container
.bind<SubscriptionPurchasedEventHandler>(TYPES.Auth_SubscriptionPurchasedEventHandler)
.toConstantValue(
@ -1516,6 +1527,7 @@ export class ContainerConfigLoader {
const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
['ACCOUNT_DELETION_REQUESTED', container.get(TYPES.Auth_AccountDeletionRequestedEventHandler)],
['ACCOUNT_DELETION_VERIFICATION_PASSED', container.get(TYPES.Auth_AccountDeletionVerificationPassedEventHandler)],
['SUBSCRIPTION_PURCHASED', container.get(TYPES.Auth_SubscriptionPurchasedEventHandler)],
['SUBSCRIPTION_CANCELLED', container.get(TYPES.Auth_SubscriptionCancelledEventHandler)],
['SUBSCRIPTION_RENEWED', container.get(TYPES.Auth_SubscriptionRenewedEventHandler)],

View file

@ -171,6 +171,7 @@ const TYPES = {
Auth_DeleteAccountsFromCSVFile: Symbol.for('Auth_DeleteAccountsFromCSVFile'),
// Handlers
Auth_AccountDeletionRequestedEventHandler: Symbol.for('Auth_AccountDeletionRequestedEventHandler'),
Auth_AccountDeletionVerificationPassedEventHandler: Symbol.for('Auth_AccountDeletionVerificationPassedEventHandler'),
Auth_SubscriptionPurchasedEventHandler: Symbol.for('Auth_SubscriptionPurchasedEventHandler'),
Auth_SubscriptionCancelledEventHandler: Symbol.for('Auth_SubscriptionCancelledEventHandler'),
Auth_SubscriptionReassignedEventHandler: Symbol.for('Auth_SubscriptionReassignedEventHandler'),

View file

@ -20,6 +20,7 @@ import {
StatisticPersistenceRequestedEvent,
SessionCreatedEvent,
SessionRefreshedEvent,
AccountDeletionVerificationRequestedEvent,
} from '@standardnotes/domain-events'
import { Predicate, PredicateVerificationResult } from '@standardnotes/predicates'
import { TimerInterface } from '@standardnotes/time'
@ -33,6 +34,24 @@ import { KeyParamsData } from '@standardnotes/responses'
export class DomainEventFactory implements DomainEventFactoryInterface {
constructor(@inject(TYPES.Auth_Timer) private timer: TimerInterface) {}
createAccountDeletionVerificationRequestedEvent(dto: {
userUuid: string
email: string
}): AccountDeletionVerificationRequestedEvent {
return {
type: 'ACCOUNT_DELETION_VERIFICATION_REQUESTED',
createdAt: this.timer.getUTCDate(),
meta: {
correlation: {
userIdentifier: dto.userUuid,
userIdentifierType: 'uuid',
},
origin: DomainEventService.Auth,
},
payload: dto,
}
}
createSessionCreatedEvent(dto: { userUuid: string }): SessionCreatedEvent {
return {
type: 'SESSION_CREATED',

View file

@ -18,6 +18,7 @@ import {
StatisticPersistenceRequestedEvent,
SessionCreatedEvent,
SessionRefreshedEvent,
AccountDeletionVerificationRequestedEvent,
} from '@standardnotes/domain-events'
import { InviteeIdentifierType } from '../SharedSubscription/InviteeIdentifierType'
import { KeyParamsData } from '@standardnotes/responses'
@ -56,6 +57,10 @@ export interface DomainEventFactoryInterface {
ownerUuid: string
}
}): AccountDeletionRequestedEvent
createAccountDeletionVerificationRequestedEvent(dto: {
userUuid: string
email: string
}): AccountDeletionVerificationRequestedEvent
createUserRolesChangedEvent(userUuid: string, email: string, currentRoles: string[]): UserRolesChangedEvent
createUserEmailChangedEvent(userUuid: string, fromEmail: string, toEmail: string): UserEmailChangedEvent
createUserDisabledSessionUserAgentLoggingEvent(dto: {

View file

@ -0,0 +1,21 @@
import { AccountDeletionVerificationPassedEvent, DomainEventHandlerInterface } from '@standardnotes/domain-events'
import { Logger } from 'winston'
import { DeleteAccount } from '../UseCase/DeleteAccount/DeleteAccount'
export class AccountDeletionVerificationPassedEventHandler implements DomainEventHandlerInterface {
constructor(
private deleteAccount: DeleteAccount,
private logger: Logger,
) {}
async handle(event: AccountDeletionVerificationPassedEvent): Promise<void> {
const result = await this.deleteAccount.execute({
userUuid: event.payload.userUuid,
})
if (result.isFailed()) {
this.logger.error(`AccountDeletionVerificationPassedEventHandler failed: ${result.getError()}`)
}
}
}

View file

@ -1,33 +1,51 @@
import { Logger } from 'winston'
import { Result } from '@standardnotes/domain-core'
import { AccountDeletionVerificationRequestedEvent, DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { CSVFileReaderInterface } from '../../CSV/CSVFileReaderInterface'
import { DeleteAccount } from '../DeleteAccount/DeleteAccount'
import { DeleteAccountsFromCSVFile } from './DeleteAccountsFromCSVFile'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
import { User } from '../../User/User'
describe('DeleteAccountsFromCSVFile', () => {
let csvFileReader: CSVFileReaderInterface
let deleteAccount: DeleteAccount
let userRepository: UserRepositoryInterface
let domainEventPublisher: DomainEventPublisherInterface
let domainEventFactory: DomainEventFactoryInterface
let logger: Logger
const createUseCase = () => new DeleteAccountsFromCSVFile(csvFileReader, deleteAccount, logger)
const createUseCase = () =>
new DeleteAccountsFromCSVFile(csvFileReader, domainEventPublisher, domainEventFactory, userRepository, logger)
beforeEach(() => {
const user = {} as jest.Mocked<User>
csvFileReader = {} as jest.Mocked<CSVFileReaderInterface>
csvFileReader.getValues = jest.fn().mockResolvedValue(Result.ok(['email1']))
deleteAccount = {} as jest.Mocked<DeleteAccount>
deleteAccount.execute = jest.fn().mockResolvedValue(Result.ok(''))
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findAllByUsernameOrEmail = jest.fn().mockResolvedValue([user])
domainEventPublisher = {} as jest.Mocked<DomainEventPublisherInterface>
domainEventPublisher.publish = jest.fn()
domainEventFactory = {} as jest.Mocked<DomainEventFactoryInterface>
domainEventFactory.createAccountDeletionVerificationRequestedEvent = jest
.fn()
.mockReturnValue({} as jest.Mocked<AccountDeletionVerificationRequestedEvent>)
logger = {} as jest.Mocked<Logger>
logger.info = jest.fn()
})
it('should delete accounts', async () => {
it('should request account deletion verification', async () => {
const useCase = createUseCase()
const result = await useCase.execute({ fileName: 'test.csv', dryRun: false })
expect(domainEventPublisher.publish).toHaveBeenCalled()
expect(result.isFailed()).toBeFalsy()
})
@ -56,12 +74,12 @@ describe('DeleteAccountsFromCSVFile', () => {
const result = await useCase.execute({ fileName: 'test.csv', dryRun: true })
expect(deleteAccount.execute).not.toHaveBeenCalled()
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
expect(result.isFailed()).toBeFalsy()
})
it('should return error if delete account fails', async () => {
deleteAccount.execute = jest.fn().mockResolvedValue(Result.fail('Oops'))
it('should return error username is invalid', async () => {
csvFileReader.getValues = jest.fn().mockResolvedValue(Result.ok(['']))
const useCase = createUseCase()
@ -69,4 +87,15 @@ describe('DeleteAccountsFromCSVFile', () => {
expect(result.isFailed()).toBeTruthy()
})
it('should do nothing if users could not be found', async () => {
userRepository.findAllByUsernameOrEmail = jest.fn().mockResolvedValue([])
const useCase = createUseCase()
const result = await useCase.execute({ fileName: 'test.csv', dryRun: false })
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
expect(result.isFailed()).toBeFalsy()
})
})

View file

@ -1,14 +1,18 @@
import { Result, UseCaseInterface } from '@standardnotes/domain-core'
import { Result, UseCaseInterface, Username } from '@standardnotes/domain-core'
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { Logger } from 'winston'
import { DeleteAccount } from '../DeleteAccount/DeleteAccount'
import { CSVFileReaderInterface } from '../../CSV/CSVFileReaderInterface'
import { DeleteAccountsFromCSVFileDTO } from './DeleteAccountsFromCSVFileDTO'
import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
export class DeleteAccountsFromCSVFile implements UseCaseInterface<void> {
constructor(
private csvFileReader: CSVFileReaderInterface,
private deleteAccount: DeleteAccount,
private domainEventPublisher: DomainEventPublisherInterface,
private domainEventFactory: DomainEventFactoryInterface,
private userRepository: UserRepositoryInterface,
private logger: Logger,
) {}
@ -33,12 +37,20 @@ export class DeleteAccountsFromCSVFile implements UseCaseInterface<void> {
}
for (const email of emails) {
const deleteAccountOrError = await this.deleteAccount.execute({
username: email,
})
const usernameOrError = Username.create(email)
if (usernameOrError.isFailed()) {
return Result.fail(usernameOrError.getError())
}
const username = usernameOrError.getValue()
if (deleteAccountOrError.isFailed()) {
return Result.fail(deleteAccountOrError.getError())
const users = await this.userRepository.findAllByUsernameOrEmail(username)
for (const user of users) {
await this.domainEventPublisher.publish(
this.domainEventFactory.createAccountDeletionVerificationRequestedEvent({
userUuid: user.uuid,
email: user.email,
}),
)
}
}

View file

@ -8,6 +8,7 @@ export interface UserRepositoryInterface {
streamTeam(memberEmail?: Email): Promise<ReadStream>
findOneByUuid(uuid: Uuid): Promise<User | null>
findOneByUsernameOrEmail(usernameOrEmail: Email | Username): Promise<User | null>
findAllByUsernameOrEmail(usernameOrEmail: Email | Username): Promise<User[]>
findAllCreatedBetween(dto: { start: Date; end: Date; offset: number; limit: number }): Promise<User[]>
countAllCreatedBetween(start: Date, end: Date): Promise<number>
save(user: User): Promise<User>

View file

@ -69,7 +69,13 @@ export class TypeORMUserRepository implements UserRepositoryInterface {
return this.ormRepository
.createQueryBuilder('user')
.where('user.email = :email', { email: usernameOrEmail.value })
.cache(`user_email_${usernameOrEmail.value}`, 60000)
.getOne()
}
async findAllByUsernameOrEmail(usernameOrEmail: Email | Username): Promise<User[]> {
return this.ormRepository
.createQueryBuilder('user')
.where('user.email = :email', { email: usernameOrEmail.value })
.getMany()
}
}

View file

@ -0,0 +1,7 @@
import { DomainEventInterface } from './DomainEventInterface'
import { AccountDeletionVerificationPassedEventPayload } from './AccountDeletionVerificationPassedEventPayload'
export interface AccountDeletionVerificationPassedEvent extends DomainEventInterface {
type: 'ACCOUNT_DELETION_VERIFICATION_PASSED'
payload: AccountDeletionVerificationPassedEventPayload
}

View file

@ -0,0 +1,4 @@
export interface AccountDeletionVerificationPassedEventPayload {
userUuid: string
email: string
}

View file

@ -0,0 +1,7 @@
import { DomainEventInterface } from './DomainEventInterface'
import { AccountDeletionVerificationRequestedEventPayload } from './AccountDeletionVerificationRequestedEventPayload'
export interface AccountDeletionVerificationRequestedEvent extends DomainEventInterface {
type: 'ACCOUNT_DELETION_VERIFICATION_REQUESTED'
payload: AccountDeletionVerificationRequestedEventPayload
}

View file

@ -0,0 +1,4 @@
export interface AccountDeletionVerificationRequestedEventPayload {
userUuid: string
email: string
}

View file

@ -1,5 +1,9 @@
export * from './Event/AccountDeletionRequestedEvent'
export * from './Event/AccountDeletionRequestedEventPayload'
export * from './Event/AccountDeletionVerificationPassedEvent'
export * from './Event/AccountDeletionVerificationPassedEventPayload'
export * from './Event/AccountDeletionVerificationRequestedEvent'
export * from './Event/AccountDeletionVerificationRequestedEventPayload'
export * from './Event/DiscountApplyRequestedEvent'
export * from './Event/DiscountApplyRequestedEventPayload'
export * from './Event/DiscountWithdrawRequestedEvent'

View file

@ -160,6 +160,7 @@ import { DumpItem } from '../Domain/UseCase/Syncing/DumpItem/DumpItem'
import { SyncResponse20200115 } from '../Domain/Item/SyncResponse/SyncResponse20200115'
import { SyncResponse } from '@standardnotes/grpc'
import { SyncResponseGRPCMapper } from '../Mapping/gRPC/SyncResponseGRPCMapper'
import { AccountDeletionVerificationRequestedEventHandler } from '../Domain/Handler/AccountDeletionVerificationRequestedEventHandler'
export class ContainerConfigLoader {
private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@ -907,6 +908,18 @@ export class ContainerConfigLoader {
container.get<Logger>(TYPES.Sync_Logger),
),
)
container
.bind<AccountDeletionVerificationRequestedEventHandler>(
TYPES.Sync_AccountDeletionVerificationRequestedEventHandler,
)
.toConstantValue(
new AccountDeletionVerificationRequestedEventHandler(
container.get<ItemRepositoryInterface>(TYPES.Sync_SQLItemRepository),
container.get<DomainEventPublisherInterface>(TYPES.Sync_DomainEventPublisher),
container.get<DomainEventFactoryInterface>(TYPES.Sync_DomainEventFactory),
container.get<Logger>(TYPES.Sync_Logger),
),
)
container
.bind<ItemRevisionCreationRequestedEventHandler>(TYPES.Sync_ItemRevisionCreationRequestedEventHandler)
.toConstantValue(
@ -957,6 +970,10 @@ export class ContainerConfigLoader {
const eventHandlers: Map<string, DomainEventHandlerInterface> = new Map([
['DUPLICATE_ITEM_SYNCED', container.get(TYPES.Sync_DuplicateItemSyncedEventHandler)],
['ACCOUNT_DELETION_REQUESTED', container.get(TYPES.Sync_AccountDeletionRequestedEventHandler)],
[
'ACCOUNT_DELETION_VERIFICATION_REQUESTED',
container.get(TYPES.Sync_AccountDeletionVerificationRequestedEventHandler),
],
['ITEM_REVISION_CREATION_REQUESTED', container.get(TYPES.Sync_ItemRevisionCreationRequestedEventHandler)],
[
'SHARED_VAULT_FILE_UPLOADED',

View file

@ -85,6 +85,9 @@ const TYPES = {
Sync_DumpItem: Symbol.for('Sync_DumpItem'),
// Handlers
Sync_AccountDeletionRequestedEventHandler: Symbol.for('Sync_AccountDeletionRequestedEventHandler'),
Sync_AccountDeletionVerificationRequestedEventHandler: Symbol.for(
'Sync_AccountDeletionVerificationRequestedEventHandler',
),
Sync_DuplicateItemSyncedEventHandler: Symbol.for('Sync_DuplicateItemSyncedEventHandler'),
Sync_EmailBackupRequestedEventHandler: Symbol.for('Sync_EmailBackupRequestedEventHandler'),
Sync_ItemRevisionCreationRequestedEventHandler: Symbol.for('Sync_ItemRevisionCreationRequestedEventHandler'),

View file

@ -1,5 +1,6 @@
/* istanbul ignore file */
import {
AccountDeletionVerificationPassedEvent,
DomainEventService,
DuplicateItemSyncedEvent,
EmailRequestedEvent,
@ -22,6 +23,24 @@ import { DomainEventFactoryInterface } from './DomainEventFactoryInterface'
export class DomainEventFactory implements DomainEventFactoryInterface {
constructor(private timer: TimerInterface) {}
createAccountDeletionVerificationPassedEvent(dto: {
userUuid: string
email: string
}): AccountDeletionVerificationPassedEvent {
return {
type: 'ACCOUNT_DELETION_VERIFICATION_PASSED',
createdAt: this.timer.getUTCDate(),
meta: {
correlation: {
userIdentifier: dto.userUuid,
userIdentifierType: 'uuid',
},
origin: DomainEventService.SyncingServer,
},
payload: dto,
}
}
createUserDesignatedAsSurvivorInSharedVaultEvent(dto: {
sharedVaultUuid: string
userUuid: string

View file

@ -1,4 +1,5 @@
import {
AccountDeletionVerificationPassedEvent,
DuplicateItemSyncedEvent,
EmailRequestedEvent,
ItemDumpedEvent,
@ -93,4 +94,8 @@ export interface DomainEventFactoryInterface {
userUuid: string
timestamp: number
}): UserDesignatedAsSurvivorInSharedVaultEvent
createAccountDeletionVerificationPassedEvent(dto: {
userUuid: string
email: string
}): AccountDeletionVerificationPassedEvent
}

View file

@ -0,0 +1,48 @@
import {
AccountDeletionVerificationRequestedEvent,
DomainEventHandlerInterface,
DomainEventPublisherInterface,
} from '@standardnotes/domain-events'
import { Uuid } from '@standardnotes/domain-core'
import { Logger } from 'winston'
import { ItemRepositoryInterface } from '../Item/ItemRepositoryInterface'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
export class AccountDeletionVerificationRequestedEventHandler implements DomainEventHandlerInterface {
constructor(
private itemRepository: ItemRepositoryInterface,
private domainEventPublisher: DomainEventPublisherInterface,
private domainEventFactory: DomainEventFactoryInterface,
private logger: Logger,
) {}
async handle(event: AccountDeletionVerificationRequestedEvent): Promise<void> {
const userUuidOrError = Uuid.create(event.payload.userUuid)
if (userUuidOrError.isFailed()) {
this.logger.error(`AccountDeletionVerificationRequestedEventHandler failed: ${userUuidOrError.getError()}`)
return
}
const userUuid = userUuidOrError.getValue()
const itemsCount = await this.itemRepository.countAll({
userUuid: userUuid.value,
})
if (itemsCount !== 0) {
this.logger.warn(
`AccountDeletionVerificationRequestedEventHandler: User ${userUuid.value} has ${itemsCount} items and cannot be deleted.`,
)
return
}
await this.domainEventPublisher.publish(
this.domainEventFactory.createAccountDeletionVerificationPassedEvent({
userUuid: userUuid.value,
email: event.payload.email,
}),
)
}
}