mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 20:54:07 -08:00
feat(core): Initial workflow history API (#7234)
Github issue / Community forum post (link here to close automatically):
This commit is contained in:
parent
5c57e2ccc3
commit
0083a9e45d
|
@ -26,6 +26,8 @@ import {
|
||||||
import { WorkflowsService } from '@/workflows/workflows.services';
|
import { WorkflowsService } from '@/workflows/workflows.services';
|
||||||
import { InternalHooks } from '@/InternalHooks';
|
import { InternalHooks } from '@/InternalHooks';
|
||||||
import { RoleService } from '@/services/role.service';
|
import { RoleService } from '@/services/role.service';
|
||||||
|
import { isWorkflowHistoryLicensed } from '@/workflows/workflowHistory/workflowHistoryHelper.ee';
|
||||||
|
import { WorkflowHistoryService } from '@/workflows/workflowHistory/workflowHistory.service.ee';
|
||||||
|
|
||||||
export = {
|
export = {
|
||||||
createWorkflow: [
|
createWorkflow: [
|
||||||
|
@ -177,6 +179,10 @@ export = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isWorkflowHistoryLicensed()) {
|
||||||
|
await Container.get(WorkflowHistoryService).saveVersion(req.user, sharedWorkflow.workflow);
|
||||||
|
}
|
||||||
|
|
||||||
if (sharedWorkflow.workflow.active) {
|
if (sharedWorkflow.workflow.active) {
|
||||||
try {
|
try {
|
||||||
await workflowRunner.add(sharedWorkflow.workflowId, 'update');
|
await workflowRunner.add(sharedWorkflow.workflowId, 'update');
|
||||||
|
|
|
@ -178,6 +178,8 @@ import { JwtService } from './services/jwt.service';
|
||||||
import { RoleService } from './services/role.service';
|
import { RoleService } from './services/role.service';
|
||||||
import { UserService } from './services/user.service';
|
import { UserService } from './services/user.service';
|
||||||
import { OrchestrationController } from './controllers/orchestration.controller';
|
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);
|
const exec = promisify(callbackExec);
|
||||||
|
|
||||||
|
@ -470,6 +472,7 @@ export class Server extends AbstractServer {
|
||||||
LICENSE_FEATURES.SHOW_NON_PROD_BANNER,
|
LICENSE_FEATURES.SHOW_NON_PROD_BANNER,
|
||||||
),
|
),
|
||||||
debugInEditor: isDebugInEditorLicensed(),
|
debugInEditor: isDebugInEditorLicensed(),
|
||||||
|
history: isWorkflowHistoryEnabled(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLdapEnabled()) {
|
if (isLdapEnabled()) {
|
||||||
|
@ -559,6 +562,7 @@ export class Server extends AbstractServer {
|
||||||
Container.get(WorkflowStatisticsController),
|
Container.get(WorkflowStatisticsController),
|
||||||
Container.get(ExternalSecretsController),
|
Container.get(ExternalSecretsController),
|
||||||
Container.get(OrchestrationController),
|
Container.get(OrchestrationController),
|
||||||
|
Container.get(WorkflowHistoryController),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLdapEnabled()) {
|
if (isLdapEnabled()) {
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
|
import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
|
||||||
import { jsonColumnType } from './AbstractEntity';
|
import { WithTimestamps, jsonColumnType } from './AbstractEntity';
|
||||||
import { IConnections } from 'n8n-workflow';
|
import { IConnections } from 'n8n-workflow';
|
||||||
import type { INode } from 'n8n-workflow';
|
import type { INode } from 'n8n-workflow';
|
||||||
import { WorkflowEntity } from './WorkflowEntity';
|
import { WorkflowEntity } from './WorkflowEntity';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class WorkflowHistory {
|
export class WorkflowHistory extends WithTimestamps {
|
||||||
@PrimaryColumn()
|
@PrimaryColumn()
|
||||||
versionId: string;
|
versionId: string;
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ import type { ExecutionData } from '../entities/ExecutionData';
|
||||||
import { ExecutionEntity } from '../entities/ExecutionEntity';
|
import { ExecutionEntity } from '../entities/ExecutionEntity';
|
||||||
import { ExecutionMetadata } from '../entities/ExecutionMetadata';
|
import { ExecutionMetadata } from '../entities/ExecutionMetadata';
|
||||||
import { ExecutionDataRepository } from './executionData.repository';
|
import { ExecutionDataRepository } from './executionData.repository';
|
||||||
import { TIME } from '@/constants';
|
import { TIME, inTest } from '@/constants';
|
||||||
|
|
||||||
function parseFiltersToQueryBuilder(
|
function parseFiltersToQueryBuilder(
|
||||||
qb: SelectQueryBuilder<ExecutionEntity>,
|
qb: SelectQueryBuilder<ExecutionEntity>,
|
||||||
|
@ -93,7 +93,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
) {
|
) {
|
||||||
super(ExecutionEntity, dataSource.manager);
|
super(ExecutionEntity, dataSource.manager);
|
||||||
|
|
||||||
if (!this.isMainInstance) return;
|
if (!this.isMainInstance || inTest) return;
|
||||||
|
|
||||||
if (this.isPruningEnabled) this.setSoftDeletionInterval();
|
if (this.isPruningEnabled) this.setSoftDeletionInterval();
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,7 @@ import type { UserManagementMailer } from '@/UserManagement/email';
|
||||||
import type { Variables } from '@db/entities/Variables';
|
import type { Variables } from '@db/entities/Variables';
|
||||||
import type { WorkflowEntity } from './databases/entities/WorkflowEntity';
|
import type { WorkflowEntity } from './databases/entities/WorkflowEntity';
|
||||||
import type { CredentialsEntity } from './databases/entities/CredentialsEntity';
|
import type { CredentialsEntity } from './databases/entities/CredentialsEntity';
|
||||||
|
import type { WorkflowHistory } from './databases/entities/WorkflowHistory';
|
||||||
|
|
||||||
export class UserUpdatePayload implements Pick<User, 'email' | 'firstName' | 'lastName'> {
|
export class UserUpdatePayload implements Pick<User, 'email' | 'firstName' | 'lastName'> {
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
|
@ -545,3 +546,20 @@ export declare namespace OrchestrationRequest {
|
||||||
type GetAll = AuthenticatedRequest;
|
type GetAll = AuthenticatedRequest;
|
||||||
type Get = AuthenticatedRequest<{ id: string }, {}, {}, {}>;
|
type Get = AuthenticatedRequest<{ id: string }, {}, {}, {}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------
|
||||||
|
// /workflow-history
|
||||||
|
// ----------------------------------
|
||||||
|
|
||||||
|
export declare namespace WorkflowHistoryRequest {
|
||||||
|
type GetList = AuthenticatedRequest<
|
||||||
|
{ workflowId: string },
|
||||||
|
Array<Omit<WorkflowHistory, 'nodes' | 'connections'>>,
|
||||||
|
{},
|
||||||
|
ListQuery.Options
|
||||||
|
>;
|
||||||
|
type GetVersion = AuthenticatedRequest<
|
||||||
|
{ workflowId: string; versionId: string },
|
||||||
|
WorkflowHistory
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<SharedWorkflow | null> {
|
||||||
|
return this.sharedWorkflowRepository.findOne({
|
||||||
|
where: {
|
||||||
|
...(!user.isOwner && { userId: user.id }),
|
||||||
|
workflowId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getList(
|
||||||
|
user: User,
|
||||||
|
workflowId: string,
|
||||||
|
take: number,
|
||||||
|
skip: number,
|
||||||
|
): Promise<Array<Omit<WorkflowHistory, 'nodes' | 'connections'>>> {
|
||||||
|
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<WorkflowHistory> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,3 +5,7 @@ export function isWorkflowHistoryLicensed() {
|
||||||
const license = Container.get(License);
|
const license = Container.get(License);
|
||||||
return license.isWorkflowHistoryLicensed();
|
return license.isWorkflowHistoryLicensed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isWorkflowHistoryEnabled() {
|
||||||
|
return isWorkflowHistoryLicensed();
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
import { WorkflowHistoryRepository } from '@db/repositories/workflowHistory.repository';
|
|
||||||
import { Service } from 'typedi';
|
|
||||||
|
|
||||||
@Service()
|
|
||||||
export class WorkflowHistoryService {
|
|
||||||
constructor(private readonly workflowHistoryRepository: WorkflowHistoryRepository) {}
|
|
||||||
}
|
|
|
@ -33,6 +33,8 @@ import { WorkflowRepository } from '@/databases/repositories';
|
||||||
import { RoleService } from '@/services/role.service';
|
import { RoleService } from '@/services/role.service';
|
||||||
import { OwnershipService } from '@/services/ownership.service';
|
import { OwnershipService } from '@/services/ownership.service';
|
||||||
import { isStringArray, isWorkflowIdValid } from '@/utils';
|
import { isStringArray, isWorkflowIdValid } from '@/utils';
|
||||||
|
import { isWorkflowHistoryLicensed } from './workflowHistory/workflowHistoryHelper.ee';
|
||||||
|
import { WorkflowHistoryService } from './workflowHistory/workflowHistory.service.ee';
|
||||||
|
|
||||||
export class WorkflowsService {
|
export class WorkflowsService {
|
||||||
static async getSharing(
|
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'];
|
const relations = config.getEnv('workflowTagsDisabled') ? [] : ['tags'];
|
||||||
|
|
||||||
// We sadly get nothing back from "update". Neither if it updated a record
|
// We sadly get nothing back from "update". Neither if it updated a record
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { UserSettings } from 'n8n-core';
|
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 { DataSource as Connection } from 'typeorm';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import * as Db from '@/Db';
|
import * as Db from '@/Db';
|
||||||
|
@ -26,12 +27,17 @@ import type { ExecutionData } from '@db/entities/ExecutionData';
|
||||||
import { generateNanoId } from '@db/utils/generators';
|
import { generateNanoId } from '@db/utils/generators';
|
||||||
import { RoleService } from '@/services/role.service';
|
import { RoleService } from '@/services/role.service';
|
||||||
import { VariablesService } from '@/environments/variables/variables.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 { separate } from '@/utils';
|
||||||
|
|
||||||
import { randomPassword } from '@/Ldap/helpers';
|
import { randomPassword } from '@/Ldap/helpers';
|
||||||
import { TOTPService } from '@/Mfa/totp.service';
|
import { TOTPService } from '@/Mfa/totp.service';
|
||||||
import { MfaService } from '@/Mfa/mfa.service';
|
import { MfaService } from '@/Mfa/mfa.service';
|
||||||
|
import type { WorkflowHistory } from '@/databases/entities/WorkflowHistory';
|
||||||
|
|
||||||
export type TestDBType = 'postgres' | 'mysql';
|
export type TestDBType = 'postgres' | 'mysql';
|
||||||
|
|
||||||
|
@ -118,7 +124,12 @@ export async function truncate(collections: CollectionName[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const collection of rest) {
|
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<any> }).delete({});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -572,6 +583,33 @@ export async function getVariableById(id: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------
|
||||||
|
// workflow history
|
||||||
|
// ----------------------------------
|
||||||
|
|
||||||
|
export async function createWorkflowHistoryItem(
|
||||||
|
workflowId: string,
|
||||||
|
data?: Partial<WorkflowHistory>,
|
||||||
|
) {
|
||||||
|
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
|
// connection options
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
|
|
@ -6,8 +6,12 @@ import type { Server } from 'http';
|
||||||
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import type { BooleanLicenseFeature, ICredentialsDb, IDatabaseCollections } from '@/Interfaces';
|
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<any> };
|
||||||
|
|
||||||
export type EndpointGroup =
|
export type EndpointGroup =
|
||||||
| 'me'
|
| 'me'
|
||||||
|
@ -29,7 +33,8 @@ export type EndpointGroup =
|
||||||
| 'externalSecrets'
|
| 'externalSecrets'
|
||||||
| 'mfa'
|
| 'mfa'
|
||||||
| 'metrics'
|
| 'metrics'
|
||||||
| 'executions';
|
| 'executions'
|
||||||
|
| 'workflowHistory';
|
||||||
|
|
||||||
export interface SetupProps {
|
export interface SetupProps {
|
||||||
applyAuth?: boolean;
|
applyAuth?: boolean;
|
||||||
|
|
|
@ -65,6 +65,7 @@ import { JwtService } from '@/services/jwt.service';
|
||||||
import { RoleService } from '@/services/role.service';
|
import { RoleService } from '@/services/role.service';
|
||||||
import { UserService } from '@/services/user.service';
|
import { UserService } from '@/services/user.service';
|
||||||
import { executionsController } from '@/executions/executions.controller';
|
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.
|
* 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.jwtSecret', 'My JWT secret');
|
||||||
config.set('userManagement.isInstanceOwnerSetUp', true);
|
config.set('userManagement.isInstanceOwnerSetUp', true);
|
||||||
config.set('executions.pruneData', false);
|
|
||||||
|
|
||||||
if (enabledFeatures) {
|
if (enabledFeatures) {
|
||||||
Container.get(License).isFeatureEnabled = (feature) => enabledFeatures.includes(feature);
|
Container.get(License).isFeatureEnabled = (feature) => enabledFeatures.includes(feature);
|
||||||
|
@ -313,6 +313,9 @@ export const setupTestServer = ({
|
||||||
case 'externalSecrets':
|
case 'externalSecrets':
|
||||||
registerController(app, config, Container.get(ExternalSecretsController));
|
registerController(app, config, Container.get(ExternalSecretsController));
|
||||||
break;
|
break;
|
||||||
|
case 'workflowHistory':
|
||||||
|
registerController(app, config, Container.get(WorkflowHistoryController));
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
226
packages/cli/test/integration/workflowHistory.api.test.ts
Normal file
226
packages/cli/test/integration/workflowHistory.api.test.ts
Normal file
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue