From e703bb7415554218fc61fd17e1234956afce18c8 Mon Sep 17 00:00:00 2001 From: Chuck Driesler Date: Tue, 9 Jul 2024 17:26:59 +0100 Subject: [PATCH] WEB-1140 manage user workspace membership services (#2460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(workspaces): drop createdByUserId from the dataschema * feat(workspaces): repositories WIP * merge * protect against removing last admin in workspace * quick impl and stub tests * add tests * services * unit tests for role services * fix(workspaces): maybe tests work like this * fix(workspaces): dry * fix(workspaces): initialize tests better * fix(workspaces): so true * fix(workspaces): right * fix(workspaces): self nit * fix(workspaces): better repository structure * fix(workspaces): repair tests, use `example.org` * fix(workspaces): add tests for new repo functions, repair other tests * fix(workspaces): better distinction between service-level guarantees and repo-level guarantees * fix(workspaces): review comments and stencil tests * fix(workspaces): add tests * fix(workspaces): tests work --------- Co-authored-by: Gergő Jedlicska --- .../modules/workspaces/domain/operations.ts | 42 ++++- .../modules/workspaces/errors/workspace.ts | 7 + .../workspaces/repositories/workspaces.ts | 61 ++++++- .../services/workspaceRoleCreation.ts | 89 +++++++++ .../tests/integration/repositories.spec.ts | 161 +++++++++++++++- .../{ => services}/workspaceCreation.spec.ts | 0 .../services/workspaceRoleCreation.spec.ts | 172 ++++++++++++++++++ .../utils/isUserLastWorkspaceAdmin.spec.ts | 56 ++++++ .../utils/isUserLastWorkspaceAdmin.ts | 13 ++ .../modules/workspacesCore/domain/events.ts | 10 +- 10 files changed, 593 insertions(+), 18 deletions(-) create mode 100644 packages/server/modules/workspaces/errors/workspace.ts create mode 100644 packages/server/modules/workspaces/services/workspaceRoleCreation.ts rename packages/server/modules/workspaces/tests/unit/{ => services}/workspaceCreation.spec.ts (100%) create mode 100644 packages/server/modules/workspaces/tests/unit/services/workspaceRoleCreation.spec.ts create mode 100644 packages/server/modules/workspaces/tests/unit/utils/isUserLastWorkspaceAdmin.spec.ts create mode 100644 packages/server/modules/workspaces/utils/isUserLastWorkspaceAdmin.ts diff --git a/packages/server/modules/workspaces/domain/operations.ts b/packages/server/modules/workspaces/domain/operations.ts index 48b36498e..562c5712d 100644 --- a/packages/server/modules/workspaces/domain/operations.ts +++ b/packages/server/modules/workspaces/domain/operations.ts @@ -20,17 +20,49 @@ export type GetWorkspace = (args: GetWorkspaceArgs) => Promise /** WorkspaceRole */ -export type UpsertWorkspaceRole = (args: WorkspaceAcl) => Promise - -type GetWorkspaceRoleArgs = { +type DeleteWorkspaceRoleArgs = { workspaceId: string userId: string } -export type GetWorkspaceRole = ( - args: GetWorkspaceRoleArgs +export type DeleteWorkspaceRole = ( + args: DeleteWorkspaceRoleArgs ) => Promise +type GetWorkspaceRolesArgs = { + workspaceId: string +} + +/** Get all roles in a given workspaces. */ +export type GetWorkspaceRoles = (args: GetWorkspaceRolesArgs) => Promise + +type GetWorkspaceRoleForUserArgs = { + userId: string + workspaceId: string +} + +/** Get role for given user in a specific workspace. */ +export type GetWorkspaceRoleForUser = ( + args: GetWorkspaceRoleForUserArgs +) => Promise + +type GetWorkspaceRolesForUserArgs = { + userId: string +} + +type GetWorkspaceRolesForUserOptions = { + /** If provided, limit results to roles in given workspaces. */ + workspaceIdFilter?: string[] +} + +/** Get roles for given user across several (or all) workspaces. */ +export type GetWorkspaceRolesForUser = ( + args: GetWorkspaceRolesForUserArgs, + options?: GetWorkspaceRolesForUserOptions +) => Promise + +export type UpsertWorkspaceRole = (args: WorkspaceAcl) => Promise + /** Blob */ export type StoreBlob = (args: string) => Promise diff --git a/packages/server/modules/workspaces/errors/workspace.ts b/packages/server/modules/workspaces/errors/workspace.ts new file mode 100644 index 000000000..addb3583f --- /dev/null +++ b/packages/server/modules/workspaces/errors/workspace.ts @@ -0,0 +1,7 @@ +import { BaseError } from '@/modules/shared/errors/base' + +export class WorkspaceAdminRequiredError extends BaseError { + static defaultMessage = 'Cannot remove last admin from a workspace' + static code = 'WORKSPACE_ADMIN_REQUIRED_ERROR' + static statusCode = 400 +} diff --git a/packages/server/modules/workspaces/repositories/workspaces.ts b/packages/server/modules/workspaces/repositories/workspaces.ts index b0327ff3e..46677005d 100644 --- a/packages/server/modules/workspaces/repositories/workspaces.ts +++ b/packages/server/modules/workspaces/repositories/workspaces.ts @@ -1,7 +1,10 @@ import { Workspace, WorkspaceAcl } from '@/modules/workspaces/domain/types' import { + DeleteWorkspaceRole, GetWorkspace, - GetWorkspaceRole, + GetWorkspaceRoleForUser, + GetWorkspaceRoles, + GetWorkspaceRolesForUser, UpsertWorkspace, UpsertWorkspaceRole } from '@/modules/workspaces/domain/operations' @@ -35,21 +38,59 @@ export const upsertWorkspaceFactory = .merge(['description', 'logoUrl', 'name', 'updatedAt']) } -export const getWorkspaceRoleFactory = - ({ db }: { db: Knex }): GetWorkspaceRole => - async ({ userId, workspaceId }) => { - const acl = await tables - .workspacesAcl(db) - .select('*') - .where({ userId, workspaceId }) - .first() +export const getWorkspaceRolesFactory = + ({ db }: { db: Knex }): GetWorkspaceRoles => + async ({ workspaceId }) => { + return await tables.workspacesAcl(db).select('*').where({ workspaceId }) + } - return acl || null +export const getWorkspaceRoleForUserFactory = + ({ db }: { db: Knex }): GetWorkspaceRoleForUser => + async ({ userId, workspaceId }) => { + return ( + (await tables + .workspacesAcl(db) + .select('*') + .where({ userId, workspaceId }) + .first()) ?? null + ) + } + +export const getWorkspaceRolesForUserFactory = + ({ db }: { db: Knex }): GetWorkspaceRolesForUser => + async ({ userId }, options) => { + const workspaceIdFilter = options?.workspaceIdFilter ?? [] + + const query = tables.workspacesAcl(db).select('*').where({ userId }) + + if (workspaceIdFilter.length > 0) { + query.whereIn('workspaceId', workspaceIdFilter) + } + + return await query + } + +export const deleteWorkspaceRoleFactory = + ({ db }: { db: Knex }): DeleteWorkspaceRole => + async ({ userId, workspaceId }) => { + const deletedRoles = await tables + .workspacesAcl(db) + .where({ workspaceId, userId }) + .delete('*') + + if (deletedRoles.length === 0) { + return null + } + + // Given `workspaceId` and `userId` define a primary key for `workspace_acl` table, + // query returns either 0 or 1 row in all cases + return deletedRoles[0] } export const upsertWorkspaceRoleFactory = ({ db }: { db: Knex }): UpsertWorkspaceRole => async ({ userId, workspaceId, role }) => { + // Verify requested role is valid workspace role const validRoles = Object.values(Roles.Workspace) if (!validRoles.includes(role)) { throw new Error(`Unexpected workspace role provided: ${role}`) diff --git a/packages/server/modules/workspaces/services/workspaceRoleCreation.ts b/packages/server/modules/workspaces/services/workspaceRoleCreation.ts new file mode 100644 index 000000000..00ef43db7 --- /dev/null +++ b/packages/server/modules/workspaces/services/workspaceRoleCreation.ts @@ -0,0 +1,89 @@ +import { + DeleteWorkspaceRole, + EmitWorkspaceEvent, + GetWorkspaceRoleForUser, + GetWorkspaceRoles, + UpsertWorkspaceRole +} from '@/modules/workspaces/domain/operations' +import { WorkspaceAcl } from '@/modules/workspaces/domain/types' +import { WorkspaceAdminRequiredError } from '@/modules/workspaces/errors/workspace' +import { isUserLastWorkspaceAdmin } from '@/modules/workspaces/utils/isUserLastWorkspaceAdmin' +import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' + +type WorkspaceRoleDeleteArgs = { + userId: string + workspaceId: string +} + +export const deleteWorkspaceRoleFactory = + ({ + getWorkspaceRoles, + deleteWorkspaceRole, + emitWorkspaceEvent + }: { + getWorkspaceRoles: GetWorkspaceRoles + deleteWorkspaceRole: DeleteWorkspaceRole + emitWorkspaceEvent: EmitWorkspaceEvent + }) => + async ({ + userId, + workspaceId + }: WorkspaceRoleDeleteArgs): Promise => { + const workspaceRoles = await getWorkspaceRoles({ workspaceId }) + + if (isUserLastWorkspaceAdmin(workspaceRoles, userId)) { + throw new WorkspaceAdminRequiredError() + } + + const deletedRole = await deleteWorkspaceRole({ userId, workspaceId }) + + if (!deletedRole) { + return null + } + + emitWorkspaceEvent({ event: WorkspaceEvents.RoleDeleted, payload: deletedRole }) + + return deletedRole + } + +type WorkspaceRoleGetArgs = { + userId: string + workspaceId: string +} + +export const getWorkspaceRoleFactory = + ({ getWorkspaceRoleForUser }: { getWorkspaceRoleForUser: GetWorkspaceRoleForUser }) => + async ({ + userId, + workspaceId + }: WorkspaceRoleGetArgs): Promise => { + return await getWorkspaceRoleForUser({ userId, workspaceId }) + } + +export const setWorkspaceRoleFactory = + ({ + getWorkspaceRoles, + upsertWorkspaceRole, + emitWorkspaceEvent + }: { + getWorkspaceRoles: GetWorkspaceRoles + upsertWorkspaceRole: UpsertWorkspaceRole + emitWorkspaceEvent: EmitWorkspaceEvent + }) => + async ({ userId, workspaceId, role }: WorkspaceAcl): Promise => { + const workspaceRoles = await getWorkspaceRoles({ workspaceId }) + + if ( + isUserLastWorkspaceAdmin(workspaceRoles, userId) && + role !== 'workspace:admin' + ) { + throw new WorkspaceAdminRequiredError() + } + + await upsertWorkspaceRole({ userId, workspaceId, role }) + + await emitWorkspaceEvent({ + event: WorkspaceEvents.RoleUpdated, + payload: { userId, workspaceId, role } + }) + } diff --git a/packages/server/modules/workspaces/tests/integration/repositories.spec.ts b/packages/server/modules/workspaces/tests/integration/repositories.spec.ts index 4a832c9ec..6a65d74b0 100644 --- a/packages/server/modules/workspaces/tests/integration/repositories.spec.ts +++ b/packages/server/modules/workspaces/tests/integration/repositories.spec.ts @@ -1,18 +1,43 @@ import { + deleteWorkspaceRoleFactory, + getWorkspaceRoleForUserFactory, getWorkspaceFactory, upsertWorkspaceFactory, - upsertWorkspaceRoleFactory + upsertWorkspaceRoleFactory, + getWorkspaceRolesFactory, + getWorkspaceRolesForUserFactory } from '@/modules/workspaces/repositories/workspaces' import db from '@/db/knex' import cryptoRandomString from 'crypto-random-string' import { expect } from 'chai' import { Workspace, WorkspaceAcl } from '@/modules/workspaces/domain/types' import { expectToThrow } from '@/test/assertionHelper' +import { BasicTestUser, createTestUser } from '@/test/authHelper' const getWorkspace = getWorkspaceFactory({ db }) const upsertWorkspace = upsertWorkspaceFactory({ db }) +const deleteWorkspaceRole = deleteWorkspaceRoleFactory({ db }) +const getWorkspaceRoles = getWorkspaceRolesFactory({ db }) +const getWorkspaceRoleForUser = getWorkspaceRoleForUserFactory({ db }) +const getWorkspaceRolesForUser = getWorkspaceRolesForUserFactory({ db }) const upsertWorkspaceRole = upsertWorkspaceRoleFactory({ db }) +const createAndStoreTestUser = async (): Promise => { + const testId = cryptoRandomString({ length: 6 }) + + const userRecord: BasicTestUser = { + name: `test-user-${testId}`, + email: `test-user-${testId}@example.org`, + password: '', + id: '', + role: 'server:user' + } + + await createTestUser(userRecord) + + return userRecord +} + const createAndStoreTestWorkspace = async (): Promise => { const workspace: Workspace = { id: cryptoRandomString({ length: 10 }), @@ -79,6 +104,140 @@ describe('Workspace repositories', () => { }) }) + describe('deleteWorkspaceRoleFactory creates a function, that', () => { + it('deletes specified workspace role', async () => { + const { id: userId } = await createAndStoreTestUser() + const { id: workspaceId } = await createAndStoreTestWorkspace() + + await upsertWorkspaceRole({ userId, workspaceId, role: 'workspace:member' }) + await deleteWorkspaceRole({ userId, workspaceId }) + + const role = await getWorkspaceRoleForUser({ userId, workspaceId }) + + expect(role).to.be.null + }) + it('returns deleted workspace role', async () => { + const { id: userId } = await createAndStoreTestUser() + const { id: workspaceId } = await createAndStoreTestWorkspace() + + const createdRole: WorkspaceAcl = { + userId, + workspaceId, + role: 'workspace:member' + } + await upsertWorkspaceRole(createdRole) + const deletedRole = await deleteWorkspaceRole({ userId, workspaceId }) + + expect(deletedRole).to.deep.equal(createdRole) + }) + it('return null if role does not exist', async () => { + const deletedRole = await deleteWorkspaceRole({ userId: '', workspaceId: '' }) + + expect(deletedRole).to.be.null + }) + }) + + describe('getWorkspaceRolesFactory creates a function, that', () => { + it('returns all roles in a given workspace', async () => { + const { id: workspaceId } = await createAndStoreTestWorkspace() + + const { id: userIdA } = await createAndStoreTestUser() + const { id: userIdB } = await createAndStoreTestUser() + + await upsertWorkspaceRole({ + workspaceId, + userId: userIdA, + role: 'workspace:admin' + }) + await upsertWorkspaceRole({ + workspaceId, + userId: userIdB, + role: 'workspace:admin' + }) + + const workspaceRoles = await getWorkspaceRoles({ workspaceId }) + + expect(workspaceRoles.length).to.equal(2) + expect(workspaceRoles.some(({ userId }) => userId === userIdA)).to.be.true + expect(workspaceRoles.some(({ userId }) => userId === userIdB)).to.be.true + }) + }) + + describe('getWorkspaceRoleForUserFactory creates a function, that', () => { + it('returns the current role for a given user in a given workspace', async () => { + const { id: userId } = await createAndStoreTestUser() + const { id: workspaceId } = await createAndStoreTestWorkspace() + + await upsertWorkspaceRole({ workspaceId, userId, role: 'workspace:admin' }) + + const workspaceRole = await getWorkspaceRoleForUser({ userId, workspaceId }) + + expect(workspaceRole).to.not.be.null + expect(workspaceRole?.userId).to.equal(userId) + }) + it('returns `null` if the given user does not have a role in the given workspace', async () => { + const workspaceRole = await getWorkspaceRoleForUser({ + userId: 'invalid-user-id', + workspaceId: 'invalid-workspace-id' + }) + + expect(workspaceRole).to.be.null + }) + }) + + describe('getWorkspaceRolesForUserFactory creates a function, that', () => { + it('returns the current role for a given user across all workspaces', async () => { + const { id: userId } = await createAndStoreTestUser() + + const { id: workspaceIdA } = await createAndStoreTestWorkspace() + const { id: workspaceIdB } = await createAndStoreTestWorkspace() + + await upsertWorkspaceRole({ + workspaceId: workspaceIdA, + userId, + role: 'workspace:admin' + }) + await upsertWorkspaceRole({ + workspaceId: workspaceIdB, + userId, + role: 'workspace:admin' + }) + + const workspaceRoles = await getWorkspaceRolesForUser({ userId }) + + expect(workspaceRoles.length).to.equal(2) + expect(workspaceRoles.some(({ workspaceId }) => workspaceId === workspaceIdA)).to + .be.true + expect(workspaceRoles.some(({ workspaceId }) => workspaceId === workspaceIdB)).to + .be.true + }) + it('returns the current role for workspaces specified by the workspace id filter, if provided', async () => { + const { id: userId } = await createAndStoreTestUser() + + const { id: workspaceIdA } = await createAndStoreTestWorkspace() + const { id: workspaceIdB } = await createAndStoreTestWorkspace() + + await upsertWorkspaceRole({ + workspaceId: workspaceIdA, + userId, + role: 'workspace:admin' + }) + await upsertWorkspaceRole({ + workspaceId: workspaceIdB, + userId, + role: 'workspace:admin' + }) + + const workspaceRoles = await getWorkspaceRolesForUser( + { userId }, + { workspaceIdFilter: [workspaceIdA] } + ) + + expect(workspaceRoles.length).to.equal(1) + expect(workspaceRoles[0].workspaceId).to.equal(workspaceIdA) + }) + }) + describe('upsertWorkspaceRoleFactory creates a function, that', () => { it('throws if an unknown role is provided', async () => { const role: WorkspaceAcl = { diff --git a/packages/server/modules/workspaces/tests/unit/workspaceCreation.spec.ts b/packages/server/modules/workspaces/tests/unit/services/workspaceCreation.spec.ts similarity index 100% rename from packages/server/modules/workspaces/tests/unit/workspaceCreation.spec.ts rename to packages/server/modules/workspaces/tests/unit/services/workspaceCreation.spec.ts diff --git a/packages/server/modules/workspaces/tests/unit/services/workspaceRoleCreation.spec.ts b/packages/server/modules/workspaces/tests/unit/services/workspaceRoleCreation.spec.ts new file mode 100644 index 000000000..9c20fea27 --- /dev/null +++ b/packages/server/modules/workspaces/tests/unit/services/workspaceRoleCreation.spec.ts @@ -0,0 +1,172 @@ +import { WorkspaceAcl } from '@/modules/workspaces/domain/types' +import { + deleteWorkspaceRoleFactory, + setWorkspaceRoleFactory +} from '@/modules/workspaces/services/workspaceRoleCreation' +import { WorkspaceEvents } from '@/modules/workspacesCore/domain/events' +import { expectToThrow } from '@/test/assertionHelper' +import { Roles } from '@speckle/shared' +import { expect } from 'chai' +import cryptoRandomString from 'crypto-random-string' + +describe('Workspace role services', () => { + describe('deleteWorkspaceRoleFactory creates a function, that', () => { + it('deletes the workspace role', async () => { + const userId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + + const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member } + + let storedRoles: WorkspaceAcl[] = [role] + + const deleteWorkspaceRole = deleteWorkspaceRoleFactory({ + getWorkspaceRoles: async () => storedRoles, + deleteWorkspaceRole: async ({ userId, workspaceId }) => { + const role = storedRoles.find( + (r) => r.userId === userId && r.workspaceId === workspaceId + ) + + storedRoles = storedRoles.filter((r) => r.userId !== userId) + + return role ?? null + }, + emitWorkspaceEvent: async () => [] + }) + + const deletedRole = await deleteWorkspaceRole({ userId, workspaceId }) + + expect(storedRoles.length).to.equal(0) + expect(deletedRole).to.deep.equal(role) + }) + it('emits a role-deleted event', async () => { + const eventData = { + isCalled: false, + event: '', + payload: {} + } + + const userId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + + const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member } + + const storedRoles: WorkspaceAcl[] = [role] + + const deleteWorkspaceRole = deleteWorkspaceRoleFactory({ + getWorkspaceRoles: async () => storedRoles, + deleteWorkspaceRole: async () => { + return storedRoles[0] + }, + emitWorkspaceEvent: async ({ event, payload }) => { + eventData.isCalled = true + eventData.event = event + eventData.payload = payload + + return [] + } + }) + + await deleteWorkspaceRole({ userId, workspaceId }) + + expect(eventData.isCalled).to.be.true + expect(eventData.event).to.equal(WorkspaceEvents.RoleDeleted) + expect(eventData.payload).to.deep.equal(role) + }) + it('throws if attempting to delete the last admin from a workspace', async () => { + const userId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + + const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Admin } + + let storedRoles: WorkspaceAcl[] = [role] + + const deleteWorkspaceRole = deleteWorkspaceRoleFactory({ + getWorkspaceRoles: async () => storedRoles, + deleteWorkspaceRole: async ({ userId, workspaceId }) => { + const role = storedRoles.find( + (r) => r.userId === userId && r.workspaceId === workspaceId + ) + + storedRoles = storedRoles.filter((r) => r.userId !== userId) + + return role ?? null + }, + emitWorkspaceEvent: async () => [] + }) + + await expectToThrow(() => deleteWorkspaceRole({ userId, workspaceId })) + }) + }) + + describe('setWorkspaceRoleFactory creates a function, that', () => { + it('sets the workspace role', async () => { + const userId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + + const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member } + + const storedRoles: WorkspaceAcl[] = [] + + const setWorkspaceRole = setWorkspaceRoleFactory({ + getWorkspaceRoles: async () => storedRoles, + upsertWorkspaceRole: async (role) => { + storedRoles.push(role) + }, + emitWorkspaceEvent: async () => [] + }) + + await setWorkspaceRole(role) + + expect(storedRoles.length).to.equal(1) + expect(storedRoles[0]).to.deep.equal(role) + }) + it('emits a role-updated event', async () => { + const eventData = { + isCalled: false, + event: '', + payload: {} + } + + const userId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + + const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Member } + + const setWorkspaceRole = setWorkspaceRoleFactory({ + getWorkspaceRoles: async () => [], + upsertWorkspaceRole: async () => {}, + emitWorkspaceEvent: async ({ event, payload }) => { + eventData.isCalled = true + eventData.event = event + eventData.payload = payload + + return [] + } + }) + + await setWorkspaceRole(role) + + expect(eventData.isCalled).to.be.true + expect(eventData.event).to.equal(WorkspaceEvents.RoleUpdated) + expect(eventData.payload).to.deep.equal(role) + }) + it('throws if attempting to remove the last admin in a workspace', async () => { + const userId = cryptoRandomString({ length: 10 }) + const workspaceId = cryptoRandomString({ length: 10 }) + + const role: WorkspaceAcl = { userId, workspaceId, role: Roles.Workspace.Admin } + + const storedRoles: WorkspaceAcl[] = [role] + + const setWorkspaceRole = setWorkspaceRoleFactory({ + getWorkspaceRoles: async () => storedRoles, + upsertWorkspaceRole: async () => {}, + emitWorkspaceEvent: async () => [] + }) + + await expectToThrow(() => + setWorkspaceRole({ ...role, role: Roles.Workspace.Member }) + ) + }) + }) +}) diff --git a/packages/server/modules/workspaces/tests/unit/utils/isUserLastWorkspaceAdmin.spec.ts b/packages/server/modules/workspaces/tests/unit/utils/isUserLastWorkspaceAdmin.spec.ts new file mode 100644 index 000000000..7721e15b4 --- /dev/null +++ b/packages/server/modules/workspaces/tests/unit/utils/isUserLastWorkspaceAdmin.spec.ts @@ -0,0 +1,56 @@ +import { isUserLastWorkspaceAdmin } from '@/modules/workspaces/utils/isUserLastWorkspaceAdmin' +import { WorkspaceAcl } from '@/modules/workspaces/domain/types' +import { expect } from 'chai' +import { Roles } from '@speckle/shared' + +describe('given a workspace with several admins', () => { + const workspaceRoles: WorkspaceAcl[] = [ + { workspaceId: 'workspace-id', userId: 'non-admin', role: Roles.Workspace.Member }, + { workspaceId: 'workspace-id', userId: 'admin-a', role: Roles.Workspace.Admin }, + { workspaceId: 'workspace-id', userId: 'admin-b', role: Roles.Workspace.Admin } + ] + + describe('when testing a non-admin user', () => { + it('should return false', () => { + expect(isUserLastWorkspaceAdmin(workspaceRoles, 'non-admin')).to.be.false + }) + }) + + describe('when testing an admin user', () => { + it('should return false', () => { + expect(isUserLastWorkspaceAdmin(workspaceRoles, 'admin-a')).to.be.false + }) + }) +}) + +describe('given a workspace with one admin', () => { + const workspaceRoles: WorkspaceAcl[] = [ + { workspaceId: 'workspace-id', userId: 'non-admin', role: Roles.Workspace.Member }, + { workspaceId: 'workspace-id', userId: 'admin', role: Roles.Workspace.Admin } + ] + + describe('when testing a non-admin user', () => { + it('should return false', () => { + expect(isUserLastWorkspaceAdmin(workspaceRoles, 'non-admin')).to.be.false + }) + }) + + describe('when testing an admin user', () => { + it('should return true', () => { + expect(isUserLastWorkspaceAdmin(workspaceRoles, 'admin')).to.be.true + }) + }) +}) + +describe('given a workspace', () => { + const workspaceRoles: WorkspaceAcl[] = [ + { workspaceId: 'workspace-id', userId: 'non-admin', role: Roles.Workspace.Member }, + { workspaceId: 'workspace-id', userId: 'admin', role: Roles.Workspace.Admin } + ] + + describe('when testing a non-workspace user', () => { + it('should return false', () => { + expect(isUserLastWorkspaceAdmin(workspaceRoles, 'random-id')).to.be.false + }) + }) +}) diff --git a/packages/server/modules/workspaces/utils/isUserLastWorkspaceAdmin.ts b/packages/server/modules/workspaces/utils/isUserLastWorkspaceAdmin.ts new file mode 100644 index 000000000..03f87b2ec --- /dev/null +++ b/packages/server/modules/workspaces/utils/isUserLastWorkspaceAdmin.ts @@ -0,0 +1,13 @@ +import { WorkspaceAcl } from '@/modules/workspaces/domain/types' + +export const isUserLastWorkspaceAdmin = ( + workspaceRoles: WorkspaceAcl[], + userId: string +): boolean => { + const workspaceAdmins = workspaceRoles.filter( + ({ role }) => role === 'workspace:admin' + ) + const isUserAdmin = workspaceAdmins.some((role) => role.userId === userId) + + return isUserAdmin && workspaceAdmins.length === 1 +} diff --git a/packages/server/modules/workspacesCore/domain/events.ts b/packages/server/modules/workspacesCore/domain/events.ts index cacbe1a4d..ca718d38a 100644 --- a/packages/server/modules/workspacesCore/domain/events.ts +++ b/packages/server/modules/workspacesCore/domain/events.ts @@ -1,13 +1,19 @@ -import { Workspace } from '@/modules/workspaces/domain/types' +import { Workspace, WorkspaceAcl } from '@/modules/workspaces/domain/types' export const WorkspaceEvents = { - Created: 'created' + Created: 'created', + RoleDeleted: 'role-deleted', + RoleUpdated: 'role-updated' } as const export type WorkspaceEvents = (typeof WorkspaceEvents)[keyof typeof WorkspaceEvents] type WorkspaceCreatedPayload = Workspace +type WorkspaceRoleDeletedPayload = WorkspaceAcl +type WorkspaceRoleUpdatedPayload = WorkspaceAcl export type WorkspaceEventsPayloads = { [WorkspaceEvents.Created]: WorkspaceCreatedPayload + [WorkspaceEvents.RoleDeleted]: WorkspaceRoleDeletedPayload + [WorkspaceEvents.RoleUpdated]: WorkspaceRoleUpdatedPayload }