fix(auth): adding user roles upon renewal of shared subscription (#1012)

* fix(auth): adding user roles upon renewal of shared subscription

* feat(auth): add procedure to fix roles on shared subscriptions
This commit is contained in:
Karol Sójko 2023-12-29 11:07:51 +01:00 committed by GitHub
parent be7c66b145
commit 26b13ed6d4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 173 additions and 12 deletions

View file

@ -0,0 +1,67 @@
import 'reflect-metadata'
import { Logger } from 'winston'
import * as dayjs from 'dayjs'
import * as utc from 'dayjs/plugin/utc'
import { Uuid } from '@standardnotes/domain-core'
import { ContainerConfigLoader } from '../src/Bootstrap/Container'
import TYPES from '../src/Bootstrap/Types'
import { Env } from '../src/Bootstrap/Env'
import { UserSubscriptionRepositoryInterface } from '../src/Domain/Subscription/UserSubscriptionRepositoryInterface'
import { RoleServiceInterface } from '../src/Domain/Role/RoleServiceInterface'
import { UserSubscriptionType } from '../src/Domain/Subscription/UserSubscriptionType'
import { UserRepositoryInterface } from '../src/Domain/User/UserRepositoryInterface'
const fixRoles = async (
userRepository: UserRepositoryInterface,
userSubscriptionRepository: UserSubscriptionRepositoryInterface,
roleService: RoleServiceInterface,
): Promise<void> => {
const subscriptions = await userSubscriptionRepository.findActiveByType(UserSubscriptionType.Shared)
for (const subscription of subscriptions) {
const userUuidOrError = Uuid.create(subscription.userUuid)
if (userUuidOrError.isFailed()) {
continue
}
const userUuid = userUuidOrError.getValue()
const user = await userRepository.findOneByUuid(userUuid)
if (!user) {
continue
}
await roleService.addUserRoleBasedOnSubscription(user, subscription.planName)
}
}
const container = new ContainerConfigLoader('worker')
void container.load().then((container) => {
dayjs.extend(utc)
const env: Env = new Env()
env.load()
const logger: Logger = container.get(TYPES.Auth_Logger)
logger.info('Starting roles fix for shared subscriptions...')
const userRepository = container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository)
const userSubscriptionRepository = container.get<UserSubscriptionRepositoryInterface>(
TYPES.Auth_UserSubscriptionRepository,
)
const roleService = container.get<RoleServiceInterface>(TYPES.Auth_RoleService)
Promise.resolve(fixRoles(userRepository, userSubscriptionRepository, roleService))
.then(() => {
logger.info('Finished fixing roles for shared subscriptions')
process.exit(0)
})
.catch((error) => {
logger.error(`Error while fixing roles for shared subscriptions: ${(error as Error).message}`)
process.exit(1)
})
})

View file

@ -0,0 +1,11 @@
'use strict'
const path = require('path')
const pnp = require(path.normalize(path.resolve(__dirname, '../../..', '.pnp.cjs'))).setup()
const index = require(path.normalize(path.resolve(__dirname, '../dist/bin/fix_roles.js')))
Object.defineProperty(exports, '__esModule', { value: true })
exports.default = index

View file

@ -38,6 +38,10 @@ case "$COMMAND" in
exec node docker/entrypoint-fix-quota.js $EMAIL exec node docker/entrypoint-fix-quota.js $EMAIL
;; ;;
'fix-roles' )
exec node docker/entrypoint-fix-roles.js
;;
'delete-accounts' ) 'delete-accounts' )
FILE_NAME=$1 && shift 1 FILE_NAME=$1 && shift 1
MODE=$1 && shift 1 MODE=$1 && shift 1

View file

@ -1284,6 +1284,7 @@ export class ContainerConfigLoader {
), ),
container.get<UserSubscriptionRepositoryInterface>(TYPES.Auth_UserSubscriptionRepository), container.get<UserSubscriptionRepositoryInterface>(TYPES.Auth_UserSubscriptionRepository),
container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository), container.get<UserRepositoryInterface>(TYPES.Auth_UserRepository),
container.get<RoleServiceInterface>(TYPES.Auth_RoleService),
container.get<winston.Logger>(TYPES.Auth_Logger), container.get<winston.Logger>(TYPES.Auth_Logger),
), ),
) )

View file

@ -10,6 +10,7 @@ export interface UserSubscriptionRepositoryInterface {
findByUserUuid(userUuid: string): Promise<UserSubscription[]> findByUserUuid(userUuid: string): Promise<UserSubscription[]>
countByPlanName(planNames: SubscriptionPlanName[]): Promise<number> countByPlanName(planNames: SubscriptionPlanName[]): Promise<number>
findByPlanName(planNames: SubscriptionPlanName[], offset: number, limit: number): Promise<UserSubscription[]> findByPlanName(planNames: SubscriptionPlanName[], offset: number, limit: number): Promise<UserSubscription[]>
findActiveByType(type: UserSubscriptionType): Promise<UserSubscription[]>
findOneByUserUuidAndSubscriptionId(userUuid: string, subscriptionId: number): Promise<UserSubscription | null> findOneByUserUuidAndSubscriptionId(userUuid: string, subscriptionId: number): Promise<UserSubscription | null>
findBySubscriptionIdAndType(subscriptionId: number, type: UserSubscriptionType): Promise<UserSubscription[]> findBySubscriptionIdAndType(subscriptionId: number, type: UserSubscriptionType): Promise<UserSubscription[]>
findBySubscriptionId(subscriptionId: number): Promise<UserSubscription[]> findBySubscriptionId(subscriptionId: number): Promise<UserSubscription[]>

View file

@ -8,6 +8,7 @@ import { SharedSubscriptionInvitation } from '../../SharedSubscription/SharedSub
import { InviteeIdentifierType } from '../../SharedSubscription/InviteeIdentifierType' import { InviteeIdentifierType } from '../../SharedSubscription/InviteeIdentifierType'
import { User } from '../../User/User' import { User } from '../../User/User'
import { InvitationStatus } from '../../SharedSubscription/InvitationStatus' import { InvitationStatus } from '../../SharedSubscription/InvitationStatus'
import { RoleServiceInterface } from '../../Role/RoleServiceInterface'
describe('RenewSharedSubscriptions', () => { describe('RenewSharedSubscriptions', () => {
let listSharedSubscriptionInvitations: ListSharedSubscriptionInvitations let listSharedSubscriptionInvitations: ListSharedSubscriptionInvitations
@ -17,6 +18,7 @@ describe('RenewSharedSubscriptions', () => {
let logger: Logger let logger: Logger
let sharedSubscriptionInvitation: SharedSubscriptionInvitation let sharedSubscriptionInvitation: SharedSubscriptionInvitation
let user: User let user: User
let roleService: RoleServiceInterface
const createUseCase = () => const createUseCase = () =>
new RenewSharedSubscriptions( new RenewSharedSubscriptions(
@ -24,6 +26,7 @@ describe('RenewSharedSubscriptions', () => {
sharedSubscriptionInvitationRepository, sharedSubscriptionInvitationRepository,
userSubscriptionRepository, userSubscriptionRepository,
userRepository, userRepository,
roleService,
logger, logger,
) )
@ -48,8 +51,12 @@ describe('RenewSharedSubscriptions', () => {
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface> userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
userSubscriptionRepository.save = jest.fn() userSubscriptionRepository.save = jest.fn()
roleService = {} as jest.Mocked<RoleServiceInterface>
roleService.addUserRoleBasedOnSubscription = jest.fn()
userRepository = {} as jest.Mocked<UserRepositoryInterface> userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(user) userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(user)
userRepository.findOneByUuid = jest.fn().mockReturnValue(user)
logger = {} as jest.Mocked<Logger> logger = {} as jest.Mocked<Logger>
logger.error = jest.fn() logger.error = jest.fn()
@ -71,7 +78,7 @@ describe('RenewSharedSubscriptions', () => {
expect(userSubscriptionRepository.save).toBeCalledTimes(1) expect(userSubscriptionRepository.save).toBeCalledTimes(1)
}) })
it('should log error if user not found', async () => { it('should log error if user not found by email', async () => {
userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(null) userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(null)
const useCase = createUseCase() const useCase = createUseCase()
@ -88,6 +95,42 @@ describe('RenewSharedSubscriptions', () => {
expect(logger.error).toBeCalledTimes(1) expect(logger.error).toBeCalledTimes(1)
}) })
it('should log error if user not found by uuid', async () => {
sharedSubscriptionInvitation.inviteeIdentifierType = InviteeIdentifierType.Uuid
sharedSubscriptionInvitation.inviteeIdentifier = '00000000-0000-0000-0000-000000000000'
userRepository.findOneByUuid = jest.fn().mockReturnValue(null)
const useCase = createUseCase()
const result = await useCase.execute({
inviterEmail: 'inviter@test.te',
newSubscriptionId: 123,
newSubscriptionName: 'test',
newSubscriptionExpiresAt: 123,
timestamp: 123,
})
expect(result.isFailed()).toBeFalsy()
expect(logger.error).toBeCalledTimes(1)
})
it('should log error if user not found by unknown identifier type', async () => {
sharedSubscriptionInvitation.inviteeIdentifierType = 'unknown' as InviteeIdentifierType
const useCase = createUseCase()
const result = await useCase.execute({
inviterEmail: 'inviter@test.te',
newSubscriptionId: 123,
newSubscriptionName: 'test',
newSubscriptionExpiresAt: 123,
timestamp: 123,
})
expect(result.isFailed()).toBeFalsy()
expect(logger.error).toBeCalledTimes(1)
})
it('should log error if error occurs', async () => { it('should log error if error occurs', async () => {
userRepository.findOneByUsernameOrEmail = jest.fn().mockImplementation(() => { userRepository.findOneByUsernameOrEmail = jest.fn().mockImplementation(() => {
throw new Error('test') throw new Error('test')
@ -125,6 +168,24 @@ describe('RenewSharedSubscriptions', () => {
expect(logger.error).toBeCalledTimes(1) expect(logger.error).toBeCalledTimes(1)
}) })
it('should log error if uuid is invalid', async () => {
sharedSubscriptionInvitation.inviteeIdentifierType = InviteeIdentifierType.Uuid
sharedSubscriptionInvitation.inviteeIdentifier = 'invalid'
const useCase = createUseCase()
const result = await useCase.execute({
inviterEmail: 'inviter@test.te',
newSubscriptionId: 123,
newSubscriptionName: 'test',
newSubscriptionExpiresAt: 123,
timestamp: 123,
})
expect(result.isFailed()).toBeFalsy()
expect(logger.error).toBeCalledTimes(1)
})
it('should renew shared subscription for invitations by user uuid', async () => { it('should renew shared subscription for invitations by user uuid', async () => {
sharedSubscriptionInvitation.inviteeIdentifierType = InviteeIdentifierType.Uuid sharedSubscriptionInvitation.inviteeIdentifierType = InviteeIdentifierType.Uuid
sharedSubscriptionInvitation.inviteeIdentifier = '00000000-0000-0000-0000-000000000000' sharedSubscriptionInvitation.inviteeIdentifier = '00000000-0000-0000-0000-000000000000'

View file

@ -1,4 +1,4 @@
import { Result, UseCaseInterface, Username } from '@standardnotes/domain-core' import { Result, UseCaseInterface, Username, Uuid } from '@standardnotes/domain-core'
import { Logger } from 'winston' import { Logger } from 'winston'
import { RenewSharedSubscriptionsDTO } from './RenewSharedSubscriptionsDTO' import { RenewSharedSubscriptionsDTO } from './RenewSharedSubscriptionsDTO'
@ -10,6 +10,8 @@ import { UserSubscriptionType } from '../../Subscription/UserSubscriptionType'
import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface' import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { InviteeIdentifierType } from '../../SharedSubscription/InviteeIdentifierType' import { InviteeIdentifierType } from '../../SharedSubscription/InviteeIdentifierType'
import { RoleServiceInterface } from '../../Role/RoleServiceInterface'
import { User } from '../../User/User'
export class RenewSharedSubscriptions implements UseCaseInterface<void> { export class RenewSharedSubscriptions implements UseCaseInterface<void> {
constructor( constructor(
@ -17,6 +19,7 @@ export class RenewSharedSubscriptions implements UseCaseInterface<void> {
private sharedSubscriptionInvitationRepository: SharedSubscriptionInvitationRepositoryInterface, private sharedSubscriptionInvitationRepository: SharedSubscriptionInvitationRepositoryInterface,
private userSubscriptionRepository: UserSubscriptionRepositoryInterface, private userSubscriptionRepository: UserSubscriptionRepositoryInterface,
private userRepository: UserRepositoryInterface, private userRepository: UserRepositoryInterface,
private roleService: RoleServiceInterface,
private logger: Logger, private logger: Logger,
) {} ) {}
@ -31,8 +34,8 @@ export class RenewSharedSubscriptions implements UseCaseInterface<void> {
for (const invitation of acceptedInvitations) { for (const invitation of acceptedInvitations) {
try { try {
const userUuid = await this.getInviteeUserUuid(invitation.inviteeIdentifier, invitation.inviteeIdentifierType) const user = await this.getInviteeUserUuid(invitation.inviteeIdentifier, invitation.inviteeIdentifierType)
if (userUuid === null) { if (user === null) {
this.logger.error( this.logger.error(
`[SUBSCRIPTION: ${dto.newSubscriptionId}] Could not renew shared subscription for invitation: ${invitation.uuid}: Could not find user with identifier: ${invitation.inviteeIdentifier}`, `[SUBSCRIPTION: ${dto.newSubscriptionId}] Could not renew shared subscription for invitation: ${invitation.uuid}: Could not find user with identifier: ${invitation.inviteeIdentifier}`,
) )
@ -42,11 +45,13 @@ export class RenewSharedSubscriptions implements UseCaseInterface<void> {
await this.createSharedSubscription({ await this.createSharedSubscription({
subscriptionId: dto.newSubscriptionId, subscriptionId: dto.newSubscriptionId,
subscriptionName: dto.newSubscriptionName, subscriptionName: dto.newSubscriptionName,
userUuid, userUuid: user.uuid,
timestamp: dto.timestamp, timestamp: dto.timestamp,
subscriptionExpiresAt: dto.newSubscriptionExpiresAt, subscriptionExpiresAt: dto.newSubscriptionExpiresAt,
}) })
await this.roleService.addUserRoleBasedOnSubscription(user, dto.newSubscriptionName)
invitation.subscriptionId = dto.newSubscriptionId invitation.subscriptionId = dto.newSubscriptionId
invitation.updatedAt = dto.timestamp invitation.updatedAt = dto.timestamp
@ -83,7 +88,7 @@ export class RenewSharedSubscriptions implements UseCaseInterface<void> {
return this.userSubscriptionRepository.save(subscription) return this.userSubscriptionRepository.save(subscription)
} }
private async getInviteeUserUuid(inviteeIdentifier: string, inviteeIdentifierType: string): Promise<string | null> { private async getInviteeUserUuid(inviteeIdentifier: string, inviteeIdentifierType: string): Promise<User | null> {
if (inviteeIdentifierType === InviteeIdentifierType.Email) { if (inviteeIdentifierType === InviteeIdentifierType.Email) {
const usernameOrError = Username.create(inviteeIdentifier) const usernameOrError = Username.create(inviteeIdentifier)
if (usernameOrError.isFailed()) { if (usernameOrError.isFailed()) {
@ -91,14 +96,16 @@ export class RenewSharedSubscriptions implements UseCaseInterface<void> {
} }
const username = usernameOrError.getValue() const username = usernameOrError.getValue()
const user = await this.userRepository.findOneByUsernameOrEmail(username) return this.userRepository.findOneByUsernameOrEmail(username)
if (user === null) { } else if (inviteeIdentifierType === InviteeIdentifierType.Uuid) {
const uuidOrError = Uuid.create(inviteeIdentifier)
if (uuidOrError.isFailed()) {
return null return null
} }
const uuid = uuidOrError.getValue()
return user.uuid return this.userRepository.findOneByUuid(uuid)
} }
return inviteeIdentifier return null
} }
} }

View file

@ -1,3 +1,4 @@
import { SubscriptionPlanName } from '@standardnotes/domain-core'
import { TimerInterface } from '@standardnotes/time' import { TimerInterface } from '@standardnotes/time'
import { inject, injectable } from 'inversify' import { inject, injectable } from 'inversify'
import { Repository } from 'typeorm' import { Repository } from 'typeorm'
@ -6,7 +7,6 @@ import TYPES from '../../Bootstrap/Types'
import { UserSubscription } from '../../Domain/Subscription/UserSubscription' import { UserSubscription } from '../../Domain/Subscription/UserSubscription'
import { UserSubscriptionRepositoryInterface } from '../../Domain/Subscription/UserSubscriptionRepositoryInterface' import { UserSubscriptionRepositoryInterface } from '../../Domain/Subscription/UserSubscriptionRepositoryInterface'
import { UserSubscriptionType } from '../../Domain/Subscription/UserSubscriptionType' import { UserSubscriptionType } from '../../Domain/Subscription/UserSubscriptionType'
import { SubscriptionPlanName } from '@standardnotes/domain-core'
@injectable() @injectable()
export class TypeORMUserSubscriptionRepository implements UserSubscriptionRepositoryInterface { export class TypeORMUserSubscriptionRepository implements UserSubscriptionRepositoryInterface {
@ -16,6 +16,15 @@ export class TypeORMUserSubscriptionRepository implements UserSubscriptionReposi
@inject(TYPES.Auth_Timer) private timer: TimerInterface, @inject(TYPES.Auth_Timer) private timer: TimerInterface,
) {} ) {}
async findActiveByType(type: UserSubscriptionType): Promise<UserSubscription[]> {
return await this.ormRepository
.createQueryBuilder()
.where('ends_at > :timestamp', { timestamp: this.timer.getTimestampInMicroseconds() })
.andWhere('subscription_type = :type', { type })
.orderBy('created_at', 'ASC')
.getMany()
}
async countByPlanName(planNames: SubscriptionPlanName[]): Promise<number> { async countByPlanName(planNames: SubscriptionPlanName[]): Promise<number> {
return await this.ormRepository return await this.ormRepository
.createQueryBuilder() .createQueryBuilder()