feat(workspaces): add workspace slug support (#2982)

* feat(workspaces): add workspace slug support

* chore(workspaces): lint

* feat(workspaces): add slug validation and generation

* fix(workspaces): test lint miss
This commit is contained in:
Gergő Jedlicska
2024-09-18 13:29:36 +02:00
committed by GitHub
parent 56d392424d
commit 00c01db923
25 changed files with 518 additions and 82 deletions
@@ -32,11 +32,21 @@ export type GetWorkspace = (args: {
userId?: string
}) => Promise<WorkspaceWithOptionalRole | null>
export type GetWorkspaceBySlug = (args: {
workspaceSlug: string
userId?: string
}) => Promise<WorkspaceWithOptionalRole | null>
export type GetWorkspaces = (args: {
workspaceIds: string[]
userId?: string
}) => Promise<WorkspaceWithOptionalRole[]>
export type GetWorkspacesBySlug = (args: {
workspaceIds: string[]
userId?: string
}) => Promise<WorkspaceWithOptionalRole[]>
export type StoreWorkspaceDomain = (args: {
workspaceDomain: WorkspaceDomain
}) => Promise<void>
@@ -18,6 +18,18 @@ export class WorkspaceInvalidUpdateError extends BaseError {
static statusCode = 400
}
export class WorkspaceSlugTakenError extends BaseError {
static defaultMessage = 'The given workspace slug is already taken'
static code = 'WORKSPACE_SLUG_TAKEN'
static statusCode = 400
}
export class WorkspaceSlugInvalidError extends BaseError {
static defaultMessage = 'The workspace slug is invalid'
static code = 'WORKSPACE_SLUG_INVALID'
static statusCode = 400
}
export class WorkspaceInvalidRoleError extends BaseError {
static defaultMessage = 'Invalid workspace role provided'
static code = 'WORKSPACE_INVALID_ROLE_ERROR'
@@ -73,7 +73,8 @@ import {
countProjectsVersionsByWorkspaceIdFactory,
countWorkspaceRoleWithOptionalProjectRoleFactory,
getUserIdsWithRoleInWorkspaceFactory,
getWorkspaceRoleForUserFactory
getWorkspaceRoleForUserFactory,
getWorkspaceBySlugFactory
} from '@/modules/workspaces/repositories/workspaces'
import {
buildWorkspaceInviteEmailContentsFactory,
@@ -90,8 +91,10 @@ import {
createWorkspaceFactory,
deleteWorkspaceFactory,
deleteWorkspaceRoleFactory,
generateValidSlugFactory,
updateWorkspaceFactory,
updateWorkspaceRoleFactory
updateWorkspaceRoleFactory,
validateSlugFactory
} from '@/modules/workspaces/services/management'
import {
getWorkspaceProjectsFactory,
@@ -215,6 +218,13 @@ export = FF_WORKSPACES_MODULE_ENABLED
token: args.token,
workspaceId: args.workspaceId
})
},
validateWorkspaceSlug: async (_parent, args) => {
const validateSlug = validateSlugFactory({
getWorkspaceBySlug: getWorkspaceBySlugFactory({ db })
})
await validateSlug({ slug: args.slug })
return true
}
},
Mutation: {
@@ -277,9 +287,15 @@ export = FF_WORKSPACES_MODULE_ENABLED
},
WorkspaceMutations: {
create: async (_parent, args, context) => {
const { name, description, defaultLogoIndex, logo } = args.input
const { name, description, defaultLogoIndex, logo, slug } = args.input
const createWorkspace = createWorkspaceFactory({
validateSlug: validateSlugFactory({
getWorkspaceBySlug: getWorkspaceBySlugFactory({ db })
}),
generateValidSlug: generateValidSlugFactory({
getWorkspaceBySlug: getWorkspaceBySlugFactory({ db })
}),
upsertWorkspace: upsertWorkspaceFactory({ db }),
upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }),
emitWorkspaceEvent: getEventBus().emit
@@ -289,6 +305,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
userId: context.userId!,
workspaceInput: {
name,
slug,
description: description ?? null,
logo: logo ?? null,
defaultLogoIndex: defaultLogoIndex ?? 0
@@ -331,6 +348,9 @@ export = FF_WORKSPACES_MODULE_ENABLED
)
const updateWorkspace = updateWorkspaceFactory({
validateSlug: validateSlugFactory({
getWorkspaceBySlug: getWorkspaceBySlugFactory({ db })
}),
getWorkspace: getWorkspaceWithDomainsFactory({ db }),
upsertWorkspace: upsertWorkspaceFactory({ db }),
emitWorkspaceEvent: getEventBus().emit
@@ -430,6 +450,9 @@ export = FF_WORKSPACES_MODULE_ENABLED
db
}),
updateWorkspace: updateWorkspaceFactory({
validateSlug: validateSlugFactory({
getWorkspaceBySlug: getWorkspaceBySlugFactory({ db })
}),
getWorkspace: getWorkspaceWithDomainsFactory({ db }),
upsertWorkspace: upsertWorkspaceFactory({ db }),
emitWorkspaceEvent: getEventBus().emit
@@ -3,6 +3,7 @@ import { buildTableHelper } from '@/modules/core/dbSchema'
export const Workspaces = buildTableHelper('workspaces', [
'id',
'name',
'slug',
'description',
'createdAt',
'updatedAt',
@@ -14,6 +14,7 @@ import {
GetUserDiscoverableWorkspaces,
GetUserIdsWithRoleInWorkspace,
GetWorkspace,
GetWorkspaceBySlug,
GetWorkspaceCollaborators,
GetWorkspaceCollaboratorsTotalCount,
GetWorkspaceDomains,
@@ -86,6 +87,32 @@ export const getUserDiscoverableWorkspacesFactory =
>[]
}
const workspaceWithRoleBaseQuery = ({
db,
userId
}: {
db: Knex
userId?: string
}): Knex.QueryBuilder<WorkspaceWithOptionalRole, WorkspaceWithOptionalRole[]> => {
let q = db<WorkspaceWithOptionalRole, WorkspaceWithOptionalRole[]>('workspaces')
if (userId) {
q = q
.select([
...Object.values(Workspaces.col),
// Getting first role from grouped results
knex.raw(`(array_agg("workspace_acl"."role"))[1] as role`)
])
.leftJoin(DbWorkspaceAcl.name, function () {
this.on(DbWorkspaceAcl.col.workspaceId, Workspaces.col.id).andOnVal(
DbWorkspaceAcl.col.userId,
userId
)
})
.groupBy(Workspaces.col.id)
}
return q
}
export const getWorkspacesFactory =
({ db }: { db: Knex }): GetWorkspaces =>
async (params: {
@@ -96,39 +123,28 @@ export const getWorkspacesFactory =
userId?: string
}) => {
const { workspaceIds, userId } = params
if (!workspaceIds?.length) return []
const q = Workspaces.knex<WorkspaceWithOptionalRole[]>(db).whereIn(
Workspaces.col.id,
workspaceIds
)
if (userId) {
q.select([
...Object.values(Workspaces.col),
// Getting first role from grouped results
knex.raw(`(array_agg("workspace_acl"."role"))[1] as role`)
])
q.leftJoin(DbWorkspaceAcl.name, function () {
this.on(DbWorkspaceAcl.col.workspaceId, Workspaces.col.id).andOnVal(
DbWorkspaceAcl.col.userId,
userId
)
})
q.groupBy(Workspaces.col.id)
}
const results = await q
const q = workspaceWithRoleBaseQuery({ db, userId })
const results = await q.whereIn(Workspaces.col.id, workspaceIds)
return results
}
export const getWorkspaceFactory =
({ db }: { db: Knex }): GetWorkspace =>
async ({ workspaceId, userId }) => {
const [workspace] = await getWorkspacesFactory({ db })({
workspaceIds: [workspaceId],
userId
})
const workspace = await workspaceWithRoleBaseQuery({ db, userId })
.where(Workspaces.col.id, workspaceId)
.first()
return workspace || null
}
export const getWorkspaceBySlugFactory =
({ db }: { db: Knex }): GetWorkspaceBySlug =>
async ({ workspaceSlug, userId }) => {
const workspace = await workspaceWithRoleBaseQuery({ db, userId })
.where(Workspaces.col.slug, workspaceSlug)
.first()
return workspace || null
}
@@ -143,6 +159,7 @@ export const upsertWorkspaceFactory =
.merge([
'description',
'logo',
'slug',
'defaultLogoIndex',
'defaultProjectRole',
'name',
@@ -10,6 +10,7 @@ import {
GetWorkspaceWithDomains,
GetWorkspaceDomains,
UpdateWorkspace,
GetWorkspaceBySlug,
UpdateWorkspaceRole
} from '@/modules/workspaces/domain/operations'
import {
@@ -18,7 +19,12 @@ import {
WorkspaceDomain,
WorkspaceWithDomains
} from '@/modules/workspacesCore/domain/types'
import { MaybeNullOrUndefined, Roles } from '@speckle/shared'
import {
generateSlugFromName,
MaybeNullOrUndefined,
Roles,
validateWorkspaceSlug
} from '@speckle/shared'
import cryptoRandomString from 'crypto-random-string'
import { deleteStream } from '@/modules/core/repositories/streams'
import {
@@ -33,6 +39,8 @@ import {
WorkspaceProtectedError,
WorkspaceUnverifiedDomainError,
WorkspaceNoVerifiedDomainsError,
WorkspaceSlugTakenError,
WorkspaceSlugInvalidError,
WorkspaceInvalidUpdateError
} from '@/modules/workspaces/errors/workspace'
import { isUserLastWorkspaceAdmin } from '@/modules/workspaces/helpers/roles'
@@ -61,6 +69,7 @@ type WorkspaceCreateArgs = {
userId: string
workspaceInput: {
name: string
slug?: string | null
description: string | null
logo: string | null
defaultLogoIndex: number
@@ -68,14 +77,58 @@ type WorkspaceCreateArgs = {
userResourceAccessLimits: MaybeNullOrUndefined<TokenResourceIdentifier[]>
}
type GenerateValidSlug = (args: { name: string }) => Promise<string>
type ValidateWorkspaceSlug = (args: { slug: string }) => Promise<void>
export const validateSlugFactory =
({
getWorkspaceBySlug
}: {
getWorkspaceBySlug: GetWorkspaceBySlug
}): ValidateWorkspaceSlug =>
async ({ slug }) => {
try {
validateWorkspaceSlug(slug)
} catch (err) {
if (err instanceof Error) throw new WorkspaceSlugInvalidError(err.message)
throw err
}
const maybeClashingWorkspace = await getWorkspaceBySlug({
workspaceSlug: slug
})
if (maybeClashingWorkspace) throw new WorkspaceSlugTakenError()
}
export const generateValidSlugFactory =
({
getWorkspaceBySlug
}: {
getWorkspaceBySlug: GetWorkspaceBySlug
}): GenerateValidSlug =>
async ({ name }) => {
const generatedSlug = generateSlugFromName({ name })
const maybeClashingWorkspace = await getWorkspaceBySlug({
workspaceSlug: generatedSlug
})
return maybeClashingWorkspace
? `${generatedSlug}-${cryptoRandomString({ length: 5 })}`
: generatedSlug
}
export const createWorkspaceFactory =
({
upsertWorkspace,
upsertWorkspaceRole,
generateValidSlug,
validateSlug,
emitWorkspaceEvent
}: {
upsertWorkspace: UpsertWorkspace
upsertWorkspaceRole: UpsertWorkspaceRole
validateSlug: ValidateWorkspaceSlug
generateValidSlug: GenerateValidSlug
emitWorkspaceEvent: EventBus['emit']
}) =>
async ({
@@ -92,8 +145,16 @@ export const createWorkspaceFactory =
throw new ForbiddenError('You are not authorized to create a workspace')
}
let slug: string
if (workspaceInput.slug) {
await validateSlug({ slug: workspaceInput.slug })
slug = workspaceInput.slug
} else {
slug = await generateValidSlug(workspaceInput)
}
const workspace = {
...workspaceInput,
slug,
id: cryptoRandomString({ length: 10 }),
createdAt: new Date(),
updatedAt: new Date(),
@@ -168,10 +229,12 @@ const sanitizeInput = (input: Partial<Workspace>) => {
export const updateWorkspaceFactory =
({
getWorkspace,
validateSlug,
upsertWorkspace,
emitWorkspaceEvent
}: {
getWorkspace: GetWorkspaceWithDomains
validateSlug: ValidateWorkspaceSlug
upsertWorkspace: UpsertWorkspace
emitWorkspaceEvent: EventBus['emit']
}): UpdateWorkspace =>
@@ -182,7 +245,6 @@ export const updateWorkspaceFactory =
throw new WorkspaceNotFoundError()
}
// Validate incoming changes
if (
!isValidInput(workspaceInput) ||
!isValidWorkspace(workspaceInput, currentWorkspace)
@@ -190,6 +252,8 @@ export const updateWorkspaceFactory =
throw new WorkspaceInvalidUpdateError()
}
if (workspaceInput.slug) await validateSlug({ slug: workspaceInput.slug })
const workspace = {
...omit(currentWorkspace, 'domains'),
...sanitizeInput(workspaceInput),
@@ -19,7 +19,8 @@ import {
getWorkspaceFactory,
getWorkspaceWithDomainsFactory,
getWorkspaceDomainsFactory,
storeWorkspaceDomainFactory
storeWorkspaceDomainFactory,
getWorkspaceBySlugFactory
} from '@/modules/workspaces/repositories/workspaces'
import {
buildWorkspaceInviteEmailContentsFactory,
@@ -31,10 +32,13 @@ import {
updateWorkspaceRoleFactory,
deleteWorkspaceRoleFactory,
updateWorkspaceFactory,
addDomainToWorkspaceFactory
addDomainToWorkspaceFactory,
validateSlugFactory,
generateValidSlugFactory
} from '@/modules/workspaces/services/management'
import { BasicTestUser } from '@/test/authHelper'
import { CreateWorkspaceInviteMutationVariables } from '@/test/graphql/generated/graphql'
import cryptoRandomString from 'crypto-random-string'
import {
MaybeNullOrUndefined,
Roles,
@@ -51,6 +55,7 @@ export type BasicTestWorkspace = {
* Leave empty, will be filled on creation
*/
ownerId: string
slug: string
name: string
description?: string
logo?: string
@@ -60,11 +65,17 @@ export type BasicTestWorkspace = {
}
export const createTestWorkspace = async (
workspace: BasicTestWorkspace,
workspace: Omit<BasicTestWorkspace, 'slug'> & { slug?: string },
owner: BasicTestUser,
domain?: string
) => {
const createWorkspace = createWorkspaceFactory({
validateSlug: validateSlugFactory({
getWorkspaceBySlug: getWorkspaceBySlugFactory({ db })
}),
generateValidSlug: generateValidSlugFactory({
getWorkspaceBySlug: getWorkspaceBySlugFactory({ db })
}),
upsertWorkspace: upsertWorkspaceFactory({ db }),
upsertWorkspaceRole: upsertWorkspaceRoleFactory({ db }),
emitWorkspaceEvent: (...args) => getEventBus().emit(...args)
@@ -74,6 +85,7 @@ export const createTestWorkspace = async (
userId: owner.id,
workspaceInput: {
name: workspace.name,
slug: workspace.slug || cryptoRandomString({ length: 10 }),
description: workspace.description || null,
logo: workspace.logo || null,
defaultLogoIndex: 0
@@ -100,13 +112,17 @@ export const createTestWorkspace = async (
}
const updateWorkspace = updateWorkspaceFactory({
validateSlug: validateSlugFactory({
getWorkspaceBySlug: getWorkspaceBySlugFactory({ db })
}),
getWorkspace: getWorkspaceWithDomainsFactory({ db }),
upsertWorkspace: upsertWorkspaceFactory({ db }),
emitWorkspaceEvent: getEventBus().emit
emitWorkspaceEvent: (...args) => getEventBus().emit(...args)
})
if (workspace.discoverabilityEnabled) {
if (!domain) throw new Error('Domain is needed for discoverability')
await updateWorkspace({
workspaceId: newWorkspace.id,
workspaceInput: {
@@ -4,6 +4,7 @@ export const basicWorkspaceFragment = gql`
fragment BasicWorkspace on Workspace {
id
name
slug
updatedAt
createdAt
role
@@ -78,6 +78,7 @@ import { createRandomPassword } from '@/modules/core/helpers/testHelpers'
import { addOrUpdateStreamCollaborator } from '@/modules/core/services/streams/streamAccessService'
import { WorkspaceProtectedError } from '@/modules/workspaces/errors/workspace'
import { ForbiddenError } from '@/modules/shared/errors'
import cryptoRandomString from 'crypto-random-string'
enum InviteByTarget {
Email = 'email',
@@ -234,6 +235,7 @@ describe('Workspaces Invites GQL', () => {
name: 'My First Workspace',
id: '',
ownerId: '',
slug: cryptoRandomString({ length: 10 }),
domainBasedMembershipProtectionEnabled: false
}
@@ -241,12 +243,14 @@ describe('Workspaces Invites GQL', () => {
name: 'My Domain protected workspace',
id: '',
ownerId: '',
slug: cryptoRandomString({ length: 10 }),
domainBasedMembershipProtectionEnabled: true
}
const otherGuysWorkspace: BasicTestWorkspace = {
name: 'Other Guy Workspace',
id: '',
slug: cryptoRandomString({ length: 10 }),
ownerId: ''
}
@@ -587,6 +591,7 @@ describe('Workspaces Invites GQL', () => {
const myProjectInviteTargetWorkspace: BasicTestWorkspace = {
name: 'My Project Invite Target Workspace #1',
id: '',
slug: cryptoRandomString({ length: 10 }),
ownerId: ''
}
@@ -783,6 +788,7 @@ describe('Workspaces Invites GQL', () => {
const myAdministrationWorkspace: BasicTestWorkspace = {
name: 'My Administration Workspace',
id: '',
slug: cryptoRandomString({ length: 10 }),
ownerId: ''
}
@@ -908,6 +914,7 @@ describe('Workspaces Invites GQL', () => {
const myInviteTargetWorkspace: BasicTestWorkspace = {
name: 'My Invite Target Workspace',
id: '',
slug: cryptoRandomString({ length: 10 }),
ownerId: ''
}
const myInviteTargetWorkspaceStream1: BasicTestStream = {
@@ -1122,6 +1129,7 @@ describe('Workspaces Invites GQL', () => {
const brokenWorkspace: BasicTestWorkspace = {
name: 'Broken Workspace',
id: 'a',
slug: cryptoRandomString({ length: 10 }),
ownerId: ''
}
await createTestWorkspaces([[brokenWorkspace, me]])
@@ -1557,6 +1565,7 @@ describe('Workspaces Invites GQL', () => {
const otherWorkspace: BasicTestWorkspace = {
name: 'Other Workspace',
id: '',
slug: cryptoRandomString({ length: 10 }),
ownerId: ''
}
@@ -33,6 +33,7 @@ describe('Workspace project GQL CRUD', () => {
const workspace: BasicTestWorkspace = {
id: '',
ownerId: '',
slug: cryptoRandomString({ length: 10 }),
name: 'My Test Workspace'
}
@@ -185,6 +186,7 @@ describe('Workspace project GQL CRUD', () => {
const targetWorkspace: BasicTestWorkspace = {
id: '',
ownerId: '',
slug: cryptoRandomString({ length: 10 }),
name: 'Target Workspace'
}
@@ -11,7 +11,8 @@ import {
getUserDiscoverableWorkspacesFactory,
getWorkspaceWithDomainsFactory,
countWorkspaceRoleWithOptionalProjectRoleFactory,
getWorkspaceCollaboratorsFactory
getWorkspaceCollaboratorsFactory,
getWorkspaceBySlugFactory
} from '@/modules/workspaces/repositories/workspaces'
import db from '@/db/knex'
import cryptoRandomString from 'crypto-random-string'
@@ -40,8 +41,10 @@ import {
grantStreamPermissions,
upsertProjectRoleFactory
} from '@/modules/core/repositories/streams'
import { omit } from 'lodash'
const getWorkspace = getWorkspaceFactory({ db })
const getWorkspaceBySlug = getWorkspaceBySlugFactory({ db })
const getWorkspaceCollaborators = getWorkspaceCollaboratorsFactory({ db })
const upsertWorkspace = upsertWorkspaceFactory({ db })
const deleteWorkspace = deleteWorkspaceFactory({ db })
@@ -77,6 +80,7 @@ const createAndStoreTestWorkspace = async (
) => {
const workspace: Omit<Workspace, 'domains'> = {
id: cryptoRandomString({ length: 10 }),
slug: cryptoRandomString({ length: 10 }),
name: cryptoRandomString({ length: 10 }),
createdAt: new Date(),
updatedAt: new Date(),
@@ -105,6 +109,37 @@ describe('Workspace repositories', () => {
// not testing get here, we're going to use that for testing upsert
})
describe('getWorkspaceBySlugFactory creates a function, that', () => {
it('returns null if the workspace is not found', async () => {
const workspace = await getWorkspaceBySlug({
workspaceSlug: cryptoRandomString({ length: 10 })
})
expect(workspace).to.be.null
})
it('returns the workspace', async () => {
const testUserA: BasicTestUser = {
id: '',
name: 'John A Speckle',
email: 'john@example.speckle',
role: Roles.Server.Admin
}
const testWorkspace: BasicTestWorkspace = {
id: '',
ownerId: '',
slug: cryptoRandomString({ length: 10 }),
name: 'Test Workspace'
}
await createTestUsers([testUserA])
await createTestWorkspace(testWorkspace, testUserA)
const workspace = await getWorkspaceBySlug({
workspaceSlug: testWorkspace.slug
})
expect(workspace?.id).to.be.equal(testWorkspace.id)
})
})
describe('getWorkspaceCollaboratorsFactory creates a function, that', () => {
const testUserA: BasicTestUser = {
id: '',
@@ -133,6 +168,7 @@ describe('Workspace repositories', () => {
const testWorkspace: BasicTestWorkspace = {
id: '',
ownerId: '',
slug: cryptoRandomString({ length: 10 }),
name: 'Test Workspace'
}
@@ -159,17 +195,20 @@ describe('Workspace repositories', () => {
{
id: '',
ownerId: '',
name: 'Test Workspace A'
name: 'Test Workspace A',
slug: cryptoRandomString({ length: 10 })
},
{
id: '',
ownerId: '',
name: 'Test Workspace B'
name: 'Test Workspace B',
slug: cryptoRandomString({ length: 10 })
},
{
id: '',
ownerId: '',
name: 'Test Workspace C'
name: 'Test Workspace C',
slug: cryptoRandomString({ length: 10 })
}
]
@@ -229,11 +268,16 @@ describe('Workspace repositories', () => {
const storedWorkspace = await getWorkspace({ workspaceId: testWorkspace.id })
expect(storedWorkspace).to.deep.equal(testWorkspace)
const updateData = {
slug: cryptoRandomString({ length: 10 }),
name: cryptoRandomString({ length: 20 }),
createdAt: new Date()
}
await upsertWorkspace({
workspace: {
...testWorkspace,
id: cryptoRandomString({ length: 13 }),
createdAt: new Date()
...updateData
}
})
@@ -241,7 +285,10 @@ describe('Workspace repositories', () => {
workspaceId: testWorkspace.id
})
expect(modifiedStoredWorkspace).to.deep.equal(testWorkspace)
expect(modifiedStoredWorkspace).to.deep.equal({
...testWorkspace,
...omit(updateData, ['createdAt'])
})
})
})
@@ -255,6 +302,7 @@ describe('Workspace repositories', () => {
const workspace: BasicTestWorkspace = {
id: '',
ownerId: '',
slug: cryptoRandomString({ length: 10 }),
name: 'Incredibly Forgettable'
}
@@ -753,6 +801,7 @@ describe('Workspace repositories', () => {
const workspace = {
id: createRandomPassword(),
name: 'my workspace',
slug: cryptoRandomString({ length: 10 }),
ownerId: user.id
}
await createTestWorkspace(workspace, user)
@@ -786,6 +835,7 @@ describe('Workspace repositories', () => {
const workspace = {
id: createRandomPassword(),
name: 'my workspace',
slug: cryptoRandomString({ length: 10 }),
ownerId: admin.id
}
await createTestWorkspace(workspace, admin)
@@ -794,6 +844,7 @@ describe('Workspace repositories', () => {
const workspace2 = {
id: createRandomPassword(),
name: 'my workspace',
slug: cryptoRandomString({ length: 10 }),
ownerId: admin.id
}
await createTestWorkspace(workspace2, admin)
@@ -835,6 +886,7 @@ describe('Workspace repositories', () => {
const workspace = {
id: createRandomPassword(),
name: 'my workspace',
slug: cryptoRandomString({ length: 10 }),
ownerId: admin.id
}
@@ -906,6 +958,7 @@ describe('Workspace repositories', () => {
const workspace = {
id: createRandomPassword(),
name: 'my workspace',
slug: cryptoRandomString({ length: 10 }),
ownerId: admin.id
}
await createTestWorkspace(workspace, admin)
@@ -983,6 +1036,7 @@ describe('Workspace repositories', () => {
const workspace1 = {
id: createRandomPassword(),
name: 'my workspace',
slug: cryptoRandomString({ length: 10 }),
ownerId: admin.id
}
await createTestWorkspace(workspace1, admin)
@@ -990,6 +1044,7 @@ describe('Workspace repositories', () => {
const workspace2 = {
id: createRandomPassword(),
name: 'my workspace 2',
slug: cryptoRandomString({ length: 10 }),
ownerId: admin.id
}
await createTestWorkspace(workspace2, admin)
@@ -26,6 +26,7 @@ import { beforeEachContext, truncateTables } from '@/test/hooks'
import { BasicTestStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
import { Roles } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import { isUndefined } from 'lodash'
describe('Workspaces Roles GQL', () => {
@@ -64,6 +65,7 @@ describe('Workspaces Roles GQL', () => {
const workspace: BasicTestWorkspace = {
id: '',
ownerId: '',
slug: cryptoRandomString({ length: 10 }),
name: 'My Test Workspace'
}
@@ -187,6 +189,7 @@ describe('Workspaces Roles GQL', () => {
const workspace: BasicTestWorkspace = {
id: '',
ownerId: '',
slug: cryptoRandomString({ length: 10 }),
name: 'Test Workspace',
defaultProjectRole: Roles.Stream.Reviewer
}
@@ -531,6 +534,7 @@ describe('Workspaces Roles GQL', () => {
const workspace: BasicTestWorkspace = {
id: '',
ownerId: '',
slug: cryptoRandomString({ length: 10 }),
name: 'Test Workspace w/ Projects'
}
@@ -138,7 +138,8 @@ describe('Workspaces GQL CRUD', () => {
const workspace: BasicTestWorkspace = {
id: '',
ownerId: '',
name: 'Workspace A'
name: 'Workspace A',
slug: cryptoRandomString({ length: 10 })
}
const testMemberUser: BasicTestUser = {
@@ -188,6 +189,7 @@ describe('Workspaces GQL CRUD', () => {
id: '',
ownerId: '',
name: 'My Large Workspace',
slug: cryptoRandomString({ length: 10 }),
description: 'A workspace with many users and roles to test pagination.'
}
@@ -529,7 +531,10 @@ describe('Workspaces GQL CRUD', () => {
it('should return workspace cost', async () => {
const createRes = await apollo.execute(CreateWorkspaceDocument, {
input: { name: createRandomString() }
input: {
name: createRandomString(),
slug: cryptoRandomString({ length: 10 })
}
})
expect(createRes).to.not.haveGraphQLErrors()
const workspaceId = createRes.data!.workspaceMutations.create.id
@@ -666,6 +671,7 @@ describe('Workspaces GQL CRUD', () => {
const workspace = {
id: '',
name: 'test ws',
slug: cryptoRandomString({ length: 10 }),
ownerId: ''
}
await createTestWorkspace(workspace, testMemberUser)
@@ -710,9 +716,10 @@ describe('Workspaces GQL CRUD', () => {
describe('mutation workspaceMutations.create', () => {
it('should create a workspace', async () => {
const workspaceName = cryptoRandomString({ length: 6 })
const workspaceSlug = cryptoRandomString({ length: 10 })
const createRes = await apollo.execute(CreateWorkspaceDocument, {
input: { name: workspaceName }
input: { name: workspaceName, slug: workspaceSlug }
})
const getRes = await apollo.execute(GetWorkspaceDocument, {
workspaceId: createRes.data!.workspaceMutations.create.id
@@ -722,6 +729,7 @@ describe('Workspaces GQL CRUD', () => {
expect(getRes).to.not.haveGraphQLErrors()
expect(getRes.data?.workspace).to.exist
expect(getRes.data?.workspace?.name).to.equal(workspaceName)
expect(getRes.data?.workspace?.slug).to.equal(workspaceSlug)
})
})
@@ -729,6 +737,7 @@ describe('Workspaces GQL CRUD', () => {
const workspace: BasicTestWorkspace = {
id: '',
ownerId: '',
slug: cryptoRandomString({ length: 10 }),
name: 'My Test Workspace'
}
@@ -825,7 +834,7 @@ describe('Workspaces GQL CRUD', () => {
})
describe('mutation workspaceMutations.update', () => {
const workspace: BasicTestWorkspace = {
const workspace = {
id: '',
ownerId: '',
name: cryptoRandomString({ length: 6 }),
@@ -927,7 +936,7 @@ describe('Workspaces GQL CRUD', () => {
it('allows the active user to leave a workspace', async () => {
const name = cryptoRandomString({ length: 6 })
const workspaceCreateResult = await apollo.execute(CreateWorkspaceDocument, {
input: { name }
input: { name, slug: cryptoRandomString({ length: 10 }) }
})
expect(workspaceCreateResult).to.not.haveGraphQLErrors()
@@ -970,7 +979,7 @@ describe('Workspaces GQL CRUD', () => {
it('stops the last workspace admin from leaving the workspace', async () => {
const name = cryptoRandomString({ length: 6 })
const workspaceCreateResult = await apollo.execute(CreateWorkspaceDocument, {
input: { name }
input: { name, slug: cryptoRandomString({ length: 10 }) }
})
const id = workspaceCreateResult.data?.workspaceMutations.create.id
@@ -1000,7 +1009,7 @@ describe('Workspaces GQL CRUD', () => {
const workspaceName = cryptoRandomString({ length: 6 })
const createRes = await apollo.execute(CreateWorkspaceDocument, {
input: { name: workspaceName }
input: { name: workspaceName, slug: cryptoRandomString({ length: 10 }) }
})
expect(createRes).to.not.haveGraphQLErrors()
const workspaceId = createRes.data!.workspaceMutations.create.id
@@ -24,6 +24,7 @@ describe('workspace domain services', () => {
defaultLogoIndex: 0,
name: cryptoRandomString({ length: 10 }),
logo: null,
slug: cryptoRandomString({ length: 10 }),
createdAt: new Date(),
updatedAt: new Date(),
description: '',
@@ -47,6 +48,7 @@ describe('workspace domain services', () => {
defaultLogoIndex: 0,
name: cryptoRandomString({ length: 10 }),
logo: null,
slug: cryptoRandomString({ length: 10 }),
createdAt: new Date(),
updatedAt: new Date(),
description: '',
@@ -24,6 +24,7 @@ const createTestWorkspaceWithDomains = (
createdAt: new Date(),
updatedAt: new Date(),
name: createRandomPassword(),
slug: createRandomPassword(),
description: createRandomPassword(),
id: createRandomPassword(),
logo: null,
@@ -8,10 +8,12 @@ import {
addDomainToWorkspaceFactory,
createWorkspaceFactory,
deleteWorkspaceRoleFactory,
generateValidSlugFactory,
updateWorkspaceFactory,
updateWorkspaceRoleFactory
updateWorkspaceRoleFactory,
validateSlugFactory
} from '@/modules/workspaces/services/management'
import { Roles } from '@speckle/shared'
import { Roles, validateWorkspaceSlug } from '@speckle/shared'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import {
@@ -27,6 +29,8 @@ import {
WorkspaceNotFoundError,
WorkspaceNoVerifiedDomainsError,
WorkspaceProtectedError,
WorkspaceSlugInvalidError,
WorkspaceSlugTakenError,
WorkspaceUnverifiedDomainError
} from '@/modules/workspaces/errors/workspace'
import { UserEmail } from '@/modules/core/domain/userEmails/types'
@@ -66,6 +70,8 @@ const buildCreateWorkspaceWithTestContext = (
}) => {
context.storedWorkspaces.push(workspace)
},
validateSlug: async () => {},
generateValidSlug: async () => cryptoRandomString({ length: 10 }),
upsertWorkspaceRole: async (workspaceAcl: WorkspaceAcl) => {
context.storedRoles.push(workspaceAcl)
},
@@ -88,6 +94,7 @@ const getCreateWorkspaceInput = () => {
userId: cryptoRandomString({ length: 10 }),
workspaceInput: {
description: 'foobar',
slug: cryptoRandomString({ length: 10 }),
logo: null,
name: cryptoRandomString({ length: 6 }),
defaultLogoIndex: 0
@@ -96,19 +103,117 @@ const getCreateWorkspaceInput = () => {
}
describe('Workspace services', () => {
describe('isSlugValid', () => {
it('throws for url unsafe characters', async () => {
const err = await expectToThrow(() => {
validateWorkspaceSlug('{{{}}}}}')
})
expect(err.message).to.contain('only lowercase letters, numbers')
})
it('throws for too short inputs', async () => {
const err = await expectToThrow(() => {
validateWorkspaceSlug('{')
})
expect(err.message).to.contain('characters long.')
})
it('throws for too long inputs', async () => {
const err = await expectToThrow(() => {
validateWorkspaceSlug(cryptoRandomString({ length: 31 }))
})
expect(err.message).to.contain('slug must not exceed')
})
it('throws for invalid start', async () => {
const err = await expectToThrow(() => {
validateWorkspaceSlug('-asdfasdf-')
})
expect(err.message).to.contain('cannot start or end with a')
})
it('returns true for valid slugs', () => {
validateWorkspaceSlug('asdf-asdf')
// if it did not throw, we're good
expect(true)
})
})
describe('validateSlugFactory creates a function, that', () => {
it('throws WorkspaceSlugTakenError if the input slug clashes an existing workspace', async () => {
const validateSlug = validateSlugFactory({
getWorkspaceBySlug: async () =>
({ id: cryptoRandomString({ length: 10 }) } as Workspace)
})
const err = await expectToThrow(async () => {
await validateSlug({
slug: cryptoRandomString({ length: 10 })
})
})
expect(err.message).to.be.equal(new WorkspaceSlugTakenError().message)
})
it('throws validation error for invalid slugs', async () => {
const validateSlug = validateSlugFactory({
getWorkspaceBySlug: async () => null
})
const err = await expectToThrow(async () => {
await validateSlug({
slug: '-----'
})
})
expect(err.message).to.contain('cannot start or end with a hyphen')
})
})
describe('generateValidSlugFactory creates a function, that', () => {
it('generates a slug from the input name', async () => {
const slug = await generateValidSlugFactory({
getWorkspaceBySlug: async () => null
})({ name: 'Foo bAr{ }baZ' })
expect(slug).to.be.equal('foo-bar-baz')
})
it('adds a random string to the generated slug if it clashes an existing one', async () => {
const slug = await generateValidSlugFactory({
getWorkspaceBySlug: async () =>
({ id: cryptoRandomString({ length: 10 }) } as Workspace)
})({ name: 'FoobAr' })
expect(slug).contain('foobar-')
expect(slug.length).to.be.equal(12)
})
})
describe('createWorkspaceFactory creates a function, that', () => {
it('stores the workspace', async () => {
const { context, createWorkspace } = buildCreateWorkspaceWithTestContext()
it('throws WorkspaceSlugInvalidError if the input slug is not valid', async () => {
const { createWorkspace } = buildCreateWorkspaceWithTestContext({
validateSlug: async () => {
throw new WorkspaceSlugInvalidError()
}
})
const { userId, workspaceInput } = getCreateWorkspaceInput()
const err = await expectToThrow(async () => {
await createWorkspace({
userId,
workspaceInput: { ...workspaceInput, slug: 'asdf{{}}}' },
userResourceAccessLimits: null
})
})
expect(err.message).to.be.equal(new WorkspaceSlugInvalidError().message)
})
it('generates a workspace slug from the workspace name', async () => {
const generatedSlug = cryptoRandomString({ length: 10 })
const { userId, workspaceInput } = getCreateWorkspaceInput()
const { context, createWorkspace } = buildCreateWorkspaceWithTestContext({
generateValidSlug: async () => generatedSlug
})
const workspace = await createWorkspace({
userId,
workspaceInput,
workspaceInput: { ...workspaceInput, slug: null },
userResourceAccessLimits: null
})
expect(context.storedWorkspaces.length).to.equal(1)
expect(context.storedWorkspaces[0]).to.deep.equal(omit(workspace, 'domains'))
expect(omit(context.storedWorkspaces[0], 'slug')).to.deep.equal(
omit(workspace, 'domains', 'slug')
)
expect(context.storedWorkspaces[0].slug).to.equal(generatedSlug)
})
it('makes the workspace creator becomes a workspace:admin', async () => {
const { context, createWorkspace } = buildCreateWorkspaceWithTestContext()
@@ -150,6 +255,7 @@ describe('Workspace services', () => {
const workspaceId = cryptoRandomString({ length: 10 })
const workspace: WorkspaceWithDomains = {
id: workspaceId,
slug: cryptoRandomString({ length: 10 }),
name: cryptoRandomString({ length: 10 }),
description: cryptoRandomString({ length: 20 }),
createdAt: new Date(),
@@ -167,6 +273,9 @@ describe('Workspace services', () => {
const err = await expectToThrow(async () => {
await updateWorkspaceFactory({
getWorkspace: async () => null,
validateSlug: async () => {
expect.fail()
},
emitWorkspaceEvent: async () => {
expect.fail()
},
@@ -188,6 +297,9 @@ describe('Workspace services', () => {
emitWorkspaceEvent: async () => {
expect.fail()
},
validateSlug: async () => {
expect.fail()
},
upsertWorkspace: async () => {
expect.fail()
}
@@ -208,6 +320,9 @@ describe('Workspace services', () => {
emitWorkspaceEvent: async () => {
expect.fail()
},
validateSlug: async () => {
expect.fail()
},
upsertWorkspace: async () => {
expect.fail()
}
@@ -220,6 +335,29 @@ describe('Workspace services', () => {
})
expect(err.message).to.be.equal('Provided logo is malformed')
})
it('validates description length', async () => {
const workspace = createTestWorkspaceWithDomainsData()
const err = await expectToThrow(async () => {
await updateWorkspaceFactory({
getWorkspace: async () => workspace,
emitWorkspaceEvent: async () => {
expect.fail()
},
validateSlug: async () => {
throw new WorkspaceSlugInvalidError()
},
upsertWorkspace: async () => {
expect.fail()
}
})({
workspaceId: workspace.id,
workspaceInput: {
slug: '{}{}{}{}'
}
})
})
expect(err.message).to.be.equal(new WorkspaceSlugInvalidError().message)
})
it('does not allow turning on discoverability if the workspace has no verified domains', async () => {
const workspace = createTestWorkspaceWithDomainsData()
const err = await expectToThrow(async () => {
@@ -228,6 +366,7 @@ describe('Workspace services', () => {
emitWorkspaceEvent: async () => {
expect.fail()
},
validateSlug: async () => {},
upsertWorkspace: async () => {
expect.fail()
}
@@ -249,6 +388,7 @@ describe('Workspace services', () => {
emitWorkspaceEvent: async () => {
expect.fail()
},
validateSlug: async () => {},
upsertWorkspace: async () => {
expect.fail()
}
@@ -271,6 +411,8 @@ describe('Workspace services', () => {
emitWorkspaceEvent: async () => {
return []
},
validateSlug: async () => {},
upsertWorkspace: async ({ workspace }) => {
newWorkspaceName = workspace.name
}
@@ -309,6 +451,7 @@ describe('Workspace services', () => {
emitWorkspaceEvent: async () => {
return []
},
validateSlug: async () => {},
upsertWorkspace: async ({ workspace }) => {
updatedWorkspace = workspace
}
@@ -932,6 +1075,7 @@ describe('Workspace role services', () => {
userId,
id: workspaceId,
name: cryptoRandomString({ length: 10 }),
slug: cryptoRandomString({ length: 10 }),
logo: null,
createdAt: new Date(),
updatedAt: new Date(),
@@ -975,6 +1119,7 @@ describe('Workspace role services', () => {
const workspace: Workspace = {
id: workspaceId,
name: cryptoRandomString({ length: 10 }),
slug: cryptoRandomString({ length: 10 }),
logo: null,
createdAt: new Date(),
updatedAt: new Date(),
@@ -1030,6 +1175,7 @@ describe('Workspace role services', () => {
const workspace: Workspace = {
id: workspaceId,
name: cryptoRandomString({ length: 10 }),
slug: cryptoRandomString({ length: 10 }),
logo: null,
createdAt: new Date(),
updatedAt: new Date(),
@@ -1091,6 +1237,7 @@ describe('Workspace role services', () => {
const workspaceWithoutDomains = {
id: workspaceId,
name: cryptoRandomString({ length: 10 }),
slug: cryptoRandomString({ length: 10 }),
logo: null,
createdAt: new Date(),
updatedAt: new Date(),