feat(Bitbucket Trigger Node): Access token credentials (#20912)

This commit is contained in:
Michael Kret 2025-10-28 11:13:30 +02:00 committed by GitHub
parent e181c48d6a
commit 6ec2c820f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1324 additions and 20 deletions

View File

@ -0,0 +1,52 @@
import type {
ICredentialDataDecryptedObject,
ICredentialTestRequest,
ICredentialType,
IHttpRequestOptions,
INodeProperties,
} from 'n8n-workflow';
export class BitbucketAccessTokenApi implements ICredentialType {
name = 'bitbucketAccessTokenApi';
displayName = 'Bitbucket Access Token API';
documentationUrl = 'bitbuckettokenapi';
properties: INodeProperties[] = [
{
displayName: 'Email',
name: 'email',
type: 'string',
default: '',
},
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string',
typeOptions: { password: true },
default: '',
},
];
async authenticate(
credentials: ICredentialDataDecryptedObject,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
const encodedApiKey = Buffer.from(`${credentials.email}:${credentials.accessToken}`).toString(
'base64',
);
if (!requestOptions.headers) {
requestOptions.headers = {};
}
requestOptions.headers.Authorization = `Basic ${encodedApiKey}`;
return requestOptions;
}
test: ICredentialTestRequest = {
request: {
baseURL: 'https://api.bitbucket.org/2.0',
url: '/user',
},
};
}

View File

@ -22,7 +22,8 @@ export class BitbucketTrigger implements INodeType {
name: 'bitbucketTrigger',
icon: 'file:bitbucket.svg',
group: ['trigger'],
version: 1,
version: [1, 1.1],
defaultVersion: 1.1,
description: 'Handle Bitbucket events via webhooks',
defaults: {
name: 'Bitbucket Trigger',
@ -34,6 +35,20 @@ export class BitbucketTrigger implements INodeType {
name: 'bitbucketApi',
required: true,
testedBy: 'bitbucketApiTest',
displayOptions: {
show: {
authentication: ['password'],
},
},
},
{
name: 'bitbucketAccessTokenApi',
required: true,
displayOptions: {
show: {
authentication: ['accessToken'],
},
},
},
],
webhooks: [
@ -45,6 +60,48 @@ export class BitbucketTrigger implements INodeType {
},
],
properties: [
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Password (Deprecated)',
value: 'password',
},
{
name: 'Access Token',
value: 'accessToken',
},
],
default: 'password',
displayOptions: {
show: {
'@version': [1],
},
},
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'options',
options: [
{
name: 'Password (Deprecated)',
value: 'password',
},
{
name: 'Access Token',
value: 'accessToken',
},
],
default: 'accessToken',
displayOptions: {
show: {
'@version': [1.1],
},
},
},
{
displayName: 'Resource',
name: 'resource',

View File

@ -6,6 +6,7 @@ import type {
JsonObject,
IHttpRequestMethods,
IRequestOptions,
IHttpRequestOptions,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
@ -13,30 +14,50 @@ export async function bitbucketApiRequest(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
resource: string,
body: any = {},
qs: IDataObject = {},
uri?: string,
option: IDataObject = {},
): Promise<any> {
const credentials = await this.getCredentials('bitbucketApi');
let options: IRequestOptions = {
method,
auth: {
user: credentials.username as string,
password: credentials.appPassword as string,
},
qs,
body,
uri: uri || `https://api.bitbucket.org/2.0${resource}`,
json: true,
};
options = Object.assign({}, options, option);
if (Object.keys(options.body as IDataObject).length === 0) {
delete options.body;
}
try {
const authentication = this.getNodeParameter('authentication', 0) as 'password' | 'accessToken';
if (authentication === 'accessToken') {
const httpRequestOptions: IHttpRequestOptions = {
method,
qs,
body,
url: uri || `https://api.bitbucket.org/2.0${resource}`,
json: true,
};
if (Object.keys(httpRequestOptions.body as IDataObject).length === 0) {
delete httpRequestOptions.body;
}
return await this.helpers.httpRequestWithAuthentication.call(
this,
'bitbucketAccessTokenApi',
httpRequestOptions,
);
}
const credentials = await this.getCredentials('bitbucketApi');
const options: IRequestOptions = {
method,
auth: {
user: credentials.username as string,
password: credentials.appPassword as string,
},
qs,
body,
uri: uri || `https://api.bitbucket.org/2.0${resource}`,
json: true,
};
if (Object.keys(options.body as IDataObject).length === 0) {
delete options.body;
}
return await this.helpers.request(options);
} catch (error) {
throw new NodeApiError(this.getNode(), error as JsonObject);

View File

@ -0,0 +1,670 @@
import { mockDeep } from 'jest-mock-extended';
import type {
ICredentialsDecrypted,
ICredentialTestFunctions,
IHookFunctions,
ILoadOptionsFunctions,
INode,
IWebhookFunctions,
} from 'n8n-workflow';
import { BitbucketTrigger } from '../BitbucketTrigger.node';
import * as GenericFunctions from '../GenericFunctions';
describe('BitbucketTrigger', () => {
let bitbucketTrigger: BitbucketTrigger;
const bitbucketApiRequestSpy = jest.spyOn(GenericFunctions, 'bitbucketApiRequest');
const bitbucketApiRequestAllItemsSpy = jest.spyOn(
GenericFunctions,
'bitbucketApiRequestAllItems',
);
const mockNode: INode = {
id: 'test-node-id',
name: 'Bitbucket Trigger Test',
type: 'n8n-nodes-base.bitbucketTrigger',
typeVersion: 1.1,
position: [0, 0],
parameters: {},
};
beforeEach(() => {
jest.resetAllMocks();
bitbucketTrigger = new BitbucketTrigger();
});
describe('credential test', () => {
const mockCredentialTestFunctions = mockDeep<ICredentialTestFunctions>();
beforeEach(() => {
mockCredentialTestFunctions.helpers.request.mockClear();
});
it('should return success for valid credentials', async () => {
const mockCredentials: ICredentialsDecrypted = {
id: 'test-cred-id',
name: 'Test Bitbucket Credentials',
type: 'bitbucketApi',
data: {
username: 'testuser',
appPassword: 'testpassword',
},
};
const mockResponse = {
username: 'testuser',
display_name: 'Test User',
};
mockCredentialTestFunctions.helpers.request.mockResolvedValue(mockResponse);
const result = await bitbucketTrigger.methods.credentialTest.bitbucketApiTest.call(
mockCredentialTestFunctions,
mockCredentials,
);
expect(mockCredentialTestFunctions.helpers.request).toHaveBeenCalledWith({
method: 'GET',
auth: {
user: 'testuser',
password: 'testpassword',
},
uri: 'https://api.bitbucket.org/2.0/user',
json: true,
timeout: 5000,
});
expect(result).toEqual({
status: 'OK',
message: 'Authentication successful!',
});
});
it('should return error for invalid credentials', async () => {
const mockCredentials: ICredentialsDecrypted = {
id: 'test-cred-id',
name: 'Test Bitbucket Credentials',
type: 'bitbucketApi',
data: {
username: 'testuser',
appPassword: 'wrongpassword',
},
};
const mockResponse = {
error: 'Invalid credentials',
};
mockCredentialTestFunctions.helpers.request.mockResolvedValue(mockResponse);
const result = await bitbucketTrigger.methods.credentialTest.bitbucketApiTest.call(
mockCredentialTestFunctions,
mockCredentials,
);
expect(result).toEqual({
status: 'Error',
message: 'Token is not valid: Invalid credentials',
});
});
it('should return error when request fails', async () => {
const mockCredentials: ICredentialsDecrypted = {
id: 'test-cred-id',
name: 'Test Bitbucket Credentials',
type: 'bitbucketApi',
data: {
username: 'testuser',
appPassword: 'testpassword',
},
};
const mockError = new Error('Network error');
mockCredentialTestFunctions.helpers.request.mockRejectedValue(mockError);
const result = await bitbucketTrigger.methods.credentialTest.bitbucketApiTest.call(
mockCredentialTestFunctions,
mockCredentials,
);
expect(result).toEqual({
status: 'Error',
message: 'Settings are not valid: Error: Network error',
});
});
});
describe('load options methods', () => {
const mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
beforeEach(() => {
mockLoadOptionsFunctions.getNode.mockReturnValue(mockNode);
});
describe('getWorkspaceEvents', () => {
it('should return workspace events', async () => {
const mockEvents = [
{
event: 'repo:push',
description: 'Repository push',
},
{
event: 'repo:fork',
description: 'Repository fork',
},
];
bitbucketApiRequestAllItemsSpy.mockResolvedValue(mockEvents);
const result =
await bitbucketTrigger.methods.loadOptions.getWorkspaceEvents.call(
mockLoadOptionsFunctions,
);
expect(bitbucketApiRequestAllItemsSpy).toHaveBeenCalledWith(
'values',
'GET',
'/hook_events/workspace',
);
expect(result).toEqual([
{
name: 'repo:push',
value: 'repo:push',
description: 'Repository push',
},
{
name: 'repo:fork',
value: 'repo:fork',
description: 'Repository fork',
},
]);
});
it('should handle empty events list', async () => {
bitbucketApiRequestAllItemsSpy.mockResolvedValue([]);
const result =
await bitbucketTrigger.methods.loadOptions.getWorkspaceEvents.call(
mockLoadOptionsFunctions,
);
expect(result).toEqual([]);
});
});
describe('getRepositoriesEvents', () => {
it('should return repository events', async () => {
const mockEvents = [
{
event: 'repo:push',
description: 'Repository push',
},
{
event: 'pullrequest:created',
description: 'Pull request created',
},
];
bitbucketApiRequestAllItemsSpy.mockResolvedValue(mockEvents);
const result =
await bitbucketTrigger.methods.loadOptions.getRepositoriesEvents.call(
mockLoadOptionsFunctions,
);
expect(bitbucketApiRequestAllItemsSpy).toHaveBeenCalledWith(
'values',
'GET',
'/hook_events/repository',
);
expect(result).toEqual([
{
name: 'repo:push',
value: 'repo:push',
description: 'Repository push',
},
{
name: 'pullrequest:created',
value: 'pullrequest:created',
description: 'Pull request created',
},
]);
});
});
describe('getRepositories', () => {
it('should return repositories for workspace', async () => {
const mockRepositories = [
{
slug: 'repo1',
description: 'First repository',
},
{
slug: 'repo2',
description: 'Second repository',
},
];
mockLoadOptionsFunctions.getCurrentNodeParameter.mockReturnValue('test-workspace');
bitbucketApiRequestAllItemsSpy.mockResolvedValue(mockRepositories);
const result =
await bitbucketTrigger.methods.loadOptions.getRepositories.call(mockLoadOptionsFunctions);
expect(mockLoadOptionsFunctions.getCurrentNodeParameter).toHaveBeenCalledWith('workspace');
expect(bitbucketApiRequestAllItemsSpy).toHaveBeenCalledWith(
'values',
'GET',
'/repositories/test-workspace',
);
expect(result).toEqual([
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'repo1',
value: 'repo1',
description: 'First repository',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'repo2',
value: 'repo2',
description: 'Second repository',
},
]);
});
});
describe('getWorkspaces', () => {
it('should return workspaces', async () => {
const mockWorkspaces = [
{
name: 'Workspace 1',
slug: 'workspace1',
},
{
name: 'Workspace 2',
slug: 'workspace2',
},
];
bitbucketApiRequestAllItemsSpy.mockResolvedValue(mockWorkspaces);
const result =
await bitbucketTrigger.methods.loadOptions.getWorkspaces.call(mockLoadOptionsFunctions);
expect(bitbucketApiRequestAllItemsSpy).toHaveBeenCalledWith('values', 'GET', '/workspaces');
expect(result).toEqual([
{
name: 'Workspace 1',
value: 'workspace1',
},
{
name: 'Workspace 2',
value: 'workspace2',
},
]);
});
});
});
describe('webhook methods', () => {
const mockHookFunctions = mockDeep<IHookFunctions>();
beforeEach(() => {
mockHookFunctions.getNode.mockReturnValue(mockNode);
mockHookFunctions.getNodeWebhookUrl.mockReturnValue('https://test.n8n.io/webhook/test');
mockHookFunctions.getWorkflowStaticData.mockReturnValue({});
});
describe('checkExists', () => {
it('should return true if webhook exists for workspace', async () => {
const mockHooks = {
values: [
{
uuid: '{12345678-1234-1234-1234-123456789012}',
url: 'https://test.n8n.io/webhook/test',
active: true,
},
{
uuid: '{87654321-4321-4321-4321-210987654321}',
url: 'https://other.webhook.url',
active: true,
},
],
};
mockHookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'resource') return 'workspace';
if (paramName === 'workspace') return 'test-workspace';
return undefined;
});
bitbucketApiRequestSpy.mockResolvedValue(mockHooks);
const result =
await bitbucketTrigger.webhookMethods.default.checkExists.call(mockHookFunctions);
expect(bitbucketApiRequestSpy).toHaveBeenCalledWith(
'GET',
'/workspaces/test-workspace/hooks',
);
expect(result).toBe(true);
// Check that webhook ID is stored
const staticData = mockHookFunctions.getWorkflowStaticData('node');
expect(staticData.webhookId).toBe('12345678-1234-1234-1234-123456789012');
});
it('should return true if webhook exists for repository', async () => {
const mockHooks = {
values: [
{
uuid: '{12345678-1234-1234-1234-123456789012}',
url: 'https://test.n8n.io/webhook/test',
active: true,
},
],
};
mockHookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'resource') return 'repository';
if (paramName === 'workspace') return 'test-workspace';
if (paramName === 'repository') return 'test-repo';
return undefined;
});
bitbucketApiRequestSpy.mockResolvedValue(mockHooks);
const result =
await bitbucketTrigger.webhookMethods.default.checkExists.call(mockHookFunctions);
expect(bitbucketApiRequestSpy).toHaveBeenCalledWith(
'GET',
'/repositories/test-workspace/test-repo/hooks',
);
expect(result).toBe(true);
});
it('should return false if webhook does not exist', async () => {
const mockHooks = {
values: [
{
uuid: '{87654321-4321-4321-4321-210987654321}',
url: 'https://other.webhook.url',
active: true,
},
],
};
mockHookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'resource') return 'workspace';
if (paramName === 'workspace') return 'test-workspace';
return undefined;
});
bitbucketApiRequestSpy.mockResolvedValue(mockHooks);
const result =
await bitbucketTrigger.webhookMethods.default.checkExists.call(mockHookFunctions);
expect(result).toBe(false);
});
it('should return false if webhook exists but is inactive', async () => {
const mockHooks = {
values: [
{
uuid: '{12345678-1234-1234-1234-123456789012}',
url: 'https://test.n8n.io/webhook/test',
active: false,
},
],
};
mockHookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'resource') return 'workspace';
if (paramName === 'workspace') return 'test-workspace';
return undefined;
});
bitbucketApiRequestSpy.mockResolvedValue(mockHooks);
const result =
await bitbucketTrigger.webhookMethods.default.checkExists.call(mockHookFunctions);
expect(result).toBe(false);
});
});
describe('create', () => {
it('should create webhook for workspace', async () => {
const mockResponse = {
uuid: '{12345678-1234-1234-1234-123456789012}',
url: 'https://test.n8n.io/webhook/test',
active: true,
};
mockHookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'resource') return 'workspace';
if (paramName === 'workspace') return 'test-workspace';
if (paramName === 'events') return ['repo:push', 'repo:fork'];
return undefined;
});
bitbucketApiRequestSpy.mockResolvedValue(mockResponse);
const result = await bitbucketTrigger.webhookMethods.default.create.call(mockHookFunctions);
expect(bitbucketApiRequestSpy).toHaveBeenCalledWith(
'POST',
'/workspaces/test-workspace/hooks',
{
description: 'n8n webhook',
url: 'https://test.n8n.io/webhook/test',
active: true,
events: ['repo:push', 'repo:fork'],
},
);
expect(result).toBe(true);
// Check that webhook ID is stored
const staticData = mockHookFunctions.getWorkflowStaticData('node');
expect(staticData.webhookId).toBe('12345678-1234-1234-1234-123456789012');
});
it('should create webhook for repository', async () => {
const mockResponse = {
uuid: '{12345678-1234-1234-1234-123456789012}',
url: 'https://test.n8n.io/webhook/test',
active: true,
};
mockHookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'resource') return 'repository';
if (paramName === 'workspace') return 'test-workspace';
if (paramName === 'repository') return 'test-repo';
if (paramName === 'events') return ['pullrequest:created'];
return undefined;
});
bitbucketApiRequestSpy.mockResolvedValue(mockResponse);
const result = await bitbucketTrigger.webhookMethods.default.create.call(mockHookFunctions);
expect(bitbucketApiRequestSpy).toHaveBeenCalledWith(
'POST',
'/repositories/test-workspace/test-repo/hooks',
{
description: 'n8n webhook',
url: 'https://test.n8n.io/webhook/test',
active: true,
events: ['pullrequest:created'],
},
);
expect(result).toBe(true);
});
});
describe('delete', () => {
it('should delete webhook for workspace', async () => {
const staticData = { webhookId: '12345678-1234-1234-1234-123456789012' };
mockHookFunctions.getWorkflowStaticData.mockReturnValue(staticData);
mockHookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'resource') return 'workspace';
if (paramName === 'workspace') return 'test-workspace';
return undefined;
});
bitbucketApiRequestSpy.mockResolvedValue({});
const result = await bitbucketTrigger.webhookMethods.default.delete.call(mockHookFunctions);
expect(bitbucketApiRequestSpy).toHaveBeenCalledWith(
'DELETE',
'/workspaces/test-workspace/hooks/12345678-1234-1234-1234-123456789012',
);
expect(result).toBe(true);
expect(staticData.webhookId).toBeUndefined();
});
it('should delete webhook for repository', async () => {
const staticData = { webhookId: '12345678-1234-1234-1234-123456789012' };
mockHookFunctions.getWorkflowStaticData.mockReturnValue(staticData);
mockHookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'resource') return 'repository';
if (paramName === 'workspace') return 'test-workspace';
if (paramName === 'repository') return 'test-repo';
return undefined;
});
bitbucketApiRequestSpy.mockResolvedValue({});
const result = await bitbucketTrigger.webhookMethods.default.delete.call(mockHookFunctions);
expect(bitbucketApiRequestSpy).toHaveBeenCalledWith(
'DELETE',
'/repositories/test-workspace/test-repo/hooks/12345678-1234-1234-1234-123456789012',
);
expect(result).toBe(true);
expect(staticData.webhookId).toBeUndefined();
});
it('should return false if delete fails', async () => {
const staticData = { webhookId: '12345678-1234-1234-1234-123456789012' };
mockHookFunctions.getWorkflowStaticData.mockReturnValue(staticData);
mockHookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'resource') return 'workspace';
if (paramName === 'workspace') return 'test-workspace';
return undefined;
});
bitbucketApiRequestSpy.mockRejectedValue(new Error('Delete failed'));
const result = await bitbucketTrigger.webhookMethods.default.delete.call(mockHookFunctions);
expect(result).toBe(false);
// Webhook ID should still be present since delete failed
expect(staticData.webhookId).toBe('12345678-1234-1234-1234-123456789012');
});
});
});
describe('webhook', () => {
const mockWebhookFunctions = mockDeep<IWebhookFunctions>();
beforeEach(() => {
mockWebhookFunctions.getNode.mockReturnValue(mockNode);
});
it('should process webhook with matching UUID', async () => {
const webhookId = '12345678-1234-1234-1234-123456789012';
const staticData = { webhookId };
const mockRequestBody = {
repository: {
name: 'test-repo',
},
push: {
changes: [
{
new: {
name: 'main',
},
},
],
},
};
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue(staticData);
mockWebhookFunctions.getRequestObject.mockReturnValue({
body: mockRequestBody,
} as any);
mockWebhookFunctions.getHeaderData.mockReturnValue({
'x-hook-uuid': webhookId,
});
mockWebhookFunctions.helpers.returnJsonArray.mockReturnValue([{ json: mockRequestBody }]);
const result = await bitbucketTrigger.webhook.call(mockWebhookFunctions);
expect(result).toEqual({
workflowData: [[{ json: mockRequestBody }]],
});
});
it('should return empty object for non-matching UUID', async () => {
const webhookId = '12345678-1234-1234-1234-123456789012';
const staticData = { webhookId };
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue(staticData);
mockWebhookFunctions.getHeaderData.mockReturnValue({
'x-hook-uuid': 'different-uuid',
});
const result = await bitbucketTrigger.webhook.call(mockWebhookFunctions);
expect(result).toEqual({});
});
it('should handle array request body', async () => {
const webhookId = '12345678-1234-1234-1234-123456789012';
const staticData = { webhookId };
const mockRequestBody = [
{
repository: { name: 'repo1' },
},
{
repository: { name: 'repo2' },
},
];
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue(staticData);
mockWebhookFunctions.getRequestObject.mockReturnValue({
body: mockRequestBody,
} as any);
mockWebhookFunctions.getHeaderData.mockReturnValue({
'x-hook-uuid': webhookId,
});
mockWebhookFunctions.helpers.returnJsonArray.mockReturnValue([
{ json: mockRequestBody[0] },
{ json: mockRequestBody[1] },
]);
const result = await bitbucketTrigger.webhook.call(mockWebhookFunctions);
expect(mockWebhookFunctions.helpers.returnJsonArray).toHaveBeenCalledWith(mockRequestBody);
expect(result).toEqual({
workflowData: [[{ json: mockRequestBody[0] }, { json: mockRequestBody[1] }]],
});
});
});
});

View File

@ -0,0 +1,503 @@
import { mockDeep } from 'jest-mock-extended';
import type {
IExecuteFunctions,
IHookFunctions,
ILoadOptionsFunctions,
INode,
IHttpRequestOptions,
IRequestOptions,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { bitbucketApiRequest, bitbucketApiRequestAllItems } from '../GenericFunctions';
describe('Bitbucket GenericFunctions', () => {
describe('bitbucketApiRequest', () => {
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
const mockHookFunctions = mockDeep<IHookFunctions>();
const mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
const mockNode: INode = {
id: 'test-node-id',
name: 'Bitbucket Test',
type: 'n8n-nodes-base.bitbucket',
typeVersion: 1.1,
position: [0, 0],
parameters: {},
};
beforeEach(() => {
jest.resetAllMocks();
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockHookFunctions.getNode.mockReturnValue(mockNode);
mockLoadOptionsFunctions.getNode.mockReturnValue(mockNode);
});
describe('with access token authentication', () => {
beforeEach(() => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('accessToken');
mockHookFunctions.getNodeParameter.mockReturnValue('accessToken');
mockLoadOptionsFunctions.getNodeParameter.mockReturnValue('accessToken');
});
it('should make successful API request with access token', async () => {
const mockResponse = { data: 'test response' };
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue(mockResponse);
const result = await bitbucketApiRequest.call(
mockExecuteFunctions,
'GET',
'/repositories',
{},
{},
);
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
'bitbucketAccessTokenApi',
{
method: 'GET',
qs: {},
url: 'https://api.bitbucket.org/2.0/repositories',
json: true,
},
);
expect(result).toEqual(mockResponse);
});
it('should handle custom URI with access token', async () => {
const mockResponse = { data: 'test response' };
const customUri = 'https://custom.api.bitbucket.org/2.0/custom';
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue(mockResponse);
await bitbucketApiRequest.call(
mockExecuteFunctions,
'POST',
'/repositories',
{ name: 'test' },
{ page: 1 },
customUri,
);
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
'bitbucketAccessTokenApi',
{
method: 'POST',
qs: { page: 1 },
body: { name: 'test' },
url: customUri,
json: true,
},
);
});
it('should remove empty body with access token', async () => {
const mockResponse = { data: 'test response' };
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue(mockResponse);
await bitbucketApiRequest.call(mockExecuteFunctions, 'GET', '/repositories');
const expectedOptions: IHttpRequestOptions = {
method: 'GET',
qs: {},
url: 'https://api.bitbucket.org/2.0/repositories',
json: true,
};
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
'bitbucketAccessTokenApi',
expectedOptions,
);
});
it('should work with hook functions', async () => {
const mockResponse = { values: [] };
mockHookFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue(mockResponse);
const result = await bitbucketApiRequest.call(mockHookFunctions, 'GET', '/workspaces');
expect(mockHookFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
'bitbucketAccessTokenApi',
{
method: 'GET',
qs: {},
url: 'https://api.bitbucket.org/2.0/workspaces',
json: true,
},
);
expect(result).toEqual(mockResponse);
});
it('should work with load options functions', async () => {
const mockResponse = { values: [] };
mockLoadOptionsFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue(
mockResponse,
);
const result = await bitbucketApiRequest.call(
mockLoadOptionsFunctions,
'GET',
'/hook_events/workspace',
);
expect(mockLoadOptionsFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
'bitbucketAccessTokenApi',
{
method: 'GET',
qs: {},
url: 'https://api.bitbucket.org/2.0/hook_events/workspace',
json: true,
},
);
expect(result).toEqual(mockResponse);
});
});
describe('with password authentication', () => {
beforeEach(() => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('password');
mockHookFunctions.getNodeParameter.mockReturnValue('password');
mockLoadOptionsFunctions.getNodeParameter.mockReturnValue('password');
});
it('should make successful API request with password', async () => {
const mockCredentials = {
username: 'testuser',
appPassword: 'testpassword',
};
const mockResponse = { data: 'test response' };
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockExecuteFunctions.helpers.request.mockResolvedValue(mockResponse);
const result = await bitbucketApiRequest.call(
mockExecuteFunctions,
'GET',
'/repositories',
{},
{},
);
expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith('bitbucketApi');
expect(mockExecuteFunctions.helpers.request).toHaveBeenCalledWith({
method: 'GET',
auth: {
user: 'testuser',
password: 'testpassword',
},
qs: {},
uri: 'https://api.bitbucket.org/2.0/repositories',
json: true,
});
expect(result).toEqual(mockResponse);
});
it('should handle custom URI with password', async () => {
const mockCredentials = {
username: 'testuser',
appPassword: 'testpassword',
};
const mockResponse = { data: 'test response' };
const customUri = 'https://custom.api.bitbucket.org/2.0/custom';
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockExecuteFunctions.helpers.request.mockResolvedValue(mockResponse);
await bitbucketApiRequest.call(
mockExecuteFunctions,
'POST',
'/repositories',
{ name: 'test' },
{ page: 1 },
customUri,
);
expect(mockExecuteFunctions.helpers.request).toHaveBeenCalledWith({
method: 'POST',
auth: {
user: 'testuser',
password: 'testpassword',
},
qs: { page: 1 },
body: { name: 'test' },
uri: customUri,
json: true,
});
});
it('should remove empty body with password', async () => {
const mockCredentials = {
username: 'testuser',
appPassword: 'testpassword',
};
const mockResponse = { data: 'test response' };
mockExecuteFunctions.getCredentials.mockResolvedValue(mockCredentials);
mockExecuteFunctions.helpers.request.mockResolvedValue(mockResponse);
await bitbucketApiRequest.call(mockExecuteFunctions, 'GET', '/repositories');
const expectedOptions: IRequestOptions = {
method: 'GET',
auth: {
user: 'testuser',
password: 'testpassword',
},
qs: {},
uri: 'https://api.bitbucket.org/2.0/repositories',
json: true,
};
expect(mockExecuteFunctions.helpers.request).toHaveBeenCalledWith(expectedOptions);
});
});
describe('error handling', () => {
beforeEach(() => {
mockExecuteFunctions.getNodeParameter.mockReturnValue('accessToken');
});
it('should throw NodeApiError when request fails', async () => {
const mockError = new Error('API request failed');
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockRejectedValue(mockError);
await expect(
bitbucketApiRequest.call(mockExecuteFunctions, 'GET', '/repositories'),
).rejects.toThrow(NodeApiError);
expect(mockExecuteFunctions.getNode).toHaveBeenCalled();
});
it('should preserve original error details in NodeApiError', async () => {
const mockError = {
message: 'Unauthorized',
statusCode: 401,
response: { body: { error: 'Invalid credentials' } },
};
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockRejectedValue(mockError);
await expect(
bitbucketApiRequest.call(mockExecuteFunctions, 'GET', '/repositories'),
).rejects.toThrow(NodeApiError);
});
});
});
describe('bitbucketApiRequestAllItems', () => {
const mockExecuteFunctions = mockDeep<IExecuteFunctions>();
const mockNode: INode = {
id: 'test-node-id',
name: 'Bitbucket Test',
type: 'n8n-nodes-base.bitbucket',
typeVersion: 1.1,
position: [0, 0],
parameters: {},
};
beforeEach(() => {
jest.resetAllMocks();
mockExecuteFunctions.getNode.mockReturnValue(mockNode);
mockExecuteFunctions.getNodeParameter.mockReturnValue('accessToken');
});
it('should handle single page response', async () => {
const mockResponse = {
values: [{ id: 1 }, { id: 2 }],
// no 'next' property means single page
};
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue(mockResponse);
const result = await bitbucketApiRequestAllItems.call(
mockExecuteFunctions,
'values',
'GET',
'/repositories',
);
expect(result).toEqual([{ id: 1 }, { id: 2 }]);
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledTimes(1);
});
it('should handle multiple pages', async () => {
const firstPageResponse = {
values: [{ id: 1 }, { id: 2 }],
next: 'https://api.bitbucket.org/2.0/repositories?page=2',
};
const secondPageResponse = {
values: [{ id: 3 }, { id: 4 }],
next: 'https://api.bitbucket.org/2.0/repositories?page=3',
};
const thirdPageResponse = {
values: [{ id: 5 }],
// no 'next' property means last page
};
mockExecuteFunctions.helpers.httpRequestWithAuthentication
.mockResolvedValueOnce(firstPageResponse)
.mockResolvedValueOnce(secondPageResponse)
.mockResolvedValueOnce(thirdPageResponse);
const result = await bitbucketApiRequestAllItems.call(
mockExecuteFunctions,
'values',
'GET',
'/repositories',
);
expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }]);
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledTimes(3);
// Check that subsequent calls use the 'next' URL
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenNthCalledWith(
2,
'bitbucketAccessTokenApi',
{
method: 'GET',
qs: {},
url: 'https://api.bitbucket.org/2.0/repositories?page=2',
json: true,
},
);
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenNthCalledWith(
3,
'bitbucketAccessTokenApi',
{
method: 'GET',
qs: {},
url: 'https://api.bitbucket.org/2.0/repositories?page=3',
json: true,
},
);
});
it('should handle empty pages', async () => {
const mockResponse = {
values: [],
};
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue(mockResponse);
const result = await bitbucketApiRequestAllItems.call(
mockExecuteFunctions,
'values',
'GET',
'/repositories',
);
expect(result).toEqual([]);
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledTimes(1);
});
it('should pass through body and query parameters', async () => {
const mockResponse = {
values: [{ id: 1 }],
};
const body = { name: 'test-repo' };
const query = { role: 'admin' };
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue(mockResponse);
await bitbucketApiRequestAllItems.call(
mockExecuteFunctions,
'values',
'POST',
'/repositories',
body,
query,
);
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
'bitbucketAccessTokenApi',
{
method: 'POST',
qs: query,
body,
url: 'https://api.bitbucket.org/2.0/repositories',
json: true,
},
);
});
it('should work with different property names', async () => {
const mockResponse = {
items: [{ id: 1 }, { id: 2 }],
};
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue(mockResponse);
const result = await bitbucketApiRequestAllItems.call(
mockExecuteFunctions,
'items',
'GET',
'/custom-endpoint',
);
expect(result).toEqual([{ id: 1 }, { id: 2 }]);
});
it('should handle errors during pagination', async () => {
const firstPageResponse = {
values: [{ id: 1 }],
next: 'https://api.bitbucket.org/2.0/repositories?page=2',
};
const mockError = new Error('Network error');
mockExecuteFunctions.helpers.httpRequestWithAuthentication
.mockResolvedValueOnce(firstPageResponse)
.mockRejectedValueOnce(mockError);
await expect(
bitbucketApiRequestAllItems.call(mockExecuteFunctions, 'values', 'GET', '/repositories'),
).rejects.toThrow(NodeApiError);
});
it('should work with hook functions', async () => {
const mockHookFunctions = mockDeep<IHookFunctions>();
mockHookFunctions.getNode.mockReturnValue(mockNode);
mockHookFunctions.getNodeParameter.mockReturnValue('accessToken');
const mockResponse = {
values: [{ event: 'repo:push' }],
};
mockHookFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue(mockResponse);
const result = await bitbucketApiRequestAllItems.call(
mockHookFunctions,
'values',
'GET',
'/hook_events/repository',
);
expect(result).toEqual([{ event: 'repo:push' }]);
});
it('should work with load options functions', async () => {
const mockLoadOptionsFunctions = mockDeep<ILoadOptionsFunctions>();
mockLoadOptionsFunctions.getNode.mockReturnValue(mockNode);
mockLoadOptionsFunctions.getNodeParameter.mockReturnValue('accessToken');
const mockResponse = {
values: [{ slug: 'workspace1' }],
};
mockLoadOptionsFunctions.helpers.httpRequestWithAuthentication.mockResolvedValue(
mockResponse,
);
const result = await bitbucketApiRequestAllItems.call(
mockLoadOptionsFunctions,
'values',
'GET',
'/workspaces',
);
expect(result).toEqual([{ slug: 'workspace1' }]);
});
});
});

View File

@ -49,6 +49,7 @@
"dist/credentials/BaserowApi.credentials.js",
"dist/credentials/BeeminderApi.credentials.js",
"dist/credentials/BeeminderOAuth2Api.credentials.js",
"dist/credentials/BitbucketAccessTokenApi.credentials.js",
"dist/credentials/BitbucketApi.credentials.js",
"dist/credentials/BitlyApi.credentials.js",
"dist/credentials/BitlyOAuth2Api.credentials.js",