mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-28 03:24:56 +08:00
fix(core): Fail publication of stored versions with trigger node ids over 64 characters (#41519)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
multica-agent
Cursor Agent
parent
b46aba4a1a
commit
26c580e320
@@ -11,6 +11,12 @@ export const BUILTIN_NODES_PACKAGES = ['n8n-nodes-base', '@n8n/n8n-nodes-langcha
|
||||
*/
|
||||
export const NODE_ID_MAX_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* Whether `nodeId` is too long to store in the columns keyed on node ids.
|
||||
* Counts unicode code points to match PostgreSQL's `varchar(n)` behavior on UTF-8.
|
||||
*/
|
||||
export const isNodeIdTooLong = (nodeId: string): boolean => [...nodeId].length > NODE_ID_MAX_LENGTH;
|
||||
|
||||
/**
|
||||
* Built-in node types that need a backend module to run, keyed to that module.
|
||||
* The node panel and the AI builder offer them only while the module is enabled.
|
||||
|
||||
@@ -180,5 +180,8 @@ export const SCHEDULED_JOB_OWNER_TYPE_MAX_LENGTH = 32;
|
||||
/** Longest accepted `ownerId`. */
|
||||
export const SCHEDULED_JOB_OWNER_ID_MAX_LENGTH = 255;
|
||||
|
||||
/** Longest accepted `ownerMemberId`: a workflow node id, or an agent task id (32 characters). */
|
||||
/** Longest accepted `ownerMemberId`:
|
||||
* - a workflow node id (64 characters - see NODE_ID_MAX_LENGTH),
|
||||
* - or an agent task id (32 characters - see AGENT_TASK_ID_MAX_LENGTH).
|
||||
*/
|
||||
export const SCHEDULED_JOB_OWNER_MEMBER_ID_MAX_LENGTH = NODE_ID_MAX_LENGTH;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
SCHEDULED_JOB_NAME_MAX_LENGTH,
|
||||
SCHEDULED_JOB_OWNER_ID_MAX_LENGTH,
|
||||
SCHEDULED_JOB_OWNER_MEMBER_ID_MAX_LENGTH,
|
||||
isNodeIdTooLong,
|
||||
} from '@n8n/constants';
|
||||
|
||||
import { deprovision, provision } from './provision';
|
||||
@@ -128,7 +129,7 @@ function assertProvisionableOwner(
|
||||
}
|
||||
if (ownerMemberId === null) return;
|
||||
const ownerMemberIdLength = characterCount(ownerMemberId);
|
||||
if (ownerMemberIdLength === 0 || ownerMemberIdLength > SCHEDULED_JOB_OWNER_MEMBER_ID_MAX_LENGTH) {
|
||||
if (ownerMemberIdLength === 0 || isNodeIdTooLong(ownerMemberId)) {
|
||||
throw new InvalidOwnerMemberIdError(
|
||||
ownerMemberIdLength,
|
||||
SCHEDULED_JOB_OWNER_MEMBER_ID_MAX_LENGTH,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NODE_ID_MAX_LENGTH } from '@n8n/constants';
|
||||
import type { CredentialsRepository, User, WorkflowRepository } from '@n8n/db';
|
||||
import type {
|
||||
INode,
|
||||
@@ -1774,6 +1775,27 @@ describe('WorkflowValidationService', () => {
|
||||
expect(result.error).toContain('Cannot publish workflow');
|
||||
expect(result.error).toContain('"Webhook"');
|
||||
});
|
||||
|
||||
it('rejects a trigger node whose id is longer than NODE_ID_MAX_LENGTH', () => {
|
||||
const result = service.validateTriggerNodeIds([
|
||||
triggerNode('Cron', 'n'.repeat(NODE_ID_MAX_LENGTH)),
|
||||
triggerNode('Webhook', 'n'.repeat(NODE_ID_MAX_LENGTH + 1)),
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
isValid: false,
|
||||
error:
|
||||
'Cannot publish workflow: trigger "Webhook" has a node ID longer than 64 characters. Remove and re-add the affected nodes to give them new IDs.',
|
||||
});
|
||||
});
|
||||
|
||||
it('counts trigger node id length in characters, not UTF-16 code units', () => {
|
||||
const result = service.validateTriggerNodeIds([
|
||||
triggerNode('Cron', '😀'.repeat(NODE_ID_MAX_LENGTH)),
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ isValid: true });
|
||||
});
|
||||
});
|
||||
describe('validateRequiredInputsConnected', () => {
|
||||
const nodeTypes = mock<NodeTypes>();
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PolicyViolationError } from '@/policy/policy-violation.error';
|
||||
import type { Push } from '@/push';
|
||||
import type { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import { PublicationStatusReporter } from '@/workflows/publication/publication-status-reporter';
|
||||
import { TriggerNodeIdTooLongError } from '@/workflows/publication/trigger-node-id-too-long.error';
|
||||
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
|
||||
import type { WorkflowSharingService } from '@n8n/backend-services';
|
||||
|
||||
@@ -480,6 +481,61 @@ describe('PublicationStatusReporter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('failed for one oversized trigger pushes that trigger node id', async () => {
|
||||
const error = new TriggerNodeIdTooLongError([{ id: 'too-long', name: 'Long' }]);
|
||||
|
||||
await reporter.report(makeRecord(), {
|
||||
type: 'failed',
|
||||
error,
|
||||
triggerStatuses: [
|
||||
{
|
||||
nodeId: 'fitting',
|
||||
nodeName: 'Fitting',
|
||||
status: 'failed',
|
||||
triggerKind: 'in-memory',
|
||||
errorMessage: error.message,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(push.sendToUsers).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'workflowFailedToActivate',
|
||||
data: { workflowId: 'wf-1', errorMessage: error.message, nodeId: 'too-long' },
|
||||
},
|
||||
userIds,
|
||||
);
|
||||
});
|
||||
|
||||
test('failed for several oversized triggers pushes no node id', async () => {
|
||||
const error = new TriggerNodeIdTooLongError([
|
||||
{ id: 'first-long', name: 'First' },
|
||||
{ id: 'second-long', name: 'Second' },
|
||||
]);
|
||||
|
||||
await reporter.report(makeRecord(), {
|
||||
type: 'failed',
|
||||
error,
|
||||
triggerStatuses: [
|
||||
{
|
||||
nodeId: 'fitting',
|
||||
nodeName: 'Fitting',
|
||||
status: 'failed',
|
||||
triggerKind: 'in-memory',
|
||||
errorMessage: error.message,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(push.sendToUsers).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'workflowFailedToActivate',
|
||||
data: { workflowId: 'wf-1', errorMessage: error.message },
|
||||
},
|
||||
userIds,
|
||||
);
|
||||
});
|
||||
|
||||
// A policy refusal marks every trigger failed with the policy error, so the
|
||||
// one failed row is not the node whose own error this is.
|
||||
test('failed by policy with one failed row pushes no nodeId', async () => {
|
||||
|
||||
+133
-4
@@ -8,6 +8,7 @@ import type {
|
||||
WorkflowRepository,
|
||||
} from '@n8n/db';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { NODE_ID_MAX_LENGTH } from '@n8n/constants';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { UnrecognizedNodeTypeError } from 'n8n-core';
|
||||
import type { INode, INodeType } from 'n8n-workflow';
|
||||
@@ -125,11 +126,11 @@ describe('WorkflowPublicationApplier', () => {
|
||||
|
||||
const abort = { signal: new AbortController().signal, onDetached: vi.fn() };
|
||||
|
||||
/** Drives the trigger diff: first call returns old triggers, second returns new. */
|
||||
/** Drives the trigger diff: `oldVersion` (or none) returns old triggers, any other version returns new. */
|
||||
function setTriggerSets(oldTriggers: INode[], newTriggers: INode[]) {
|
||||
workflowTriggerActivator.getEnabledTriggerNodes
|
||||
.mockReturnValueOnce(oldTriggers)
|
||||
.mockReturnValueOnce(newTriggers);
|
||||
workflowTriggerActivator.getEnabledTriggerNodes.mockImplementation((version) =>
|
||||
version === null || version === oldVersion ? oldTriggers : newTriggers,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -1473,4 +1474,132 @@ describe('WorkflowPublicationApplier', () => {
|
||||
expect(result).toEqual({ type: 'skipped', reason: 'node-ids-healed' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger node ids longer than the column width', () => {
|
||||
const longId = 'x'.repeat(NODE_ID_MAX_LENGTH + 1);
|
||||
const fittingId = 'y'.repeat(NODE_ID_MAX_LENGTH);
|
||||
const setNode = (id: string): INode =>
|
||||
triggerNode(id, { name: 'Set', type: 'n8n-nodes-base.set' });
|
||||
const versionWith = (nodes: INode[]): WorkflowHistory =>
|
||||
({ ...makeVersion('v-2'), nodes }) as WorkflowHistory;
|
||||
|
||||
beforeEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockImplementation((type: string) => {
|
||||
if (type === 'n8n-nodes-base.executeCommand') {
|
||||
throw new UnrecognizedNodeTypeError('n8n-nodes-base', 'executeCommand');
|
||||
}
|
||||
return type.toLowerCase().includes('trigger')
|
||||
? ({ trigger: async () => ({}) } as unknown as INodeType)
|
||||
: ({} as INodeType);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nodeTypes.getByNameAndVersion.mockReset();
|
||||
});
|
||||
|
||||
test('fails before touching triggers, and reports only the triggers whose id fits', async () => {
|
||||
const longTrigger = triggerNode(longId, { name: 'Long' });
|
||||
const fittingTrigger = triggerNode(fittingId, { name: 'Fitting' });
|
||||
workflowHistoryRepository.findOneBy.mockResolvedValue(
|
||||
versionWith([longTrigger, fittingTrigger]),
|
||||
);
|
||||
workflowTriggerActivator.getEnabledTriggerNodes.mockReturnValue([
|
||||
longTrigger,
|
||||
fittingTrigger,
|
||||
]);
|
||||
|
||||
const result = await applier.apply(makeRecord(), abort);
|
||||
|
||||
const message = `Cannot publish workflow: trigger "Long" has a node ID longer than ${NODE_ID_MAX_LENGTH} characters. Remove and re-add the affected nodes to give them new IDs.`;
|
||||
expect(result).toEqual({
|
||||
type: 'failed',
|
||||
error: expect.objectContaining({
|
||||
message,
|
||||
affectedNodes: [{ id: longId, name: 'Long' }],
|
||||
}),
|
||||
triggerStatuses: [
|
||||
{
|
||||
nodeId: fittingId,
|
||||
nodeName: 'Fitting',
|
||||
status: 'failed',
|
||||
triggerKind: 'in-memory',
|
||||
errorMessage: message,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(workflowPublishedVersionRepository.setPublishedVersion).not.toHaveBeenCalled();
|
||||
expect(workflowTriggerActivator.activate).not.toHaveBeenCalled();
|
||||
expect(workflowTriggerActivator.deactivate).not.toHaveBeenCalled();
|
||||
expect(policyEnforcementService.enforceWorkflowPublish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('counts node id length in characters, not UTF-16 code units', async () => {
|
||||
const astralId = '😀'.repeat(NODE_ID_MAX_LENGTH);
|
||||
const longTrigger = triggerNode(longId, { name: 'Long' });
|
||||
const astralTrigger = triggerNode(astralId, { name: 'Astral' });
|
||||
workflowHistoryRepository.findOneBy.mockResolvedValue(
|
||||
versionWith([longTrigger, astralTrigger]),
|
||||
);
|
||||
workflowTriggerActivator.getEnabledTriggerNodes.mockReturnValue([longTrigger, astralTrigger]);
|
||||
|
||||
const result = await applier.apply(makeRecord(), abort);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'failed',
|
||||
error: { affectedNodes: [{ id: longId, name: 'Long' }] },
|
||||
triggerStatuses: [{ nodeId: astralId, nodeName: 'Astral', status: 'failed' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('names every trigger whose id is too long', async () => {
|
||||
const triggers = [
|
||||
triggerNode(longId, { name: 'First' }),
|
||||
triggerNode(`${longId}z`, { name: 'Second' }),
|
||||
];
|
||||
workflowHistoryRepository.findOneBy.mockResolvedValue(versionWith(triggers));
|
||||
workflowTriggerActivator.getEnabledTriggerNodes.mockReturnValue(triggers);
|
||||
|
||||
const result = await applier.apply(makeRecord(), abort);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
type: 'failed',
|
||||
error: expect.objectContaining({
|
||||
message: expect.stringContaining('triggers "First", "Second" have node IDs longer than'),
|
||||
affectedNodes: [
|
||||
{ id: longId, name: 'First' },
|
||||
{ id: `${longId}z`, name: 'Second' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test('fails when a node whose type cannot load has a long id, since it gets its own row', async () => {
|
||||
workflowHistoryRepository.findOneBy.mockResolvedValue(
|
||||
versionWith([
|
||||
triggerNode(longId, { name: 'Execute Command', type: 'n8n-nodes-base.executeCommand' }),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await applier.apply(makeRecord(), abort);
|
||||
|
||||
expect(result).toMatchObject({ type: 'failed', triggerStatuses: [] });
|
||||
expect(workflowPublishedVersionRepository.setPublishedVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['a non-trigger node', setNode(longId)],
|
||||
['a disabled trigger', triggerNode(longId, { disabled: true })],
|
||||
])('publishes a version where only %s has a long id', async (_, node) => {
|
||||
workflowHistoryRepository.findOneBy.mockResolvedValue(versionWith([node]));
|
||||
|
||||
const result = await applier.apply(makeRecord(), abort);
|
||||
|
||||
expect(result.type).toBe('completed');
|
||||
expect(workflowPublishedVersionRepository.setPublishedVersion).toHaveBeenCalledWith(
|
||||
'wf-1',
|
||||
'v-2',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
PublicationSkipReason,
|
||||
TriggerPublicationStatus,
|
||||
} from '@/workflows/publication/publication-result';
|
||||
import { TriggerNodeIdTooLongError } from '@/workflows/publication/trigger-node-id-too-long.error';
|
||||
import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service';
|
||||
import type { TriggerTeardownFailure } from '@/workflows/triggers/workflow-trigger-activator';
|
||||
|
||||
@@ -127,11 +128,16 @@ export class PublicationStatusReporter {
|
||||
if (!policyRefusal) {
|
||||
this.errorReporter.error(result.error, { shouldBeLogged: true });
|
||||
}
|
||||
// Name the node only when one trigger failed with its own error: the FE prefixes the toast with it.
|
||||
// A policy refusal fails every trigger with the same error, so it names none.
|
||||
// Name a node only when the error belongs to it. Shared failures name none.
|
||||
const failedTriggers = (triggerStatuses ?? []).filter((s) => s.status === 'failed');
|
||||
const nodeId =
|
||||
!policyRefusal && failedTriggers.length === 1 ? failedTriggers[0].nodeId : undefined;
|
||||
let nodeId: string | undefined;
|
||||
if (result.error instanceof TriggerNodeIdTooLongError) {
|
||||
if (result.error.affectedNodes.length === 1) {
|
||||
nodeId = result.error.affectedNodes[0]?.id;
|
||||
}
|
||||
} else if (!policyRefusal && failedTriggers.length === 1) {
|
||||
nodeId = failedTriggers[0].nodeId;
|
||||
}
|
||||
await this.pushFailedToActivate(record.workflowId, result.error.message, nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NODE_ID_MAX_LENGTH } from '@n8n/constants';
|
||||
import { UserError, type INode } from 'n8n-workflow';
|
||||
|
||||
export class TriggerNodeIdTooLongError extends UserError {
|
||||
readonly affectedNodes: ReadonlyArray<Readonly<Pick<INode, 'id' | 'name'>>>;
|
||||
|
||||
constructor(nodes: ReadonlyArray<Pick<INode, 'id' | 'name'>>) {
|
||||
const affectedNodes = nodes.map(({ id, name }) => ({ id, name }));
|
||||
const names = affectedNodes.map((node) => `"${node.name}"`).join(', ');
|
||||
const subject =
|
||||
affectedNodes.length === 1
|
||||
? `trigger ${names} has a node ID`
|
||||
: `triggers ${names} have node IDs`;
|
||||
super(
|
||||
`Cannot publish workflow: ${subject} longer than ${NODE_ID_MAX_LENGTH} characters. Remove and re-add the affected nodes to give them new IDs.`,
|
||||
);
|
||||
this.affectedNodes = affectedNodes;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { isNodeIdTooLong } from '@n8n/constants';
|
||||
import {
|
||||
WorkflowEntity,
|
||||
WorkflowHistory,
|
||||
@@ -32,6 +33,7 @@ import type {
|
||||
TriggerPublicationStatus,
|
||||
} from '@/workflows/publication/publication-result';
|
||||
import { computeTriggerDiff } from '@/workflows/publication/trigger-diff';
|
||||
import { TriggerNodeIdTooLongError } from '@/workflows/publication/trigger-node-id-too-long.error';
|
||||
import {
|
||||
isTriggerLikeNodeType,
|
||||
type WorkflowTriggerVersion,
|
||||
@@ -155,6 +157,9 @@ export class WorkflowPublicationApplier {
|
||||
const healSkip = await this.healBrokenNodeIds(workflow, newVersion);
|
||||
if (healSkip !== null) return healSkip;
|
||||
|
||||
const oversized = this.rejectOversizedTriggerNodeIds(workflow, newVersion);
|
||||
if (oversized !== null) return oversized;
|
||||
|
||||
const unrunnable = await this.handleUnrunnableVersion(
|
||||
workflow,
|
||||
oldVersion,
|
||||
@@ -705,6 +710,44 @@ export class WorkflowPublicationApplier {
|
||||
return { type: 'skipped', reason: published.published ? 'node-ids-healed' : 'superseded' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails a version whose trigger node ids cannot be stored in status rows. This
|
||||
* runs before trigger changes, so the old version keeps running. Fitting ids
|
||||
* get failed rows to prevent reconciliation retries.
|
||||
*/
|
||||
private rejectOversizedTriggerNodeIds(
|
||||
workflow: WorkflowEntity,
|
||||
newVersion: WorkflowHistory,
|
||||
): PublicationResult | null {
|
||||
const { runnable, unresolvable } = this.partitionByResolvability(newVersion);
|
||||
const enabledTriggerNodes = this.workflowTriggerActivator.getEnabledTriggerNodes(runnable);
|
||||
const oversized = [...enabledTriggerNodes, ...unresolvable.map(({ node }) => node)].filter(
|
||||
(node) => isNodeIdTooLong(node.id),
|
||||
);
|
||||
if (oversized.length === 0) return null;
|
||||
|
||||
this.logger.warn('Published version carries trigger node ids that are too long', {
|
||||
workflowId: workflow.id,
|
||||
versionId: newVersion.versionId,
|
||||
nodeNames: oversized.map((node) => node.name),
|
||||
});
|
||||
|
||||
const error = new TriggerNodeIdTooLongError(oversized);
|
||||
const storable = enabledTriggerNodes.filter((node) => !isNodeIdTooLong(node.id));
|
||||
return {
|
||||
type: 'failed',
|
||||
error,
|
||||
triggerStatuses: this.buildTriggerStatuses(
|
||||
storable,
|
||||
this.workflowTriggerActivator.getTriggerKinds(storable),
|
||||
{
|
||||
activated: [],
|
||||
failures: storable.map((node) => ({ nodeId: node.id, nodeName: node.name, error })),
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private isTriggerLikeNode(node: INode): boolean {
|
||||
try {
|
||||
return isTriggerLikeNodeType(this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NODE_ID_MAX_LENGTH, isNodeIdTooLong } from '@n8n/constants';
|
||||
import type { User } from '@n8n/db';
|
||||
import { CredentialsRepository, WorkflowRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
@@ -272,6 +273,9 @@ export class WorkflowValidationService {
|
||||
*
|
||||
* An absent id is the same defect as a shared one — `undefined` is just an id
|
||||
* that every id-less node has in common — so both are reported together.
|
||||
*
|
||||
* An id longer than `NODE_ID_MAX_LENGTH` does not fit the columns keyed on it.
|
||||
* External clients can supply such ids, so they are rejected here at publish time.
|
||||
*/
|
||||
validateTriggerNodeIds(triggerNodes: INode[]): WorkflowValidationResult {
|
||||
const violations: string[] = [];
|
||||
@@ -283,6 +287,12 @@ export class WorkflowValidationService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isNodeIdTooLong(node.id)) {
|
||||
violations.push(
|
||||
`trigger "${node.name}" has a node ID longer than ${NODE_ID_MAX_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
|
||||
const names = namesById.get(node.id);
|
||||
if (names) {
|
||||
names.push(node.name);
|
||||
|
||||
@@ -270,6 +270,73 @@ describe('WorkflowPublicationOutboxConsumer (integration)', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('completes the publication of a version whose only long node id is on a disabled trigger', async () => {
|
||||
const owner = await createOwner();
|
||||
|
||||
const enabled = scheduleNode('enabled');
|
||||
const disabled = {
|
||||
...scheduleNode('disabled'),
|
||||
id: 'n'.repeat(NODE_ID_MAX_LENGTH + 1),
|
||||
disabled: true,
|
||||
};
|
||||
const workflow = await createWorkflowWithHistory(
|
||||
{ active: true, nodes: [enabled, disabled] },
|
||||
owner,
|
||||
);
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
|
||||
await outboxRepository.enqueue(workflow.id, workflow.versionId, 'publish');
|
||||
const record = await outboxRepository.claimNextPendingRecord();
|
||||
|
||||
await consumer.processRecord(record!, abortSignal);
|
||||
|
||||
const row = await outboxRepository.findOneBy({ id: record!.id });
|
||||
expect(row?.status).toBe('completed');
|
||||
expect(activeWorkflowTriggers.get(workflow.id)?.has(enabled.id)).toBe(true);
|
||||
expect(await triggerStatusRepository.findByWorkflowId(workflow.id)).toEqual([
|
||||
expect.objectContaining({ nodeId: enabled.id, status: 'activated' }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('fails a stored version with a trigger node id that is too long, and keeps the old triggers', async () => {
|
||||
const owner = await createOwner();
|
||||
|
||||
const running = scheduleNode('running');
|
||||
const workflow = await createWorkflowWithHistory({ active: true, nodes: [running] }, owner);
|
||||
await setActiveVersion(workflow.id, workflow.versionId);
|
||||
await publishedVersionRepository.setPublishedVersion(workflow.id, workflow.versionId);
|
||||
await activeWorkflowManager.add(workflow.id, 'activate');
|
||||
|
||||
// Write the version directly, as an older release or an external client could.
|
||||
const tooLong = { ...scheduleNode('too-long'), id: 'n'.repeat(NODE_ID_MAX_LENGTH + 1) };
|
||||
const fitting = scheduleNode('fitting');
|
||||
const newVersionId = uuid();
|
||||
await createWorkflowHistoryItem(workflow.id, {
|
||||
versionId: newVersionId,
|
||||
nodes: [tooLong, fitting],
|
||||
connections: {},
|
||||
});
|
||||
await setActiveVersion(workflow.id, newVersionId);
|
||||
|
||||
await outboxRepository.enqueue(workflow.id, newVersionId, 'publish');
|
||||
const record = await outboxRepository.claimNextPendingRecord();
|
||||
|
||||
await consumer.processRecord(record!, abortSignal);
|
||||
|
||||
const row = await outboxRepository.findOneBy({ id: record!.id });
|
||||
expect(row?.status).toBe('failed');
|
||||
expect(row?.errorMessage).toContain(`longer than ${NODE_ID_MAX_LENGTH} characters`);
|
||||
expect(activeWorkflowTriggers.get(workflow.id)?.has(running.id)).toBe(true);
|
||||
expect(activeWorkflowTriggers.get(workflow.id)?.has(fitting.id)).toBe(false);
|
||||
const published = await publishedVersionRepository.getPublishedVersionWithRelations(
|
||||
workflow.id,
|
||||
);
|
||||
expect(published?.publishedVersionId).toBe(workflow.versionId);
|
||||
expect(await triggerStatusRepository.findByWorkflowId(workflow.id)).toEqual([
|
||||
expect.objectContaining({ nodeId: fitting.id, status: 'failed' }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('publishes a healed version for duplicate trigger node ids, then activates it', async () => {
|
||||
const owner = await createOwner();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user