From 0083a9e45d21928be259664532528706d0a57ecf Mon Sep 17 00:00:00 2001 From: Val <68596159+valya@users.noreply.github.com> Date: Wed, 27 Sep 2023 15:22:39 +0100 Subject: [PATCH] feat(core): Initial workflow history API (#7234) Github issue / Community forum post (link here to close automatically): --- .../handlers/workflows/workflows.handler.ts | 6 + packages/cli/src/Server.ts | 4 + .../src/databases/entities/WorkflowHistory.ts | 4 +- .../repositories/execution.repository.ts | 4 +- packages/cli/src/requests.ts | 18 ++ .../workflowHistory.controller.ee.ts | 76 ++++++ .../workflowHistory.service.ee.ts | 78 ++++++ .../workflowHistoryHelper.ee.ts | 4 + .../workflowHistoryService.ee.ts | 7 - .../cli/src/workflows/workflows.services.ts | 6 + .../cli/test/integration/shared/testDb.ts | 44 +++- packages/cli/test/integration/shared/types.ts | 9 +- .../integration/shared/utils/testServer.ts | 5 +- .../integration/workflowHistory.api.test.ts | 226 ++++++++++++++++++ 14 files changed, 474 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/workflows/workflowHistory/workflowHistory.controller.ee.ts create mode 100644 packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts delete mode 100644 packages/cli/src/workflows/workflowHistory/workflowHistoryService.ee.ts create mode 100644 packages/cli/test/integration/workflowHistory.api.test.ts diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts index b4a280779c..a14cb091dc 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -26,6 +26,8 @@ import { import { WorkflowsService } from '@/workflows/workflows.services'; import { InternalHooks } from '@/InternalHooks'; import { RoleService } from '@/services/role.service'; +import { isWorkflowHistoryLicensed } from '@/workflows/workflowHistory/workflowHistoryHelper.ee'; +import { WorkflowHistoryService } from '@/workflows/workflowHistory/workflowHistory.service.ee'; export = { createWorkflow: [ @@ -177,6 +179,10 @@ export = { } } + if (isWorkflowHistoryLicensed()) { + await Container.get(WorkflowHistoryService).saveVersion(req.user, sharedWorkflow.workflow); + } + if (sharedWorkflow.workflow.active) { try { await workflowRunner.add(sharedWorkflow.workflowId, 'update'); diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 152b1d3899..7f28c4d0e4 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -178,6 +178,8 @@ import { JwtService } from './services/jwt.service'; import { RoleService } from './services/role.service'; import { UserService } from './services/user.service'; import { OrchestrationController } from './controllers/orchestration.controller'; +import { isWorkflowHistoryEnabled } from './workflows/workflowHistory/workflowHistoryHelper.ee'; +import { WorkflowHistoryController } from './workflows/workflowHistory/workflowHistory.controller.ee'; const exec = promisify(callbackExec); @@ -470,6 +472,7 @@ export class Server extends AbstractServer { LICENSE_FEATURES.SHOW_NON_PROD_BANNER, ), debugInEditor: isDebugInEditorLicensed(), + history: isWorkflowHistoryEnabled(), }); if (isLdapEnabled()) { @@ -559,6 +562,7 @@ export class Server extends AbstractServer { Container.get(WorkflowStatisticsController), Container.get(ExternalSecretsController), Container.get(OrchestrationController), + Container.get(WorkflowHistoryController), ]; if (isLdapEnabled()) { diff --git a/packages/cli/src/databases/entities/WorkflowHistory.ts b/packages/cli/src/databases/entities/WorkflowHistory.ts index c0eacaeba9..a1db4ed177 100644 --- a/packages/cli/src/databases/entities/WorkflowHistory.ts +++ b/packages/cli/src/databases/entities/WorkflowHistory.ts @@ -1,11 +1,11 @@ import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; -import { jsonColumnType } from './AbstractEntity'; +import { WithTimestamps, jsonColumnType } from './AbstractEntity'; import { IConnections } from 'n8n-workflow'; import type { INode } from 'n8n-workflow'; import { WorkflowEntity } from './WorkflowEntity'; @Entity() -export class WorkflowHistory { +export class WorkflowHistory extends WithTimestamps { @PrimaryColumn() versionId: string; diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 058c9b7784..fb18757e72 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -25,7 +25,7 @@ import type { ExecutionData } from '../entities/ExecutionData'; import { ExecutionEntity } from '../entities/ExecutionEntity'; import { ExecutionMetadata } from '../entities/ExecutionMetadata'; import { ExecutionDataRepository } from './executionData.repository'; -import { TIME } from '@/constants'; +import { TIME, inTest } from '@/constants'; function parseFiltersToQueryBuilder( qb: SelectQueryBuilder, @@ -93,7 +93,7 @@ export class ExecutionRepository extends Repository { ) { super(ExecutionEntity, dataSource.manager); - if (!this.isMainInstance) return; + if (!this.isMainInstance || inTest) return; if (this.isPruningEnabled) this.setSoftDeletionInterval(); diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 4ce69bc81c..00bf110aeb 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -28,6 +28,7 @@ import type { UserManagementMailer } from '@/UserManagement/email'; import type { Variables } from '@db/entities/Variables'; import type { WorkflowEntity } from './databases/entities/WorkflowEntity'; import type { CredentialsEntity } from './databases/entities/CredentialsEntity'; +import type { WorkflowHistory } from './databases/entities/WorkflowHistory'; export class UserUpdatePayload implements Pick { @IsEmail() @@ -545,3 +546,20 @@ export declare namespace OrchestrationRequest { type GetAll = AuthenticatedRequest; type Get = AuthenticatedRequest<{ id: string }, {}, {}, {}>; } + +// ---------------------------------- +// /workflow-history +// ---------------------------------- + +export declare namespace WorkflowHistoryRequest { + type GetList = AuthenticatedRequest< + { workflowId: string }, + Array>, + {}, + ListQuery.Options + >; + type GetVersion = AuthenticatedRequest< + { workflowId: string; versionId: string }, + WorkflowHistory + >; +} diff --git a/packages/cli/src/workflows/workflowHistory/workflowHistory.controller.ee.ts b/packages/cli/src/workflows/workflowHistory/workflowHistory.controller.ee.ts new file mode 100644 index 0000000000..89e78a4d15 --- /dev/null +++ b/packages/cli/src/workflows/workflowHistory/workflowHistory.controller.ee.ts @@ -0,0 +1,76 @@ +import { Authorized, RestController, Get, Middleware } from '@/decorators'; +import { WorkflowHistoryRequest } from '@/requests'; +import { Service } from 'typedi'; +import { + HistoryVersionNotFoundError, + SharedWorkflowNotFoundError, + WorkflowHistoryService, +} from './workflowHistory.service.ee'; +import { Request, Response, NextFunction } from 'express'; +import { isWorkflowHistoryEnabled, isWorkflowHistoryLicensed } from './workflowHistoryHelper.ee'; +import { NotFoundError } from '@/ResponseHelper'; +import { paginationListQueryMiddleware } from '@/middlewares/listQuery/pagination'; + +const DEFAULT_TAKE = 20; + +@Service() +@Authorized() +@RestController('/workflow-history') +export class WorkflowHistoryController { + constructor(private readonly historyService: WorkflowHistoryService) {} + + @Middleware() + workflowHistoryLicense(_req: Request, res: Response, next: NextFunction) { + if (!isWorkflowHistoryLicensed()) { + res.status(403); + res.send('Workflow History license data not found'); + return; + } + next(); + } + + @Middleware() + workflowHistoryEnabled(_req: Request, res: Response, next: NextFunction) { + if (!isWorkflowHistoryEnabled()) { + res.status(403); + res.send('Workflow History is disabled'); + return; + } + next(); + } + + @Get('/workflow/:workflowId', { middlewares: [paginationListQueryMiddleware] }) + async getList(req: WorkflowHistoryRequest.GetList) { + try { + return await this.historyService.getList( + req.user, + req.params.workflowId, + req.query.take ?? DEFAULT_TAKE, + req.query.skip ?? 0, + ); + } catch (e) { + if (e instanceof SharedWorkflowNotFoundError) { + throw new NotFoundError('Could not find workflow'); + } + throw e; + } + } + + @Get('/workflow/:workflowId/version/:versionId') + async getVersion(req: WorkflowHistoryRequest.GetVersion) { + try { + return await this.historyService.getVersion( + req.user, + req.params.workflowId, + req.params.versionId, + ); + } catch (e) { + if (e instanceof SharedWorkflowNotFoundError) { + throw new NotFoundError('Could not find workflow'); + } else if (e instanceof HistoryVersionNotFoundError) { + throw new NotFoundError('Could not find version'); + } + throw e; + } + } +} diff --git a/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts new file mode 100644 index 0000000000..fffe2f4c8c --- /dev/null +++ b/packages/cli/src/workflows/workflowHistory/workflowHistory.service.ee.ts @@ -0,0 +1,78 @@ +import type { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; +import type { User } from '@/databases/entities/User'; +import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; +import type { WorkflowHistory } from '@/databases/entities/WorkflowHistory'; +import { SharedWorkflowRepository } from '@/databases/repositories'; +import { WorkflowHistoryRepository } from '@db/repositories/workflowHistory.repository'; +import { Service } from 'typedi'; +import { isWorkflowHistoryEnabled } from './workflowHistoryHelper.ee'; + +export class SharedWorkflowNotFoundError extends Error {} +export class HistoryVersionNotFoundError extends Error {} + +@Service() +export class WorkflowHistoryService { + constructor( + private readonly workflowHistoryRepository: WorkflowHistoryRepository, + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + ) {} + + private async getSharedWorkflow(user: User, workflowId: string): Promise { + return this.sharedWorkflowRepository.findOne({ + where: { + ...(!user.isOwner && { userId: user.id }), + workflowId, + }, + }); + } + + async getList( + user: User, + workflowId: string, + take: number, + skip: number, + ): Promise>> { + const sharedWorkflow = await this.getSharedWorkflow(user, workflowId); + if (!sharedWorkflow) { + throw new SharedWorkflowNotFoundError(); + } + return this.workflowHistoryRepository.find({ + where: { + workflowId: sharedWorkflow.workflowId, + }, + take, + skip, + select: ['workflowId', 'versionId', 'authors', 'createdAt', 'updatedAt'], + order: { createdAt: 'DESC' }, + }); + } + + async getVersion(user: User, workflowId: string, versionId: string): Promise { + const sharedWorkflow = await this.getSharedWorkflow(user, workflowId); + if (!sharedWorkflow) { + throw new SharedWorkflowNotFoundError(); + } + const hist = await this.workflowHistoryRepository.findOne({ + where: { + workflowId: sharedWorkflow.workflowId, + versionId, + }, + }); + if (!hist) { + throw new HistoryVersionNotFoundError(); + } + return hist; + } + + async saveVersion(user: User, workflow: WorkflowEntity) { + if (isWorkflowHistoryEnabled()) { + await this.workflowHistoryRepository.insert({ + authors: user.firstName + ' ' + user.lastName, + connections: workflow.connections, + nodes: workflow.nodes, + versionId: workflow.versionId, + workflowId: workflow.id, + }); + } + } +} diff --git a/packages/cli/src/workflows/workflowHistory/workflowHistoryHelper.ee.ts b/packages/cli/src/workflows/workflowHistory/workflowHistoryHelper.ee.ts index 74e399ab71..49d80b2daa 100644 --- a/packages/cli/src/workflows/workflowHistory/workflowHistoryHelper.ee.ts +++ b/packages/cli/src/workflows/workflowHistory/workflowHistoryHelper.ee.ts @@ -5,3 +5,7 @@ export function isWorkflowHistoryLicensed() { const license = Container.get(License); return license.isWorkflowHistoryLicensed(); } + +export function isWorkflowHistoryEnabled() { + return isWorkflowHistoryLicensed(); +} diff --git a/packages/cli/src/workflows/workflowHistory/workflowHistoryService.ee.ts b/packages/cli/src/workflows/workflowHistory/workflowHistoryService.ee.ts deleted file mode 100644 index 7db534da93..0000000000 --- a/packages/cli/src/workflows/workflowHistory/workflowHistoryService.ee.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { WorkflowHistoryRepository } from '@db/repositories/workflowHistory.repository'; -import { Service } from 'typedi'; - -@Service() -export class WorkflowHistoryService { - constructor(private readonly workflowHistoryRepository: WorkflowHistoryRepository) {} -} diff --git a/packages/cli/src/workflows/workflows.services.ts b/packages/cli/src/workflows/workflows.services.ts index 5a466e65ff..44a3ef3fcf 100644 --- a/packages/cli/src/workflows/workflows.services.ts +++ b/packages/cli/src/workflows/workflows.services.ts @@ -33,6 +33,8 @@ import { WorkflowRepository } from '@/databases/repositories'; import { RoleService } from '@/services/role.service'; import { OwnershipService } from '@/services/ownership.service'; import { isStringArray, isWorkflowIdValid } from '@/utils'; +import { isWorkflowHistoryLicensed } from './workflowHistory/workflowHistoryHelper.ee'; +import { WorkflowHistoryService } from './workflowHistory/workflowHistory.service.ee'; export class WorkflowsService { static async getSharing( @@ -298,6 +300,10 @@ export class WorkflowsService { ); } + if (isWorkflowHistoryLicensed()) { + await Container.get(WorkflowHistoryService).saveVersion(user, shared.workflow); + } + const relations = config.getEnv('workflowTagsDisabled') ? [] : ['tags']; // We sadly get nothing back from "update". Neither if it updated a record diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index 91fa1723c4..ee59624881 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -1,7 +1,8 @@ import { UserSettings } from 'n8n-core'; -import type { DataSourceOptions as ConnectionOptions } from 'typeorm'; +import type { DataSourceOptions as ConnectionOptions, Repository } from 'typeorm'; import { DataSource as Connection } from 'typeorm'; import { Container } from 'typedi'; +import { v4 as uuid } from 'uuid'; import config from '@/config'; import * as Db from '@/Db'; @@ -26,12 +27,17 @@ import type { ExecutionData } from '@db/entities/ExecutionData'; import { generateNanoId } from '@db/utils/generators'; import { RoleService } from '@/services/role.service'; import { VariablesService } from '@/environments/variables/variables.service'; -import { TagRepository, WorkflowTagMappingRepository } from '@/databases/repositories'; +import { + TagRepository, + WorkflowHistoryRepository, + WorkflowTagMappingRepository, +} from '@/databases/repositories'; import { separate } from '@/utils'; import { randomPassword } from '@/Ldap/helpers'; import { TOTPService } from '@/Mfa/totp.service'; import { MfaService } from '@/Mfa/mfa.service'; +import type { WorkflowHistory } from '@/databases/entities/WorkflowHistory'; export type TestDBType = 'postgres' | 'mysql'; @@ -118,7 +124,12 @@ export async function truncate(collections: CollectionName[]) { } for (const collection of rest) { - await Db.collections[collection].delete({}); + if (typeof collection === 'string') { + await Db.collections[collection].delete({}); + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await Container.get(collection as { new (): Repository }).delete({}); + } } } @@ -572,6 +583,33 @@ export async function getVariableById(id: string) { }); } +// ---------------------------------- +// workflow history +// ---------------------------------- + +export async function createWorkflowHistoryItem( + workflowId: string, + data?: Partial, +) { + return Container.get(WorkflowHistoryRepository).save({ + authors: 'John Smith', + connections: {}, + nodes: [ + { + id: 'uuid-1234', + name: 'Start', + parameters: {}, + position: [-20, 260], + type: 'n8n-nodes-base.start', + typeVersion: 1, + }, + ], + versionId: uuid(), + ...(data ?? {}), + workflowId, + }); +} + // ---------------------------------- // connection options // ---------------------------------- diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index f552c7e1f7..233392bfd7 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -6,8 +6,12 @@ import type { Server } from 'http'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { User } from '@db/entities/User'; import type { BooleanLicenseFeature, ICredentialsDb, IDatabaseCollections } from '@/Interfaces'; +import type { DataSource, Repository } from 'typeorm'; -export type CollectionName = keyof IDatabaseCollections; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type CollectionName = + | keyof IDatabaseCollections + | { new (dataSource: DataSource): Repository }; export type EndpointGroup = | 'me' @@ -29,7 +33,8 @@ export type EndpointGroup = | 'externalSecrets' | 'mfa' | 'metrics' - | 'executions'; + | 'executions' + | 'workflowHistory'; export interface SetupProps { applyAuth?: boolean; diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index 21e381ff83..356861d710 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -65,6 +65,7 @@ import { JwtService } from '@/services/jwt.service'; import { RoleService } from '@/services/role.service'; import { UserService } from '@/services/user.service'; import { executionsController } from '@/executions/executions.controller'; +import { WorkflowHistoryController } from '@/workflows/workflowHistory/workflowHistory.controller.ee'; /** * Plugin to prefix a path segment into a request URL pathname. @@ -161,7 +162,6 @@ export const setupTestServer = ({ config.set('userManagement.jwtSecret', 'My JWT secret'); config.set('userManagement.isInstanceOwnerSetUp', true); - config.set('executions.pruneData', false); if (enabledFeatures) { Container.get(License).isFeatureEnabled = (feature) => enabledFeatures.includes(feature); @@ -313,6 +313,9 @@ export const setupTestServer = ({ case 'externalSecrets': registerController(app, config, Container.get(ExternalSecretsController)); break; + case 'workflowHistory': + registerController(app, config, Container.get(WorkflowHistoryController)); + break; } } } diff --git a/packages/cli/test/integration/workflowHistory.api.test.ts b/packages/cli/test/integration/workflowHistory.api.test.ts new file mode 100644 index 0000000000..1a7fbe2bc0 --- /dev/null +++ b/packages/cli/test/integration/workflowHistory.api.test.ts @@ -0,0 +1,226 @@ +import type { SuperAgentTest } from 'supertest'; +import { License } from '@/License'; +import * as testDb from './shared/testDb'; +import * as utils from './shared/utils/'; +import type { User } from '@/databases/entities/User'; +import { WorkflowHistoryRepository } from '@/databases/repositories'; + +let owner: User; +let authOwnerAgent: SuperAgentTest; +let member: User; +let authMemberAgent: SuperAgentTest; + +const licenseLike = utils.mockInstance(License, { + isWorkflowHistoryLicensed: jest.fn().mockReturnValue(true), + isWithinUsersLimit: jest.fn().mockReturnValue(true), +}); + +const testServer = utils.setupTestServer({ endpointGroups: ['workflowHistory'] }); + +beforeAll(async () => { + owner = await testDb.createOwner(); + authOwnerAgent = testServer.authAgentFor(owner); + member = await testDb.createUser(); + authMemberAgent = testServer.authAgentFor(member); +}); + +beforeEach(() => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(true); +}); + +afterEach(async () => { + await testDb.truncate(['Workflow', 'SharedWorkflow', WorkflowHistoryRepository]); +}); + +describe('GET /workflow-history/:workflowId', () => { + test('should not work when license is not available', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const resp = await authOwnerAgent.get('/workflow-history/workflow/badid'); + expect(resp.status).toBe(403); + expect(resp.text).toBe('Workflow History license data not found'); + }); + + test('should not return anything on an invalid workflow ID', async () => { + await testDb.createWorkflow(undefined, owner); + const resp = await authOwnerAgent.get('/workflow-history/workflow/badid'); + expect(resp.status).toBe(404); + }); + + test('should not return anything if not shared with user', async () => { + const workflow = await testDb.createWorkflow(undefined, owner); + const resp = await authMemberAgent.get('/workflow-history/workflow/' + workflow.id); + expect(resp.status).toBe(404); + }); + + test('should return any empty list if no versions', async () => { + const workflow = await testDb.createWorkflow(undefined, owner); + const resp = await authOwnerAgent.get('/workflow-history/workflow/' + workflow.id); + expect(resp.status).toBe(200); + expect(resp.body).toEqual({ data: [] }); + }); + + test('should return versions for workflow', async () => { + const workflow = await testDb.createWorkflow(undefined, owner); + const versions = await Promise.all( + new Array(10) + .fill(undefined) + .map(async (_, i) => + testDb.createWorkflowHistoryItem(workflow.id, { createdAt: new Date(Date.now() + i) }), + ), + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const last = versions.sort((a, b) => b.createdAt.valueOf() - a.createdAt.valueOf())[0]! as any; + delete last.nodes; + delete last.connections; + + last.createdAt = last.createdAt.toISOString(); + last.updatedAt = last.updatedAt.toISOString(); + + const resp = await authOwnerAgent.get('/workflow-history/workflow/' + workflow.id); + expect(resp.status).toBe(200); + expect(resp.body.data).toHaveLength(10); + expect(resp.body.data[0]).toEqual(last); + }); + + test('should return versions only for workflow id provided', async () => { + const workflow = await testDb.createWorkflow(undefined, owner); + const workflow2 = await testDb.createWorkflow(undefined, owner); + const versions = await Promise.all( + new Array(10) + .fill(undefined) + .map(async (_, i) => + testDb.createWorkflowHistoryItem(workflow.id, { createdAt: new Date(Date.now() + i) }), + ), + ); + + const versions2 = await Promise.all( + new Array(10) + .fill(undefined) + .map(async (_) => testDb.createWorkflowHistoryItem(workflow2.id)), + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const last = versions.sort((a, b) => b.createdAt.valueOf() - a.createdAt.valueOf())[0]! as any; + delete last.nodes; + delete last.connections; + + last.createdAt = last.createdAt.toISOString(); + last.updatedAt = last.updatedAt.toISOString(); + + const resp = await authOwnerAgent.get('/workflow-history/workflow/' + workflow.id); + expect(resp.status).toBe(200); + expect(resp.body.data).toHaveLength(10); + expect(resp.body.data[0]).toEqual(last); + }); + + test('should work with take parameter', async () => { + const workflow = await testDb.createWorkflow(undefined, owner); + const versions = await Promise.all( + new Array(10) + .fill(undefined) + .map(async (_, i) => + testDb.createWorkflowHistoryItem(workflow.id, { createdAt: new Date(Date.now() + i) }), + ), + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const last = versions.sort((a, b) => b.createdAt.valueOf() - a.createdAt.valueOf())[0]! as any; + delete last.nodes; + delete last.connections; + + last.createdAt = last.createdAt.toISOString(); + last.updatedAt = last.updatedAt.toISOString(); + + const resp = await authOwnerAgent.get(`/workflow-history/workflow/${workflow.id}?take=5`); + expect(resp.status).toBe(200); + expect(resp.body.data).toHaveLength(5); + expect(resp.body.data[0]).toEqual(last); + }); + + test('should work with skip parameter', async () => { + const workflow = await testDb.createWorkflow(undefined, owner); + const versions = await Promise.all( + new Array(10) + .fill(undefined) + .map(async (_, i) => + testDb.createWorkflowHistoryItem(workflow.id, { createdAt: new Date(Date.now() + i) }), + ), + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const last = versions.sort((a, b) => b.createdAt.valueOf() - a.createdAt.valueOf())[5]! as any; + delete last.nodes; + delete last.connections; + + last.createdAt = last.createdAt.toISOString(); + last.updatedAt = last.updatedAt.toISOString(); + + const resp = await authOwnerAgent.get( + `/workflow-history/workflow/${workflow.id}?skip=5&take=20`, + ); + expect(resp.status).toBe(200); + expect(resp.body.data).toHaveLength(5); + expect(resp.body.data[0]).toEqual(last); + }); +}); + +describe('GET /workflow-history/workflow/:workflowId/version/:versionId', () => { + test('should not work when license is not available', async () => { + licenseLike.isWorkflowHistoryLicensed.mockReturnValue(false); + const resp = await authOwnerAgent.get('/workflow-history/workflow/badid/version/badid'); + expect(resp.status).toBe(403); + expect(resp.text).toBe('Workflow History license data not found'); + }); + + test('should not return anything on an invalid workflow ID', async () => { + const workflow = await testDb.createWorkflow(undefined, owner); + const version = await testDb.createWorkflowHistoryItem(workflow.id); + const resp = await authOwnerAgent.get( + `/workflow-history/workflow/badid/version/${version.versionId}`, + ); + expect(resp.status).toBe(404); + }); + + test('should not return anything on an invalid version ID', async () => { + const workflow = await testDb.createWorkflow(undefined, owner); + await testDb.createWorkflowHistoryItem(workflow.id); + const resp = await authOwnerAgent.get( + `/workflow-history/workflow/${workflow.id}/version/badid`, + ); + expect(resp.status).toBe(404); + }); + + test('should return version', async () => { + const workflow = await testDb.createWorkflow(undefined, owner); + const version = await testDb.createWorkflowHistoryItem(workflow.id); + const resp = await authOwnerAgent.get( + `/workflow-history/workflow/${workflow.id}/version/${version.versionId}`, + ); + expect(resp.status).toBe(200); + expect(resp.body.data).toEqual({ + ...version, + createdAt: version.createdAt.toISOString(), + updatedAt: version.updatedAt.toISOString(), + }); + }); + + test('should not return anything if not shared with user', async () => { + const workflow = await testDb.createWorkflow(undefined, owner); + const version = await testDb.createWorkflowHistoryItem(workflow.id); + const resp = await authMemberAgent.get( + `/workflow-history/workflow/${workflow.id}/version/${version.versionId}`, + ); + expect(resp.status).toBe(404); + }); + + test('should not return anything if not shared with user and using workflow owned by unshared user', async () => { + const workflow = await testDb.createWorkflow(undefined, owner); + const workflowMember = await testDb.createWorkflow(undefined, member); + const version = await testDb.createWorkflowHistoryItem(workflow.id); + const resp = await authMemberAgent.get( + `/workflow-history/workflow/${workflowMember.id}/version/${version.versionId}`, + ); + expect(resp.status).toBe(404); + }); +});