Merge pull request #3288 from specklesystems/fabians/core-ioc-62

chore(server): core IoC #62 - deleteUserFactory
This commit is contained in:
Alessandro Magionami
2024-10-16 09:45:15 +02:00
committed by GitHub
11 changed files with 175 additions and 79 deletions
@@ -66,6 +66,8 @@ export type GetStreamCollaborators = (
type?: StreamRoles
) => Promise<Array<LimitedUserWithStreamRole>>
export type GetUserDeletableStreams = (userId: string) => Promise<Array<string>>
export type LegacyGetStreamCollaborators = (params: { streamId: string }) => Promise<
{
role: string
@@ -95,7 +97,7 @@ export type CanUserFavoriteStream = (params: {
streamId: string
}) => Promise<boolean>
export type DeleteStreamRecords = (streamId: string) => Promise<number>
export type DeleteStreamRecord = (streamId: string) => Promise<number>
export type GetOnboardingBaseStream = (version: string) => Promise<Optional<Stream>>
@@ -45,8 +45,12 @@ export type UpdateUser = (
}>
) => Promise<Nullable<User>>
export type DeleteUserRecord = (userId: string) => Promise<boolean>
export type CountAdminUsers = () => Promise<number>
export type IsLastAdminUser = (userId: string) => Promise<boolean>
export type StoreUserAcl = (params: {
acl: ServerAclRecord
}) => Promise<ServerAclRecord>
@@ -107,3 +111,5 @@ export type ValidateUserPassword = (params: {
email: string
password: string
}) => Promise<boolean>
export type DeleteUser = (id: string) => Promise<boolean>
@@ -1,6 +1,5 @@
const {
getUserRole,
deleteUser,
searchUsers,
changeUserRole
} = require('@/modules/core/services/users')
@@ -17,7 +16,9 @@ const {
legacyGetUserFactory,
legacyGetUserByEmailFactory,
getUserFactory,
updateUserFactory
updateUserFactory,
isLastAdminUserFactory,
deleteUserRecordFactory
} = require('@/modules/core/repositories/users')
const { UsersMeta } = require('@/modules/core/dbSchema')
const { getServerInfo } = require('@/modules/core/services/generic')
@@ -31,11 +32,17 @@ const db = require('@/db/knex')
const { BadRequestError } = require('@/modules/shared/errors')
const { saveActivityFactory } = require('@/modules/activitystream/repositories')
const {
updateUserAndNotifyFactory
updateUserAndNotifyFactory,
deleteUserFactory
} = require('@/modules/core/services/users/management')
const {
addUserUpdatedActivityFactory
} = require('@/modules/activitystream/services/userActivity')
const {
deleteStreamFactory,
getUserDeletableStreamsFactory
} = require('@/modules/core/repositories/streams')
const { dbLogger } = require('@/logging/logging')
const getUser = legacyGetUserFactory({ db })
const getUserByEmail = legacyGetUserByEmailFactory({ db })
@@ -48,6 +55,15 @@ const updateUserAndNotify = updateUserAndNotifyFactory({
})
})
const deleteUser = deleteUserFactory({
deleteStream: deleteStreamFactory({ db }),
logger: dbLogger,
isLastAdminUser: isLastAdminUserFactory({ db }),
getUserDeletableStreams: getUserDeletableStreamsFactory({ db }),
deleteAllUserInvites: deleteAllUserInvitesFactory({ db }),
deleteUserRecord: deleteUserRecordFactory({ db })
})
/** @type {import('@/modules/core/graph/generated/graphql').Resolvers} */
module.exports = {
Query: {
@@ -182,9 +198,7 @@ module.exports = {
const user = await getUserByEmail({ email: args.userConfirmation.email })
if (!user) return false
await deleteUser({
deleteAllUserInvites: deleteAllUserInvitesFactory({ db })
})(user.id)
await deleteUser(user.id)
return true
},
@@ -201,9 +215,7 @@ module.exports = {
await throwForNotHavingServerRole(context, Roles.Server.Guest)
await validateScopes(context.scopes, Scopes.Profile.Delete)
await deleteUser({
deleteAllUserInvites: deleteAllUserInvitesFactory({ db })
})(context.userId, args.user)
await deleteUser(context.userId, args.user)
await saveActivityFactory({ db })({
streamId: null,
@@ -70,7 +70,7 @@ import {
GetStream,
GetStreamCollaborators,
GetStreams,
DeleteStreamRecords,
DeleteStreamRecord,
UpdateStreamRecord,
RevokeStreamPermissions,
GrantStreamPermissions,
@@ -97,7 +97,8 @@ import {
GetUserStreamsCount,
MarkBranchStreamUpdated,
MarkCommitStreamUpdated,
MarkOnboardingBaseStream
MarkOnboardingBaseStream,
GetUserDeletableStreams
} from '@/modules/core/domain/streams/operations'
export type { StreamWithOptionalRole, StreamWithCommitId }
@@ -880,7 +881,7 @@ export const getUserStreamCountsFactory =
}
export const deleteStreamFactory =
(deps: { db: Knex }): DeleteStreamRecords =>
(deps: { db: Knex }): DeleteStreamRecord =>
async (streamId: string) => {
// Delete stream commits (not automatically cascaded)
await deps.db.raw(
@@ -1306,3 +1307,31 @@ export const legacyGetStreamsFactory =
cursorDate: cursorDate as Nullable<Date>
}
}
export const getUserDeletableStreamsFactory =
(deps: { db: Knex }): GetUserDeletableStreams =>
async (id) => {
const streams = (await deps.db.raw(
`
-- Get the stream ids with only this user as owner
SELECT "resourceId" as id
FROM (
-- Compute (streamId, ownerCount) table for streams on which the user is owner
SELECT acl."resourceId", count(*) as cnt
FROM stream_acl acl
INNER JOIN
(
-- Get streams ids on which the user is owner
SELECT "resourceId" FROM stream_acl
WHERE role = '${Roles.Stream.Owner}' AND "userId" = ?
) AS us ON acl."resourceId" = us."resourceId"
WHERE acl.role = '${Roles.Stream.Owner}'
GROUP BY (acl."resourceId")
) AS soc
WHERE cnt = 1
`,
[id]
)) as { rows: { id: string }[] }
return streams.rows.map((s) => s.id)
}
@@ -12,10 +12,12 @@ import { markUserEmailAsVerifiedFactory } from '@/modules/core/services/users/em
import { UserWithOptionalRole } from '@/modules/core/domain/users/types'
import {
CountAdminUsers,
DeleteUserRecord,
GetUser,
GetUserByEmail,
GetUserParams,
GetUsers,
IsLastAdminUser,
LegacyGetPaginatedUsers,
LegacyGetPaginatedUsersCount,
LegacyGetUser,
@@ -382,6 +384,29 @@ export const countAdminUsersFactory =
return parseInt(count as string)
}
export const isLastAdminUserFactory =
(deps: { db: Knex }): IsLastAdminUser =>
async (userId) => {
if ((await countAdminUsersFactory(deps)()) === 1) {
const currentAdmin = await tables
.serverAcl(deps.db)
.where({ role: Roles.Server.Admin })
.first()
if (currentAdmin && currentAdmin.userId === userId) {
return true
}
}
return false
}
export const deleteUserRecordFactory =
(deps: { db: Knex }): DeleteUserRecord =>
async (id) => {
const res = await tables.users(deps.db).where({ id }).del()
return !!res
}
export const storeUserAclFactory =
(deps: { db: Knex }): StoreUserAcl =>
async (params) => {
@@ -33,7 +33,7 @@ import {
AddOrUpdateStreamCollaborator,
CreateStream,
DeleteStream,
DeleteStreamRecords,
DeleteStreamRecord,
GetStream,
IsStreamCollaborator,
LegacyCreateStream,
@@ -138,7 +138,7 @@ export const legacyCreateStreamFactory =
*/
export const deleteStreamAndNotifyFactory =
(deps: {
deleteStream: DeleteStreamRecords
deleteStream: DeleteStreamRecord
authorizeResolver: AuthorizeResolver
addStreamDeletedActivity: AddStreamDeletedActivity
deleteAllResourceInvites: DeleteAllResourceInvites
@@ -11,11 +11,8 @@ const Acl = () => ServerAclSchema.knex()
const { LIMITED_USER_FIELDS } = require('@/modules/core/helpers/userHelper')
const { omit } = require('lodash')
const { dbLogger } = require('@/logging/logging')
const { UserInputError } = require('@/modules/core/errors/userinput')
const { Roles } = require('@speckle/shared')
const { db } = require('@/db/knex')
const { deleteStreamFactory } = require('@/modules/core/repositories/streams')
const _changeUserRole = async ({ userId, role }) =>
await Acl().where({ userId }).update({ role })
@@ -76,49 +73,6 @@ module.exports = {
}
},
/**
* TODO: this should be moved to repository
* @param {{ deleteAllUserInvites: import('@/modules/serverinvites/domain/operations').DeleteAllUserInvites }} param0
*/
deleteUser({ deleteAllUserInvites }) {
const deleteStream = deleteStreamFactory({ db })
return async (id) => {
//TODO: check for the last admin user to survive
dbLogger.info('Deleting user ' + id)
await _ensureAtleastOneAdminRemains(id)
const streams = await knex.raw(
`
-- Get the stream ids with only this user as owner
SELECT "resourceId" as id
FROM (
-- Compute (streamId, ownerCount) table for streams on which the user is owner
SELECT acl."resourceId", count(*) as cnt
FROM stream_acl acl
INNER JOIN
(
-- Get streams ids on which the user is owner
SELECT "resourceId" FROM stream_acl
WHERE role = '${Roles.Stream.Owner}' AND "userId" = ?
) AS us ON acl."resourceId" = us."resourceId"
WHERE acl.role = '${Roles.Stream.Owner}'
GROUP BY (acl."resourceId")
) AS soc
WHERE cnt = 1
`,
[id]
)
for (const i in streams.rows) {
await deleteStream(streams.rows[i].id)
}
// Delete all invites (they don't have a FK, so we need to do this manually)
// THIS REALLY SHOULD BE A REACTION TO THE USER DELETED EVENT EMITTED HER
await deleteAllUserInvites(id)
return await Users().where({ id }).del()
}
},
async changeUserRole({ userId, role, guestModeEnabled = false }) {
if (!Object.values(Roles.Server).includes(role))
throw new UserInputError(`Invalid role specified: ${role}`)
@@ -3,9 +3,12 @@ import {
ChangeUserPassword,
CountAdminUsers,
CreateValidatedUser,
DeleteUser,
DeleteUserRecord,
FindOrCreateValidatedUser,
GetUser,
GetUserByEmail,
IsLastAdminUser,
StoreUser,
StoreUserAcl,
UpdateUser,
@@ -28,6 +31,12 @@ import {
ValidateAndCreateUserEmail
} from '@/modules/core/domain/userEmails/operations'
import { UsersEvents, UsersEventsEmitter } from '@/modules/core/events/usersEmitter'
import {
DeleteStreamRecord,
GetUserDeletableStreams
} from '@/modules/core/domain/streams/operations'
import { Logger } from '@/logging/logging'
import { DeleteAllUserInvites } from '@/modules/serverinvites/domain/operations'
export const MINIMUM_PASSWORD_LENGTH = 8
@@ -232,3 +241,31 @@ export const findOrCreateUserFactory =
isNewUser: true
}
}
export const deleteUserFactory =
(deps: {
deleteStream: DeleteStreamRecord
logger: Logger
isLastAdminUser: IsLastAdminUser
getUserDeletableStreams: GetUserDeletableStreams
deleteAllUserInvites: DeleteAllUserInvites
deleteUserRecord: DeleteUserRecord
}): DeleteUser =>
async (id) => {
deps.logger.info('Deleting user ' + id)
const isLastAdmin = await deps.isLastAdminUser(id)
if (isLastAdmin) {
throw new UserInputError('Cannot remove the last admin role from the server')
}
const streamIds = await deps.getUserDeletableStreams(id)
for (const id of streamIds) {
await deps.deleteStream(id)
}
// Delete all invites (they don't have a FK, so we need to do this manually)
// THIS REALLY SHOULD BE A REACTION TO THE USER DELETED EVENT EMITTED HER
await deps.deleteAllUserInvites(id)
return await deps.deleteUserRecord(id)
}
@@ -2,7 +2,7 @@
const expect = require('chai').expect
const assert = require('assert')
const { changeUserRole, searchUsers, deleteUser } = require('../services/users')
const { changeUserRole, searchUsers } = require('../services/users')
const {
createPersonalAccessToken,
revokeToken,
@@ -39,7 +39,9 @@ const {
getStreamFactory,
createStreamFactory,
grantStreamPermissionsFactory,
markCommitStreamUpdatedFactory
markCommitStreamUpdatedFactory,
deleteStreamFactory,
getUserDeletableStreamsFactory
} = require('@/modules/core/repositories/streams')
const { VersionsEmitter } = require('@/modules/core/events/versionsEmitter')
const { getObjectFactory } = require('@/modules/core/repositories/objects')
@@ -57,7 +59,8 @@ const {
findUserByTargetFactory,
insertInviteAndDeleteOldFactory,
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
updateAllInviteTargetsFactory,
deleteAllUserInvitesFactory
} = require('@/modules/serverinvites/repositories/serverInvites')
const {
collectAndValidateCoreTargetsFactory
@@ -84,7 +87,9 @@ const {
storeUserAclFactory,
legacyGetUserByEmailFactory,
updateUserFactory,
getUserByEmailFactory
getUserByEmailFactory,
isLastAdminUserFactory,
deleteUserRecordFactory
} = require('@/modules/core/repositories/users')
const {
findEmailFactory,
@@ -106,7 +111,8 @@ const {
findOrCreateUserFactory,
updateUserAndNotifyFactory,
changePasswordFactory,
validateUserPasswordFactory
validateUserPasswordFactory,
deleteUserFactory
} = require('@/modules/core/services/users/management')
const {
validateAndCreateUserEmailFactory
@@ -118,6 +124,7 @@ const { UsersEmitter } = require('@/modules/core/events/usersEmitter')
const {
addUserUpdatedActivityFactory
} = require('@/modules/activitystream/services/userActivity')
const { dbLogger } = require('@/logging/logging')
const getUser = legacyGetUserFactory({ db })
const getUsers = getUsersFactory({ db })
@@ -228,6 +235,15 @@ const validateUserPassword = validateUserPasswordFactory({
getUserByEmail: getUserByEmailFactory({ db })
})
const deleteUser = deleteUserFactory({
deleteStream: deleteStreamFactory({ db }),
logger: dbLogger,
isLastAdminUser: isLastAdminUserFactory({ db }),
getUserDeletableStreams: getUserDeletableStreamsFactory({ db }),
deleteAllUserInvites: deleteAllUserInvitesFactory({ db }),
deleteUserRecord: deleteUserRecordFactory({ db })
})
describe('Actors & Tokens @user-services', () => {
const myTestActor = {
name: 'Dimitrie Stefanescu',
@@ -347,7 +363,7 @@ describe('Actors & Tokens @user-services', () => {
})
).id
await deleteUser({ deleteAllUserInvites: async () => true })(ballmerUserId)
await deleteUser(ballmerUserId)
if ((await getStream({ streamId: soloOwnerStream.id })) !== undefined) {
assert.fail('user stream not deleted')
@@ -381,7 +397,7 @@ describe('Actors & Tokens @user-services', () => {
it('Should not delete the last admin user', async () => {
try {
await deleteUser({ deleteAllUserInvites: async () => true })(myTestActor.id)
await deleteUser(myTestActor.id)
assert.fail('boom')
} catch (err) {
expect(err.message).to.equal(
@@ -1,11 +1,7 @@
const expect = require('chai').expect
const assert = require('assert')
const {
deleteUser,
changeUserRole,
getUserRole
} = require('@/modules/core/services/users')
const { changeUserRole, getUserRole } = require('@/modules/core/services/users')
const { beforeEachContext } = require('@/test/hooks')
const { Roles } = require('@speckle/shared')
const cryptoRandomString = require('crypto-random-string')
@@ -15,7 +11,9 @@ const {
getUserFactory,
storeUserFactory,
countAdminUsersFactory,
storeUserAclFactory
storeUserAclFactory,
isLastAdminUserFactory,
deleteUserRecordFactory
} = require('@/modules/core/repositories/users')
const { db } = require('@/db/knex')
const {
@@ -32,7 +30,10 @@ const {
} = require('@/modules/emails/repositories')
const { renderEmail } = require('@/modules/emails/services/emailRendering')
const { sendEmail } = require('@/modules/emails/services/sending')
const { createUserFactory } = require('@/modules/core/services/users/management')
const {
createUserFactory,
deleteUserFactory
} = require('@/modules/core/services/users/management')
const {
validateAndCreateUserEmailFactory
} = require('@/modules/core/services/userEmails')
@@ -41,9 +42,15 @@ const {
} = require('@/modules/serverinvites/services/processing')
const {
deleteServerOnlyInvitesFactory,
updateAllInviteTargetsFactory
updateAllInviteTargetsFactory,
deleteAllUserInvitesFactory
} = require('@/modules/serverinvites/repositories/serverInvites')
const { UsersEmitter } = require('@/modules/core/events/usersEmitter')
const {
deleteStreamFactory,
getUserDeletableStreamsFactory
} = require('@/modules/core/repositories/streams')
const { dbLogger } = require('@/logging/logging')
const getUsers = legacyGetPaginatedUsersFactory({ db })
const countUsers = legacyGetPaginatedUsersCountFactory({ db })
@@ -75,6 +82,14 @@ const createUser = createUserFactory({
}),
usersEventsEmitter: UsersEmitter.emit
})
const deleteUser = deleteUserFactory({
deleteStream: deleteStreamFactory({ db }),
logger: dbLogger,
isLastAdminUser: isLastAdminUserFactory({ db }),
getUserDeletableStreams: getUserDeletableStreamsFactory({ db }),
deleteAllUserInvites: deleteAllUserInvitesFactory({ db }),
deleteUserRecord: deleteUserRecordFactory({ db })
})
describe('User admin @user-services', () => {
const myTestActor = {
@@ -111,7 +126,7 @@ describe('User admin @user-services', () => {
expect(await countUsers()).to.equal(2)
await deleteUser({ deleteAllUserInvites: async () => true })(actorId)
await deleteUser(actorId)
expect(await countUsers()).to.equal(1)
})
@@ -63,7 +63,7 @@ import { chunk, isEmpty, omit } from 'lodash'
import { userEmailsCompliantWithWorkspaceDomains } from '@/modules/workspaces/domain/logic'
import { workspaceRoles as workspaceRoleDefinitions } from '@/modules/workspaces/roles'
import { blockedDomains } from '@speckle/shared'
import { DeleteStreamRecords } from '@/modules/core/domain/streams/operations'
import { DeleteStreamRecord } from '@/modules/core/domain/streams/operations'
type WorkspaceCreateArgs = {
userId: string
@@ -278,7 +278,7 @@ export const deleteWorkspaceFactory =
deleteAllResourceInvites
}: {
deleteWorkspace: DeleteWorkspace
deleteProject: DeleteStreamRecords
deleteProject: DeleteStreamRecord
queryAllWorkspaceProjects: QueryAllWorkspaceProjects
deleteAllResourceInvites: DeleteAllResourceInvites
}) =>