From 0bd0cd2873193d406d247aec431fa96d0d598947 Mon Sep 17 00:00:00 2001 From: "n8n-cat-bot[bot]" <283985454+n8n-cat-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:26:56 +0000 Subject: [PATCH] chore: Move the deprecated GET /workflows/{id}/{versionId} alias to the decorator controller and delete the legacy workflows handler (#40340) Co-authored-by: n8n-cat-bot[bot] Co-authored-by: Sam Wooler Co-authored-by: Claude Sonnet 5.5 --- packages/@n8n/api-types/src/dto/index.ts | 2 + .../workflow-version-public.dto.ts | 12 + packages/cli/eslint.config.mjs | 1 - packages/cli/oxlint.config.mts | 1 - packages/cli/src/public-api/types.ts | 10 - .../workflows.public.controller.ts | 48 +++- ...getDeprecatedWorkflowVersion.generated.yml | 181 ++++++++++++++ .../spec/paths/workflows.id.versionId.yml | 32 --- .../spec/schemas/workflowVersion.yml | 60 ----- .../handlers/workflows/workflows.handler.ts | 52 ---- .../v1/openapi.decorator-routes.generated.yml | 3 + packages/cli/src/public-api/v1/openapi.yml | 2 - .../public-api/workflow-route-order.test.ts | 229 ++++++++++++++++++ .../integration/public-api/workflows.test.ts | 4 + .../nodes/N8n/n8n-api-coverage.json | 2 +- 15 files changed, 477 insertions(+), 162 deletions(-) create mode 100644 packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getDeprecatedWorkflowVersion.generated.yml delete mode 100644 packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.versionId.yml delete mode 100644 packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflowVersion.yml delete mode 100644 packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts create mode 100644 packages/cli/test/integration/public-api/workflow-route-order.test.ts diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 5aae48df1a3..747ea7379f6 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -526,7 +526,9 @@ export { workflowVersionListItemPublicSchema, } from './workflow-history/workflow-version-history-public.dto'; export { + DeprecatedWorkflowVersionPublicDto, WorkflowVersionPublicDto, + deprecatedWorkflowVersionPublicSchema, workflowVersionPublicSchema, } from './workflow-history/workflow-version-public.dto'; diff --git a/packages/@n8n/api-types/src/dto/workflow-history/workflow-version-public.dto.ts b/packages/@n8n/api-types/src/dto/workflow-history/workflow-version-public.dto.ts index e991d5b33f0..cc7d2257ccc 100644 --- a/packages/@n8n/api-types/src/dto/workflow-history/workflow-version-public.dto.ts +++ b/packages/@n8n/api-types/src/dto/workflow-history/workflow-version-public.dto.ts @@ -7,3 +7,15 @@ export const workflowVersionPublicSchema = activeWorkflowVersionPublicSchema.omi }); export class WorkflowVersionPublicDto extends Z.class(workflowVersionPublicSchema.shape) {} + +export const deprecatedWorkflowVersionPublicSchema = workflowVersionPublicSchema.partial({ + nodeGroups: true, + name: true, + description: true, + createdAt: true, + updatedAt: true, +}); + +export class DeprecatedWorkflowVersionPublicDto extends Z.class( + deprecatedWorkflowVersionPublicSchema.shape, +) {} diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index f89bc8f462d..33e549ef00a 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -163,7 +163,6 @@ export default defineConfig( files: [ './src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts', './src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts', - './src/public-api/v1/handlers/workflows/workflows.handler.ts', ], rules: { 'n8n-local-rules/require-public-api-controller': 'off', diff --git a/packages/cli/oxlint.config.mts b/packages/cli/oxlint.config.mts index 84db7e5a52f..375025f67c8 100644 --- a/packages/cli/oxlint.config.mts +++ b/packages/cli/oxlint.config.mts @@ -157,7 +157,6 @@ export default defineConfig({ './src/public-api/v1/handlers/evaluations/evaluations.handler.ts', './src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts', './src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts', - './src/public-api/v1/handlers/workflows/workflows.handler.ts', ], rules: { 'n8n-local-rules/require-public-api-controller': 'off', diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index 02377f6603d..771c9e8aae0 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -19,16 +19,6 @@ export type PaginatedRequest = AuthenticatedRequest< lastId?: string; } >; -export declare namespace WorkflowRequest { - type Activate = AuthenticatedRequest< - { id: string }, - {}, - { versionId?: string; name?: string; description?: string }, - {} - >; - type GetVersion = AuthenticatedRequest<{ id: string; versionId: string }, {}, {}, {}>; -} - export declare namespace PackageRequest { type Import = AuthenticatedRequest< {}, diff --git a/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts b/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts index 2e07ea24318..21536ac1560 100644 --- a/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts @@ -3,6 +3,7 @@ import { CreatedWorkflowPublicDto, CreateWorkflowPublicDto, DeletedWorkflowPublicDto, + DeprecatedWorkflowVersionPublicDto, GetWorkflowQueryDto, ListWorkflowHistoryQueryDto, ListWorkflowsQueryDto, @@ -75,7 +76,8 @@ import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-hi import { WorkflowService } from '@/workflows/workflow.service'; import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; -const DEPRECATED_ALIAS_SINCE = new Date('2026-07-23T00:00:00Z'); +const ACTIVATE_DEACTIVATE_DEPRECATED_SINCE = new Date('2026-07-23T00:00:00Z'); +const OLD_VERSION_PATH_DEPRECATED_SINCE = new Date('2026-08-26T00:00:00Z'); const UPDATE_CONFLICT_DESCRIPTION = 'Conflict, e.g. re-publication blocked by an open workflow review (then `reason` and ' + @@ -650,7 +652,7 @@ export class WorkflowsPublicController { } @Post('/:workflowId/activate') - @Deprecated({ since: DEPRECATED_ALIAS_SINCE }) + @Deprecated({ since: ACTIVATE_DEACTIVATE_DEPRECATED_SINCE }) @ApiKeyScope('workflow:activate') @ProjectScope('workflow:publish') @ApiSummary('Publish a workflow') @@ -675,7 +677,7 @@ export class WorkflowsPublicController { } @Post('/:workflowId/deactivate') - @Deprecated({ since: DEPRECATED_ALIAS_SINCE }) + @Deprecated({ since: ACTIVATE_DEACTIVATE_DEPRECATED_SINCE }) @ApiKeyScope('workflow:deactivate') @ProjectScope('workflow:unpublish') @ApiSummary('Deactivate a workflow') @@ -831,4 +833,44 @@ export class WorkflowsPublicController { return tags.map(toPublicTag); } + + /** + * Keep this route as the last registered route in the controller, so that it does not shadow other routes. + */ + @Get('/:workflowId/:workflowVersionId') + @Deprecated({ since: OLD_VERSION_PATH_DEPRECATED_SINCE }) + @ApiKeyScope('workflow:read') + @ProjectScope('workflow:read') + @ApiSummary('Retrieves a specific version of a workflow') + @ApiDescription( + 'Deprecated: use GET /workflows/{workflowId}/versions/{workflowVersionId} instead. ' + + 'Retrieves a specific version of a workflow from workflow history.', + ) + @ApiTags(['Workflow']) + @ApiResponse(200, DeprecatedWorkflowVersionPublicDto) + @ApiErrorResponse(404) + async getDeprecatedWorkflowVersion( + req: AuthenticatedRequest, + _res: Response, + @Param('workflowId', workflowIdParamSchema) workflowId: string, + @Param('workflowVersionId', workflowVersionIdParamSchema) workflowVersionId: string, + ): Promise { + try { + const version = await this.workflowHistoryService.getVersion( + req.user, + workflowId, + workflowVersionId, + { includePublishHistory: false }, + ); + + this.eventService.emit('user-retrieved-workflow-version', { + userId: req.user.id, + publicApi: true, + }); + + return toPublicWorkflowVersion(version); + } catch (error) { + throw new NotFoundError('Version not found'); + } + } } diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getDeprecatedWorkflowVersion.generated.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getDeprecatedWorkflowVersion.generated.yml new file mode 100644 index 00000000000..94cccaeeb97 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getDeprecatedWorkflowVersion.generated.yml @@ -0,0 +1,181 @@ +operationId: getDeprecatedWorkflowVersion +tags: + - Workflow +summary: Retrieves a specific version of a workflow +description: 'Deprecated: use GET /workflows/{workflowId}/versions/{workflowVersionId} instead. Retrieves a specific version of a workflow from workflow history.' +x-required-scope: workflow:read +deprecated: true +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + description: The ID of the workflow. + name: workflowId + in: path + - schema: + type: string + required: true + description: The ID of the workflow version. + name: workflowVersionId + in: path +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + versionId: + type: string + workflowId: + type: string + nodes: + type: array + description: Nodes that make up the workflow + items: + type: object + properties: + id: + type: string + example: 0f5532f9-36ba-4bef-86c7-30d607400b15 + name: + type: string + example: Jira + webhookId: + type: string + disabled: + type: boolean + notesInFlow: + type: boolean + notes: + type: string + type: + type: string + example: n8n-nodes-base.jira + typeVersion: + type: number + example: 1 + executeOnce: + type: boolean + example: false + alwaysOutputData: + type: boolean + example: false + retryOnFail: + type: boolean + example: false + maxTries: + type: number + waitBetweenTries: + type: number + continueOnFail: + type: boolean + example: false + description: use onError instead + deprecated: true + onError: + type: string + example: stopWorkflow + position: + type: array + items: + type: number + example: + - -100 + - 80 + parameters: + type: object + additionalProperties: true + credentials: + type: object + example: + jiraSoftwareCloudApi: + name: jiraApi + customTelemetryTags: + type: object + properties: + tag: + type: array + items: + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + connections: + type: object + description: Connections between nodes, keyed by source node name + example: + Jira: + main: + - - node: Jira + type: main + index: 0 + nodeGroups: + type: array + description: Visual groupings of nodes shown as frames on the canvas + items: + type: object + properties: + id: + type: string + description: Unique identifier for the node group + example: 9b1c8e2a-4d3f-4a6b-8c7d-1e2f3a4b5c6d + name: + type: string + description: Display name of the node group + example: Data processing + description: + type: string + maxLength: 155 + description: Optional plain-text description of the node group + example: Cleans and normalizes incoming records + nodeIds: + type: array + description: IDs of the nodes that belong to this group + items: + type: string + required: + - id + - name + - nodeIds + authors: + type: string + name: + type: string + nullable: true + description: + type: string + nullable: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: + - versionId + - workflowId + - nodes + - connections + - authors + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.versionId.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.versionId.yml deleted file mode 100644 index 31d5e38374a..00000000000 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.versionId.yml +++ /dev/null @@ -1,32 +0,0 @@ -get: - x-eov-operation-id: getWorkflowVersion - x-required-scope: workflow:read - x-eov-operation-handler: v1/handlers/workflows/workflows.handler - tags: - - Workflow - summary: Retrieves a specific version of a workflow - description: >- - Deprecated: use GET /workflows/{workflowId}/versions/{workflowVersionId} - instead. - Retrieves a specific version of a workflow from workflow history. - deprecated: true - parameters: - - $ref: '../schemas/parameters/workflowId.yml' - - name: versionId - in: path - required: true - description: The version ID to retrieve - schema: - type: string - example: 'abc123-def456-ghi789' - responses: - '200': - description: Operation successful. - content: - application/json: - schema: - $ref: '../schemas/workflowVersion.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflowVersion.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflowVersion.yml deleted file mode 100644 index 43d6ba93007..00000000000 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflowVersion.yml +++ /dev/null @@ -1,60 +0,0 @@ -type: object -additionalProperties: false -required: - - versionId - - workflowId - - nodes - - connections - - authors -properties: - versionId: - type: string - readOnly: true - description: The version ID of this workflow snapshot - example: abc123-def456 - workflowId: - type: string - readOnly: true - description: The workflow ID this version belongs to - example: 2tUt1wbLX592XDdX - nodes: - type: array - items: - $ref: './node.yml' - description: Nodes as they were in this version - readOnly: true - connections: - type: object - example: { Jira: { main: [[{ node: 'Jira', type: 'main', index: 0 }]] } } - description: Connections as they were in this version - readOnly: true - nodeGroups: - type: array - readOnly: true - description: Node groups as they were in this version - items: - $ref: './workflowNodeGroup.yml' - authors: - type: string - readOnly: true - description: Authors who created this version - example: John Doe - name: - type: string - nullable: true - description: Workflow name at this version - example: Workflow 1 - description: - type: string - nullable: true - description: Workflow description at this version - createdAt: - type: string - format: date-time - readOnly: true - description: When this version was created - updatedAt: - type: string - format: date-time - readOnly: true - description: When this version was last updated diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts deleted file mode 100644 index 3019c2e1cac..00000000000 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { EventService } from '@n8n/backend-services'; -import { Container } from '@n8n/di'; - -import { NotFoundError } from '@n8n/errors'; -import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; - -import type { WorkflowRequest } from '../../../types'; -import type { PublicAPIEndpoint } from '../../shared/handler.types'; -import { - publicApiScope, - projectScope, - deprecated, -} from '../../shared/middlewares/global.middleware'; - -const VERSION_PATH_DEPRECATED_SINCE = new Date('2026-08-26T00:00:00Z'); - -type WorkflowHandlers = { - getWorkflowVersion: PublicAPIEndpoint; -}; - -const workflowHandlers: WorkflowHandlers = { - getWorkflowVersion: [ - deprecated({ since: VERSION_PATH_DEPRECATED_SINCE }), - publicApiScope('workflow:read'), - projectScope('workflow:read', 'workflow'), - async (req, res) => { - const { id: workflowId, versionId } = req.params; - - try { - const version = await Container.get(WorkflowHistoryService).getVersion( - req.user, - workflowId, - versionId, - { includePublishHistory: false }, - ); - - Container.get(EventService).emit('user-retrieved-workflow-version', { - userId: req.user.id, - publicApi: true, - }); - - const { autosaved, ...versionWithoutInternalFields } = version; - - return res.json(versionWithoutInternalFields); - } catch { - throw new NotFoundError('Version not found'); - } - }, - ], -}; - -export = workflowHandlers; diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index 6ec7c9ca2b8..1b25a0dcb22 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -417,3 +417,6 @@ paths: $ref: ./handlers/workflows/spec/paths/getWorkflowTags.generated.yml put: $ref: ./handlers/workflows/spec/paths/updateWorkflowTags.generated.yml + /workflows/{workflowId}/{workflowVersionId}: + get: + $ref: ./handlers/workflows/spec/paths/getDeprecatedWorkflowVersion.generated.yml diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index e6cbf3b8185..4f8a1e2ab97 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -79,8 +79,6 @@ tags: description: Operations about workflows paths: - /workflows/{id}/{versionId}: - $ref: './handlers/workflows/spec/paths/workflows.id.versionId.yml' /n8n-packages/export: $ref: './handlers/n8n-packages/spec/paths/n8n-packages.export.yml' /n8n-packages/import: diff --git a/packages/cli/test/integration/public-api/workflow-route-order.test.ts b/packages/cli/test/integration/public-api/workflow-route-order.test.ts new file mode 100644 index 00000000000..3224ef49814 --- /dev/null +++ b/packages/cli/test/integration/public-api/workflow-route-order.test.ts @@ -0,0 +1,229 @@ +import { EventService } from '@n8n/backend-services'; +import { createWorkflow, mockInstance, testDb } from '@n8n/backend-test-utils'; +import type { User } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { v4 as uuid } from 'uuid'; + +import { Telemetry } from '@/telemetry'; + +import { createTestRun } from '../shared/db/evaluation'; +import { createTag } from '../shared/db/tags'; +import { createOwnerWithApiKey } from '../shared/db/users'; +import { createWorkflowHistoryItem } from '../shared/db/workflow-history'; +import type { SuperAgentTest } from '../shared/types'; +import * as utils from '../shared/utils/'; + +/** + * `GET /workflows/:workflowId/:workflowVersionId` matches every two-segment GET path under + * `/workflows`. Express answers with the first matching route, so the order of registration decides + * which handler gets a request. These tests call the real app with seeded data and check the body + * of each response, so a request that reaches the wrong handler fails the assertion. + * + * Once `GET /workflows/:workflowId/:workflowVersionId` is removed, these tests can be deleted. + */ +mockInstance(Telemetry); + +let owner: User; +let authOwnerAgent: SuperAgentTest; + +const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] }); + +beforeAll(async () => { + owner = await createOwnerWithApiKey(); + authOwnerAgent = testServer.publicApiAgentFor(owner); +}); + +beforeEach(async () => { + await testDb.truncate([ + 'WorkflowEntity', + 'SharedWorkflow', + 'WorkflowHistory', + 'TagEntity', + 'TestRun', + 'TestCaseExecution', + ]); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** A workflow with three saved versions, two tags and two test runs. */ +async function seedWorkflow(label: string, tagNames: string[]) { + const workflow = await createWorkflow({}, owner); + + const versions = []; + for (const [index, name] of [`${label} v1`, `${label} v2`, `${label} v3`].entries()) { + const workflowVersion = await createWorkflowHistoryItem(workflow.id, { + versionId: uuid(), + name, + description: `${name} description`, + authors: 'Test Author', + createdAt: new Date(Date.now() + index), + updatedAt: new Date(Date.now() + index), + }); + versions.push(workflowVersion); + } + + const tags = []; + for (const name of tagNames) { + const tag = await createTag({ name }, workflow); + tags.push(tag); + } + + const testRuns = [ + await createTestRun(workflow.id, { status: 'completed' }), + await createTestRun(workflow.id, { status: 'error' }), + ]; + + return { workflow, versions, tags, testRuns }; +} + +async function get(path: string) { + const emit = vi.spyOn(Container.get(EventService), 'emit'); + + const response = await authOwnerAgent.get(path); + + expect(emit.mock.calls.filter(([event]) => event === 'public-api-invoked')).toHaveLength(1); + emit.mockRestore(); + + return response; +} + +describe('workflow routes next to the deprecated version alias', () => { + let target: Awaited>; + + beforeEach(async () => { + target = await seedWorkflow('Target', ['production', 'team-a']); + }); + + test('GET /workflows/:workflowId/history returns the version list of that workflow', async () => { + const response = await get(`/workflows/${target.workflow.id}/history`); + + expect(response.statusCode).toBe(200); + expect(response.body).toStrictEqual({ + // Newest first version first. + data: [...target.versions].reverse().map((version) => ({ + versionId: version.versionId, + workflowId: target.workflow.id, + authors: 'Test Author', + name: version.name, + description: version.description, + createdAt: version.createdAt.toISOString(), + updatedAt: version.updatedAt.toISOString(), + })), + nextCursor: null, + }); + }); + + test('GET /workflows/:workflowId/tags returns the tags of that workflow', async () => { + const response = await get(`/workflows/${target.workflow.id}/tags`); + + expect(response.statusCode).toBe(200); + const [production, teamA] = target.tags; + // The tags share one creation time, so the order is not defined. Sort by name. + expect( + response.body.sort((a: { name: string }, b: { name: string }) => + a.name.localeCompare(b.name), + ), + ).toStrictEqual([ + { + id: production.id, + name: 'production', + createdAt: production.createdAt.toISOString(), + updatedAt: production.updatedAt.toISOString(), + }, + { + id: teamA.id, + name: 'team-a', + createdAt: teamA.createdAt.toISOString(), + updatedAt: teamA.updatedAt.toISOString(), + }, + ]); + }); + + test('GET /workflows/:workflowId/test-runs returns the test runs of that workflow', async () => { + const response = await get(`/workflows/${target.workflow.id}/test-runs`); + + expect(response.statusCode).toBe(200); + const [completedRun, errorRun] = target.testRuns; + // The runs share one creation time, so the order is not defined. Sort by status. + response.body.data.sort((a: { status: string }, b: { status: string }) => + a.status.localeCompare(b.status), + ); + expect(response.body).toStrictEqual({ + data: [ + { + id: completedRun.id, + status: 'completed', + runAt: null, + completedAt: null, + metrics: {}, + errorCode: null, + errorDetails: null, + finalResult: 'success', + testCaseCount: 0, + createdAt: completedRun.createdAt.toISOString(), + updatedAt: completedRun.updatedAt.toISOString(), + }, + { + id: errorRun.id, + status: 'error', + runAt: null, + completedAt: null, + metrics: {}, + errorCode: null, + errorDetails: null, + finalResult: null, + testCaseCount: 0, + createdAt: errorRun.createdAt.toISOString(), + updatedAt: errorRun.updatedAt.toISOString(), + }, + ], + nextCursor: null, + }); + }); + + test('GET /workflows/:workflowId/versions/:workflowVersionId returns the full version', async () => { + const [version] = target.versions; + + const response = await get(`/workflows/${target.workflow.id}/versions/${version.versionId}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toStrictEqual({ + versionId: version.versionId, + workflowId: target.workflow.id, + nodes: version.nodes, + connections: version.connections, + nodeGroups: [], + authors: 'Test Author', + name: 'Target v1', + description: 'Target v1 description', + createdAt: version.createdAt.toISOString(), + updatedAt: version.updatedAt.toISOString(), + }); + }); + + test('GET /workflows/:workflowId/:workflowVersionId returns the same version with a Deprecation header', async () => { + const [, version] = target.versions; + + const alias = await get(`/workflows/${target.workflow.id}/${version.versionId}`); + const canonical = await get(`/workflows/${target.workflow.id}/versions/${version.versionId}`); + + expect(alias.statusCode).toBe(200); + expect(alias.headers.deprecation).toMatch(/^@\d+$/); + expect(alias.body).toStrictEqual({ + versionId: version.versionId, + workflowId: target.workflow.id, + nodes: version.nodes, + connections: version.connections, + nodeGroups: [], + authors: 'Test Author', + name: 'Target v2', + description: 'Target v2 description', + createdAt: version.createdAt.toISOString(), + updatedAt: version.updatedAt.toISOString(), + }); + expect(alias.body).toStrictEqual(canonical.body); + }); +}); diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index ec7bc1eecb0..14485abc526 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -1073,6 +1073,7 @@ describe('GET /workflows/:id/:versionId', () => { const response = await authOwnerAgent.get('/workflows/non-existing/version-123'); expect(response.statusCode).toBe(404); + expect(response.headers.deprecation).toBe('@1787702400'); // The deprecated path keeps one message for both cases. Callers may match on it. expect(response.body.message).toBe('Version not found'); }); @@ -1110,6 +1111,9 @@ describe('GET /workflows/:id/:versionId', () => { const response = await authOwnerAgent.get(`/workflows/${workflow.id}/${versionId}`); + expect(response.statusCode).toBe(200); + expect(response.headers.deprecation).toBe('@1787702400'); + const body = response.body as Partial; expect(body).toEqual({ workflowId: workflow.id, diff --git a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json index d0deb8b9650..cc81139f591 100644 --- a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json +++ b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json @@ -151,7 +151,7 @@ "status": "covered", "nodeOperation": "workflow:update" }, - "GET /workflows/{id}/{versionId}": { + "GET /workflows/{workflowId}/{workflowVersionId}": { "status": "covered", "nodeOperation": "workflow:getVersion" },