From 0a61678310d2b6140af2ae7c79a737da7cefb0e0 Mon Sep 17 00:00:00 2001 From: Stephen Wright Date: Mon, 17 Aug 2026 09:52:52 +0000 Subject: [PATCH] feat(Confluence Node): Add Create Page operation (no-changelog) (#36008) --- .../nodes/Confluence/actions/common.ts | 24 +++ .../nodes/Confluence/actions/description.ts | 19 ++- .../Confluence/actions/page/bodyEnvelope.ts | 148 +++++++++++++++++ .../actions/page/create.operation.ts | 149 +++++++++++++++++ .../nodes/Confluence/actions/page/index.ts | 29 ++++ .../nodes/Confluence/actions/router.ts | 46 ++++-- .../Confluence/test/Confluence.node.test.ts | 11 +- .../test/actions/page/bodyEnvelope.test.ts | 100 ++++++++++++ .../actions/page/create.operation.test.ts | 154 ++++++++++++++++++ .../Confluence/test/actions/router.test.ts | 79 +++++++++ .../nodes/Confluence/test/shared.ts | 38 +++++ .../Confluence/test/transport/index.test.ts | 48 ++++++ .../nodes/Confluence/transport/index.ts | 16 +- 13 files changed, 847 insertions(+), 14 deletions(-) create mode 100644 packages/nodes-base/nodes/Confluence/actions/page/bodyEnvelope.ts create mode 100644 packages/nodes-base/nodes/Confluence/actions/page/create.operation.ts create mode 100644 packages/nodes-base/nodes/Confluence/actions/page/index.ts create mode 100644 packages/nodes-base/nodes/Confluence/test/actions/page/bodyEnvelope.test.ts create mode 100644 packages/nodes-base/nodes/Confluence/test/actions/page/create.operation.test.ts create mode 100644 packages/nodes-base/nodes/Confluence/test/actions/router.test.ts create mode 100644 packages/nodes-base/nodes/Confluence/test/shared.ts diff --git a/packages/nodes-base/nodes/Confluence/actions/common.ts b/packages/nodes-base/nodes/Confluence/actions/common.ts index bc05a7a6249..f62ee396fe5 100644 --- a/packages/nodes-base/nodes/Confluence/actions/common.ts +++ b/packages/nodes-base/nodes/Confluence/actions/common.ts @@ -76,6 +76,30 @@ export const pageRLC: INodeProperties = { export type ConfluenceBodyFormat = 'storage' | 'atlas_doc_format' | 'plainText'; +export const bodyFormatOption: INodeProperties = { + displayName: 'Body Format', + name: 'bodyFormat', + type: 'options', + options: [ + { + name: 'Atlas Doc Format', + value: 'atlas_doc_format', + description: 'The ADF JSON representation', + }, + { + name: 'Plain Text', + value: 'plainText', + description: 'Text extracted from the ADF body (dynamic macros carry no text)', + }, + { + name: 'Storage', + value: 'storage', + description: 'The raw storage-format XHTML', + }, + ], + default: 'storage', +}; + export const spaceRLC: INodeProperties = { displayName: 'Space', name: 'space', diff --git a/packages/nodes-base/nodes/Confluence/actions/description.ts b/packages/nodes-base/nodes/Confluence/actions/description.ts index 36d62c98eea..229cd5d95b2 100644 --- a/packages/nodes-base/nodes/Confluence/actions/description.ts +++ b/packages/nodes-base/nodes/Confluence/actions/description.ts @@ -2,6 +2,7 @@ import type { INodeTypeDescription } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; +import * as page from './page'; import { CONFLUENCE_CREDENTIAL_NAME } from '../transport'; export const confluenceNodeDescription: INodeTypeDescription = { @@ -10,6 +11,7 @@ export const confluenceNodeDescription: INodeTypeDescription = { icon: 'file:confluence.svg', group: ['transform'], version: 1, + subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}', description: 'Interact with the Confluence Cloud API', defaults: { name: 'Confluence', @@ -24,5 +26,20 @@ export const confluenceNodeDescription: INodeTypeDescription = { required: true, }, ], - properties: [], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Page', + value: 'page', + }, + ], + default: 'page', + }, + ...page.description, + ], }; diff --git a/packages/nodes-base/nodes/Confluence/actions/page/bodyEnvelope.ts b/packages/nodes-base/nodes/Confluence/actions/page/bodyEnvelope.ts new file mode 100644 index 00000000000..c202a33b92f --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/actions/page/bodyEnvelope.ts @@ -0,0 +1,148 @@ +import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import type { ConfluenceBodyFormat } from '../common'; +import { bodyFormatOption } from '../common'; + +export interface ConfluenceBodyEnvelope extends IDataObject { + representation: 'storage' | 'atlas_doc_format'; + value: string; +} + +export function bodyProperties(operations: string[]): INodeProperties[] { + const show = { resource: ['page'], operation: operations }; + return [ + { + ...bodyFormatOption, + default: 'plainText', + description: 'How the page content below is interpreted', + displayOptions: { show }, + // Same values as the shared selector; write-oriented descriptions + options: [ + { + name: 'Atlas Doc Format', + value: 'atlas_doc_format', + description: 'Raw Atlassian Document Format JSON document', + }, + { + name: 'Plain Text', + value: 'plainText', + description: 'Text is wrapped in paragraph blocks; no markup needed', + }, + { + name: 'Storage', + value: 'storage', + description: 'Confluence storage-format XHTML, e.g.

Title

Text

', + }, + ], + }, + { + displayName: 'Body', + name: 'bodyPlainText', + type: 'string', + typeOptions: { rows: 4 }, + default: '', + description: + 'Page content as plain text; each line becomes a paragraph. Blank lines and leading whitespace are removed.', + displayOptions: { show: { ...show, bodyFormat: ['plainText'] } }, + }, + { + displayName: 'Body (Storage HTML)', + name: 'bodyStorage', + type: 'string', + typeOptions: { rows: 4 }, + default: '', + placeholder: '

Heading

Text

', + description: 'Page content in Confluence storage format', + displayOptions: { show: { ...show, bodyFormat: ['storage'] } }, + }, + { + displayName: 'Body (ADF JSON)', + name: 'bodyAdf', + type: 'json', + default: '', + placeholder: '{ "type": "doc", "version": 1, "content": [] }', + description: 'Page content as an Atlassian Document Format document', + displayOptions: { show: { ...show, bodyFormat: ['atlas_doc_format'] } }, + }, + ]; +} + +const HTML_ESCAPES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', +}; + +function escapeHtml(text: string): string { + return text.replace(/[&<>"]/g, (char) => HTML_ESCAPES[char]); +} + +export function buildBodyEnvelope( + format: ConfluenceBodyFormat, + content: unknown, +): ConfluenceBodyEnvelope { + switch (format) { + case 'plainText': { + if (typeof content === 'object' && content !== null) { + throw new Error( + 'Plain text body must be text, got an object. Use the ADF JSON format for document objects.', + ); + } + const text = content === null || content === undefined ? '' : String(content); + const paragraphs = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line !== '') + .map((line) => `

${escapeHtml(line)}

`); + return { representation: 'storage', value: paragraphs.join('') }; + } + + case 'storage': { + if (typeof content !== 'string') { + throw new Error('Storage (HTML) body must be a string of Confluence storage-format markup'); + } + return { representation: 'storage', value: content }; + } + + case 'atlas_doc_format': { + let parsed: unknown = content; + if (typeof content === 'string') { + if (content.trim() === '') { + throw new Error('ADF JSON body is empty. Provide an ADF document object.'); + } + try { + parsed = JSON.parse(content); + } catch { + throw new Error('ADF JSON body is not valid JSON'); + } + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('ADF JSON body must be a JSON object, e.g. { "type": "doc", ... }'); + } + return { representation: 'atlas_doc_format', value: JSON.stringify(parsed) }; + } + + default: + throw new Error(`Unsupported body format "${format as string}"`); + } +} + +export function readBodyEnvelope( + ctx: IExecuteFunctions, + itemIndex: number, +): ConfluenceBodyEnvelope { + const format = ctx.getNodeParameter('bodyFormat', itemIndex, 'plainText') as ConfluenceBodyFormat; + const fieldByFormat: Record = { + plainText: 'bodyPlainText', + storage: 'bodyStorage', + atlas_doc_format: 'bodyAdf', + }; + const content = ctx.getNodeParameter(fieldByFormat[format], itemIndex, ''); + try { + return buildBodyEnvelope(format, content); + } catch (error) { + throw new NodeOperationError(ctx.getNode(), (error as Error).message, { itemIndex }); + } +} diff --git a/packages/nodes-base/nodes/Confluence/actions/page/create.operation.ts b/packages/nodes-base/nodes/Confluence/actions/page/create.operation.ts new file mode 100644 index 00000000000..4f6abd993c8 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/actions/page/create.operation.ts @@ -0,0 +1,149 @@ +import type { + IDataObject, + IExecuteFunctions, + INodeParameterResourceLocator, + INodeProperties, +} from 'n8n-workflow'; +import { NodeApiError, NodeOperationError } from 'n8n-workflow'; + +import { bodyProperties, readBodyEnvelope } from './bodyEnvelope'; +import { pageRLC, spaceRLC } from '../common'; +import { confluenceApiRequest } from '../../transport'; + +const showOnCreate = { resource: ['page'], operation: ['create'] }; + +export const description: INodeProperties[] = [ + { + ...spaceRLC, + required: true, + description: 'The space to create the page in', + displayOptions: { show: showOnCreate }, + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + required: true, + placeholder: 'e.g. Weekly Report', + description: 'The title of the new page', + displayOptions: { show: showOnCreate }, + }, + ...bodyProperties(['create']), + { + ...pageRLC, + displayName: 'Parent Page', + name: 'parentPage', + required: false, + description: + 'The page to create the new page under. Leave empty to create under the space homepage.', + // By Title needs a title-to-ID resolver the create path does not have yet + modes: (pageRLC.modes ?? []).filter((mode) => mode.name !== 'title'), + displayOptions: { + show: showOnCreate, + // The API rejects root-level + parentId; hiding makes the combination unrepresentable + hide: { '/options.rootLevel': [true] }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { show: showOnCreate }, + options: [ + { + displayName: 'Create as Draft', + name: 'createAsDraft', + type: 'boolean', + default: false, + description: 'Whether to create the page as a draft instead of publishing it', + }, + { + displayName: 'Private', + name: 'private', + type: 'boolean', + default: false, + description: + 'Whether only the creating user can view and edit the page. The creator is the connected account, which needs permission to restrict content in the space.', + }, + { + displayName: 'Root Level', + name: 'rootLevel', + type: 'boolean', + default: false, + description: + 'Whether to create the page at the space root, outside the space homepage tree. Cannot be combined with a parent page.', + }, + ], + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const spaceId = this.getNodeParameter('space', itemIndex, undefined, { + extractValue: true, + }) as string; + const options = this.getNodeParameter('options', itemIndex, {}); + + const rawTitle: unknown = this.getNodeParameter('title', itemIndex, ''); + // Objects coerce to '' so validation rejects them instead of titling the page '[object Object]' + const title = + typeof rawTitle === 'string' + ? rawTitle.trim() + : rawTitle === null || rawTitle === undefined || typeof rawTitle === 'object' + ? '' + : String(rawTitle).trim(); + + if (!spaceId) { + throw new NodeOperationError(this.getNode(), 'Space is required', { itemIndex }); + } + if (!title) { + throw new NodeOperationError(this.getNode(), 'Title is required', { itemIndex }); + } + + const body: IDataObject = { + spaceId, + status: options.createAsDraft ? 'draft' : 'current', + title, + body: readBodyEnvelope(this, itemIndex), + }; + + if (!options.rootLevel) { + const parentRef = this.getNodeParameter('parentPage', itemIndex, '') as + | INodeParameterResourceLocator + | string; + const rawParentValue = + typeof parentRef === 'object' && parentRef !== null ? parentRef.value : parentRef; + // The field is optional: an empty By URL value means "no parent", so only + // extract (and regex-validate) once something is set + if (String(rawParentValue ?? '').trim() !== '') { + const parentId = this.getNodeParameter('parentPage', itemIndex, '', { + extractValue: true, + }) as string; + if (parentId) body.parentId = parentId; + } + } + + const qs: IDataObject = {}; + if (options.private) qs.private = true; + if (options.rootLevel) qs['root-level'] = true; + + try { + return await confluenceApiRequest.call(this, 'POST', '/wiki/api/v2/pages', body, qs); + } catch (error) { + // Private creation applies a content restriction under the hood, and Atlassian + // masks the failing permission/scope check as 404 on this endpoint + if (options.private && error instanceof NodeApiError && error.httpCode === '404') { + throw new NodeOperationError(this.getNode(), 'Could not create the page as private', { + itemIndex, + description: + 'Atlassian reports this as "not found", but it usually means the restriction step was refused: the connected user needs the "Add/Delete restrictions" permission in the space, and the credential\'s OAuth app must allow the content-restriction scopes. Try again without the Private option, or check the space permissions.', + }); + } + throw error; + } +} diff --git a/packages/nodes-base/nodes/Confluence/actions/page/index.ts b/packages/nodes-base/nodes/Confluence/actions/page/index.ts new file mode 100644 index 00000000000..5c392a13b01 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/actions/page/index.ts @@ -0,0 +1,29 @@ +import type { INodeProperties } from 'n8n-workflow'; + +import * as create from './create.operation'; + +export { create }; + +export const description: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['page'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new page in a space', + action: 'Create a page', + }, + ], + default: 'create', + }, + ...create.description, +]; diff --git a/packages/nodes-base/nodes/Confluence/actions/router.ts b/packages/nodes-base/nodes/Confluence/actions/router.ts index 4a4cf8920f5..6a3b690a91f 100644 --- a/packages/nodes-base/nodes/Confluence/actions/router.ts +++ b/packages/nodes-base/nodes/Confluence/actions/router.ts @@ -1,10 +1,11 @@ import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; +import * as page from './page'; + /** - * Compile-checked contract for operation modules. The router body (owned by ENT-125) - * calls `..execute.call(this, i)` once per item, SharePoint v2 - * shape; the other op tickets (ENT-126/319/127/305/327/306) implement against this. + * Compile-checked contract for operation modules. The router calls + * `..execute.call(this, i)` once per item. */ export type ConfluenceOperation = ( this: IExecuteFunctions, @@ -12,16 +13,41 @@ export type ConfluenceOperation = ( ) => Promise; export async function router(this: IExecuteFunctions): Promise { - // Fallbacks: the shell ships properties: [], so these parameters don't exist yet + const items = this.getInputData(); const resource = this.getNodeParameter('resource', 0, ''); const operation = this.getNodeParameter('operation', 0, ''); - switch (resource) { - // Op tickets (ENT-125/126/319/127/305/327/306) add their resource cases here - default: - throw new NodeOperationError( - this.getNode(), - `The operation "${resource}:${operation}" is not supported`, + const returnData: INodeExecutionData[] = []; + + for (let i = 0; i < items.length; i++) { + try { + let responseData: IDataObject | IDataObject[]; + + switch (`${resource}:${operation}`) { + case 'page:create': + responseData = await page.create.execute.call(this, i); + break; + default: + throw new NodeOperationError( + this.getNode(), + `The operation "${resource}:${operation}" is not supported`, + ); + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData), + { itemData: { item: i } }, ); + returnData.push.apply(returnData, executionData); + } catch (error) { + if (this.continueOnFail()) { + const message = error instanceof Error ? error.message : String(error); + returnData.push({ json: { error: message }, pairedItem: { item: i } }); + continue; + } + throw error; + } } + + return [returnData]; } diff --git a/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts b/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts index b9ca6224ed5..27e30c8277d 100644 --- a/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts +++ b/packages/nodes-base/nodes/Confluence/test/Confluence.node.test.ts @@ -7,12 +7,19 @@ import { Confluence } from '../Confluence.node'; describe('Confluence Node', () => { const node = new Confluence(); - it('should ship gated: hidden, no properties, not usable as a tool', () => { + it('should ship gated: hidden and not usable as a tool', () => { expect(node.description.hidden).toBe(true); - expect(node.description.properties).toEqual([]); expect(node.description.usableAsTool).toBeUndefined(); }); + it('should expose the page resource with the create operation', () => { + const resource = node.description.properties.find((p) => p.name === 'resource'); + expect(resource?.options).toEqual([expect.objectContaining({ value: 'page' })]); + + const operation = node.description.properties.find((p) => p.name === 'operation'); + expect(operation?.options).toEqual([expect.objectContaining({ value: 'create' })]); + }); + it('should reference the confluenceCloudOAuth2Api credential by name', () => { expect(node.description.credentials).toEqual([ { name: 'confluenceCloudOAuth2Api', required: true }, diff --git a/packages/nodes-base/nodes/Confluence/test/actions/page/bodyEnvelope.test.ts b/packages/nodes-base/nodes/Confluence/test/actions/page/bodyEnvelope.test.ts new file mode 100644 index 00000000000..238c4db5111 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/test/actions/page/bodyEnvelope.test.ts @@ -0,0 +1,100 @@ +import { NodeOperationError } from 'n8n-workflow'; + +import { buildBodyEnvelope, readBodyEnvelope } from '../../../actions/page/bodyEnvelope'; +import { mockExecuteCtx } from '../../shared'; + +describe('buildBodyEnvelope', () => { + describe('plainText', () => { + it.each([ + ['wraps a single line in a paragraph block', 'Hello world', '

Hello world

'], + [ + 'wraps each non-blank line in its own paragraph', + 'First\nSecond\n\n \nThird', + '

First

Second

Third

', + ], + [ + 'escapes markup so text cannot inject storage format', + '', + '

<script>1 & 2 > "0"</script>

', + ], + ['produces an empty value for empty input', '', ''], + ['coerces number expression results instead of dropping them', 42, '

42

'], + ['coerces boolean expression results instead of dropping them', true, '

true

'], + ])('%s', (_name, input, value) => { + expect(buildBodyEnvelope('plainText', input)).toEqual({ representation: 'storage', value }); + }); + + it('rejects object content instead of creating an empty page', () => { + expect(() => buildBodyEnvelope('plainText', { some: 'object' })).toThrow( + 'must be text, got an object', + ); + }); + }); + + describe('storage', () => { + it('passes storage markup through verbatim', () => { + const html = '

Title

x
'; + expect(buildBodyEnvelope('storage', html)).toEqual({ + representation: 'storage', + value: html, + }); + }); + + it('rejects non-string content', () => { + expect(() => buildBodyEnvelope('storage', { html: '

x

' })).toThrow( + 'Storage (HTML) body must be a string', + ); + }); + }); + + describe('atlas_doc_format', () => { + const doc = { type: 'doc', version: 1, content: [] }; + + it.each([ + ['serializes an already-parsed document object', doc], + ['parses and re-serializes a JSON string', JSON.stringify(doc)], + ])('%s', (_name, input) => { + expect(buildBodyEnvelope('atlas_doc_format', input)).toEqual({ + representation: 'atlas_doc_format', + value: JSON.stringify(doc), + }); + }); + + it.each([ + ['invalid JSON', '{not json', 'not valid JSON'], + ['an empty string', ' ', 'empty'], + ['an array document', JSON.stringify([1, 2]), 'must be a JSON object'], + ['a null document', 'null', 'must be a JSON object'], + ['a scalar document', '"text"', 'must be a JSON object'], + ])('rejects %s', (_name, input, message) => { + expect(() => buildBodyEnvelope('atlas_doc_format', input)).toThrow(message); + }); + }); +}); + +describe('readBodyEnvelope', () => { + it('reads the field matching the selected format', () => { + const ctx = mockExecuteCtx({ + bodyFormat: 'storage', + bodyStorage: '

from storage field

', + bodyPlainText: 'from the wrong field', + }); + expect(readBodyEnvelope(ctx, 0).value).toBe('

from storage field

'); + }); + + it('defaults to plain text', () => { + const ctx = mockExecuteCtx({ bodyPlainText: 'Hello' }); + expect(readBodyEnvelope(ctx, 0)).toEqual({ representation: 'storage', value: '

Hello

' }); + }); + + it('wraps envelope errors in a NodeOperationError carrying the item index', () => { + const ctx = mockExecuteCtx({ bodyFormat: 'atlas_doc_format', bodyAdf: '{broken' }); + try { + readBodyEnvelope(ctx, 3); + throw new Error('expected readBodyEnvelope to throw'); + } catch (error) { + expect(error).toBeInstanceOf(NodeOperationError); + expect((error as NodeOperationError).context.itemIndex).toBe(3); + } + }); +}); diff --git a/packages/nodes-base/nodes/Confluence/test/actions/page/create.operation.test.ts b/packages/nodes-base/nodes/Confluence/test/actions/page/create.operation.test.ts new file mode 100644 index 00000000000..230964a8450 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/test/actions/page/create.operation.test.ts @@ -0,0 +1,154 @@ +import { NodeApiError, NodeOperationError } from 'n8n-workflow'; +import type { Mock } from 'vitest'; + +import { execute } from '../../../actions/page/create.operation'; +import { confluenceApiRequest } from '../../../transport'; +import { mockExecuteCtx, testNode } from '../../shared'; + +vi.mock('../../../transport', async (importOriginal) => ({ + ...(await importOriginal()), + confluenceApiRequest: vi.fn(), +})); + +const apiRequest = confluenceApiRequest as unknown as Mock; + +const baseParams: Record = { + space: { mode: 'list', value: '111' }, + title: 'My Page', + bodyFormat: 'plainText', + bodyPlainText: 'Hello', + parentPage: '', + options: {}, +}; + +describe('page:create', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiRequest.mockResolvedValue({ id: '222', title: 'My Page' }); + }); + + it('posts the assembled envelope to the v2 pages endpoint', async () => { + const result = await execute.call(mockExecuteCtx(baseParams), 0); + + expect(apiRequest).toHaveBeenCalledWith( + 'POST', + '/wiki/api/v2/pages', + { + spaceId: '111', + status: 'current', + title: 'My Page', + body: { representation: 'storage', value: '

Hello

' }, + }, + {}, + ); + expect(result).toEqual({ id: '222', title: 'My Page' }); + }); + + it.each([ + [ + 'includes parentId when a parent page is set', + { parentPage: { mode: 'id', value: '98304' } }, + { parentId: '98304' }, + {}, + ], + [ + 'sends root-level and drops the parent when Root Level is on', + { parentPage: { mode: 'id', value: '98304' }, options: { rootLevel: true } }, + {}, + { 'root-level': true }, + ], + [ + 'sends the private query param when Private is on', + { options: { private: true } }, + {}, + { private: true }, + ], + [ + 'creates a draft when Create as Draft is on', + { options: { createAsDraft: true } }, + { status: 'draft' }, + {}, + ], + ['trims the title', { title: ' Padded ' }, { title: 'Padded' }, {}], + ['coerces a non-string title from an expression', { title: 32 }, { title: '32' }, {}], + [ + 'combines Private, Root Level, and Create as Draft', + { + parentPage: { mode: 'id', value: '98304' }, + options: { private: true, rootLevel: true, createAsDraft: true }, + }, + { status: 'draft' }, + { private: true, 'root-level': true }, + ], + ])('%s', async (_name, overrides, expectedBody, expectedQs) => { + await execute.call(mockExecuteCtx({ ...baseParams, ...overrides }), 0); + + const [, , body, qs] = apiRequest.mock.calls[0]; + expect(body).toMatchObject(expectedBody); + if (!('parentId' in expectedBody)) expect(body).not.toHaveProperty('parentId'); + expect(qs).toEqual(expectedQs); + }); + + it.each([ + ['space', { ...baseParams, space: { mode: 'list', value: '' } }, 'Space is required'], + ['title', { ...baseParams, title: ' ' }, 'Title is required'], + ])('rejects a missing %s without calling the API', async (_field, params, message) => { + const ctx = mockExecuteCtx(params); + + await expect(execute.call(ctx, 0)).rejects.toThrow(NodeOperationError); + await expect(execute.call(ctx, 0)).rejects.toThrow(message); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + it('rejects an object title instead of creating a page named [object Object]', async () => { + const ctx = mockExecuteCtx({ ...baseParams, title: { some: 'object' } }); + + await expect(execute.call(ctx, 0)).rejects.toThrow('Title is required'); + expect(apiRequest).not.toHaveBeenCalled(); + }); + + // Atlassian masks the failing restriction step behind a bare 404; the node + // replaces it with guidance only when Private was the requested option + it('maps a 404 with Private on to an actionable error', async () => { + apiRequest.mockRejectedValue( + new NodeApiError(testNode, { message: 'not found' }, { httpCode: '404' }), + ); + const ctx = mockExecuteCtx({ ...baseParams, options: { private: true } }); + + const error = await execute + .call(ctx, 0) + .then(() => null) + .catch((thrown: NodeOperationError) => thrown); + + expect(error).toBeInstanceOf(NodeOperationError); + expect(error?.message).toBe('Could not create the page as private'); + expect(error?.description).toContain('Add/Delete restrictions'); + }); + + it.each([ + [ + 'a 404 without Private', + {}, + new NodeApiError(testNode, { message: 'x' }, { httpCode: '404' }), + ], + [ + 'a non-404 with Private on', + { private: true }, + new NodeApiError(testNode, { message: 'x' }, { httpCode: '400' }), + ], + ])('passes %s through unchanged', async (_name, options, thrown) => { + apiRequest.mockRejectedValue(thrown); + + await expect(execute.call(mockExecuteCtx({ ...baseParams, options }), 0)).rejects.toBe(thrown); + }); + + it('treats an empty By URL parent as "no parent" instead of failing extraction', async () => { + await execute.call( + mockExecuteCtx({ ...baseParams, parentPage: { mode: 'url', value: '' } }), + 0, + ); + + const [, , body] = apiRequest.mock.calls[0]; + expect(body).not.toHaveProperty('parentId'); + }); +}); diff --git a/packages/nodes-base/nodes/Confluence/test/actions/router.test.ts b/packages/nodes-base/nodes/Confluence/test/actions/router.test.ts new file mode 100644 index 00000000000..ca5e5c7a283 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/test/actions/router.test.ts @@ -0,0 +1,79 @@ +import { NodeOperationError } from 'n8n-workflow'; +import type { Mock } from 'vitest'; + +import { router } from '../../actions/router'; +import { confluenceApiRequest } from '../../transport'; +import { mockExecuteCtx } from '../shared'; + +vi.mock('../../transport', async (importOriginal) => ({ + ...(await importOriginal()), + confluenceApiRequest: vi.fn(), +})); + +const apiRequest = confluenceApiRequest as unknown as Mock; + +const createParams: Record = { + resource: 'page', + operation: 'create', + space: { mode: 'list', value: '111' }, + title: 'My Page', + bodyFormat: 'plainText', + bodyPlainText: 'Hello', + parentPage: '', + options: {}, +}; + +describe('Confluence router', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiRequest.mockResolvedValue({ id: '222', title: 'My Page' }); + }); + + it('dispatches page:create per item and pairs outputs to their inputs', async () => { + const result = await router.call(mockExecuteCtx(createParams, 2)); + + expect(apiRequest).toHaveBeenCalledTimes(2); + expect(result).toEqual([ + [ + { json: { id: '222', title: 'My Page' }, pairedItem: { item: 0 } }, + { json: { id: '222', title: 'My Page' }, pairedItem: { item: 1 } }, + ], + ]); + }); + + it('emits an error item and continues with later items when continue-on-fail is on', async () => { + const ctx = mockExecuteCtx(createParams, 2); + ctx.continueOnFail.mockReturnValue(true); + apiRequest.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce({ id: '333' }); + + expect(await router.call(ctx)).toEqual([ + [ + { json: { error: 'boom' }, pairedItem: { item: 0 } }, + { json: { id: '333' }, pairedItem: { item: 1 } }, + ], + ]); + }); + + it('rethrows when continue-on-fail is off', async () => { + const ctx = mockExecuteCtx(createParams); + ctx.continueOnFail.mockReturnValue(false); + apiRequest.mockRejectedValue(new Error('boom')); + + await expect(router.call(ctx)).rejects.toThrow('boom'); + }); + + it.each([ + ['constructor', 'create'], + ['__proto__', 'create'], + ['page', 'constructor'], + ['page', 'hasOwnProperty'], + ])('rejects inherited-property lookups (%s:%s) as unsupported', async (resource, operation) => { + const ctx = mockExecuteCtx({ ...createParams, resource, operation }); + + await expect(router.call(ctx)).rejects.toThrow(NodeOperationError); + await expect(router.call(ctx)).rejects.toThrow( + `The operation "${resource}:${operation}" is not supported`, + ); + expect(apiRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nodes-base/nodes/Confluence/test/shared.ts b/packages/nodes-base/nodes/Confluence/test/shared.ts new file mode 100644 index 00000000000..3c3af722ba7 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/test/shared.ts @@ -0,0 +1,38 @@ +import type { IExecuteFunctions, INode } from 'n8n-workflow'; +import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended'; + +export const testNode: INode = { + id: 'test', + name: 'Confluence', + type: 'n8n-nodes-base.confluence', + typeVersion: 1, + position: [0, 0], + parameters: {}, +}; + +export function mockExecuteCtx( + params: Record, + items = 1, +): DeepMockProxy { + const ctx = mockDeep(); + ctx.getInputData.mockReturnValue(Array.from({ length: items }, () => ({ json: {} }))); + ctx.getNodeParameter.mockImplementation( + (name: string, _i?: number, fallback?: unknown, options?: { extractValue?: boolean }) => { + const value = name in params ? params[name] : fallback; + // Mimic extractValue unwrapping a resource locator to its value, so a + // call site that forgets to ask for extraction fails the assertions. + if (options?.extractValue && value && typeof value === 'object' && 'value' in value) { + return (value as { value: unknown }).value as never; + } + return value as never; + }, + ); + ctx.getNode.mockReturnValue(testNode); + ctx.helpers.returnJsonArray.mockImplementation((data) => + (Array.isArray(data) ? data : [data]).map((json) => ({ json })), + ); + ctx.helpers.constructExecutionMetaData.mockImplementation( + (data, { itemData }) => data.map((entry) => ({ ...entry, pairedItem: itemData })) as never, + ); + return ctx; +} diff --git a/packages/nodes-base/nodes/Confluence/test/transport/index.test.ts b/packages/nodes-base/nodes/Confluence/test/transport/index.test.ts index 1d657af1420..7baa55c2964 100644 --- a/packages/nodes-base/nodes/Confluence/test/transport/index.test.ts +++ b/packages/nodes-base/nodes/Confluence/test/transport/index.test.ts @@ -108,6 +108,54 @@ describe('confluenceApiRequest', () => { expect(error?.messages).toContain('boom'); }); + it("surfaces Atlassian's v2 error envelope instead of the generic status message", async () => { + mockHttpRequestWithAuthentication + .mockResolvedValueOnce(accessibleResources) + .mockRejectedValueOnce({ + message: 'Request failed with status code 404', + response: { + status: 404, + data: { + errors: [ + { + status: 404, + code: 'NOT_FOUND', + title: 'Page not found', + detail: 'No page with this ID exists', + }, + ], + }, + }, + }); + + const error = await confluenceApiRequest + .call(ctx, 'GET', '/wiki/api/v2/pages/1') + .then(() => null) + .catch((thrown: NodeApiError) => thrown); + + expect(error).toBeInstanceOf(NodeApiError); + expect(error?.message).toBe('Page not found'); + expect(error?.description).toBe('No page with this ID exists'); + }); + + it('falls back to the generic wrap when the envelope carries no usable title', async () => { + mockHttpRequestWithAuthentication + .mockResolvedValueOnce(accessibleResources) + .mockRejectedValueOnce({ + message: 'boom', + response: { status: 500, data: { errors: [{ title: '' }] } }, + }); + + const error = await confluenceApiRequest + .call(ctx, 'GET', '/wiki/api/v2/pages') + .then(() => null) + .catch((thrown: NodeApiError) => thrown); + + expect(error).toBeInstanceOf(NodeApiError); + expect(error?.httpCode).toBe('500'); + expect(error?.messages).toContain('boom'); + }); + it('surfaces the cloudId lookup error when no site matches', async () => { ctx.getCredentials.mockResolvedValue({ domain: 'https://missing.atlassian.net' }); diff --git a/packages/nodes-base/nodes/Confluence/transport/index.ts b/packages/nodes-base/nodes/Confluence/transport/index.ts index c3614ac53b9..da6338c862e 100644 --- a/packages/nodes-base/nodes/Confluence/transport/index.ts +++ b/packages/nodes-base/nodes/Confluence/transport/index.ts @@ -52,6 +52,20 @@ export async function confluenceApiRequest( options, ); } catch (error) { - throw new NodeApiError(this.getNode(), error as JsonObject); + // Atlassian's v2 error envelope sits in response.data.errors; without this, + // NodeApiError stops at Axios's generic "Request failed with status code N" + const envelope = (error as { response?: { data?: { errors?: unknown } } }).response?.data + ?.errors; + const first = Array.isArray(envelope) + ? (envelope[0] as { title?: unknown; detail?: unknown }) + : undefined; + const title = typeof first?.title === 'string' && first.title !== '' ? first.title : undefined; + const detail = + typeof first?.detail === 'string' && first.detail !== '' ? first.detail : undefined; + throw new NodeApiError( + this.getNode(), + error as JsonObject, + title ? { message: title, description: detail } : undefined, + ); } }