fix(core): Prevent workflow history saving error from happening (#7812)

When performing actions such as renaming a workflow or updating its
settings, n8n errors with "Failed to save workflow version" in the
console although the saving process was successful. We are now correctly
checking whether `nodes` and `connections` exist and only then save a
snapshot.

Github issue / Community forum post (link here to close automatically):
This commit is contained in:
Omar Ajoue 2023-12-13 11:41:06 +00:00 committed by GitHub
parent b00b9057a4
commit e5581ce802
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 290 additions and 158 deletions

View file

@ -66,7 +66,10 @@ export class WorkflowHistoryService {
} }
async saveVersion(user: User, workflow: WorkflowEntity, workflowId: string) { async saveVersion(user: User, workflow: WorkflowEntity, workflowId: string) {
if (isWorkflowHistoryEnabled()) { // On some update scenarios, `nodes` and `connections` are missing, such as when
// changing workflow settings or renaming. In these cases, we don't want to save
// a new version
if (isWorkflowHistoryEnabled() && workflow.nodes && workflow.connections) {
try { try {
await this.workflowHistoryRepository.insert({ await this.workflowHistoryRepository.insert({
authors: user.firstName + ' ' + user.lastName, authors: user.firstName + ' ' + user.lastName,

View file

@ -4,6 +4,7 @@ import { NodeApiError, ErrorReporterProxy as ErrorReporter, Workflow } from 'n8n
import type { FindManyOptions, FindOptionsSelect, FindOptionsWhere, UpdateResult } from 'typeorm'; import type { FindManyOptions, FindOptionsSelect, FindOptionsWhere, UpdateResult } from 'typeorm';
import { In, Like } from 'typeorm'; import { In, Like } from 'typeorm';
import pick from 'lodash/pick'; import pick from 'lodash/pick';
import omit from 'lodash/omit';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import * as WorkflowHelpers from '@/WorkflowHelpers'; import * as WorkflowHelpers from '@/WorkflowHelpers';
@ -230,21 +231,9 @@ export class WorkflowsService {
); );
} }
let onlyActiveUpdate = false; if (Object.keys(omit(workflow, ['id', 'versionId', 'active'])).length > 0) {
// Update the workflow's version when changing properties such as
if ( // `name`, `pinData`, `nodes`, `connections`, `settings` or `tags`
(Object.keys(workflow).length === 3 &&
workflow.id !== undefined &&
workflow.versionId !== undefined &&
workflow.active !== undefined) ||
(Object.keys(workflow).length === 2 &&
workflow.versionId !== undefined &&
workflow.active !== undefined)
) {
// we're just updating the active status of the workflow, don't update the versionId
onlyActiveUpdate = true;
} else {
// Update the workflow's version
workflow.versionId = uuid(); workflow.versionId = uuid();
logger.verbose( logger.verbose(
`Updating versionId for workflow ${workflowId} for user ${user.id} after saving`, `Updating versionId for workflow ${workflowId} for user ${user.id} after saving`,
@ -320,7 +309,7 @@ export class WorkflowsService {
); );
} }
if (!onlyActiveUpdate && workflow.versionId !== shared.workflow.versionId) { if (workflow.versionId !== shared.workflow.versionId) {
await Container.get(WorkflowHistoryService).saveVersion(user, workflow, workflowId); await Container.get(WorkflowHistoryService).saveVersion(user, workflow, workflowId);
} }

View file

@ -193,7 +193,7 @@ function generateCredentialEntity(credentialId: string) {
return credentialEntity; return credentialEntity;
} }
function getWorkflow(options?: { export function getWorkflow(options?: {
addNodeWithoutCreds?: boolean; addNodeWithoutCreds?: boolean;
addNodeWithOneCred?: boolean; addNodeWithOneCred?: boolean;
addNodeWithTwoCreds?: boolean; addNodeWithTwoCreds?: boolean;

View file

@ -4,50 +4,33 @@ import { Role } from '@db/entities/Role';
import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { User } from '@db/entities/User'; import { User } from '@db/entities/User';
import { RoleService } from '@/services/role.service'; import { RoleService } from '@/services/role.service';
import { CredentialsEntity } from '@db/entities/CredentialsEntity';
import type { SharedCredentials } from '@db/entities/SharedCredentials'; import type { SharedCredentials } from '@db/entities/SharedCredentials';
import { mockInstance } from '../../shared/mocking'; import { mockInstance } from '../../shared/mocking';
import {
randomCredentialPayload,
randomEmail,
randomInteger,
randomName,
} from '../../integration/shared/random';
import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; import { WorkflowEntity } from '@/databases/entities/WorkflowEntity';
import { UserRepository } from '@/databases/repositories/user.repository'; import { UserRepository } from '@/databases/repositories/user.repository';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import {
mockCredRole,
mockCredential,
mockUser,
mockInstanceOwnerRole,
wfOwnerRole,
} from '../shared/mockObjects';
const wfOwnerRole = () => describe('OwnershipService', () => {
Object.assign(new Role(), { const roleService = mockInstance(RoleService);
scope: 'workflow', const userRepository = mockInstance(UserRepository);
name: 'owner', const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository);
id: randomInteger(),
});
const mockCredRole = (name: 'owner' | 'editor'): Role => const ownershipService = new OwnershipService(
Object.assign(new Role(), { mock(),
scope: 'credentials', userRepository,
name, roleService,
id: randomInteger(), sharedWorkflowRepository,
}); );
const mockInstanceOwnerRole = () => beforeEach(() => {
Object.assign(new Role(), { jest.clearAllMocks();
scope: 'global',
name: 'owner',
id: randomInteger(),
});
const mockCredential = (): CredentialsEntity =>
Object.assign(new CredentialsEntity(), randomCredentialPayload());
const mockUser = (attributes?: Partial<User>): User =>
Object.assign(new User(), {
id: randomInteger(),
email: randomEmail(),
firstName: randomName(),
lastName: randomName(),
...attributes,
}); });
describe('OwnershipService', () => { describe('OwnershipService', () => {
@ -196,3 +179,4 @@ describe('OwnershipService', () => {
}); });
}); });
}); });
});

View file

@ -0,0 +1,114 @@
import { User } from '@db/entities/User';
import { WorkflowHistoryRepository } from '@db/repositories/workflowHistory.repository';
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
import { WorkflowHistoryService } from '@/workflows/workflowHistory/workflowHistory.service.ee';
import { mockInstance } from '../../shared/mocking';
import { Logger } from '@/Logger';
import { getWorkflow } from '../WorkflowHelpers.test';
import { mockClear } from 'jest-mock-extended';
const workflowHistoryRepository = mockInstance(WorkflowHistoryRepository);
const logger = mockInstance(Logger);
const sharedWorkflowRepository = mockInstance(SharedWorkflowRepository);
const workflowHistoryService = new WorkflowHistoryService(
logger,
workflowHistoryRepository,
sharedWorkflowRepository,
);
const testUser = Object.assign(new User(), {
id: '1234',
password: 'passwordHash',
mfaEnabled: false,
firstName: 'John',
lastName: 'Doe',
});
let isWorkflowHistoryEnabled = true;
jest.mock('@/workflows/workflowHistory/workflowHistoryHelper.ee', () => {
return {
isWorkflowHistoryEnabled: jest.fn(() => isWorkflowHistoryEnabled),
};
});
describe('WorkflowHistoryService', () => {
beforeEach(() => {
mockClear(workflowHistoryRepository.insert);
});
describe('saveVersion', () => {
it('should save a new version when workflow history is enabled and nodes and connections are present', async () => {
// Arrange
isWorkflowHistoryEnabled = true;
const workflow = getWorkflow({ addNodeWithoutCreds: true });
const workflowId = '123';
workflow.connections = {};
workflow.id = workflowId;
workflow.versionId = '456';
// Act
await workflowHistoryService.saveVersion(testUser, workflow, workflowId);
// Assert
expect(workflowHistoryRepository.insert).toHaveBeenCalledWith({
authors: 'John Doe',
connections: {},
nodes: workflow.nodes,
versionId: workflow.versionId,
workflowId,
});
});
it('should not save a new version when workflow history is disabled', async () => {
// Arrange
isWorkflowHistoryEnabled = false;
const workflow = getWorkflow({ addNodeWithoutCreds: true });
const workflowId = '123';
workflow.connections = {};
workflow.id = workflowId;
workflow.versionId = '456';
// Act
await workflowHistoryService.saveVersion(testUser, workflow, workflowId);
// Assert
expect(workflowHistoryRepository.insert).not.toHaveBeenCalled();
});
it('should not save a new version when nodes or connections are missing', async () => {
// Arrange
isWorkflowHistoryEnabled = true;
const workflow = getWorkflow({ addNodeWithoutCreds: true });
const workflowId = '123';
workflow.id = workflowId;
workflow.versionId = '456';
// Nodes are set but connections is empty
// Act
await workflowHistoryService.saveVersion(testUser, workflow, workflowId);
// Assert
expect(workflowHistoryRepository.insert).not.toHaveBeenCalled();
});
it('should log an error when failed to save workflow history version', async () => {
// Arrange
isWorkflowHistoryEnabled = true;
const workflow = getWorkflow({ addNodeWithoutCreds: true });
const workflowId = '123';
workflow.connections = {};
workflow.id = workflowId;
workflow.versionId = '456';
workflowHistoryRepository.insert.mockRejectedValueOnce(new Error('Test error'));
// Act
await workflowHistoryService.saveVersion(testUser, workflow, workflowId);
// Assert
expect(workflowHistoryRepository.insert).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
'Failed to save workflow history version for workflow 123',
expect.any(Error),
);
});
});
});

View file

@ -0,0 +1,42 @@
import { User } from '@db/entities/User';
import { Role } from '@db/entities/Role';
import { CredentialsEntity } from '@db/entities/CredentialsEntity';
import {
randomCredentialPayload,
randomEmail,
randomInteger,
randomName,
} from '../../integration/shared/random';
export const wfOwnerRole = () =>
Object.assign(new Role(), {
scope: 'workflow',
name: 'owner',
id: randomInteger(),
});
export const mockCredRole = (name: 'owner' | 'editor'): Role =>
Object.assign(new Role(), {
scope: 'credentials',
name,
id: randomInteger(),
});
export const mockCredential = (): CredentialsEntity =>
Object.assign(new CredentialsEntity(), randomCredentialPayload());
export const mockUser = (): User =>
Object.assign(new User(), {
id: randomInteger(),
email: randomEmail(),
firstName: randomName(),
lastName: randomName(),
});
export const mockInstanceOwnerRole = () =>
Object.assign(new Role(), {
scope: 'global',
name: 'owner',
id: randomInteger(),
});