Files
speckle-server/packages/server/modules/automate/tests/automations.spec.ts
T
Kristaps Fabians Geikins bde148f286 chore(server): migrating fully to ESM (#5042)
* wip

* some extra fixes

* stuff kinda works?

* need to figure out mocks

* need to figure out mocks

* fix db listener

* gqlgen fix

* minor gqlgen watch adjustment

* lint fixes

* delete old codegen file

* converting migrations to ESM

* getModuleDIrectory

* vitest sort of works

* added back ts-vitest

* resolve gql double load

* fixing test timeout configs

* TSC lint fix

* fix automate tests

* moar debugging

* debugging

* more debugging

* codegen update

* server works

* yargs migrated

* chore(server): getting rid of global mocks for Server ESM (#5046)

* got rid of email mock

* got rid of comment mocks

* got rid of multi region mocks

* got rid of stripe mock

* admin override mock updated

* removed final mock

* fixing import.meta.resolve calls

* another import.meta.resolve fix

* added requested test

* nyc ESM fix

* removed unneeded deps + linting

* yarn lock forgot to commit

* tryna fix flakyness

* email capture util fix

* sendEmail fix

* fix TSX check

* sender transporter fix + CR comments

* merge main fix

* test fixx

* circleci fix

* gqlgen bigint fix

* error formatter fix

* more error formatting improvements

* esmloader added to Dockerfile

* more dockerfile fixes

* bg jobs fix
2025-07-14 10:26:19 +03:00

590 lines
18 KiB
TypeScript

import {
AutomationCreationError,
AutomationRevisionCreationError,
AutomationUpdateError
} from '@/modules/automate/errors/management'
import {
getAutomationFactory,
updateAutomationFactory
} from '@/modules/automate/repositories/automations'
import { validateAndUpdateAutomationFactory } from '@/modules/automate/services/automationManagement'
import {
AuthCodePayloadAction,
createStoredAuthCodeFactory
} from '@/modules/automate/services/authCode'
import { getGenericRedis } from '@/modules/shared/redis/redis'
import { ProjectAutomationRevisionCreateInput } from '@/modules/core/graph/generated/graphql'
import { BranchRecord } from '@/modules/core/helpers/types'
import { getLatestStreamBranchFactory } from '@/modules/core/repositories/branches'
import { expectToThrow } from '@/test/assertionHelper'
import { BasicTestUser, createTestUsers } from '@/test/authHelper'
import {
AutomateValidateAuthCodeDocument,
GetProjectAutomationDocument
} from '@/test/graphql/generated/graphql'
import {
TestApolloServer,
createTestContext,
testApolloServer
} from '@/test/graphqlHelper'
import { beforeEachContext } from '@/test/hooks'
import {
TestAutomationWithRevision,
buildAutomationCreate,
buildAutomationRevisionCreate,
createTestAutomation,
generateFunctionId,
generateFunctionReleaseId,
truncateAutomations
} from '@/test/speckle-helpers/automationHelper'
import { BasicTestStream, createTestStreams } from '@/test/speckle-helpers/streamHelper'
import { Automate, Roles } from '@speckle/shared'
import { expect } from 'chai'
import { times } from 'lodash-es'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { db } from '@/db/knex'
import {
addOrUpdateStreamCollaboratorFactory,
validateStreamAccessFactory
} from '@/modules/core/services/streams/access'
import { authorizeResolver } from '@/modules/shared'
import {
getStreamRolesFactory,
grantStreamPermissionsFactory
} from '@/modules/core/repositories/streams'
import { getUserFactory } from '@/modules/core/repositories/users'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { AutomationEvents } from '@/modules/automate/domain/events'
/**
* TODO: Extra test ideas
* - Function input validation & matching against an existing function release on exec engine
* - All of the Automation/Function/Run GQL resolvers
*/
const { FF_AUTOMATE_MODULE_ENABLED } = getFeatureFlags()
const getUser = getUserFactory({ db })
const validateStreamAccess = validateStreamAccessFactory({ authorizeResolver })
const addOrUpdateStreamCollaborator = addOrUpdateStreamCollaboratorFactory({
validateStreamAccess,
getUser,
grantStreamPermissions: grantStreamPermissionsFactory({ db }),
getStreamRoles: getStreamRolesFactory({ db }),
emitEvent: getEventBus().emit
})
const buildAutomationUpdate = () => {
const getAutomation = getAutomationFactory({ db })
const updateDbAutomation = updateAutomationFactory({ db })
const update = validateAndUpdateAutomationFactory({
getAutomation,
updateAutomation: updateDbAutomation,
eventEmit: getEventBus().emit
})
return update
}
;(FF_AUTOMATE_MODULE_ENABLED ? describe : describe.skip)(
'Automations @automate',
() => {
const me: BasicTestUser = {
id: '',
name: 'Itsa Me!',
email: 'me@automate.com',
role: Roles.Server.User
}
const otherGuy: BasicTestUser = {
id: '',
name: 'Other dude',
email: 'otherguy@automate.com',
role: Roles.Server.User
}
const myStream: BasicTestStream = {
id: '',
name: 'First stream',
isPublic: true,
ownerId: ''
}
before(async () => {
await beforeEachContext()
await createTestUsers([me, otherGuy])
await createTestStreams([[myStream, me]])
})
describe('creation', () => {
;[
{ name: '', error: 'too short' },
{ name: 'a'.repeat(256), error: 'too long' }
].forEach(({ name, error }) => {
it(`fails if name is ${error}`, async () => {
const create = buildAutomationCreate()
const e = await expectToThrow(
async () =>
await create({
input: { name, enabled: true },
projectId: myStream.id,
userId: me.id
})
)
expect(e).to.have.property('name', AutomationCreationError.name)
expect(e).to.have.property(
'message',
'Automation name should be a string between the length of 1 and 255 characters.'
)
})
})
it('creates an automation', async () => {
let eventFired = false
const name = 'My Super Automation #1'
getEventBus().listenOnce(AutomationEvents.Created, async ({ payload }) => {
expect(payload.automation.name).to.equal(name)
eventFired = true
})
const create = buildAutomationCreate()
const automation = await create({
input: { name, enabled: true },
projectId: myStream.id,
userId: me.id
})
expect(automation).to.be.ok
expect(automation.automation).to.be.ok
expect(automation.token).to.be.ok
expect(automation.automation.name).to.equal(name)
expect(eventFired).to.be.true
})
})
describe('updating', () => {
let createdAutomation: Awaited<
ReturnType<ReturnType<typeof buildAutomationCreate>>
>
const create = buildAutomationCreate()
before(async () => {
const create = buildAutomationCreate()
createdAutomation = await create({
input: { name: 'Automation #1', enabled: true },
projectId: myStream.id,
userId: me.id
})
})
it('fails if refering to an automation that doesnt exist', async () => {
const update = buildAutomationUpdate()
const e = await expectToThrow(
async () =>
await update({
input: { id: 'non-existent', enabled: false },
userId: me.id,
projectId: myStream.id
})
)
expect(e).to.have.property('name', AutomationUpdateError.name)
expect(e).to.have.property('message', 'Automation not found')
})
it('fails if automation is mismatched with specified project id', async () => {
const update = buildAutomationUpdate()
const e = await expectToThrow(
async () =>
await update({
input: { id: createdAutomation.automation.id, enabled: false },
userId: me.id,
projectId: 'non-existent'
})
)
expect(e).to.have.property('message', 'Automation not found')
})
it('only updates set & non-null values', async () => {
const update = buildAutomationUpdate()
const { automation: initAutomation } = await create({
input: { name: 'Automation #2', enabled: true },
projectId: myStream.id,
userId: me.id
})
let eventFired = false
getEventBus().listenOnce(AutomationEvents.Updated, async ({ payload }) => {
expect(payload.automation.name).to.equal(initAutomation.name)
expect(payload.automation.enabled).to.be.false
expect(payload.automation.id).to.equal(initAutomation.id)
eventFired = true
})
const updatedAutomation = await update({
input: { id: initAutomation.id, enabled: false },
userId: me.id,
projectId: myStream.id
})
expect(updatedAutomation).to.be.ok
expect(updatedAutomation.enabled).to.be.false
expect(updatedAutomation.name).to.equal(initAutomation.name)
expect(eventFired).to.be.true
})
it('updates all available properties', async () => {
const update = buildAutomationUpdate()
const { automation: initAutomation } = await create({
input: { name: 'Automation #3', enabled: true },
projectId: myStream.id,
userId: me.id
})
const input = {
id: initAutomation.id,
name: 'Updated Automation',
enabled: false
}
const updatedAutomation = await update({
input,
userId: me.id,
projectId: myStream.id
})
expect(updatedAutomation).to.be.ok
expect(updatedAutomation.enabled).to.eq(input.enabled)
expect(updatedAutomation.name).to.equal(input.name)
})
})
describe('revision creation', () => {
let createdAutomation: Awaited<
ReturnType<ReturnType<typeof buildAutomationCreate>>
>
let projectModel: BranchRecord
const validAutomationRevisionCreateInput =
(): ProjectAutomationRevisionCreateInput => ({
automationId: createdAutomation.automation.id,
functions: [
{
functionReleaseId: generateFunctionReleaseId(),
functionId: generateFunctionId(),
parameters: null
}
],
triggerDefinitions: <Automate.AutomateTypes.TriggerDefinitionsSchema>{
version: 1.0,
definitions: [
{
type: 'VERSION_CREATED',
modelId: projectModel.id
}
]
}
})
before(async () => {
const createAutomation = buildAutomationCreate()
createdAutomation = await createAutomation({
input: { name: 'Automation #2', enabled: true },
projectId: myStream.id,
userId: me.id
})
projectModel = await getLatestStreamBranchFactory({ db })(myStream.id)
})
it('works successfully', async () => {
let eventFired = false
getEventBus().listenOnce(
AutomationEvents.CreatedRevision,
async ({ payload }) => {
expect(payload.automation.id).to.equal(createdAutomation.automation.id)
expect(payload.revision).to.be.ok
eventFired = true
}
)
const create = buildAutomationRevisionCreate()
const ret = await create({
userId: me.id,
input: validAutomationRevisionCreateInput(),
projectId: myStream.id
})
expect(ret).to.be.ok
expect(ret.id).to.be.ok
expect(ret.active).to.be.true
expect(ret.automationId).to.equal(createdAutomation.automation.id)
expect(ret.triggers.length).to.be.ok
expect(ret.functions.length).to.be.ok
expect(eventFired).to.be.true
})
it('fails if automation does not exist', async () => {
const create = buildAutomationRevisionCreate()
const e = await expectToThrow(
async () =>
await create({
userId: me.id,
input: {
...validAutomationRevisionCreateInput(),
automationId: 'non-existent'
},
projectId: myStream.id
})
)
expect(e).to.have.property('name', AutomationUpdateError.name)
expect(e).to.have.property('message', 'Automation not found')
})
it('fails if user does not have access to the project', async () => {
const create = buildAutomationRevisionCreate()
const e = await expectToThrow(
async () =>
await create({
userId: otherGuy.id,
input: validAutomationRevisionCreateInput(),
projectId: myStream.id
})
)
expect(e)
.to.have.property('message')
.match(/^User does not have required access to stream/)
})
it('fails if automation is mismatched with specified project id', async () => {
const create = buildAutomationRevisionCreate()
const e = await expectToThrow(
async () =>
await create({
userId: me.id,
input: validAutomationRevisionCreateInput(),
projectId: 'non-existent'
})
)
expect(e).to.have.property('message', 'Automation not found')
})
;[
{ val: null, error: 'null object' },
{ val: {}, error: 'empty object' },
{ val: { version: 1.0 }, error: 'missing definitions' },
{ val: { version: '1.0', error: 'non-numeric version' } },
{ val: { version: 1.0, definitions: null }, error: 'null definitions' },
{
val: { version: 1.0, definitions: [null] },
error: 'null definition'
},
{
val: { version: 1.0, definitions: [{}] },
error: 'empty definition'
},
{
val: { version: 1.0, definitions: [{ type: 'VERSION_CREATED' }] },
error: 'missing modelId'
},
{
val: { version: 1.0, definitions: [{ type: 'aaaa', modelId: '123' }] },
error: 'invalid trigger'
}
].forEach(({ val, error }) => {
it('fails with invalid trigger definitions: ' + error, async () => {
const create = buildAutomationRevisionCreate()
const e = await expectToThrow(
async () =>
await create({
userId: me.id,
input: {
...validAutomationRevisionCreateInput(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
triggerDefinitions: val as any
},
projectId: myStream.id
})
)
expect(e instanceof AutomationRevisionCreationError, e.toString()).to.be.true
})
})
it('fails if empty trigger definitions', async () => {
const create = buildAutomationRevisionCreate()
const e = await expectToThrow(
async () =>
await create({
userId: me.id,
input: {
...validAutomationRevisionCreateInput(),
triggerDefinitions: { version: 1.0, definitions: [] }
},
projectId: myStream.id
})
)
expect(e.message).to.eq('At least one trigger definition is required')
})
it('fails with invalid function parameters', async () => {
const create = buildAutomationRevisionCreate()
const input = validAutomationRevisionCreateInput()
input.functions.forEach((fn) => {
fn.parameters = '{invalid'
})
const e = await expectToThrow(
async () =>
await create({
userId: me.id,
input,
projectId: myStream.id
})
)
expect(e.message).to.match(/^Failed to decrypt one or more function/i)
})
it('fails when refering to nonexistent function releases', async () => {
const create = buildAutomationRevisionCreate({
overrides: {
getFunctionRelease: async () => {
// TODO: Update once we know how exec engine should respond
throw new Error('Function release with ID XXX not found')
}
}
})
const input = validAutomationRevisionCreateInput()
input.functions.forEach((fn) => {
fn.functionReleaseId = 'non-existent'
})
const e = await expectToThrow(
async () =>
await create({
userId: me.id,
input,
projectId: myStream.id
})
)
expect(e.message).to.match(/^Function release with ID .*? not found/)
})
})
describe('auth code handshake', () => {
let apollo: TestApolloServer
before(async () => {
apollo = await testApolloServer() // unauthenticated
})
it('fails if code is invalid', async () => {
const res = await apollo.execute(AutomateValidateAuthCodeDocument, {
payload: {
code: 'invalid',
userId: 'a',
action: 'aty'
}
})
expect(res).to.haveGraphQLErrors('Invalid automate auth payload')
expect(res.data?.automateValidateAuthCode).to.not.be.ok
})
it('succeeds with valid code', async () => {
const storeCode = createStoredAuthCodeFactory({
redis: getGenericRedis()
})
const code = await storeCode({
userId: me.id,
action: AuthCodePayloadAction.BecomeFunctionAuthor
})
const res = await apollo.execute(AutomateValidateAuthCodeDocument, {
payload: code
})
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.automateValidateAuthCode).to.be.true
})
})
describe.skip('retrieval', () => {
let apollo: TestApolloServer
const someCollaborator: BasicTestUser = {
id: '',
name: 'Collaborator dude',
email: 'otherguy2@automate.com',
role: Roles.Server.User
}
const TOTAL_AUTOMATION_COUNT = 20
// const SEARCH_STRING = 'bababooey'
// const PAGINATION_LIMIT = Math.floor(TOTAL_AUTOMATION_COUNT / 3) // ~3 pages
// const ITEMS_W_SEARCH_STRING = Math.floor(TOTAL_AUTOMATION_COUNT / 4)
let testAutomations: TestAutomationWithRevision[]
before(async () => {
await truncateAutomations()
await createTestUsers([someCollaborator])
await addOrUpdateStreamCollaborator(
myStream.id,
someCollaborator.id,
Roles.Stream.Contributor,
me.id
)
apollo = await testApolloServer({
context: await createTestContext({
userId: me.id,
token: 'abc',
role: Roles.Server.User
})
})
testAutomations = await Promise.all(
times(TOTAL_AUTOMATION_COUNT, async (i) =>
createTestAutomation({
userId: me.id,
projectId: myStream.id,
automation: {
name: `Retrieval Test Automation #${i}`
},
revision: {
functionId: generateFunctionId(),
functionReleaseId: generateFunctionReleaseId()
}
})
)
)
})
describe('when retrieving single automation', () => {
it('fails if user is not the owner of the project', async () => {
const res = await apollo.execute(GetProjectAutomationDocument, {
projectId: myStream.id,
automationId: testAutomations[0].automation.automation.id
})
expect(res).to.haveGraphQLErrors('Not allowed')
expect(res.data?.project).to.be.ok
expect(res.data?.project?.automation).to.not.be.ok
})
})
})
}
)