feat(Confluence Node): Add Create Page operation (no-changelog) (#36008)

This commit is contained in:
Stephen Wright
2026-08-17 09:52:52 +00:00
committed by GitHub
parent 1b79604bb9
commit 0a61678310
13 changed files with 847 additions and 14 deletions
@@ -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',
@@ -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,
],
};
@@ -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. <h2>Title</h2><p>Text</p>',
},
],
},
{
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: '<h2>Heading</h2><p>Text</p>',
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<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
};
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) => `<p>${escapeHtml(line)}</p>`);
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<ConfluenceBodyFormat, string> = {
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 });
}
}
@@ -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<IDataObject | IDataObject[]> {
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;
}
}
@@ -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,
];
@@ -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 `<resource>.<operation>.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
* `<resource>.<operation>.execute.call(this, i)` once per item.
*/
export type ConfluenceOperation = (
this: IExecuteFunctions,
@@ -12,16 +13,41 @@ export type ConfluenceOperation = (
) => Promise<IDataObject | IDataObject[]>;
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
// 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];
}
@@ -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 },
@@ -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', '<p>Hello world</p>'],
[
'wraps each non-blank line in its own paragraph',
'First\nSecond\n\n \nThird',
'<p>First</p><p>Second</p><p>Third</p>',
],
[
'escapes markup so text cannot inject storage format',
'<script>1 & 2 > "0"</script>',
'<p>&lt;script&gt;1 &amp; 2 &gt; &quot;0&quot;&lt;/script&gt;</p>',
],
['produces an empty value for empty input', '', ''],
['coerces number expression results instead of dropping them', 42, '<p>42</p>'],
['coerces boolean expression results instead of dropping them', true, '<p>true</p>'],
])('%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 = '<h2>Title</h2><table><tr><td>x</td></tr></table>';
expect(buildBodyEnvelope('storage', html)).toEqual({
representation: 'storage',
value: html,
});
});
it('rejects non-string content', () => {
expect(() => buildBodyEnvelope('storage', { html: '<p>x</p>' })).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: '<p>from storage field</p>',
bodyPlainText: 'from the wrong field',
});
expect(readBodyEnvelope(ctx, 0).value).toBe('<p>from storage field</p>');
});
it('defaults to plain text', () => {
const ctx = mockExecuteCtx({ bodyPlainText: 'Hello' });
expect(readBodyEnvelope(ctx, 0)).toEqual({ representation: 'storage', value: '<p>Hello</p>' });
});
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);
}
});
});
@@ -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<object>()),
confluenceApiRequest: vi.fn(),
}));
const apiRequest = confluenceApiRequest as unknown as Mock;
const baseParams: Record<string, unknown> = {
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: '<p>Hello</p>' },
},
{},
);
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');
});
});
@@ -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<object>()),
confluenceApiRequest: vi.fn(),
}));
const apiRequest = confluenceApiRequest as unknown as Mock;
const createParams: Record<string, unknown> = {
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();
});
});
@@ -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<string, unknown>,
items = 1,
): DeepMockProxy<IExecuteFunctions> {
const ctx = mockDeep<IExecuteFunctions>();
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;
}
@@ -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' });
@@ -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,
);
}
}