feat(core): Initial workflow history API (#7234)

Github issue / Community forum post (link here to close automatically):
This commit is contained in:
Val 2023-09-27 15:22:39 +01:00 committed by GitHub
parent 5c57e2ccc3
commit 0083a9e45d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 474 additions and 17 deletions

View file

@ -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');

View file

@ -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()) {

View file

@ -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;

View file

@ -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<ExecutionEntity>,
@ -93,7 +93,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
) {
super(ExecutionEntity, dataSource.manager);
if (!this.isMainInstance) return;
if (!this.isMainInstance || inTest) return;
if (this.isPruningEnabled) this.setSoftDeletionInterval();

View file

@ -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<User, 'email' | 'firstName' | 'lastName'> {
@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<Omit<WorkflowHistory, 'nodes' | 'connections'>>,
{},
ListQuery.Options
>;
type GetVersion = AuthenticatedRequest<
{ workflowId: string; versionId: string },
WorkflowHistory
>;
}

View file

@ -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;
}
}
}

View file

@ -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,
});
}
}
}

View file

@ -5,3 +5,7 @@ export function isWorkflowHistoryLicensed() {
const license = Container.get(License);
return license.isWorkflowHistoryLicensed();
}
export function isWorkflowHistoryEnabled() {
return isWorkflowHistoryLicensed();
}

View file

@ -1,7 +0,0 @@
import { WorkflowHistoryRepository } from '@db/repositories/workflowHistory.repository';
import { Service } from 'typedi';
@Service()
export class WorkflowHistoryService {
constructor(private readonly workflowHistoryRepository: WorkflowHistoryRepository) {}
}

View file

@ -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

View file

@ -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) {
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
// ----------------------------------

View file

@ -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<any> };
export type EndpointGroup =
| 'me'
@ -29,7 +33,8 @@ export type EndpointGroup =
| 'externalSecrets'
| 'mfa'
| 'metrics'
| 'executions';
| 'executions'
| 'workflowHistory';
export interface SetupProps {
applyAuth?: boolean;

View file

@ -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;
}
}
}

View 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);
});
});