Files
speckle-server/packages/server/modules/serverinvites/tests/invites.spec.js
T
Gergő Jedlicska c6cd4c311d feat(serverinvites): create domain module in server invites (#2401)
* chore(serverinvites): repository refactor for multiregion

* chore(serverinvites): remove migrated functions from old repository

* chore(serverinvites): refactor serverInviteForToken resolver for multiregion

* chore(serverinvites): invite processing service refactor for multiregion

* chore(serverinvites): subscription refactor for multiregion

* chore(serverinvites): move buildEmailContents to dedicated file

* chore(serverinvites): deleteAllStreamInvites function multiregion refactor

* chore(serverinvites): refactor deleteServerOnlyInvites multiregion repository

* chore(serverinvites): complete repository refactor for multiregion

* feat(serverinvites): create domain module in server invites

* fix(serverinvites): no relative imports

* feat(serverinvites): extract individual types from repository

* feat(serverinvites): move interfaces to operations

* fix(serverinvites): update imports referencing old interfaces file

* fix(serverinvites): type mismatch for insert invite and delete old

* chore(serverinvites): refactor to single repo function

* test(serverinvites): fix tests

* fix(serverinvites): use domain types in all places

* feat(serverinvites): WIP unity

* feat(serverinvites): move to new facory names and types

* feat(serverinvites): fix tests

* fix(serverinvites): use factory name

---------

Co-authored-by: Alessandro Magionami <alessandro.magionami@gmail.com>
2024-06-25 13:24:37 +02:00

812 lines
25 KiB
JavaScript

const crs = require('crypto-random-string')
const { buildApolloServer } = require('@/app')
const { Streams, Users, ServerInvites } = require('@/modules/core/dbSchema')
const { Roles, AllScopes } = require('@/modules/core/helpers/mainConstants')
const { createUser } = require('@/modules/core/services/users')
const { addLoadersToCtx } = require('@/modules/shared/middleware')
const {
createServerInvite,
createStreamInvite,
resendInvite,
batchCreateServerInvites,
batchCreateStreamInvites,
getStreamInvite,
useUpStreamInvite,
cancelStreamInvite,
getStreamPendingCollaborators,
getStreamInvites,
deleteInvite
} = require('@/test/graphql/serverInvites')
const { truncateTables } = require('@/test/hooks')
const { expect } = require('chai')
const {
createStream,
grantPermissionsStream
} = require('@/modules/core/services/streams')
const { getUserStreamRole } = require('@/test/speckle-helpers/streamHelper')
const { createInviteDirectlyFactory } = require('@/test/speckle-helpers/inviteHelper')
const { buildAuthenticatedApolloServer } = require('@/test/serverHelper')
const { EmailSendingServiceMock } = require('@/test/mocks/global')
const db = require('@/db/knex')
const {
findInviteByTokenFactory,
findInviteFactory
} = require('@/modules/serverinvites/repositories/serverInvites')
async function cleanup() {
await truncateTables([ServerInvites.name, Streams.name, Users.name])
}
const findInviteByToken = findInviteByTokenFactory({ db })
const findInvite = findInviteFactory({ db })
function getInviteTokenFromEmailParams(emailParams) {
const { text } = emailParams
const [, inviteId] = text.match(/\?token=(.*?)(\s|&)/i)
return inviteId
}
const createInviteDirectly = createInviteDirectlyFactory({ db })
async function validateInviteExistanceFromEmail(emailParams) {
// Validate that invite exists
const token = getInviteTokenFromEmailParams(emailParams)
expect(token).to.be.ok
const invite = await findInviteByToken(token)
expect(invite).to.be.ok
return invite
}
const mailerMock = EmailSendingServiceMock
describe('[Stream & Server Invites]', () => {
const me = {
name: 'Authenticated server invites guy',
email: 'serverinvitesguy@gmail.com',
password: 'sn3aky-1337-b1m',
id: undefined
}
const otherGuy = {
name: 'Some Other DUde',
email: 'otherguy111@gmail.com',
password: 'sn3aky-1337-b1m',
id: undefined
}
const myPrivateStream = {
name: 'My Private Stream 1',
isPublic: false,
id: undefined
}
const otherGuysStream = {
name: 'Other guys stream 1',
isPublic: false,
id: undefined
}
before(async () => {
await cleanup()
// Seeding
await Promise.all([
createUser(me).then((id) => (me.id = id)),
createUser(otherGuy).then((id) => (otherGuy.id = id))
])
await Promise.all([
createStream({ ...myPrivateStream, ownerId: me.id }).then(
(id) => (myPrivateStream.id = id)
),
createStream({ ...otherGuysStream, ownerId: otherGuy.id }).then(
(id) => (otherGuysStream.id = id)
)
])
})
after(async () => {
await cleanup()
})
afterEach(() => {
mailerMock.resetMockedFunctions()
})
describe('When user authenticated', () => {
/** @type {import('apollo-server-express').ApolloServer} */
let apollo
before(async () => {
apollo = await buildApolloServer({
context: () =>
addLoadersToCtx({
auth: true,
userId: me.id,
role: Roles.Server.User,
token: 'asd',
scopes: AllScopes
})
})
})
describe('and inviting to server', () => {
const createInvite = (input) => createServerInvite(apollo, input)
it("can't invite an already registered user", async () => {
const { errors, data } = await createInvite({
email: otherGuy.email,
message: 'hey dude'
})
expect(data?.serverInviteCreate).to.be.not.ok
expect(errors).to.be.ok
expect(errors.map((e) => e.message).join('|')).to.contain(
'email is already associated with an account'
)
})
it('can invite new user', async () => {
const targetEmail = 'randomguy@random.com'
const messagePart1 = '1234hiiiiduuuuude'
const messagePart2 = 'yepppppp'
const unsanitaryMessage = `<a href="https://google.com">${messagePart1}</a> <script>${messagePart2}</script>`
const sendEmailInvocations = mailerMock.hijackFunction(
'sendEmail',
async () => true
)
const result = await createInvite({
email: targetEmail,
message: unsanitaryMessage
})
// Check that operation was successful
expect(result.data?.serverInviteCreate).to.be.ok
expect(result.errors).to.be.not.ok
// Check that email was sent out
expect(sendEmailInvocations.args).to.have.lengthOf(1)
const emailParams = sendEmailInvocations.args[0][0]
expect(emailParams).to.be.ok
expect(emailParams.to).to.eq(targetEmail)
expect(emailParams.subject).to.be.ok
// Check that message was sanitized
expect(emailParams.text).to.contain(messagePart1)
expect(emailParams.text).to.not.contain(messagePart2)
expect(emailParams.html).to.contain(messagePart1)
expect(emailParams.html).to.not.contain(messagePart2)
// Validate that invite exists
await validateInviteExistanceFromEmail(emailParams)
})
it("can't invite a user whose email is already registered", async () => {
const result = await createInvite({
email: otherGuy.email
})
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'This email is already associated with an account'
)
})
it("can't generate a message that is too long", async () => {
const result = await createInvite({
email: 'aaggaggg@asdasd.com',
message: crs({ length: 1025 })
})
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'Personal message too long'
)
})
})
describe('and inviting to stream', () => {
const otherGuyAlreadyInvitedStream = {
name: 'Other guy is already here stream',
isPublic: false,
id: undefined
}
const createInvite = (input) => createStreamInvite(apollo, input)
before(async () => {
// Create a stream and make sure otherGuy is already a contributor there
await createStream({ ...otherGuyAlreadyInvitedStream, ownerId: me.id }).then(
(id) => (otherGuyAlreadyInvitedStream.id = id)
)
await grantPermissionsStream({
streamId: otherGuyAlreadyInvitedStream.id,
role: Roles.Stream.Contributor,
userId: otherGuy.id
})
})
const alreadyInvitedUserDataSet = [
{ display: 'by user id', userId: true },
{ display: 'by email', userId: false }
]
alreadyInvitedUserDataSet.forEach(({ display, userId }) => {
it(`can't invite an already added user ${display}`, async () => {
const { errors, data } = await createInvite({
email: userId ? null : otherGuy.email,
userId: userId ? otherGuy.id : null,
message: 'hey dude come to my stream',
streamId: otherGuyAlreadyInvitedStream.id
})
expect(data?.serverInviteCreate).to.be.not.ok
expect(errors).to.be.ok
expect(errors.map((e) => e.message).join('|')).to.contain(
'user is already a collaborator'
)
})
})
it("can't invite with an invalid role", async () => {
const result = await createInvite({
email: 'badroleguy@speckle.com',
streamId: myPrivateStream.id,
role: 'aaa'
})
expect(result.data?.streamInviteCreate).to.be.not.ok
expect(result.errors).to.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'Unexpected stream invite role'
)
})
const userTypesDataSet = [
{
display: 'registered user',
user: otherGuy,
stream: myPrivateStream,
email: null
},
{
display: 'registered user (with custom role)',
user: otherGuy,
stream: myPrivateStream,
email: null,
role: Roles.Stream.Owner
},
{
display: 'unregistered user',
user: null,
stream: myPrivateStream,
email: 'randomer22@lool.com'
},
{
display: 'unregistered user (with custom role)',
user: null,
stream: myPrivateStream,
email: 'randomer22@lool.com',
Role: Roles.Stream.Reviewer
}
]
userTypesDataSet.forEach(({ display, user, stream, email, role }) => {
it(`can invite a ${display}`, async () => {
const messagePart1 = '1234hiiiiduuuuude'
const messagePart2 = 'yepppppp'
const unsanitaryMessage = `<a href="https://google.com">${messagePart1}</a> <script>${messagePart2}</script>`
const targetEmail = email || user.email
const sendEmailInvocations = mailerMock.hijackFunction(
'sendEmail',
async () => true
)
const result = await createInvite({
email,
message: unsanitaryMessage,
userId: user?.id || null,
streamId: stream?.id || null,
role: role || null
})
// Check that operation was successful
expect(result.data?.streamInviteCreate).to.be.ok
expect(result.errors).to.be.not.ok
// Check that email was sent out
const emailParams = sendEmailInvocations.args[0][0]
expect(emailParams).to.be.ok
expect(emailParams.to).to.eq(targetEmail)
expect(emailParams.subject).to.be.ok
// Check that message was sanitized
expect(emailParams.text).to.contain(messagePart1)
expect(emailParams.text).to.not.contain(messagePart2)
expect(emailParams.html).to.contain(messagePart1)
expect(emailParams.html).to.not.contain(messagePart2)
// Validate that invite exists
const invite = await validateInviteExistanceFromEmail(emailParams)
expect(invite.role).to.eq(role || Roles.Stream.Contributor)
})
})
it("can't invite user to a nonexistant stream", async () => {
const result = await createInvite({
email: 'whocares@really.com',
streamId: 'ayoooooooo'
})
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'not found'
)
})
it("can't invite user to a stream, if not its owner", async () => {
const result = await createInvite({
email: 'whocares@really.com',
streamId: otherGuysStream.id
})
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'You are not authorized to access this resource'
)
})
it("can't invite a nonexistant user ID to a stream", async () => {
const result = await createInvite({
userId: 'bababooey',
streamId: myPrivateStream.id
})
expect(result.data).to.not.be.ok
expect((result.errors || []).map((e) => e.message).join('|')).to.contain(
'Attempting to invite an invalid user'
)
})
})
describe('and administrating invites', () => {
const serverInvite1 = {
message: 'some server invite1',
email: 'serverinvite1recipient@google.com',
inviteId: undefined,
token: undefined
}
const streamInvite1 = {
message: 'some stream invite1',
email: 'somestreaminvite1recipient@google.com',
stream: myPrivateStream,
inviteId: undefined,
token: undefined
}
const streamInvite2 = {
message: 'some stream invite2',
user: otherGuy,
stream: myPrivateStream,
inviteId: undefined,
token: undefined
}
const invites = [serverInvite1, streamInvite1, streamInvite2]
before(async () => {
apollo = await buildApolloServer({
context: () =>
addLoadersToCtx({
auth: true,
userId: me.id,
role: Roles.Server.Admin, // Marking the user as an admin
token: 'asd',
scopes: AllScopes
})
})
// Creating some invites
await Promise.all(
invites.map((i) =>
createInviteDirectly(i, me.id).then((o) => {
i.inviteId = o.inviteId
i.token = o.token
})
)
)
})
it('they can resend pre-existing invites irregardless of type', async () => {
const sendEmailInvocations = mailerMock.hijackFunction(
'sendEmail',
async () => true,
{ times: invites.length }
)
const inviteIds = invites.map((i) => i.inviteId)
const results = await Promise.all(
inviteIds.map((inviteId) => resendInvite(apollo, { inviteId }))
)
for (const result of results) {
expect(result.data?.inviteResend).to.be.ok
expect(result.errors).to.not.be.ok
}
expect(sendEmailInvocations.length()).to.eq(inviteIds.length)
})
it('they can delete pre-existing invites irregardless of type', async () => {
// Create a couple of invites and resolve their IDs
const deletableInvites = [
{
message: 'some server invite1',
email: 'serverinvite1recipient@google.com',
inviteId: undefined,
token: undefined
},
{
message: 'some stream invite1',
email: 'somestreaminvite1recipient@google.com',
stream: myPrivateStream,
inviteId: undefined,
token: undefined
}
]
await Promise.all(
deletableInvites.map((i) =>
createInviteDirectly(i, me.id).then((o) => {
i.inviteId = o.inviteId
i.token = o.token
})
)
)
// Delete all invites
for (const invite of deletableInvites) {
const result = await deleteInvite(apollo, {
inviteId: invite.inviteId
})
expect(result.data?.inviteDelete).to.be.ok
expect(result.errors).to.not.be.ok
}
// Validate that invites no longer exist
const invitesInDb = await Promise.all(
deletableInvites.map((i) => findInvite(i.inviteId))
)
expect(invitesInDb.every((i) => !i)).to.be.true
})
it('they can batch create server invites', async () => {
const emails = ['abababa1@mail.com', 'abababa2@mail.com', 'abababa3@mail.com']
const message = 'ayyoyoyoyoy'
const sendEmailInvocations = mailerMock.hijackFunction(
'sendEmail',
async () => true,
{ times: emails.length }
)
const result = await batchCreateServerInvites(apollo, {
message,
emails
})
expect(result.data?.serverInviteBatchCreate).to.be.ok
expect(result.errors).to.not.be.ok
expect(sendEmailInvocations.length()).to.eq(emails.length)
for (const email of emails) {
const emailParams = sendEmailInvocations.args.find(([p]) => p.to === email)[0]
expect(emailParams).to.be.ok
expect(emailParams.html).to.contain(message)
expect(emailParams.text).to.contain(message)
await validateInviteExistanceFromEmail(emailParams)
}
})
it('they can batch create stream invites', async () => {
/** @type {import('@/test/graphql/serverInvites').StreamInviteCreateInput[]} */
const inputs = [
{
email: 'ayyayyyyyyy@asdasdad.com',
message: 'yoo bruh',
streamId: myPrivateStream.id
},
{
email: 'ayyayasdadsasdyy@asdasdad.com',
message: 'yoo bruh',
streamId: myPrivateStream.id
},
{
userId: otherGuy.id,
message: 'waddup',
streamId: myPrivateStream.id
},
{
email: 'someroleguy@asdasdad.com',
message: 'yoo bruh',
streamId: myPrivateStream.id,
role: Roles.Stream.Reviewer
}
]
const sendEmailInvocations = mailerMock.hijackFunction(
'sendEmail',
async () => false,
{ times: inputs.length }
)
const result = await batchCreateStreamInvites(apollo, inputs)
expect(result.data?.streamInviteBatchCreate).to.be.ok
expect(result.errors).to.not.be.ok
expect(sendEmailInvocations.length()).to.eq(inputs.length)
for (const inputData of inputs) {
const emailParams = sendEmailInvocations.args.find(([p]) =>
inputData.email ? p.to === inputData.email : p.to === otherGuy.email
)[0]
expect(emailParams).to.be.ok
expect(emailParams.html).to.contain(inputData.message)
expect(emailParams.text).to.contain(inputData.message)
const invite = await validateInviteExistanceFromEmail(emailParams)
expect(invite.role).to.eq(inputData.role || Roles.Stream.Contributor)
}
})
})
describe('and they are looking at a stream invite', async () => {
const inviteFromOtherGuy = {
message: 'some stream invite3',
user: me,
stream: otherGuysStream,
inviteId: undefined,
token: undefined
}
beforeEach(async () => {
// Create an invite before each test so that we can mutate them
// in each test as needed
await createInviteDirectly(inviteFromOtherGuy, otherGuy.id).then((o) => {
inviteFromOtherGuy.inviteId = o.inviteId
inviteFromOtherGuy.token = o.token
})
})
const inviteRetrievalDataset = [
{ display: 'by token', withId: true },
{ display: 'without a token', withId: false }
]
inviteRetrievalDataset.forEach(({ display, withId }) => {
it(`the invite can be retrieved ${display}`, async () => {
const result = await getStreamInvite(apollo, {
streamId: inviteFromOtherGuy.stream.id,
token: withId ? inviteFromOtherGuy.token : null
})
expect(result.data?.streamInvite).to.be.ok
expect(result.errors).to.not.be.ok
const data = result.data.streamInvite
expect(data.inviteId).to.eq(inviteFromOtherGuy.inviteId)
expect(data.token).to.eq(inviteFromOtherGuy.token)
expect(data.streamId).to.eq(inviteFromOtherGuy.stream.id)
expect(data.title).to.eq(me.name)
expect(data.user.id).eq(me.id)
expect(data.user.name).to.eq(me.name)
expect(data.invitedBy.id).eq(otherGuy.id)
expect(data.invitedBy.name).eq(otherGuy.name)
})
})
const useUpDataSet = [
{ display: 'declined', accept: false },
{ display: 'accepted', accept: true }
]
useUpDataSet.forEach(({ display, accept }) => {
it(`the invite can be ${display}`, async () => {
const token = inviteFromOtherGuy.token
const inviteId = inviteFromOtherGuy.inviteId
const streamId = inviteFromOtherGuy.stream.id
const { data, errors } = await useUpStreamInvite(apollo, {
accept,
token,
streamId
})
expect(data?.streamInviteUse).to.be.ok
expect(errors).to.not.be.ok
expect(await findInvite(inviteId)).to.be.not.ok
const userStreamRole = await getUserStreamRole(me.id, streamId)
expect(userStreamRole).to.eq(accept ? Roles.Stream.Contributor : null)
})
})
})
describe('and they are managing their own stream collaborators', async () => {
// Streams
const myPublicStream = {
name: 'My public stream 1',
isPublic: true,
id: undefined
}
const otherGuysPublicStream = {
name: 'Other guys public stream 1',
isPublic: true,
id: undefined
}
// Invites
const dynamicInvite = {
message: 'some stream invite i did3',
user: otherGuy,
stream: myPublicStream,
inviteId: undefined
}
const myInvite = {
message: 'another of my streams',
user: otherGuy,
stream: myPublicStream,
inviteId: undefined
}
const otherGuysInvite = {
message: 'a stream belonging to the other guy',
user: me,
stream: otherGuysPublicStream,
inviteId: undefined
}
before(async () => {
// Create streams
await Promise.all([
createStream({ ...myPublicStream, ownerId: me.id }).then(
(id) => (myPublicStream.id = id)
),
createStream({ ...otherGuysPublicStream, ownerId: otherGuy.id }).then(
(id) => (otherGuysPublicStream.id = id)
)
])
// Create a couple of static invites that shouldn't be mutated in tests
await Promise.all([
createInviteDirectly(myInvite, me.id).then((o) => {
myInvite.inviteId = o.inviteId
myInvite.token = o.token
}),
createInviteDirectly(otherGuysInvite, otherGuy.id).then((o) => {
otherGuysInvite.inviteId = o.inviteId
otherGuysInvite.token = o.token
})
])
})
beforeEach(async () => {
// Create an invite before each test so that we can mutate them
// in each test as needed
await createInviteDirectly(dynamicInvite, me.id).then((o) => {
dynamicInvite.inviteId = o.inviteId
dynamicInvite.token = o.token
})
})
it('a pending invite can be deleted', async () => {
const inviteId = dynamicInvite.inviteId
const { data, errors } = await cancelStreamInvite(apollo, {
streamId: dynamicInvite.stream.id,
inviteId
})
expect(data?.streamInviteCancel).to.be.ok
expect(errors).to.be.not.ok
expect(await findInvite(inviteId)).to.be.not.ok
})
it('own pending collaborators can be retrieved', async () => {
const streamId = myPublicStream.id
const { data, errors } = await getStreamPendingCollaborators(apollo, {
streamId
})
expect(errors).to.be.not.ok
expect(data.stream).to.be.ok
expect(data.stream.id).to.eq(streamId)
const pendingCollaborators = data.stream.pendingCollaborators || []
expect(pendingCollaborators).to.have.length(1)
const pendingCollaborator = pendingCollaborators[0]
expect(pendingCollaborator.user?.id).to.eq(otherGuy.id)
// tokens shouldn't be resolved, as they're for other people
expect(pendingCollaborator.token).to.be.null
})
it("a foreign stream's pending collaborators can't be retrieved", async () => {
const streamId = otherGuysPublicStream.id
const { data, errors } = await getStreamPendingCollaborators(apollo, {
streamId
})
expect(data.stream).to.be.ok
expect(data.stream.id).to.eq(streamId)
expect(data.stream.pendingCollaborators).to.be.not.ok
expect(errors).to.be.ok
expect(errors.map((e) => e.message).join('|')).to.contain(
'You are not authorized to access this resource'
)
})
})
describe('and they are looking at all of their stream invites', async () => {
/** @type {import('apollo-server-express').ApolloServer} */
let apollo
const ownInvitesGuy = {
name: "Some guy who's invited a lot",
email: 'mrinvitedguy111@gmail.com',
password: 'sn3aky-1337-b1m',
id: undefined
}
before(async () => {
// Create the user
await createUser(ownInvitesGuy).then((id) => (ownInvitesGuy.id = id))
// Invite him to a few streams
await Promise.all([
createInviteDirectly(
{
user: ownInvitesGuy,
stream: myPrivateStream
},
me.id
),
createInviteDirectly(
{
user: ownInvitesGuy,
stream: otherGuysStream
},
otherGuy.id
)
])
// Build authenticated apollo instance
apollo = await buildAuthenticatedApolloServer(ownInvitesGuy.id)
})
it('all invites can be retrieved successfully', async () => {
const { data, errors } = await getStreamInvites(apollo)
expect(errors).to.be.not.ok
expect(data.streamInvites).to.be.ok
expect(data.streamInvites.length).to.eq(2)
const expectedStreamIds = [myPrivateStream.id, otherGuysStream.id]
const firstInvite = data.streamInvites[0]
const secondInvite = data.streamInvites[1]
expect(expectedStreamIds.includes(firstInvite.streamId)).to.be.ok
expect(expectedStreamIds.includes(secondInvite.streamId)).to.be.ok
})
})
})
})