feat(Confluence Node): Add attachments Get Many with file download (no-changelog) (#36713)

Co-authored-by: Ilfat Mindubaev <ilfat.mindubaev@n8n.io>
This commit is contained in:
Stephen Wright
2026-08-24 12:15:44 +00:00
committed by GitHub
parent f79eccf2c1
commit 9d97cee448
10 changed files with 837 additions and 64 deletions
@@ -0,0 +1,138 @@
import type {
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeProperties,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { returnAllOrLimit } from '@utils/descriptions';
import { updateDisplayOptions } from '@utils/utilities';
import { confluenceApiRequest, confluenceApiRequestBinary } from '../../transport';
import {
PAGE_LIMIT,
extractNextCursor,
optionalSpaceRLC,
pageRLC,
parsePositiveInt,
resolvePageId,
} from '../common';
import type { ConfluenceBinaryOperation } from '../router';
const properties: INodeProperties[] = [
{
...optionalSpaceRLC,
description:
'Limits page selection and By Title lookups to one space. Leave empty or pick "All Spaces" to search across all spaces.',
},
{
...pageRLC,
description: 'The page whose attachments to fetch',
},
...returnAllOrLimit,
{
displayName: 'Download',
name: 'download',
type: 'boolean',
default: false,
description:
"Whether to also download each attachment's file and attach it to the item's binary output",
},
{
displayName: 'Put Output File in Field',
name: 'binaryPropertyName',
type: 'string',
placeholder: 'e.g. data',
default: 'data',
required: true,
description: 'Use this field name in the following nodes, to use the binary file data',
hint: 'The name of the output binary field to put the file in',
displayOptions: { show: { download: [true] } },
},
];
const displayOptions = {
show: {
resource: ['attachment'],
operation: ['getMany'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
function asOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined;
}
export const execute: ConfluenceBinaryOperation = async function (
this: IExecuteFunctions,
itemIndex: number,
) {
const pageId = await resolvePageId.call(this, itemIndex);
const returnAll = this.getNodeParameter('returnAll', itemIndex, false);
const limit = returnAll
? Infinity
: parsePositiveInt.call(
this,
this.getNodeParameter('limit', itemIndex, 100),
'Limit',
itemIndex,
);
const download = this.getNodeParameter('download', itemIndex, false);
const endpoint = `/wiki/api/v2/pages/${encodeURIComponent(pageId)}/attachments`;
const attachments: IDataObject[] = [];
let cursor: string | undefined;
const seenCursors = new Set<string>();
do {
const qs: IDataObject = { limit: Math.min(limit - attachments.length, PAGE_LIMIT) };
if (cursor !== undefined) qs.cursor = cursor;
const response = await confluenceApiRequest.call(this, 'GET', endpoint, {}, qs);
const records = Array.isArray(response.results) ? (response.results as IDataObject[]) : [];
attachments.push.apply(attachments, records);
const next = extractNextCursor(response);
// A next link revisiting any earlier page would loop forever under Return All
if (next === undefined || seenCursors.has(next)) break;
seenCursors.add(next);
cursor = next;
} while (attachments.length < limit);
const items: INodeExecutionData[] = (returnAll ? attachments : attachments.slice(0, limit)).map(
(attachment) => ({ json: attachment }),
);
if (download) {
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', itemIndex, 'data');
for (const item of items) {
const downloadLink = item.json.downloadLink;
if (typeof downloadLink !== 'string' || !downloadLink.startsWith('/')) {
throw new NodeOperationError(
this.getNode(),
`Attachment "${asOptionalString(item.json.title) ?? asOptionalString(item.json.id) ?? 'unknown'}" has no usable download link`,
{ itemIndex },
);
}
// downloadLink is server-relative to /wiki with a raw, unencoded filename in the
// path; encode the segments and keep the query string (version/cache params) intact
const querySplit = downloadLink.lastIndexOf('?');
const path = (querySplit === -1 ? downloadLink : downloadLink.slice(0, querySplit))
.split('/')
.map(encodeURIComponent)
.join('/');
const query = querySplit === -1 ? '' : downloadLink.slice(querySplit);
const buffer = await confluenceApiRequestBinary.call(this, `/wiki${path}${query}`);
item.binary = {
[binaryPropertyName]: await this.helpers.prepareBinaryData(
buffer,
asOptionalString(item.json.title),
asOptionalString(item.json.mediaType),
),
};
}
}
return items;
};
@@ -0,0 +1,29 @@
import type { INodeProperties } from 'n8n-workflow';
import * as getMany from './getMany.operation';
export { getMany };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['attachment'],
},
},
options: [
{
name: 'Get Many',
value: 'getMany',
description: 'List the attachments on a page, optionally downloading each file',
action: 'Get many attachments',
},
],
default: 'getMany',
},
...getMany.description,
];
@@ -196,14 +196,44 @@ export async function resolveSpaceKey(
return space.key;
}
export function extractNextCursor(response: IDataObject): string | undefined {
export type NextPageParam = { key: 'cursor' | 'start'; value: string };
export function extractNextPageParam(response: IDataObject): NextPageParam | undefined {
const next = (response._links as IDataObject | undefined)?.next;
if (typeof next !== 'string' || next === '') return undefined;
let params: URLSearchParams;
try {
return new URL(next, 'https://api.atlassian.com').searchParams.get('cursor') ?? undefined;
params = new URL(next, 'https://api.atlassian.com').searchParams;
} catch {
return undefined;
}
const cursor = params.get('cursor');
if (cursor !== null && cursor !== '') return { key: 'cursor', value: cursor };
// Older responses page by start offset instead of cursor
const start = params.get('start');
return start === null || start === '' ? undefined : { key: 'start', value: start };
}
export function extractNextCursor(response: IDataObject): string | undefined {
const next = extractNextPageParam(response);
return next?.key === 'cursor' ? next.value : undefined;
}
/** Validates a count parameter that an expression may hand back as a numeric string. */
export function parsePositiveInt(
this: IExecuteFunctions,
raw: unknown,
label: string,
itemIndex: number,
): number {
const value = Number(raw);
if (!Number.isFinite(value) || value < 1) {
throw new NodeOperationError(this.getNode(), `${label} must be a number of at least 1`, {
itemIndex,
});
}
return Math.floor(value);
}
function asString(value: unknown): string {
@@ -2,6 +2,7 @@
import type { INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import * as attachment from './attachment';
import * as page from './page';
import { CONFLUENCE_CREDENTIAL_NAME } from '../transport';
@@ -33,6 +34,10 @@ export const confluenceNodeDescription: INodeTypeDescription = {
type: 'options',
noDataExpression: true,
options: [
{
name: 'Attachment',
value: 'attachment',
},
{
name: 'Page',
value: 'page',
@@ -40,6 +45,7 @@ export const confluenceNodeDescription: INodeTypeDescription = {
],
default: 'page',
},
...attachment.description,
...page.description,
],
};
@@ -1,6 +1,7 @@
import type { IDataObject, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import * as attachment from './attachment';
import * as page from './page';
/**
@@ -12,6 +13,12 @@ export type ConfluenceOperation = (
itemIndex: number,
) => Promise<IDataObject | IDataObject[]>;
/** Variant for operations that emit their own execution items (e.g. binary output). */
export type ConfluenceBinaryOperation = (
this: IExecuteFunctions,
itemIndex: number,
) => Promise<INodeExecutionData[]>;
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const resource = this.getNodeParameter('resource', 0, '');
@@ -21,9 +28,13 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
for (let i = 0; i < items.length; i++) {
try {
let responseData: IDataObject | IDataObject[];
let responseData: IDataObject | IDataObject[] | undefined;
let responseItems: INodeExecutionData[] | undefined;
switch (`${resource}:${operation}`) {
case 'attachment:getMany':
responseItems = await attachment.getMany.execute.call(this, i);
break;
case 'page:append':
responseData = await page.append.execute.call(this, i);
break;
@@ -47,7 +58,7 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
responseItems ?? this.helpers.returnJsonArray(responseData ?? []),
{ itemData: { item: i } },
);
returnData.push.apply(returnData, executionData);
@@ -13,12 +13,20 @@ describe('Confluence Node', () => {
expect(node.description.usableAsTool).toBeUndefined();
});
it('should expose the page resource with the append, create, delete, get and update operations', () => {
it('should expose the attachment and page resources with their operations', () => {
const resource = node.description.properties.find((p) => p.name === 'resource');
expect(resource?.options).toEqual([expect.objectContaining({ value: 'page' })]);
expect(resource?.options).toEqual([
expect.objectContaining({ value: 'attachment' }),
expect.objectContaining({ value: 'page' }),
]);
const operation = node.description.properties.find((p) => p.name === 'operation');
expect(operation?.options).toEqual([
const operations = node.description.properties.filter((p) => p.name === 'operation');
const operationsFor = (resourceName: string) =>
operations.find((p) => (p.displayOptions?.show?.resource ?? []).includes(resourceName));
expect(operationsFor('attachment')?.options).toEqual([
expect.objectContaining({ value: 'getMany' }),
]);
expect(operationsFor('page')?.options).toEqual([
expect.objectContaining({ value: 'append' }),
expect.objectContaining({ value: 'create' }),
expect.objectContaining({ value: 'delete' }),
@@ -0,0 +1,251 @@
import type { IBinaryData } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { description, execute } from '../../../actions/attachment/getMany.operation';
import { confluenceApiRequest, confluenceApiRequestBinary } from '../../../transport';
import { mockExecuteCtx } from '../../shared';
vi.mock('../../../transport', () => ({
CONFLUENCE_CREDENTIAL_NAME: 'confluenceCloudOAuth2Api',
confluenceApiRequest: vi.fn(),
confluenceApiRequestBinary: vi.fn(),
}));
const apiRequest = vi.mocked(confluenceApiRequest);
const binaryRequest = vi.mocked(confluenceApiRequestBinary);
const ENDPOINT = '/wiki/api/v2/pages/9/attachments';
const baseParams: Record<string, unknown> = {
resource: 'attachment',
operation: 'getMany',
page: { mode: 'id', value: '9' },
returnAll: false,
limit: 100,
download: false,
};
function attachmentPage(records: Array<Record<string, unknown>>, next?: string) {
return {
results: records,
...(next === undefined ? {} : { _links: { next } }),
};
}
async function runGetMany(overrides: Record<string, unknown> = {}) {
const ctx = mockExecuteCtx({ ...baseParams, ...overrides });
ctx.helpers.prepareBinaryData.mockImplementation(
async (buffer, fileName, mimeType): Promise<IBinaryData> => ({
data: (buffer as Buffer).toString('base64'),
fileName,
mimeType: mimeType ?? 'application/octet-stream',
}),
);
return await execute.call(ctx, 0);
}
describe('attachment:getMany', () => {
beforeEach(() => {
vi.clearAllMocks();
apiRequest.mockResolvedValue(attachmentPage([]));
binaryRequest.mockResolvedValue(Buffer.from('file-bytes'));
});
it('keeps the field-specific display conditions alongside the operation scoping', () => {
const limitProperty = description.find((p) => p.name === 'limit');
expect(limitProperty?.displayOptions?.show).toEqual({
resource: ['attachment'],
operation: ['getMany'],
returnAll: [false],
});
const binaryProperty = description.find((p) => p.name === 'binaryPropertyName');
expect(binaryProperty?.displayOptions?.show).toEqual({
resource: ['attachment'],
operation: ['getMany'],
download: [true],
});
});
it('lists attachments and returns one item per record', async () => {
apiRequest.mockResolvedValue(
attachmentPage([
{ id: 'a1', title: 'notes.txt', mediaType: 'text/plain', fileSize: 5 },
{ id: 'a2', title: 'plan.pdf', mediaType: 'application/pdf', fileSize: 9 },
]),
);
const result = await runGetMany();
expect(apiRequest).toHaveBeenCalledTimes(1);
expect(apiRequest).toHaveBeenCalledWith('GET', ENDPOINT, {}, { limit: 100 });
expect(result).toEqual([
{ json: { id: 'a1', title: 'notes.txt', mediaType: 'text/plain', fileSize: 5 } },
{ json: { id: 'a2', title: 'plan.pdf', mediaType: 'application/pdf', fileSize: 9 } },
]);
});
it('paginates with the next cursor until exhausted', async () => {
apiRequest
.mockResolvedValueOnce(attachmentPage([{ id: 'a1' }], `${ENDPOINT}?cursor=c1`))
.mockResolvedValueOnce(attachmentPage([{ id: 'a2' }]));
const result = await runGetMany({ returnAll: true });
expect(apiRequest).toHaveBeenCalledTimes(2);
expect(apiRequest).toHaveBeenNthCalledWith(1, 'GET', ENDPOINT, {}, { limit: 250 });
expect(apiRequest).toHaveBeenNthCalledWith(
2,
'GET',
ENDPOINT,
{},
{ limit: 250, cursor: 'c1' },
);
expect(result).toEqual([{ json: { id: 'a1' } }, { json: { id: 'a2' } }]);
});
it('stops fetching once the limit is met and truncates', async () => {
apiRequest.mockResolvedValue(
attachmentPage([{ id: 'a1' }, { id: 'a2' }], `${ENDPOINT}?cursor=c1`),
);
const result = await runGetMany({ limit: 1 });
expect(apiRequest).toHaveBeenCalledTimes(1);
expect(apiRequest).toHaveBeenCalledWith('GET', ENDPOINT, {}, { limit: 1 });
expect(result).toEqual([{ json: { id: 'a1' } }]);
});
it('stops when the next cursor revisits an earlier page', async () => {
apiRequest.mockResolvedValue(attachmentPage([{ id: 'a1' }], `${ENDPOINT}?cursor=same`));
await runGetMany({ returnAll: true });
expect(apiRequest).toHaveBeenCalledTimes(2);
});
it('rejects a non-positive limit from an expression', async () => {
const promise = runGetMany({ limit: 0 });
await expect(promise).rejects.toThrow('Limit must be a number of at least 1');
expect(apiRequest).not.toHaveBeenCalled();
});
it('downloads each attachment into the binary field', async () => {
apiRequest.mockResolvedValue(
attachmentPage([
{
id: 'a1',
title: 'notes.txt',
mediaType: 'text/plain',
downloadLink: '/download/attachments/9/notes.txt?version=1&api=v2',
},
]),
);
const result = await runGetMany({ download: true });
expect(binaryRequest).toHaveBeenCalledWith(
'/wiki/download/attachments/9/notes.txt?version=1&api=v2',
);
expect(result).toEqual([
{
json: expect.objectContaining({ id: 'a1' }),
binary: {
data: {
data: Buffer.from('file-bytes').toString('base64'),
fileName: 'notes.txt',
mimeType: 'text/plain',
},
},
},
]);
});
it('percent-encodes raw filename characters while keeping the query string intact', async () => {
apiRequest.mockResolvedValue(
attachmentPage([
{
id: 'a1',
title: 'report #3.pdf',
downloadLink: '/download/attachments/9/report #3.pdf?version=1&api=v2',
},
]),
);
await runGetMany({ download: true });
expect(binaryRequest).toHaveBeenCalledWith(
'/wiki/download/attachments/9/report%20%233.pdf?version=1&api=v2',
);
});
it('downloads only the retained attachments after truncation, each into its own binary', async () => {
apiRequest.mockResolvedValue(
attachmentPage([
{ id: 'a1', downloadLink: '/download/a1' },
{ id: 'a2', downloadLink: '/download/a2' },
{ id: 'a3' },
]),
);
binaryRequest.mockImplementation(async (endpoint) => Buffer.from(endpoint));
const result = await runGetMany({ download: true, limit: 2 });
expect(binaryRequest).toHaveBeenCalledTimes(2);
expect(binaryRequest).toHaveBeenNthCalledWith(1, '/wiki/download/a1');
expect(binaryRequest).toHaveBeenNthCalledWith(2, '/wiki/download/a2');
expect(result).toHaveLength(2);
expect(result[0].binary?.data.data).toBe(Buffer.from('/wiki/download/a1').toString('base64'));
expect(result[1].binary?.data.data).toBe(Buffer.from('/wiki/download/a2').toString('base64'));
});
it('keeps decrementing the page limit while following cursors under a finite limit', async () => {
const fullPage = Array.from({ length: 250 }, (_, i) => ({ id: `r${i}` }));
apiRequest
.mockResolvedValueOnce(attachmentPage(fullPage, `${ENDPOINT}?cursor=c1`))
.mockResolvedValueOnce(
attachmentPage(Array.from({ length: 50 }, (_, i) => ({ id: `s${i}` }))),
);
const result = await runGetMany({ limit: 300 });
expect(apiRequest).toHaveBeenCalledTimes(2);
expect(apiRequest).toHaveBeenNthCalledWith(2, 'GET', ENDPOINT, {}, { limit: 50, cursor: 'c1' });
expect(result).toHaveLength(300);
});
it('puts the file in a custom binary field', async () => {
apiRequest.mockResolvedValue(
attachmentPage([{ id: 'a1', downloadLink: '/download/attachments/9/a' }]),
);
const result = await runGetMany({ download: true, binaryPropertyName: 'file' });
expect(result[0].binary).toHaveProperty('file');
expect(result[0].binary).not.toHaveProperty('data');
});
it('throws when an attachment has no usable download link', async () => {
apiRequest.mockResolvedValue(attachmentPage([{ id: 'a1', title: 'notes.txt' }]));
const promise = runGetMany({ download: true });
await expect(promise).rejects.toThrow(NodeOperationError);
await expect(promise).rejects.toThrow('"notes.txt" has no usable download link');
expect(binaryRequest).not.toHaveBeenCalled();
});
it('rejects a download link that is not server-relative', async () => {
apiRequest.mockResolvedValue(
attachmentPage([
{ id: 'a1', title: 'notes.txt', downloadLink: 'https://elsewhere.example/f.txt' },
]),
);
const promise = runGetMany({ download: true });
await expect(promise).rejects.toThrow('"notes.txt" has no usable download link');
expect(binaryRequest).not.toHaveBeenCalled();
});
});
@@ -49,6 +49,28 @@ describe('Confluence router', () => {
]);
});
it('dispatches attachment:getMany and pairs the emitted items', async () => {
apiRequest.mockResolvedValue({ results: [{ id: 'a1', title: 'notes.txt' }] });
const result = await router.call(
mockExecuteCtx({
resource: 'attachment',
operation: 'getMany',
page: { mode: 'id', value: '9' },
returnAll: true,
download: false,
}),
);
expect(apiRequest).toHaveBeenCalledWith(
'GET',
'/wiki/api/v2/pages/9/attachments',
{},
{ limit: 250 },
);
expect(result).toEqual([[{ json: { id: 'a1', title: 'notes.txt' }, pairedItem: { item: 0 } }]]);
});
it('dispatches page:delete and returns the deletion report', async () => {
const result = await router.call(
mockExecuteCtx({
@@ -1,22 +1,58 @@
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import type { IExecuteFunctions, INode, JsonObject } from 'n8n-workflow';
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
import type { Mock, Mocked } from 'vitest';
import { mockDeep } from 'vitest-mock-extended';
import { clearAtlassianCloudIdCache } from '@utils/atlassian';
import { confluenceApiRequest } from '../../transport';
import { confluenceApiRequest, confluenceApiRequestBinary } from '../../transport';
const accessibleResources = [
{ id: 'cloud-1', url: 'https://example.atlassian.net', name: 'example' },
{ id: 'cloud-2', url: 'https://Other.Atlassian.NET' },
];
const pageNotFoundResponse = {
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',
},
],
},
},
};
describe('confluenceApiRequest', () => {
let ctx: Mocked<IExecuteFunctions>;
let mockNode: INode;
let mockHttpRequestWithAuthentication: Mock;
async function captureRejection(endpoint: string): Promise<NodeApiError | null> {
return await confluenceApiRequest
.call(ctx, 'GET', endpoint)
.then(() => null)
.catch((thrown: NodeApiError) => thrown);
}
function failNextRequest(error: unknown): void {
mockHttpRequestWithAuthentication
.mockResolvedValueOnce(accessibleResources)
.mockRejectedValueOnce(error);
}
function failNextRequestWrapped(payload: JsonObject): NodeApiError {
const wrapped = new NodeApiError(mockNode, payload);
failNextRequest(wrapped);
return wrapped;
}
beforeEach(() => {
vi.clearAllMocks();
clearAtlassianCloudIdCache();
@@ -94,14 +130,9 @@ describe('confluenceApiRequest', () => {
});
it('wraps request failures in NodeApiError, keeping status and message', async () => {
mockHttpRequestWithAuthentication
.mockResolvedValueOnce(accessibleResources)
.mockRejectedValueOnce({ message: 'boom', response: { status: 403 } });
failNextRequest({ message: 'boom', response: { status: 403 } });
const error = await confluenceApiRequest
.call(ctx, 'GET', '/wiki/api/v2/pages')
.then(() => null)
.catch((thrown: NodeApiError) => thrown);
const error = await captureRejection('/wiki/api/v2/pages');
expect(error).toBeInstanceOf(NodeApiError);
expect(error?.httpCode).toBe('403');
@@ -109,29 +140,9 @@ describe('confluenceApiRequest', () => {
});
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',
},
],
},
},
});
failNextRequest(pageNotFoundResponse);
const error = await confluenceApiRequest
.call(ctx, 'GET', '/wiki/api/v2/pages/1')
.then(() => null)
.catch((thrown: NodeApiError) => thrown);
const error = await captureRejection('/wiki/api/v2/pages/1');
expect(error).toBeInstanceOf(NodeApiError);
expect(error?.message).toBe('Page not found');
@@ -139,23 +150,65 @@ describe('confluenceApiRequest', () => {
});
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: '' }] } },
});
failNextRequest({
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);
const error = await captureRejection('/wiki/api/v2/pages');
expect(error).toBeInstanceOf(NodeApiError);
expect(error?.httpCode).toBe('500');
expect(error?.messages).toContain('boom');
});
// In production the request helper rejects with an already-wrapped NodeApiError; these cases pin that path
it('surfaces the v1 top-level message from a wrapped NodeApiError', async () => {
const wrapped = failNextRequestWrapped({
message: 'Request failed with status code 400',
response: {
status: 400,
data: {
statusCode: 400,
data: { authorized: true, valid: false, errors: [], successful: false },
message: 'Could not parse cql : expecting alphanumeric',
},
},
} as JsonObject);
const error = await captureRejection('/wiki/rest/api/search');
expect(error).toBeInstanceOf(NodeApiError);
expect(error).not.toBe(wrapped);
expect(error?.message).toBe('Could not parse cql : expecting alphanumeric');
expect(error?.httpCode).toBe('400');
expect(error?.context.data).toEqual({
statusCode: 400,
data: { authorized: true, valid: false, errors: [], successful: false },
message: 'Could not parse cql : expecting alphanumeric',
});
});
it("surfaces Atlassian's v2 envelope from a wrapped NodeApiError", async () => {
const wrapped = failNextRequestWrapped(pageNotFoundResponse as JsonObject);
const error = await captureRejection('/wiki/api/v2/pages/1');
expect(error).toBeInstanceOf(NodeApiError);
expect(error).not.toBe(wrapped);
expect(error?.message).toBe('Page not found');
expect(error?.description).toBe('No page with this ID exists');
expect(error?.httpCode).toBe('404');
});
it('rethrows a wrapped NodeApiError unchanged when there is no response body', async () => {
const wrapped = failNextRequestWrapped({ message: 'socket hang up' } as JsonObject);
const error = await captureRejection('/wiki/api/v2/pages');
expect(error).toBe(wrapped);
});
it('surfaces the cloudId lookup error when no site matches', async () => {
ctx.getCredentials.mockResolvedValue({ domain: 'https://missing.atlassian.net' });
@@ -174,3 +227,123 @@ describe('confluenceApiRequest', () => {
expect(mockHttpRequestWithAuthentication).not.toHaveBeenCalled();
});
});
describe('confluenceApiRequestBinary', () => {
let ctx: Mocked<IExecuteFunctions>;
let mockHttpRequestWithAuthentication: Mock;
beforeEach(() => {
vi.clearAllMocks();
clearAtlassianCloudIdCache();
ctx = mockDeep<IExecuteFunctions>();
mockHttpRequestWithAuthentication = vi.fn().mockResolvedValue(accessibleResources);
ctx.helpers.httpRequestWithAuthentication = mockHttpRequestWithAuthentication;
ctx.getNode.mockReturnValue({
id: 'test-node',
name: 'Test Confluence Node',
type: 'n8n-nodes-base.confluence',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
ctx.getCredentials.mockResolvedValue({ domain: 'https://example.atlassian.net/wiki' });
});
it('fetches the endpoint through the gateway as a Buffer', async () => {
const bytes = Buffer.from('file-bytes');
mockHttpRequestWithAuthentication
.mockResolvedValueOnce(accessibleResources)
.mockResolvedValueOnce(bytes);
const data = await confluenceApiRequestBinary.call(ctx, '/wiki/download/attachments/9/a.txt');
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
2,
'confluenceCloudOAuth2Api',
expect.objectContaining({
method: 'GET',
url: 'https://api.atlassian.com/ex/confluence/cloud-1/wiki/download/attachments/9/a.txt',
encoding: 'arraybuffer',
sendCredentialsOnCrossOriginRedirect: false,
}),
);
expect(data).toBe(bytes);
});
it('coerces non-Buffer binary responses to a Buffer', async () => {
mockHttpRequestWithAuthentication
.mockResolvedValueOnce(accessibleResources)
.mockResolvedValueOnce('plain-text-body');
const data = await confluenceApiRequestBinary.call(ctx, '/wiki/download/attachments/9/a.txt');
expect(Buffer.isBuffer(data)).toBe(true);
expect(data.toString()).toBe('plain-text-body');
});
it('coerces an ArrayBuffer response to a Buffer', async () => {
mockHttpRequestWithAuthentication
.mockResolvedValueOnce(accessibleResources)
.mockResolvedValueOnce(new TextEncoder().encode('ab-bytes').buffer);
const data = await confluenceApiRequestBinary.call(ctx, '/wiki/download/attachments/9/a.txt');
expect(Buffer.isBuffer(data)).toBe(true);
expect(data.toString()).toBe('ab-bytes');
});
it('rejects an unusable binary response with a NodeOperationError', async () => {
mockHttpRequestWithAuthentication
.mockResolvedValueOnce(accessibleResources)
.mockResolvedValueOnce({ unexpected: true });
await expect(
confluenceApiRequestBinary.call(ctx, '/wiki/download/attachments/9/a.txt'),
).rejects.toThrow('Confluence returned an unexpected binary response');
});
it('wraps request failures in NodeApiError, keeping the status', async () => {
mockHttpRequestWithAuthentication
.mockResolvedValueOnce(accessibleResources)
.mockRejectedValueOnce({ message: 'boom', response: { status: 404 } });
const error = await confluenceApiRequestBinary
.call(ctx, '/wiki/download/attachments/9/a.txt')
.then(() => null)
.catch((thrown: NodeApiError) => thrown);
expect(error).toBeInstanceOf(NodeApiError);
expect(error?.httpCode).toBe('404');
});
it("surfaces Atlassian's v2 error envelope on download failures", async () => {
mockHttpRequestWithAuthentication
.mockResolvedValueOnce(accessibleResources)
.mockRejectedValueOnce({
message: 'Request failed with status code 404',
response: {
status: 404,
data: {
errors: [
{
status: 404,
code: 'NOT_FOUND',
title: 'Attachment not found',
detail: 'No attachment with this ID exists',
},
],
},
},
});
const error = await confluenceApiRequestBinary
.call(ctx, '/wiki/download/attachments/9/a.txt')
.then(() => null)
.catch((thrown: NodeApiError) => thrown);
expect(error).toBeInstanceOf(NodeApiError);
expect(error?.message).toBe('Attachment not found');
expect(error?.description).toBe('No attachment with this ID exists');
expect(error?.httpCode).toBe('404');
});
});
@@ -12,6 +12,75 @@ import { getAtlassianApiBaseUrl, getAtlassianCloudId } from '@utils/atlassian';
export const CONFLUENCE_CREDENTIAL_NAME = 'confluenceCloudOAuth2Api';
interface CaughtRequestError {
response?: { status?: unknown; data?: unknown };
}
interface ExtractedApiError {
message: string;
description?: string;
data: IDataObject;
}
function extractApiMessage(body: unknown): ExtractedApiError | undefined {
if (typeof body !== 'object' || body === null) return undefined;
const data = body as IDataObject;
const { errors: v2Errors, message: v1Message } = data as {
errors?: unknown;
message?: unknown;
};
const first = Array.isArray(v2Errors)
? (v2Errors[0] as { title?: unknown; detail?: unknown } | undefined)
: undefined;
if (typeof first?.title === 'string' && first.title !== '') {
return {
message: first.title,
description:
typeof first.detail === 'string' && first.detail !== '' ? first.detail : undefined,
data,
};
}
if (typeof v1Message === 'string' && v1Message !== '') return { message: v1Message, data };
return undefined;
}
// NodeApiError's constructor short-circuits on re-wrap (returns the same
// instance, dropping any option overrides), so enrichment needs a fresh error.
function toConfluenceApiError(
this: IExecuteFunctions | ILoadOptionsFunctions,
error: unknown,
): NodeApiError {
const wrapped = error instanceof NodeApiError ? error : undefined;
const body = wrapped ? wrapped.context.data : (error as CaughtRequestError).response?.data;
const extracted = extractApiMessage(body);
if (extracted !== undefined) {
let httpCode: string | undefined;
if (wrapped) {
httpCode = wrapped.httpCode ?? undefined;
} else {
const status = (error as CaughtRequestError).response?.status;
httpCode =
typeof status === 'number' || typeof status === 'string' ? String(status) : undefined;
}
const sanitizedError: JsonObject = { message: extracted.message };
const fresh = new NodeApiError(this.getNode(), sanitizedError, {
message: extracted.message,
description: extracted.description,
httpCode,
});
// Keep the raw response body visible in the NDV's error-data pane
fresh.context.data = extracted.data;
return fresh;
}
if (wrapped) return wrapped;
return new NodeApiError(this.getNode(), error as JsonObject);
}
export async function confluenceApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
@@ -52,20 +121,56 @@ export async function confluenceApiRequest(
options,
);
} catch (error) {
// 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,
);
throw toConfluenceApiError.call(this, error);
}
}
/**
* Fetches a binary resource (e.g. an attachment's server-relative `downloadLink`)
* through the gateway and returns its raw bytes. Same base-URL concatenation rule
* as `confluenceApiRequest`: the endpoint can never change the host.
*/
export async function confluenceApiRequestBinary(
this: IExecuteFunctions,
endpoint: string,
): Promise<Buffer> {
const credentials = await this.getCredentials(CONFLUENCE_CREDENTIAL_NAME);
const siteUrl = credentials.domain;
if (typeof siteUrl !== 'string' || siteUrl === '') {
throw new NodeOperationError(
this.getNode(),
'The Confluence credential is missing the Site URL field',
);
}
const cloudId = await getAtlassianCloudId.call(
this,
CONFLUENCE_CREDENTIAL_NAME,
siteUrl,
'confluence',
);
// Downloads 302 to the Atlassian media host, which authenticates the hop via its
// own signed token in the redirect URL; the OAuth header must not follow cross-origin.
const options: IHttpRequestOptions = {
method: 'GET',
url: `${getAtlassianApiBaseUrl('confluence', cloudId)}${endpoint}`,
encoding: 'arraybuffer',
sendCredentialsOnCrossOriginRedirect: false,
};
let data: unknown;
try {
data = await this.helpers.httpRequestWithAuthentication.call(
this,
CONFLUENCE_CREDENTIAL_NAME,
options,
);
} catch (error) {
throw toConfluenceApiError.call(this, error);
}
if (Buffer.isBuffer(data)) return data;
if (data instanceof ArrayBuffer) return Buffer.from(data);
if (typeof data === 'string') return Buffer.from(data);
throw new NodeOperationError(this.getNode(), 'Confluence returned an unexpected binary response');
}