feat(Confluence Node): Add Delete Page operation (no-changelog) (#36685)

This commit is contained in:
Ilfat Mindubaev
2026-08-20 12:07:04 +00:00
committed by GitHub
parent 33bb45358e
commit 4544cedde2
6 changed files with 337 additions and 2 deletions
@@ -0,0 +1,97 @@
import type { IExecuteFunctions, INodeProperties } from 'n8n-workflow';
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
import { confluenceApiRequest } from '../../transport';
import { optionalSpaceRLC, pageRLC, resolvePageId } from '../common';
import type { ConfluenceOperation } from '../router';
const showOnDelete = { resource: ['page'], operation: ['delete'] };
export const description: INodeProperties[] = [
{
displayName:
"Deleting a page does not delete its child pages — they move up to the deleted page's parent",
name: 'deleteChildrenNotice',
type: 'notice',
default: '',
displayOptions: { show: showOnDelete },
},
{
...optionalSpaceRLC,
description:
'Limits page selection and By Title lookups to one space. Leave empty or pick "All Spaces" to search across all spaces.',
displayOptions: { show: showOnDelete },
},
{
...pageRLC,
description: 'The page to delete',
displayOptions: { show: showOnDelete },
},
{
displayName: 'Permanently Delete (Purge)',
name: 'purge',
type: 'boolean',
default: false,
description:
"Whether to permanently delete the page instead of moving it to trash. This cannot be undone and requires admin permission in the page's space.",
displayOptions: { show: showOnDelete },
},
];
export const execute: ConfluenceOperation = async function (
this: IExecuteFunctions,
itemIndex: number,
) {
const purge = this.getNodeParameter('purge', itemIndex, false) as boolean;
const pageId = await resolvePageId.call(this, itemIndex);
const endpoint = `/wiki/api/v2/pages/${encodeURIComponent(pageId)}`;
try {
await confluenceApiRequest.call(this, 'DELETE', endpoint);
} catch (error) {
// The OAuth scope is only the ceiling — deleting also needs the space-level permission
if (error instanceof NodeApiError && error.httpCode === '403') {
throw new NodeOperationError(this.getNode(), 'Confluence refused to delete the page', {
itemIndex,
description:
'The connected user needs the "Delete pages" permission in the page\'s space; no OAuth scope change can grant it.',
});
}
const notFound = error instanceof NodeApiError && error.httpCode === '404';
// Confluence masks permission failures on this endpoint as 404
if (notFound && !purge) {
throw new NodeOperationError(this.getNode(), 'Confluence could not delete the page', {
itemIndex,
description:
'The page may not exist or may already be in the trash, the connected user may lack view or "Delete pages" permission in the page\'s space (Confluence reports permission failures as "not found"), or the page is an unsaved draft.',
});
}
// A page that is already in the trash 404s on the plain DELETE; when purging,
// continue so the purge request still runs (a missing page 404s again there)
if (!notFound) {
throw error;
}
}
// The API only purges pages that are already trashed, so purge is a second request
if (purge) {
try {
await confluenceApiRequest.call(this, 'DELETE', endpoint, {}, { purge: true });
} catch (error) {
if (error instanceof NodeApiError && error.httpCode === '403') {
throw new NodeOperationError(
this.getNode(),
'The page was moved to trash, but could not be purged',
{
itemIndex,
description:
'Permanently deleting a page requires admin permission in its space. The page remains in the trash and can be restored from the Confluence UI.',
},
);
}
throw error;
}
}
return { deleted: true, pageId, purged: purge };
};
@@ -2,10 +2,11 @@ import type { INodeProperties } from 'n8n-workflow';
import * as append from './append.operation';
import * as create from './create.operation';
import * as del from './delete.operation';
import * as get from './get.operation';
import * as update from './update.operation';
export { append, create, get, update };
export { append, create, del as delete, get, update };
export const description: INodeProperties[] = [
{
@@ -31,6 +32,12 @@ export const description: INodeProperties[] = [
description: 'Create a new page in a space',
action: 'Create a page',
},
{
name: 'Delete',
value: 'delete',
description: 'Move a page to trash, or permanently delete it',
action: 'Delete a page',
},
{
name: 'Get',
value: 'get',
@@ -48,6 +55,7 @@ export const description: INodeProperties[] = [
},
...append.description,
...create.description,
...del.description,
...get.description,
...update.description,
];
@@ -30,6 +30,9 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
case 'page:create':
responseData = await page.create.execute.call(this, i);
break;
case 'page:delete':
responseData = await page.delete.execute.call(this, i);
break;
case 'page:get':
responseData = await page.get.execute.call(this, i);
break;
@@ -13,7 +13,7 @@ describe('Confluence Node', () => {
expect(node.description.usableAsTool).toBeUndefined();
});
it('should expose the page resource with the append, create, get and update operations', () => {
it('should expose the page resource with the append, create, delete, get and update operations', () => {
const resource = node.description.properties.find((p) => p.name === 'resource');
expect(resource?.options).toEqual([expect.objectContaining({ value: 'page' })]);
@@ -21,6 +21,7 @@ describe('Confluence Node', () => {
expect(operation?.options).toEqual([
expect.objectContaining({ value: 'append' }),
expect.objectContaining({ value: 'create' }),
expect.objectContaining({ value: 'delete' }),
expect.objectContaining({ value: 'get' }),
expect.objectContaining({ value: 'update' }),
]);
@@ -0,0 +1,210 @@
import type {
IExecuteFunctions,
IGetNodeParameterOptions,
INode,
INodeParameterResourceLocator,
} from 'n8n-workflow';
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
import { mockDeep } from 'vitest-mock-extended';
import { execute } from '../../../actions/page/delete.operation';
import { confluenceApiRequest } from '../../../transport';
vi.mock('../../../transport', () => ({
CONFLUENCE_CREDENTIAL_NAME: 'confluenceCloudOAuth2Api',
confluenceApiRequest: vi.fn(),
}));
const apiRequest = vi.mocked(confluenceApiRequest);
const mockNode: INode = {
id: 'test-node',
name: 'Test Confluence Node',
type: 'n8n-nodes-base.confluence',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
function createContext(params: Record<string, unknown>) {
const ctx = mockDeep<IExecuteFunctions>();
ctx.getNode.mockReturnValue(mockNode);
ctx.getNodeParameter.mockImplementation(
(name: string, _itemIndex?: number, fallback?: unknown, options?: IGetNodeParameterOptions) => {
if (name === 'page' && options?.extractValue === true) {
const page = params.page as INodeParameterResourceLocator;
return (params.pageExtracted ?? page.value) as never;
}
return (params[name] ?? fallback) as never;
},
);
return ctx;
}
function forbidden(): NodeApiError {
return new NodeApiError(mockNode, { message: 'Forbidden' }, { httpCode: '403' });
}
describe('Confluence page:delete operation', () => {
beforeEach(() => {
vi.clearAllMocks();
apiRequest.mockResolvedValue({});
});
it('moves a page to trash with a single delete request by default', async () => {
const ctx = createContext({ page: { mode: 'id', value: '123' }, purge: false });
const result = await execute.call(ctx, 0);
expect(apiRequest).toHaveBeenCalledTimes(1);
expect(apiRequest).toHaveBeenCalledWith('DELETE', '/wiki/api/v2/pages/123');
expect(result).toEqual({ deleted: true, pageId: '123', purged: false });
});
it('purges through the trash-then-purge two-step', async () => {
const ctx = createContext({ page: { mode: 'id', value: '123' }, purge: true });
const result = await execute.call(ctx, 0);
expect(apiRequest).toHaveBeenCalledTimes(2);
expect(apiRequest).toHaveBeenNthCalledWith(1, 'DELETE', '/wiki/api/v2/pages/123');
expect(apiRequest).toHaveBeenNthCalledWith(
2,
'DELETE',
'/wiki/api/v2/pages/123',
{},
{ purge: true },
);
expect(result).toEqual({ deleted: true, pageId: '123', purged: true });
});
it('deletes a page by URL through the extracted ID', async () => {
const ctx = createContext({
page: {
mode: 'url',
value: 'https://example.atlassian.net/wiki/spaces/DOCS/pages/456/My+Page',
},
pageExtracted: '456',
});
await execute.call(ctx, 0);
expect(ctx.getNodeParameter).toHaveBeenCalledWith('page', 0, '', { extractValue: true });
expect(apiRequest).toHaveBeenCalledWith('DELETE', '/wiki/api/v2/pages/456');
});
it('resolves a By Title selection to its page ID before deleting', async () => {
apiRequest.mockResolvedValueOnce({ results: [{ id: '777', title: 'Doc', spaceId: '1' }] });
const ctx = createContext({ page: { mode: 'title', value: 'Doc' } });
const result = await execute.call(ctx, 0);
expect(apiRequest).toHaveBeenNthCalledWith(
1,
'GET',
'/wiki/api/v2/pages',
{},
{ title: 'Doc', limit: 250 },
);
expect(apiRequest).toHaveBeenNthCalledWith(2, 'DELETE', '/wiki/api/v2/pages/777');
expect(result).toEqual({ deleted: true, pageId: '777', purged: false });
});
it('still purges a page that is already in the trash', async () => {
apiRequest
.mockRejectedValueOnce(
new NodeApiError(mockNode, { message: 'Not found' }, { httpCode: '404' }),
)
.mockResolvedValueOnce({});
const ctx = createContext({ page: { mode: 'id', value: '123' }, purge: true });
const result = await execute.call(ctx, 0);
expect(apiRequest).toHaveBeenCalledTimes(2);
expect(apiRequest).toHaveBeenNthCalledWith(
2,
'DELETE',
'/wiki/api/v2/pages/123',
{},
{ purge: true },
);
expect(result).toEqual({ deleted: true, pageId: '123', purged: true });
});
it('surfaces the purge-step error when a purged page does not exist at all', async () => {
const notFound = () =>
new NodeApiError(mockNode, { message: 'Not found' }, { httpCode: '404' });
apiRequest.mockRejectedValueOnce(notFound());
const purgeError = notFound();
apiRequest.mockRejectedValueOnce(purgeError);
const ctx = createContext({ page: { mode: 'id', value: '123' }, purge: true });
await expect(execute.call(ctx, 0)).rejects.toBe(purgeError);
expect(apiRequest).toHaveBeenCalledTimes(2);
});
it('lists the masked-permission causes when a plain delete is not found', async () => {
apiRequest.mockRejectedValueOnce(
new NodeApiError(mockNode, { message: 'Not found' }, { httpCode: '404' }),
);
const ctx = createContext({ page: { mode: 'id', value: '123' }, purge: false });
const promise = execute.call(ctx, 0);
await expect(promise).rejects.toThrow(NodeOperationError);
await expect(promise).rejects.toThrow('Confluence could not delete the page');
await expect(promise).rejects.toMatchObject({
description: expect.stringContaining('Confluence reports permission failures as "not found"'),
});
});
it('does not attempt the purge when the trash step fails', async () => {
apiRequest.mockRejectedValueOnce(new Error('boom'));
const ctx = createContext({ page: { mode: 'id', value: '123' }, purge: true });
await expect(execute.call(ctx, 0)).rejects.toThrow('boom');
expect(apiRequest).toHaveBeenCalledTimes(1);
});
it('hints at the space "Delete pages" permission when the trash step is forbidden', async () => {
apiRequest.mockRejectedValueOnce(forbidden());
const ctx = createContext({ page: { mode: 'id', value: '123' }, purge: false });
const promise = execute.call(ctx, 0);
await expect(promise).rejects.toThrow(NodeOperationError);
await expect(promise).rejects.toThrow('Confluence refused to delete the page');
await expect(promise).rejects.toMatchObject({
description: expect.stringContaining('"Delete pages" permission'),
});
});
it('reports the page as trashed-but-restorable when the purge step is forbidden', async () => {
apiRequest.mockResolvedValueOnce({}).mockRejectedValueOnce(forbidden());
const ctx = createContext({ page: { mode: 'id', value: '123' }, purge: true });
const promise = execute.call(ctx, 0);
await expect(promise).rejects.toThrow(NodeOperationError);
await expect(promise).rejects.toThrow('The page was moved to trash, but could not be purged');
await expect(promise).rejects.toMatchObject({
description: expect.stringContaining('admin permission'),
});
});
it('rethrows non-403 API errors untouched', async () => {
const serverError = new NodeApiError(mockNode, { message: 'oops' }, { httpCode: '500' });
apiRequest.mockRejectedValueOnce(serverError);
const ctx = createContext({ page: { mode: 'id', value: '123' } });
await expect(execute.call(ctx, 0)).rejects.toBe(serverError);
});
it('throws when the page reference is empty', async () => {
const ctx = createContext({ page: { mode: 'id', value: ' ' } });
await expect(execute.call(ctx, 0)).rejects.toThrow(NodeOperationError);
await expect(execute.call(ctx, 0)).rejects.toThrow("The 'Page' parameter is empty");
expect(apiRequest).not.toHaveBeenCalled();
});
});
@@ -49,6 +49,22 @@ describe('Confluence router', () => {
]);
});
it('dispatches page:delete and returns the deletion report', async () => {
const result = await router.call(
mockExecuteCtx({
resource: 'page',
operation: 'delete',
page: { mode: 'id', value: '1' },
purge: false,
}),
);
expect(apiRequest).toHaveBeenCalledWith('DELETE', '/wiki/api/v2/pages/1');
expect(result).toEqual([
[{ json: { deleted: true, pageId: '1', purged: false }, pairedItem: { item: 0 } }],
]);
});
it('dispatches page:get and returns the fetched page', async () => {
const result = await router.call(mockExecuteCtx(getParams));