import { FunctionRunReportStatusError, FunctionRunNotFoundError } from '@/modules/automate/errors/runs' import { ManuallyTriggerAutomationDeps, ensureRunConditionsFactory, manuallyTriggerAutomationFactory, onModelVersionCreateFactory, triggerAutomationRevisionRunFactory } from '@/modules/automate/services/trigger' import { AutomationRecord, AutomationRevisionRecord, AutomationRunStatuses, AutomationTriggerDefinitionRecord, AutomationTriggerType, BaseTriggerManifest, LiveAutomation, RunTriggerSource, VersionCreatedTriggerManifest, VersionCreationTriggerType, isVersionCreatedTriggerManifest } from '@/modules/automate/helpers/types' import cryptoRandomString from 'crypto-random-string' import { expect } from 'chai' import { BasicTestUser, createTestUsers } from '@/test/authHelper' import { BasicTestStream, createTestStream, createTestStreams } from '@/test/speckle-helpers/streamHelper' import { createTestCommit } from '@/test/speckle-helpers/commitHelper' import { InsertableAutomationRun, storeAutomationFactory, storeAutomationTokenFactory, storeAutomationRevisionFactory, getAutomationFactory, updateAutomationFactory, getFunctionRunFactory, upsertAutomationFunctionRunFactory, getFullAutomationRunByIdFactory, upsertAutomationRunFactory, getAutomationTokenFactory, getAutomationTriggerDefinitionsFactory, getFullAutomationRevisionMetadataFactory, updateAutomationRevisionFactory, updateAutomationRunFactory } from '@/modules/automate/repositories/automations' import { beforeEachContext, truncateTables } from '@/test/hooks' import { Automate, TIME_MS } from '@speckle/shared' import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' import { getBranchLatestCommitsFactory, getLatestStreamBranchFactory } from '@/modules/core/repositories/branches' import { buildAutomationCreate, buildAutomationRevisionCreate, generateFunctionId, generateFunctionReleaseId } from '@/test/speckle-helpers/automationHelper' import { expectToThrow } from '@/test/assertionHelper' import { Commits } from '@/modules/core/dbSchema' import { BranchRecord } from '@/modules/core/helpers/types' import { reportFunctionRunStatusFactory } from '@/modules/automate/services/runsManagement' import { AutomateRunStatus } from '@/modules/core/graph/generated/graphql' import { getEncryptionKeyPairFor, getEncryptionPublicKey, getFunctionInputDecryptorFactory } from '@/modules/automate/services/encryption' import { buildDecryptor } from '@/modules/shared/utils/libsodium' import { mapGqlStatusToDbStatus } from '@/modules/automate/utils/automateFunctionRunStatus' import { db } from '@/db/knex' import { getCommitFactory } from '@/modules/core/repositories/commits' import { validateStreamAccessFactory } from '@/modules/core/services/streams/access' import { authorizeResolver } from '@/modules/shared' import { createAppTokenFactory } from '@/modules/core/services/tokens' import { storeApiTokenFactory, storeTokenResourceAccessDefinitionsFactory, storeTokenScopesFactory, storeUserServerAppTokenFactory } from '@/modules/core/repositories/tokens' import { getEventBus } from '@/modules/shared/services/eventBus' import { AutomationRunEvents } from '@/modules/automate/domain/events' const { FF_AUTOMATE_MODULE_ENABLED } = getFeatureFlags() const storeAutomation = storeAutomationFactory({ db }) const storeAutomationToken = storeAutomationTokenFactory({ db }) const storeAutomationRevision = storeAutomationRevisionFactory({ db }) const getAutomation = getAutomationFactory({ db }) const updateAutomation = updateAutomationFactory({ db }) const getFunctionRun = getFunctionRunFactory({ db }) const upsertAutomationFunctionRun = upsertAutomationFunctionRunFactory({ db }) const getFullAutomationRunById = getFullAutomationRunByIdFactory({ db }) const upsertAutomationRun = upsertAutomationRunFactory({ db }) const getAutomationToken = getAutomationTokenFactory({ db }) const getAutomationTriggerDefinitions = getAutomationTriggerDefinitionsFactory({ db }) const getFullAutomationRevisionMetadata = getFullAutomationRevisionMetadataFactory({ db }) const updateAutomationRevision = updateAutomationRevisionFactory({ db }) const updateAutomationRun = updateAutomationRunFactory({ db }) const getBranchLatestCommits = getBranchLatestCommitsFactory({ db }) const getCommit = getCommitFactory({ db }) const validateStreamAccess = validateStreamAccessFactory({ authorizeResolver }) const createAppToken = createAppTokenFactory({ storeApiToken: storeApiTokenFactory({ db }), storeTokenScopes: storeTokenScopesFactory({ db }), storeTokenResourceAccessDefinitions: storeTokenResourceAccessDefinitionsFactory({ db }), storeUserServerAppToken: storeUserServerAppTokenFactory({ db }) }) ;(FF_AUTOMATE_MODULE_ENABLED ? describe : describe.skip)( 'Automate triggers @automate', () => { const testUser: BasicTestUser = { id: cryptoRandomString({ length: 10 }), name: 'The Automaton', email: 'the@automaton.com' } const otherUser: BasicTestUser = { id: cryptoRandomString({ length: 10 }), name: 'The Automaton Other', email: 'theother@automaton.com' } const testUserStream: BasicTestStream = { id: '', name: 'First stream', isPublic: true, ownerId: '' } const otherUserStream: BasicTestStream = { id: '', name: 'Other stream', isPublic: true, ownerId: '' } let testUserStreamModel: BranchRecord let createdAutomation: Awaited>> let createdRevision: Awaited< ReturnType> > let publicKey: string before(async () => { await beforeEachContext() await createTestUsers([testUser, otherUser]) publicKey = await getEncryptionPublicKey() const createAutomation = buildAutomationCreate() const createRevision = buildAutomationRevisionCreate() await createTestStreams([ [testUserStream, testUser], [otherUserStream, otherUser] ]) const [projectModel, newAutomation] = await Promise.all([ getLatestStreamBranchFactory({ db })(testUserStream.id), createAutomation({ userId: testUser.id, projectId: testUserStream.id, input: { name: 'Manually Triggerable Automation', enabled: true } }) ]) testUserStreamModel = projectModel createdAutomation = newAutomation createdRevision = await createRevision({ userId: testUser.id, input: { automationId: createdAutomation.automation.id, triggerDefinitions: { version: 1.0, definitions: [{ type: 'VERSION_CREATED', modelId: testUserStreamModel.id }] }, functions: [ { functionReleaseId: generateFunctionReleaseId(), functionId: generateFunctionId(), parameters: null } ] }, projectId: testUserStream.id }) expect(createdRevision).to.be.ok }) describe('On model version create', () => { it('No trigger no run', async () => { const triggered: Record = {} await onModelVersionCreateFactory({ getAutomation: async () => ({} as AutomationRecord), getAutomationRevision: async () => ({} as AutomationRevisionRecord), getTriggers: async () => [], triggerFunction: async ({ manifest, revisionId }) => { triggered[revisionId] = manifest return { automationRunId: cryptoRandomString({ length: 10 }) } } })({ modelId: cryptoRandomString({ length: 10 }), versionId: cryptoRandomString({ length: 10 }), projectId: cryptoRandomString({ length: 10 }) }) expect(Object.keys(triggered)).length(0) }) it('Does not trigger test automations', async () => { const triggered: Record = {} await onModelVersionCreateFactory({ getAutomation: async () => ({ isTestAutomation: true } as AutomationRecord), getAutomationRevision: async () => ({} as AutomationRevisionRecord), getTriggers: async () => [], triggerFunction: async ({ manifest, revisionId }) => { triggered[revisionId] = manifest return { automationRunId: cryptoRandomString({ length: 10 }) } } })({ modelId: cryptoRandomString({ length: 10 }), versionId: cryptoRandomString({ length: 10 }), projectId: cryptoRandomString({ length: 10 }) }) expect(Object.keys(triggered)).length(0) }) it('Triggers all automation runs associated with the model', async () => { const storedTriggers: AutomationTriggerDefinitionRecord< typeof VersionCreationTriggerType >[] = [ { triggerType: VersionCreationTriggerType, triggeringId: cryptoRandomString({ length: 10 }), automationRevisionId: cryptoRandomString({ length: 10 }) }, { triggerType: VersionCreationTriggerType, triggeringId: cryptoRandomString({ length: 10 }), automationRevisionId: cryptoRandomString({ length: 10 }) } ] const triggered: Record = {} const versionId = cryptoRandomString({ length: 10 }) const projectId = cryptoRandomString({ length: 10 }) await onModelVersionCreateFactory({ getAutomation: async () => ({} as AutomationRecord), getAutomationRevision: async () => ({} as AutomationRevisionRecord), getTriggers: async < T extends AutomationTriggerType = AutomationTriggerType >() => storedTriggers as AutomationTriggerDefinitionRecord[], triggerFunction: async ({ revisionId, manifest }) => { if (!isVersionCreatedTriggerManifest(manifest)) { throw new Error('unexpected trigger type') } triggered[revisionId] = manifest return { automationRunId: cryptoRandomString({ length: 10 }) } } })({ modelId: cryptoRandomString({ length: 10 }), versionId, projectId }) expect(Object.keys(triggered)).length(storedTriggers.length) storedTriggers.forEach((st) => { const expectedTrigger: VersionCreatedTriggerManifest = { versionId, modelId: st.triggeringId, triggerType: st.triggerType, projectId } expect(triggered[st.automationRevisionId]).deep.equal(expectedTrigger) }) }) it('Failing automation runs do NOT break other runs.', async () => { const storedTriggers: AutomationTriggerDefinitionRecord[] = [ { triggerType: VersionCreationTriggerType, triggeringId: cryptoRandomString({ length: 10 }), automationRevisionId: cryptoRandomString({ length: 10 }) }, { triggerType: VersionCreationTriggerType, triggeringId: cryptoRandomString({ length: 10 }), automationRevisionId: cryptoRandomString({ length: 10 }) } ] const triggered: Record = {} const versionId = cryptoRandomString({ length: 10 }) await onModelVersionCreateFactory({ getAutomation: async () => ({} as AutomationRecord), getAutomationRevision: async () => ({} as AutomationRevisionRecord), getTriggers: async < T extends AutomationTriggerType = AutomationTriggerType >() => storedTriggers as AutomationTriggerDefinitionRecord[], triggerFunction: async ({ revisionId, manifest }) => { if (!isVersionCreatedTriggerManifest(manifest)) { throw new Error('unexpected trigger type') } if (revisionId === storedTriggers[0].automationRevisionId) throw new Error('first one is borked') triggered[revisionId] = manifest return { automationRunId: cryptoRandomString({ length: 10 }) } } })({ modelId: cryptoRandomString({ length: 10 }), versionId, projectId: cryptoRandomString({ length: 10 }) }) expect(Object.keys(triggered)).length(storedTriggers.length - 1) }) }) describe('Triggering an automation revision run', () => { it('Throws if run conditions are not met', async () => { try { await triggerAutomationRevisionRunFactory({ automateRunTrigger: async () => ({ automationRunId: cryptoRandomString({ length: 10 }) }), getFunctionInputDecryptor: getFunctionInputDecryptorFactory({ buildDecryptor }), getEncryptionKeyPairFor, createAppToken, emitEvent: getEventBus().emit, getAutomationToken, upsertAutomationRun, getFullAutomationRevisionMetadata, getBranchLatestCommits, getCommit })({ revisionId: cryptoRandomString({ length: 10 }), manifest: { versionId: cryptoRandomString({ length: 10 }), triggerType: VersionCreationTriggerType, modelId: cryptoRandomString({ length: 10 }) }, source: RunTriggerSource.Manual }) throw new Error('this should have thrown') } catch (error) { if (!(error instanceof Error)) throw error expect(error.message).contains( "Cannot trigger the given revision, it doesn't exist" ) } }) it('Saves run with an error if automate run trigger fails', async () => { const userId = testUser.id const project = { name: cryptoRandomString({ length: 10 }), id: cryptoRandomString({ length: 10 }), ownerId: userId, isPublic: true } await createTestStream(project, testUser) const version = { id: cryptoRandomString({ length: 10 }), streamId: project.id, objectId: null, authorId: userId } // @ts-expect-error force setting the objectId to null await createTestCommit(version) // create automation, const automation: LiveAutomation = { id: cryptoRandomString({ length: 10 }), createdAt: new Date(), updatedAt: new Date(), name: cryptoRandomString({ length: 15 }), enabled: true, projectId: project.id, executionEngineAutomationId: cryptoRandomString({ length: 10 }), isTestAutomation: false, isDeleted: false, userId } const automationToken = { automationId: automation.id, automateToken: cryptoRandomString({ length: 10 }), automateRefreshToken: cryptoRandomString({ length: 10 }) } await storeAutomation(automation) await storeAutomationToken(automationToken) const automationRevisionId = cryptoRandomString({ length: 10 }) const trigger = { triggerType: VersionCreationTriggerType, triggeringId: cryptoRandomString({ length: 10 }) } // create revision, await storeAutomationRevision({ id: automationRevisionId, createdAt: new Date(), automationId: automation.id, active: true, triggers: [trigger], userId, publicKey, functions: [ { functionInputs: null, functionReleaseId: cryptoRandomString({ length: 10 }), functionId: cryptoRandomString({ length: 10 }) } ] }) const thrownError = 'trigger failed' const { automationRunId } = await triggerAutomationRevisionRunFactory({ automateRunTrigger: async () => { throw new Error(thrownError) }, getFunctionInputDecryptor: getFunctionInputDecryptorFactory({ buildDecryptor }), getEncryptionKeyPairFor, createAppToken, emitEvent: getEventBus().emit, getAutomationToken, upsertAutomationRun, getFullAutomationRevisionMetadata, getBranchLatestCommits, getCommit })({ revisionId: automationRevisionId, manifest: { versionId: version.id, modelId: trigger.triggeringId, triggerType: trigger.triggerType, projectId: project.id }, source: RunTriggerSource.Manual }) const storedRun = await getFullAutomationRunById(automationRunId) if (!storedRun) throw new Error('cant fint the stored run') const expectedStatus = 'exception' expect(storedRun.status).to.equal(expectedStatus) for (const run of storedRun.functionRuns) { expect(run.status).to.equal(expectedStatus) expect(run.statusMessage).to.equal(thrownError) } }) it('Saves run with the execution engine run id if trigger is successful', async () => { // create user, project, model, version const userId = testUser.id const project = { name: cryptoRandomString({ length: 10 }), id: cryptoRandomString({ length: 10 }), ownerId: userId, isPublic: true } await createTestStream(project, testUser) const version = { id: cryptoRandomString({ length: 10 }), streamId: project.id, objectId: null, authorId: userId } // @ts-expect-error force setting the objectId to null await createTestCommit(version) // create automation, const automation: LiveAutomation = { id: cryptoRandomString({ length: 10 }), createdAt: new Date(), updatedAt: new Date(), name: cryptoRandomString({ length: 15 }), enabled: true, projectId: project.id, executionEngineAutomationId: cryptoRandomString({ length: 10 }), isTestAutomation: false, isDeleted: false, userId } const automationToken = { automationId: automation.id, automateToken: cryptoRandomString({ length: 10 }), automateRefreshToken: cryptoRandomString({ length: 10 }) } await storeAutomation(automation) await storeAutomationToken(automationToken) const automationRevisionId = cryptoRandomString({ length: 10 }) const trigger = { triggerType: VersionCreationTriggerType, triggeringId: cryptoRandomString({ length: 10 }) } // create revision, await storeAutomationRevision({ id: automationRevisionId, createdAt: new Date(), automationId: automation.id, active: true, triggers: [trigger], userId, publicKey, functions: [ { functionInputs: null, functionReleaseId: cryptoRandomString({ length: 10 }), functionId: cryptoRandomString({ length: 10 }) } ] }) let eventFired = false getEventBus().listenOnce( AutomationRunEvents.Created, async ({ payload }) => { expect(payload.automation.id).to.equal(automation.id) expect(payload.run.automationRevisionId).to.equal(automationRevisionId) expect(payload.source).to.equal(RunTriggerSource.Manual) eventFired = true }, { timeout: TIME_MS.second } ) const executionEngineRunId = cryptoRandomString({ length: 10 }) const { automationRunId } = await triggerAutomationRevisionRunFactory({ automateRunTrigger: async () => ({ automationRunId: executionEngineRunId }), getFunctionInputDecryptor: getFunctionInputDecryptorFactory({ buildDecryptor }), getEncryptionKeyPairFor, createAppToken, emitEvent: getEventBus().emit, getAutomationToken, upsertAutomationRun, getFullAutomationRevisionMetadata, getBranchLatestCommits, getCommit })({ revisionId: automationRevisionId, manifest: { versionId: version.id, modelId: trigger.triggeringId, triggerType: trigger.triggerType, projectId: project.id }, source: RunTriggerSource.Manual }) const storedRun = await getFullAutomationRunById(automationRunId) if (!storedRun) throw new Error('cant fint the stored run') const expectedStatus = 'pending' expect(storedRun.status).to.equal(expectedStatus) expect(storedRun.executionEngineRunId).to.equal(executionEngineRunId) for (const run of storedRun.functionRuns) { expect(run.status).to.equal(expectedStatus) } expect(eventFired).to.be.true }) }) describe('Run conditions are NOT met if', () => { it("the referenced revision doesn't exist", async () => { try { await ensureRunConditionsFactory({ revisionGetter: async () => null, versionGetter: async () => undefined, automationTokenGetter: async () => null })({ revisionId: cryptoRandomString({ length: 10 }), manifest: { triggerType: VersionCreationTriggerType, modelId: cryptoRandomString({ length: 10 }), versionId: cryptoRandomString({ length: 10 }) } }) throw new Error('this should have thrown') } catch (error) { if (!(error instanceof Error)) throw error expect(error.message).contains( "Cannot trigger the given revision, it doesn't exist" ) } }) it('the automation is not enabled', async () => { try { await ensureRunConditionsFactory({ revisionGetter: async () => ({ id: cryptoRandomString({ length: 10 }), name: cryptoRandomString({ length: 10 }), projectId: cryptoRandomString({ length: 10 }), enabled: false, createdAt: new Date(), updatedAt: new Date(), executionEngineAutomationId: cryptoRandomString({ length: 10 }), userId: cryptoRandomString({ length: 10 }), isTestAutomation: false, isDeleted: false, revision: { id: cryptoRandomString({ length: 10 }), createdAt: new Date(), publicKey, userId: cryptoRandomString({ length: 10 }), active: false, triggers: [], functions: [], automationId: cryptoRandomString({ length: 10 }), automationToken: cryptoRandomString({ length: 15 }) } }), versionGetter: async () => undefined, automationTokenGetter: async () => null })({ revisionId: cryptoRandomString({ length: 10 }), manifest: { triggerType: VersionCreationTriggerType, modelId: cryptoRandomString({ length: 10 }), versionId: cryptoRandomString({ length: 10 }) } }) throw new Error('this should have thrown') } catch (error) { if (!(error instanceof Error)) throw error expect(error.message).contains( 'The automation is not enabled, cannot trigger it' ) } }) it('the revision is not active', async () => { try { await ensureRunConditionsFactory({ revisionGetter: async () => ({ id: cryptoRandomString({ length: 10 }), name: cryptoRandomString({ length: 10 }), projectId: cryptoRandomString({ length: 10 }), enabled: true, createdAt: new Date(), updatedAt: new Date(), executionEngineAutomationId: cryptoRandomString({ length: 10 }), userId: cryptoRandomString({ length: 10 }), isTestAutomation: false, isDeleted: false, revision: { publicKey, active: false, triggers: [], functions: [], automationId: cryptoRandomString({ length: 10 }), automationToken: cryptoRandomString({ length: 15 }), id: cryptoRandomString({ length: 10 }), createdAt: new Date(), userId: cryptoRandomString({ length: 10 }) } }), versionGetter: async () => undefined, automationTokenGetter: async () => null })({ revisionId: cryptoRandomString({ length: 10 }), manifest: { triggerType: VersionCreationTriggerType, modelId: cryptoRandomString({ length: 10 }), versionId: cryptoRandomString({ length: 10 }) } }) throw new Error('this should have thrown') } catch (error) { if (!(error instanceof Error)) throw error expect(error.message).contains( 'The automation revision is not active, cannot trigger it' ) } }) it("the revision doesn't have the referenced trigger", async () => { try { await ensureRunConditionsFactory({ revisionGetter: async () => ({ id: cryptoRandomString({ length: 10 }), createdAt: new Date(), updatedAt: new Date(), userId: cryptoRandomString({ length: 10 }), name: cryptoRandomString({ length: 10 }), projectId: cryptoRandomString({ length: 10 }), enabled: true, executionEngineAutomationId: cryptoRandomString({ length: 10 }), isTestAutomation: false, isDeleted: false, revision: { publicKey, id: cryptoRandomString({ length: 10 }), createdAt: new Date(), userId: cryptoRandomString({ length: 10 }), active: true, triggers: [], functions: [], automationId: cryptoRandomString({ length: 10 }), automationToken: cryptoRandomString({ length: 15 }) } }), versionGetter: async () => undefined, automationTokenGetter: async () => null })({ revisionId: cryptoRandomString({ length: 10 }), manifest: { triggerType: VersionCreationTriggerType, modelId: cryptoRandomString({ length: 10 }), versionId: cryptoRandomString({ length: 10 }) } }) throw new Error('this should have thrown') } catch (error) { if (!(error instanceof Error)) throw error expect(error.message).contains( "The given revision doesn't have a trigger registered matching the input trigger" ) } }) it('the trigger is not a versionCreation type', async () => { const manifest: VersionCreatedTriggerManifest = { // @ts-expect-error: intentionally using invalid type here triggerType: 'bogusTrigger' as const, modelId: cryptoRandomString({ length: 10 }), versionId: cryptoRandomString({ length: 10 }) } try { await ensureRunConditionsFactory({ revisionGetter: async () => ({ id: cryptoRandomString({ length: 10 }), name: cryptoRandomString({ length: 10 }), projectId: cryptoRandomString({ length: 10 }), enabled: true, createdAt: new Date(), updatedAt: new Date(), executionEngineAutomationId: cryptoRandomString({ length: 10 }), userId: cryptoRandomString({ length: 10 }), isTestAutomation: false, isDeleted: false, revision: { publicKey, id: cryptoRandomString({ length: 10 }), createdAt: new Date(), userId: cryptoRandomString({ length: 10 }), active: true, triggers: [ { triggeringId: manifest.modelId, triggerType: manifest.triggerType, automationRevisionId: cryptoRandomString({ length: 10 }) } ], functions: [], automationId: cryptoRandomString({ length: 10 }) } }), versionGetter: async () => undefined, automationTokenGetter: async () => null })({ revisionId: cryptoRandomString({ length: 10 }), manifest }) throw new Error('this should have thrown') } catch (error) { if (!(error instanceof Error)) throw error expect(error.message).contains('Only model version triggers are supported') } }) it("the version that is referenced on the trigger, doesn't exist", async () => { const manifest: VersionCreatedTriggerManifest = { triggerType: VersionCreationTriggerType, modelId: cryptoRandomString({ length: 10 }), versionId: cryptoRandomString({ length: 10 }), projectId: cryptoRandomString({ length: 10 }) } try { await ensureRunConditionsFactory({ revisionGetter: async () => ({ id: cryptoRandomString({ length: 10 }), name: cryptoRandomString({ length: 10 }), projectId: cryptoRandomString({ length: 10 }), enabled: true, createdAt: new Date(), updatedAt: new Date(), executionEngineAutomationId: cryptoRandomString({ length: 10 }), userId: cryptoRandomString({ length: 10 }), isTestAutomation: false, isDeleted: false, revision: { id: cryptoRandomString({ length: 10 }), createdAt: new Date(), userId: cryptoRandomString({ length: 10 }), active: true, publicKey, triggers: [ { triggerType: manifest.triggerType, triggeringId: manifest.modelId, automationRevisionId: cryptoRandomString({ length: 10 }) } ], functions: [], automationId: cryptoRandomString({ length: 10 }), automationToken: cryptoRandomString({ length: 15 }) } }), versionGetter: async () => undefined, automationTokenGetter: async () => null })({ revisionId: cryptoRandomString({ length: 10 }), manifest }) throw new Error('this should have thrown') } catch (error) { if (!(error instanceof Error)) throw error expect(error.message).contains('The triggering version is not found') } }) it("the author, that created the triggering version doesn't exist", async () => { const manifest: VersionCreatedTriggerManifest = { triggerType: VersionCreationTriggerType, modelId: cryptoRandomString({ length: 10 }), versionId: cryptoRandomString({ length: 10 }), projectId: cryptoRandomString({ length: 10 }) } try { await ensureRunConditionsFactory({ revisionGetter: async () => ({ id: cryptoRandomString({ length: 10 }), name: cryptoRandomString({ length: 10 }), projectId: cryptoRandomString({ length: 10 }), createdAt: new Date(), updatedAt: new Date(), enabled: true, executionEngineAutomationId: cryptoRandomString({ length: 10 }), userId: cryptoRandomString({ length: 10 }), isTestAutomation: false, isDeleted: false, revision: { id: cryptoRandomString({ length: 10 }), userId: cryptoRandomString({ length: 10 }), active: true, publicKey, triggers: [ { triggeringId: manifest.modelId, triggerType: manifest.triggerType, automationRevisionId: cryptoRandomString({ length: 10 }) } ], createdAt: new Date(), functions: [], automationId: cryptoRandomString({ length: 10 }), automationToken: cryptoRandomString({ length: 15 }) } }), versionGetter: async () => ({ author: null, id: cryptoRandomString({ length: 10 }), createdAt: new Date(), message: 'foobar', parents: [], referencedObject: cryptoRandomString({ length: 10 }), totalChildrenCount: null, sourceApplication: 'test suite', streamId: cryptoRandomString({ length: 10 }), branchId: cryptoRandomString({ length: 10 }), branchName: cryptoRandomString({ length: 10 }) }), automationTokenGetter: async () => null })({ revisionId: cryptoRandomString({ length: 10 }), manifest }) throw new Error('this should have thrown') } catch (error) { if (!(error instanceof Error)) throw error expect(error.message).contains( "The user, that created the triggering version doesn't exist any more" ) } }) it("the automation doesn't have a token available", async () => { const manifest: VersionCreatedTriggerManifest = { triggerType: VersionCreationTriggerType, modelId: cryptoRandomString({ length: 10 }), versionId: cryptoRandomString({ length: 10 }), projectId: cryptoRandomString({ length: 10 }) } try { await ensureRunConditionsFactory({ revisionGetter: async () => ({ id: cryptoRandomString({ length: 10 }), name: cryptoRandomString({ length: 10 }), projectId: cryptoRandomString({ length: 10 }), enabled: true, createdAt: new Date(), updatedAt: new Date(), executionEngineAutomationId: cryptoRandomString({ length: 10 }), userId: cryptoRandomString({ length: 10 }), isTestAutomation: false, isDeleted: false, revision: { id: cryptoRandomString({ length: 10 }), userId: cryptoRandomString({ length: 10 }), createdAt: new Date(), active: true, publicKey, triggers: [ { triggeringId: manifest.modelId, triggerType: manifest.triggerType, automationRevisionId: cryptoRandomString({ length: 10 }) } ], functions: [], automationId: cryptoRandomString({ length: 10 }), automationToken: cryptoRandomString({ length: 15 }) } }), versionGetter: async () => ({ author: cryptoRandomString({ length: 10 }), id: cryptoRandomString({ length: 10 }), createdAt: new Date(), message: 'foobar', parents: [], referencedObject: cryptoRandomString({ length: 10 }), totalChildrenCount: null, sourceApplication: 'test suite', streamId: cryptoRandomString({ length: 10 }), branchId: cryptoRandomString({ length: 10 }), branchName: cryptoRandomString({ length: 10 }) }), automationTokenGetter: async () => null })({ revisionId: cryptoRandomString({ length: 10 }), manifest }) throw new Error('this should have thrown') } catch (error) { if (!(error instanceof Error)) throw error expect(error.message).contains('Cannot find a token for the automation') } }) it('the automation is a test automation', async () => { const manifest: VersionCreatedTriggerManifest = { triggerType: VersionCreationTriggerType, modelId: cryptoRandomString({ length: 10 }), versionId: cryptoRandomString({ length: 10 }), projectId: cryptoRandomString({ length: 10 }) } try { await ensureRunConditionsFactory({ revisionGetter: async () => ({ id: cryptoRandomString({ length: 10 }), name: cryptoRandomString({ length: 10 }), projectId: cryptoRandomString({ length: 10 }), enabled: true, createdAt: new Date(), updatedAt: new Date(), executionEngineAutomationId: null, userId: cryptoRandomString({ length: 10 }), isTestAutomation: true, isDeleted: false, revision: { id: cryptoRandomString({ length: 10 }), userId: cryptoRandomString({ length: 10 }), createdAt: new Date(), active: true, publicKey, triggers: [ { triggeringId: manifest.modelId, triggerType: manifest.triggerType, automationRevisionId: cryptoRandomString({ length: 10 }) } ], functions: [], automationId: cryptoRandomString({ length: 10 }), automationToken: cryptoRandomString({ length: 15 }) } }), versionGetter: async () => ({ author: cryptoRandomString({ length: 10 }), id: cryptoRandomString({ length: 10 }), createdAt: new Date(), message: 'foobar', parents: [], referencedObject: cryptoRandomString({ length: 10 }), totalChildrenCount: null, sourceApplication: 'test suite', streamId: cryptoRandomString({ length: 10 }), branchId: cryptoRandomString({ length: 10 }), branchName: cryptoRandomString({ length: 10 }) }), automationTokenGetter: async () => null })({ revisionId: cryptoRandomString({ length: 10 }), manifest }) throw new Error('this should have thrown') } catch (error) { if (!(error instanceof Error)) throw error expect(error.message).contains('This is a test automation') } }) }) describe('Run triggered manually', () => { const buildManuallyTriggerAutomation = ( overrides?: Partial ) => { const trigger = manuallyTriggerAutomationFactory({ getAutomationTriggerDefinitions, getAutomation, getBranchLatestCommits, triggerFunction: triggerAutomationRevisionRunFactory({ automateRunTrigger: async () => ({ automationRunId: cryptoRandomString({ length: 10 }) }), getFunctionInputDecryptor: getFunctionInputDecryptorFactory({ buildDecryptor }), getEncryptionKeyPairFor, createAppToken, emitEvent: getEventBus().emit, getAutomationToken, upsertAutomationRun, getFullAutomationRevisionMetadata, getBranchLatestCommits, getCommit }), validateStreamAccess, ...(overrides || {}) }) return trigger } it('fails if referring to nonexistent automation', async () => { const trigger = buildManuallyTriggerAutomation() const e = await expectToThrow( async () => await trigger({ automationId: cryptoRandomString({ length: 10 }), userId: testUser.id, projectId: testUserStream.id }) ) expect(e.message).to.eq('Automation not found') }) it('fails if project id is mismatched from automation id', async () => { const trigger = buildManuallyTriggerAutomation() const e = await expectToThrow( async () => await trigger({ automationId: createdAutomation.automation.id, userId: testUser.id, projectId: otherUserStream.id }) ) expect(e.message).to.eq('Automation not found') }) it('fails if revision has no version creation triggers', async () => { const trigger = buildManuallyTriggerAutomation({ getAutomationTriggerDefinitions: async () => [] }) const e = await expectToThrow( async () => await trigger({ automationId: createdAutomation.automation.id, userId: testUser.id, projectId: testUserStream.id }) ) expect(e.message).to.eq( 'No model version creation triggers found for the automation' ) }) it('fails if user does not have access to automation', async () => { const trigger = buildManuallyTriggerAutomation() const e = await expectToThrow( async () => await trigger({ automationId: createdAutomation.automation.id, userId: otherUser.id, projectId: testUserStream.id }) ) expect(e.message).to.eq('User does not have required access to stream') }) it('fails if no versions found for any triggers', async () => { const trigger = buildManuallyTriggerAutomation() const e = await expectToThrow( async () => await trigger({ automationId: createdAutomation.automation.id, userId: testUser.id, projectId: testUserStream.id }) ) expect(e.message).to.eq( 'Selected model has no versions so it cannot be used to trigger an automation.' ) }) describe('with valid versions available', () => { beforeEach(async () => { await createTestCommit({ id: '', objectId: '', streamId: testUserStream.id, authorId: testUser.id, branchId: '' }) }) afterEach(async () => { await truncateTables([Commits.name]) await Promise.all([ updateAutomation({ id: createdAutomation.automation.id, enabled: true }), updateAutomationRevision({ id: createdRevision.id, active: true }) ]) }) it('fails if automation is disabled', async () => { await updateAutomation({ id: createdAutomation.automation.id, enabled: false }) const trigger = buildManuallyTriggerAutomation() const e = await expectToThrow( async () => await trigger({ automationId: createdAutomation.automation.id, userId: testUser.id, projectId: testUserStream.id }) ) expect(e.message).to.eq('The automation is not enabled, cannot trigger it') }) it('fails if automation revision is disabled', async () => { await updateAutomationRevision({ id: createdRevision.id, active: false }) const trigger = buildManuallyTriggerAutomation() const e = await expectToThrow( async () => await trigger({ automationId: createdAutomation.automation.id, userId: testUser.id, projectId: testUserStream.id }) ) expect(e.message).to.eq( 'No model version creation triggers found for the automation' ) }) it('succeeds', async () => { const trigger = buildManuallyTriggerAutomation() const { automationRunId } = await trigger({ automationId: createdAutomation.automation.id, userId: testUser.id, projectId: testUserStream.id }) const storedRun = await getFullAutomationRunById(automationRunId) expect(storedRun).to.be.ok const expectedStatus = 'pending' expect(storedRun!.status).to.equal(expectedStatus) for (const run of storedRun!.functionRuns) { expect(run.status).to.equal(expectedStatus) } }) }) }) describe('Existing automation run', () => { let automationRun: InsertableAutomationRun before(async () => { const testVersion = { id: cryptoRandomString({ length: 10 }), authorId: testUser.id, streamId: testUserStream.id, branchName: testUserStreamModel.name, objectId: '', branchId: '' } await createTestCommit(testVersion) // Insert automation run directly to DB automationRun = { id: cryptoRandomString({ length: 10 }), automationRevisionId: createdRevision.id, createdAt: new Date(), updatedAt: new Date(), status: AutomationRunStatuses.running, executionEngineRunId: cryptoRandomString({ length: 10 }), triggers: [ { triggeringId: testVersion.id, triggerType: VersionCreationTriggerType } ], functionRuns: [ { functionId: generateFunctionId(), functionReleaseId: generateFunctionReleaseId(), id: cryptoRandomString({ length: 15 }), status: AutomationRunStatuses.running, elapsed: 0, results: null, contextView: null, statusMessage: null, createdAt: new Date(), updatedAt: new Date() } ] } await upsertAutomationRun(automationRun) }) describe('status update report', () => { const buildReportFunctionRunStatus = () => { const report = reportFunctionRunStatusFactory({ getAutomationFunctionRunRecord: getFunctionRun, upsertAutomationFunctionRunRecord: upsertAutomationFunctionRun, automationRunUpdater: updateAutomationRun, emitEvent: getEventBus().emit }) return report } it('fails fn with invalid functionRunId', async () => { const report = buildReportFunctionRunStatus() const functionRunId = 'nonexistent' const params: Parameters[0] = { runId: functionRunId, status: mapGqlStatusToDbStatus(AutomateRunStatus.Succeeded), statusMessage: null, results: null, contextView: null, projectId: testUserStream.id } await expect(report(params)).to.eventually.be.rejectedWith( FunctionRunNotFoundError ) }) it('fails fn with invalid status', async () => { const report = buildReportFunctionRunStatus() const functionRunId = automationRun.functionRuns[0].id const params: Parameters[0] = { runId: functionRunId, status: mapGqlStatusToDbStatus(AutomateRunStatus.Pending), statusMessage: null, results: null, contextView: null, projectId: testUserStream.id } await expect(report(params)).to.eventually.be.rejectedWith( FunctionRunReportStatusError, /^Invalid status change/ ) }) ;[ { val: 1, error: 'invalid type' }, { val: { version: '1.0', values: { objectResults: [] }, error: 'invalid version' } }, { val: { version: 1.0, values: {} }, error: 'invalid values object' }, { val: { version: 1.0, values: { objectResults: [1] } }, error: 'invalid objectResults item type' }, { val: { version: 1.0, values: { objectResults: [{}] } }, error: 'invalid objectResults item keys' } ].forEach(({ val, error }) => { it('fails fn with invalid results: ' + error, async () => { const report = buildReportFunctionRunStatus() const functionRunId = automationRun.functionRuns[0].id const params: Parameters[0] = { runId: functionRunId, status: mapGqlStatusToDbStatus(AutomateRunStatus.Succeeded), statusMessage: null, results: val as unknown as Automate.AutomateTypes.ResultsSchema, contextView: null, projectId: testUserStream.id } await expect(report(params)).to.eventually.be.rejectedWith( Automate.UnformattableResultsSchemaError ) }) }) it('fails fn with invalid contextView url', async () => { const report = buildReportFunctionRunStatus() const functionRunId = automationRun.functionRuns[0].id const params: Parameters[0] = { runId: functionRunId, status: mapGqlStatusToDbStatus(AutomateRunStatus.Succeeded), statusMessage: null, results: null, contextView: 'invalid-url', projectId: testUserStream.id } await expect(report(params)).to.eventually.be.rejectedWith( FunctionRunReportStatusError, 'Context view must start with a forward slash' ) }) it('succeeds', async () => { const report = buildReportFunctionRunStatus() const functionRunId = automationRun.functionRuns[0].id const contextView = '/a/b/c' const params: Parameters[0] = { runId: functionRunId, status: mapGqlStatusToDbStatus(AutomateRunStatus.Succeeded), statusMessage: null, results: null, contextView, projectId: testUserStream.id } let eventFired = false getEventBus().listenOnce( AutomationRunEvents.StatusUpdated, async ({ payload }) => { expect(payload.functionRun.id).to.equal(functionRunId) eventFired = true }, { timeout: TIME_MS.second } ) await expect(report(params)).to.eventually.be.true const [updatedRun, updatedFnRun] = await Promise.all([ getFullAutomationRunById(automationRun.id), getFunctionRun(functionRunId) ]) expect(updatedRun?.status).to.equal(AutomationRunStatuses.succeeded) expect(updatedFnRun?.status).to.equal(AutomationRunStatuses.succeeded) expect(updatedFnRun?.contextView).to.equal(contextView) expect(eventFired).to.be.true }) }) }) } )