mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(Confluence Node): Add Confluence Cloud OAuth2 credential (#35732)
This commit is contained in:
parent
5e2779e0be
commit
f9fd8a83e4
|
|
@ -106,6 +106,7 @@ export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [
|
|||
'oAuth2Api',
|
||||
'googleOAuth2Api',
|
||||
'microsoftOAuth2Api',
|
||||
'atlassianOAuth2Api',
|
||||
'highLevelOAuth2Api',
|
||||
'mcpOAuth2Api',
|
||||
'snowflakeOAuth2Api',
|
||||
|
|
|
|||
|
|
@ -181,7 +181,13 @@ const validateCredential = async (credentialId: string, credentialTypeName: stri
|
|||
// Check if this is an OAuth credential type
|
||||
const credentialType = credentialsStore.getCredentialTypeByName(credentialTypeName);
|
||||
const isOAuth = credentialType?.extends?.some((ext) =>
|
||||
['oAuth2Api', 'oAuth1Api', 'googleOAuth2Api', 'microsoftOAuth2Api'].includes(ext),
|
||||
[
|
||||
'oAuth2Api',
|
||||
'oAuth1Api',
|
||||
'googleOAuth2Api',
|
||||
'microsoftOAuth2Api',
|
||||
'atlassianOAuth2Api',
|
||||
].includes(ext),
|
||||
);
|
||||
|
||||
let isValid = false;
|
||||
|
|
|
|||
|
|
@ -390,6 +390,33 @@ describe('useAppCredentials', () => {
|
|||
expect(appEntries.value[0].supportsInstantOAuth).toBe(false);
|
||||
});
|
||||
|
||||
it('should prioritize a vendor-base OAuth2 child over API key credentials', () => {
|
||||
const oAuth2Cred = createMockCredentialType({
|
||||
name: 'jiraSoftwareCloudOAuth2Api',
|
||||
displayName: 'Jira SW Cloud OAuth2',
|
||||
extends: ['atlassianOAuth2Api'],
|
||||
});
|
||||
const apiKeyCred = createMockCredentialType({
|
||||
name: 'jiraSoftwareCloudApi',
|
||||
displayName: 'Jira SW Cloud API',
|
||||
});
|
||||
|
||||
credentialsStore.allCredentialTypes = [apiKeyCred, oAuth2Cred];
|
||||
|
||||
nodeTypesStore.visibleNodeTypes = [
|
||||
createMockNodeType({
|
||||
name: 'n8n-nodes-base.jira',
|
||||
displayName: 'Jira Software',
|
||||
credentials: [{ name: 'jiraSoftwareCloudApi' }, { name: 'jiraSoftwareCloudOAuth2Api' }],
|
||||
}),
|
||||
];
|
||||
|
||||
const { appEntries } = useAppCredentials();
|
||||
|
||||
expect(appEntries.value).toHaveLength(1);
|
||||
expect(appEntries.value[0].credentialType?.name).toBe('jiraSoftwareCloudOAuth2Api');
|
||||
});
|
||||
|
||||
it('should mark supportsInstantOAuth as false for API key credentials', () => {
|
||||
const apiKeyCred = createMockCredentialType({
|
||||
name: 'stripeApi',
|
||||
|
|
|
|||
|
|
@ -164,16 +164,22 @@ export function useAppCredentials() {
|
|||
// Ensure community node previews are fetched
|
||||
void nodeTypesStore.fetchCommunityNodePreviews();
|
||||
|
||||
const isOAuthCredentialType = (credType: ICredentialType): boolean => {
|
||||
const isOAuth2CredentialType = (credType: ICredentialType): boolean => {
|
||||
if (!credType.extends) return false;
|
||||
return (
|
||||
credType.extends.includes('oAuth2Api') ||
|
||||
credType.extends.includes('oAuth1Api') ||
|
||||
credType.extends.includes('googleOAuth2Api') ||
|
||||
credType.extends.includes('microsoftOAuth2Api')
|
||||
credType.extends.includes('microsoftOAuth2Api') ||
|
||||
credType.extends.includes('atlassianOAuth2Api')
|
||||
);
|
||||
};
|
||||
|
||||
const isOAuth1CredentialType = (credType: ICredentialType): boolean =>
|
||||
credType.extends?.includes('oAuth1Api') ?? false;
|
||||
|
||||
const isOAuthCredentialType = (credType: ICredentialType): boolean =>
|
||||
isOAuth2CredentialType(credType) || isOAuth1CredentialType(credType);
|
||||
|
||||
const isGoogleOAuthType = (credType: ICredentialType): boolean => {
|
||||
if (!credType.extends) return false;
|
||||
return credType.extends.includes('googleOAuth2Api');
|
||||
|
|
@ -211,8 +217,8 @@ export function useAppCredentials() {
|
|||
const getCredentialPriority = (credType: ICredentialType): number => {
|
||||
const isGoogle = isGoogleOAuthType(credType);
|
||||
const isMicrosoft = isMicrosoftOAuthType(credType);
|
||||
const isOAuth2 = credType.extends?.includes('oAuth2Api');
|
||||
const isOAuth1 = credType.extends?.includes('oAuth1Api');
|
||||
const isOAuth2 = isOAuth2CredentialType(credType);
|
||||
const isOAuth1 = isOAuth1CredentialType(credType);
|
||||
const hasInstant = hasInstantOAuth(credType);
|
||||
|
||||
// Google/Microsoft OAuth with native sign-in buttons are highest priority
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import type { Icon, ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class AtlassianOAuth2Api implements ICredentialType {
|
||||
name = 'atlassianOAuth2Api';
|
||||
|
||||
extends = ['oAuth2Api'];
|
||||
|
||||
icon: Icon = 'file:icons/Atlassian.svg';
|
||||
|
||||
displayName = 'Atlassian OAuth2 API';
|
||||
|
||||
documentationUrl = 'atlassian';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
// Named `domain` so existing Jira OAuth2 credentials, which store the value
|
||||
// under this key, keep working.
|
||||
displayName: 'Site URL',
|
||||
name: 'domain',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'https://your-site.atlassian.net',
|
||||
required: true,
|
||||
description:
|
||||
'The URL of your Atlassian site, e.g. https://your-site.atlassian.net. The scheme and any path (like /wiki) are ignored.',
|
||||
},
|
||||
{
|
||||
displayName: 'Grant Type',
|
||||
name: 'grantType',
|
||||
type: 'hidden',
|
||||
default: 'authorizationCode',
|
||||
},
|
||||
{
|
||||
displayName: 'Authorization URL',
|
||||
name: 'authUrl',
|
||||
type: 'hidden',
|
||||
default: 'https://auth.atlassian.com/authorize',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Access Token URL',
|
||||
name: 'accessTokenUrl',
|
||||
type: 'hidden',
|
||||
default: 'https://auth.atlassian.com/oauth/token',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
// `prompt=consent` forces the Atlassian consent screen (which shows the
|
||||
// active account and offers a switch) instead of a silent re-auth.
|
||||
displayName: 'Auth URI Query Parameters',
|
||||
name: 'authQueryParameters',
|
||||
type: 'hidden',
|
||||
default: 'audience=api.atlassian.com&prompt=consent',
|
||||
},
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'hidden',
|
||||
default: 'header',
|
||||
},
|
||||
// The `scope` field inherited from `oAuth2Api` stays visible and user-defined;
|
||||
// product credentials override it with their own defaults.
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
import { confluenceDefaultScopes } from './common/atlassian-scopes';
|
||||
|
||||
const defaultScopes = confluenceDefaultScopes;
|
||||
|
||||
export class ConfluenceCloudOAuth2Api implements ICredentialType {
|
||||
name = 'confluenceCloudOAuth2Api';
|
||||
|
||||
extends = ['atlassianOAuth2Api'];
|
||||
|
||||
displayName = 'Confluence Cloud OAuth2 API';
|
||||
|
||||
documentationUrl = 'confluence';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Custom Scopes',
|
||||
name: 'customScopes',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Define custom scopes',
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'The default scopes needed for the node to work are already set. If you change these the node may not function correctly.',
|
||||
name: 'customScopesNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
customScopes: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Enabled Scopes',
|
||||
name: 'enabledScopes',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
customScopes: [true],
|
||||
},
|
||||
},
|
||||
default: defaultScopes.join(' '),
|
||||
description: 'Scopes that should be enabled',
|
||||
},
|
||||
{
|
||||
displayName: 'Scope',
|
||||
name: 'scope',
|
||||
type: 'hidden',
|
||||
default:
|
||||
'={{$self["customScopes"] ? $self["enabledScopes"] : "' + defaultScopes.join(' ') + '"}}',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -1,64 +1,19 @@
|
|||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
const defaultScopes = [
|
||||
'read:jira-user',
|
||||
'read:jira-work',
|
||||
'write:jira-work',
|
||||
'manage:jira-webhook',
|
||||
'manage:jira-user',
|
||||
'offline_access',
|
||||
];
|
||||
import { jiraDefaultScopes } from './common/atlassian-scopes';
|
||||
|
||||
const defaultScopes = jiraDefaultScopes;
|
||||
|
||||
export class JiraSoftwareCloudOAuth2Api implements ICredentialType {
|
||||
name = 'jiraSoftwareCloudOAuth2Api';
|
||||
|
||||
extends = ['oAuth2Api'];
|
||||
extends = ['atlassianOAuth2Api'];
|
||||
|
||||
displayName = 'Jira SW Cloud OAuth2 API';
|
||||
|
||||
documentationUrl = 'jira';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Domain',
|
||||
name: 'domain',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'https://example.atlassian.net',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Grant Type',
|
||||
name: 'grantType',
|
||||
type: 'hidden',
|
||||
default: 'authorizationCode',
|
||||
},
|
||||
{
|
||||
displayName: 'Authorization URL',
|
||||
name: 'authUrl',
|
||||
type: 'hidden',
|
||||
default: 'https://auth.atlassian.com/authorize',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Access Token URL',
|
||||
name: 'accessTokenUrl',
|
||||
type: 'hidden',
|
||||
default: 'https://auth.atlassian.com/oauth/token',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Auth URI Query Parameters',
|
||||
name: 'authQueryParameters',
|
||||
type: 'hidden',
|
||||
default: 'audience=api.atlassian.com&prompt=consent',
|
||||
},
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
type: 'hidden',
|
||||
default: 'header',
|
||||
},
|
||||
{
|
||||
displayName: 'Custom Scopes',
|
||||
name: 'customScopes',
|
||||
|
|
|
|||
39
packages/nodes-base/credentials/common/atlassian-scopes.ts
Normal file
39
packages/nodes-base/credentials/common/atlassian-scopes.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Default OAuth2 scopes for the Atlassian product credentials — only scopes the
|
||||
* nodes actually use, since Atlassian rejects any requested scope that isn't
|
||||
* enabled on the OAuth app.
|
||||
*
|
||||
* https://developer.atlassian.com/cloud/jira/platform/scopes-for-oauth-2-3LO-and-forge-apps/
|
||||
* https://developer.atlassian.com/cloud/confluence/scopes-for-oauth-2-3LO-and-forge-apps/
|
||||
*/
|
||||
export const offlineAccessScope = 'offline_access';
|
||||
|
||||
// Omits the previously shipped `manage:jira-user`: retired from Atlassian's catalog
|
||||
// and silently dropped at consent, so no token ever carried it.
|
||||
export const jiraDefaultScopes = [
|
||||
'read:jira-user',
|
||||
'read:jira-work',
|
||||
'write:jira-work',
|
||||
'manage:jira-webhook',
|
||||
offlineAccessScope,
|
||||
];
|
||||
|
||||
// Granular scopes only: the Confluence v2 REST API rejects classic scopes, and the
|
||||
// v1 endpoints the node still uses accept granular ones too.
|
||||
export const confluenceDefaultScopes = [
|
||||
'read:page:confluence',
|
||||
'write:page:confluence',
|
||||
'read:hierarchical-content:confluence',
|
||||
'read:space:confluence',
|
||||
'read:attachment:confluence',
|
||||
'read:comment:confluence',
|
||||
'read:label:confluence',
|
||||
'read:content-details:confluence',
|
||||
'write:attachment:confluence',
|
||||
'delete:attachment:confluence',
|
||||
'write:comment:confluence',
|
||||
'delete:comment:confluence',
|
||||
'write:label:confluence',
|
||||
'delete:page:confluence',
|
||||
offlineAccessScope,
|
||||
];
|
||||
1
packages/nodes-base/credentials/icons/Atlassian.svg
Normal file
1
packages/nodes-base/credentials/icons/Atlassian.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg fill="#0052CC" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Atlassian</title><path d="M7.12 11.084a.683.683 0 00-1.16.126L.075 22.974a.703.703 0 00.63 1.018h8.19a.678.678 0 00.63-.39c1.767-3.65.696-9.203-2.406-12.52zM11.434.386a15.515 15.515 0 00-.906 15.317l3.95 7.9a.703.703 0 00.628.388h8.19a.703.703 0 00.63-1.017L12.63.38a.664.664 0 00-1.196.006z"/></svg>
|
||||
|
After Width: | Height: | Size: 393 B |
|
|
@ -0,0 +1,54 @@
|
|||
import type { INodeProperties } from 'n8n-workflow';
|
||||
import { NodeHelpers } from 'n8n-workflow';
|
||||
|
||||
import { AtlassianOAuth2Api } from '../AtlassianOAuth2Api.credentials';
|
||||
import { OAuth2Api } from '../OAuth2Api.credentials';
|
||||
|
||||
describe('AtlassianOAuth2Api Credential', () => {
|
||||
const atlassianOAuth2Api = new AtlassianOAuth2Api();
|
||||
|
||||
it('should have correct credential metadata', () => {
|
||||
expect(atlassianOAuth2Api.name).toBe('atlassianOAuth2Api');
|
||||
expect(atlassianOAuth2Api.extends).toEqual(['oAuth2Api']);
|
||||
|
||||
const domainProperty = atlassianOAuth2Api.properties.find((p) => p.name === 'domain');
|
||||
expect(domainProperty?.displayName).toBe('Site URL');
|
||||
expect(domainProperty?.required).toBe(true);
|
||||
expect(domainProperty?.placeholder).toBe('https://your-site.atlassian.net');
|
||||
|
||||
const authUrlProperty = atlassianOAuth2Api.properties.find((p) => p.name === 'authUrl');
|
||||
expect(authUrlProperty?.default).toBe('https://auth.atlassian.com/authorize');
|
||||
|
||||
const accessTokenUrlProperty = atlassianOAuth2Api.properties.find(
|
||||
(p) => p.name === 'accessTokenUrl',
|
||||
);
|
||||
expect(accessTokenUrlProperty?.default).toBe('https://auth.atlassian.com/oauth/token');
|
||||
|
||||
const authQueryParamsProperty = atlassianOAuth2Api.properties.find(
|
||||
(p) => p.name === 'authQueryParameters',
|
||||
);
|
||||
expect(authQueryParamsProperty?.default).toBe('audience=api.atlassian.com&prompt=consent');
|
||||
|
||||
const authenticationProperty = atlassianOAuth2Api.properties.find(
|
||||
(p) => p.name === 'authentication',
|
||||
);
|
||||
expect(authenticationProperty?.default).toBe('header');
|
||||
|
||||
const grantTypeProperty = atlassianOAuth2Api.properties.find((p) => p.name === 'grantType');
|
||||
expect(grantTypeProperty?.default).toBe('authorizationCode');
|
||||
});
|
||||
|
||||
it('should leave the inherited scope field visible and editable', () => {
|
||||
expect(atlassianOAuth2Api.properties.find((p) => p.name === 'scope')).toBeUndefined();
|
||||
expect(atlassianOAuth2Api.properties.find((p) => p.name === 'customScopes')).toBeUndefined();
|
||||
expect(atlassianOAuth2Api.properties.find((p) => p.name === 'enabledScopes')).toBeUndefined();
|
||||
|
||||
const resolved: INodeProperties[] = [];
|
||||
NodeHelpers.mergeNodeProperties(resolved, new OAuth2Api().properties);
|
||||
NodeHelpers.mergeNodeProperties(resolved, atlassianOAuth2Api.properties);
|
||||
|
||||
const scope = resolved.find((p) => p.name === 'scope');
|
||||
expect(scope?.type).toBe('string');
|
||||
expect(scope?.default).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
import { NodeHelpers } from 'n8n-workflow';
|
||||
import nock from 'nock';
|
||||
|
||||
import { AtlassianOAuth2Api } from '../AtlassianOAuth2Api.credentials';
|
||||
import { ConfluenceCloudOAuth2Api } from '../ConfluenceCloudOAuth2Api.credentials';
|
||||
import { OAuth2Api } from '../OAuth2Api.credentials';
|
||||
|
||||
describe('ConfluenceCloudOAuth2Api Credential', () => {
|
||||
const confluenceOAuth2Api = new ConfluenceCloudOAuth2Api();
|
||||
const defaultScopes = (
|
||||
confluenceOAuth2Api.properties.find((p) => p.name === 'enabledScopes')?.default as string
|
||||
).split(' ');
|
||||
|
||||
// Endpoints derive from the resolved extends chain so base-credential regressions fail here
|
||||
const resolvedProperties: INodeProperties[] = [];
|
||||
NodeHelpers.mergeNodeProperties(resolvedProperties, new OAuth2Api().properties);
|
||||
NodeHelpers.mergeNodeProperties(resolvedProperties, new AtlassianOAuth2Api().properties);
|
||||
NodeHelpers.mergeNodeProperties(resolvedProperties, confluenceOAuth2Api.properties);
|
||||
const resolvedDefault = (name: string) =>
|
||||
resolvedProperties.find((p) => p.name === name)?.default as string;
|
||||
|
||||
const authorizationUri = resolvedDefault('authUrl');
|
||||
const accessTokenUri = resolvedDefault('accessTokenUrl');
|
||||
const baseUrl = new URL(accessTokenUri).origin;
|
||||
const redirectUri = 'http://localhost:5678/rest/oauth2-credential/callback';
|
||||
const clientId = 'test-client-id';
|
||||
const clientSecret = 'test-client-secret';
|
||||
|
||||
const createOAuthClient = (scopes: string[]) =>
|
||||
new ClientOAuth2({
|
||||
clientId,
|
||||
clientSecret,
|
||||
accessTokenUri,
|
||||
authorizationUri,
|
||||
redirectUri,
|
||||
scopes,
|
||||
});
|
||||
|
||||
const mockTokenEndpoint = (code: string, responseScopes: string[]) => {
|
||||
nock(baseUrl)
|
||||
.post('/oauth/token', (body: Record<string, unknown>) => {
|
||||
return (
|
||||
body.code === code &&
|
||||
body.grant_type === 'authorization_code' &&
|
||||
body.redirect_uri === redirectUri
|
||||
);
|
||||
})
|
||||
.reply(200, {
|
||||
access_token: 'test-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
scope: responseScopes.join(' '),
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('should have correct credential metadata', () => {
|
||||
expect(confluenceOAuth2Api.name).toBe('confluenceCloudOAuth2Api');
|
||||
expect(confluenceOAuth2Api.extends).toEqual(['atlassianOAuth2Api']);
|
||||
|
||||
const pinnedScopes = [
|
||||
'read:page:confluence',
|
||||
'write:page:confluence',
|
||||
'read:hierarchical-content:confluence',
|
||||
'read:space:confluence',
|
||||
'read:attachment:confluence',
|
||||
'read:comment:confluence',
|
||||
'read:label:confluence',
|
||||
'read:content-details:confluence',
|
||||
'write:attachment:confluence',
|
||||
'delete:attachment:confluence',
|
||||
'write:comment:confluence',
|
||||
'delete:comment:confluence',
|
||||
'write:label:confluence',
|
||||
'delete:page:confluence',
|
||||
'offline_access',
|
||||
];
|
||||
expect(defaultScopes).toEqual(pinnedScopes);
|
||||
|
||||
const scopeProperty = confluenceOAuth2Api.properties.find((p) => p.name === 'scope');
|
||||
expect(scopeProperty?.type).toBe('hidden');
|
||||
expect(scopeProperty?.default).toContain(pinnedScopes.join(' '));
|
||||
});
|
||||
|
||||
it('should resolve the extends chain', () => {
|
||||
const byName = (name: string) => resolvedProperties.find((p) => p.name === name);
|
||||
|
||||
const domain = byName('domain');
|
||||
expect(domain?.type).toBe('string');
|
||||
expect(domain?.required).toBe(true);
|
||||
expect(domain?.displayName).toBe('Site URL');
|
||||
|
||||
expect(byName('grantType')?.default).toBe('authorizationCode');
|
||||
expect(byName('authUrl')?.default).toBe('https://auth.atlassian.com/authorize');
|
||||
expect(byName('accessTokenUrl')?.default).toBe('https://auth.atlassian.com/oauth/token');
|
||||
expect(byName('authQueryParameters')?.default).toBe(
|
||||
'audience=api.atlassian.com&prompt=consent',
|
||||
);
|
||||
expect(byName('authentication')?.default).toBe('header');
|
||||
expect(byName('customScopes')?.default).toBe(false);
|
||||
expect(byName('enabledScopes')?.default).toBe(defaultScopes.join(' '));
|
||||
|
||||
const scope = byName('scope');
|
||||
expect(scope?.type).toBe('hidden');
|
||||
expect(scope?.default).toBe(
|
||||
'={{$self["customScopes"] ? $self["enabledScopes"] : "' + defaultScopes.join(' ') + '"}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should inherit the Site URL field from atlassianOAuth2Api', () => {
|
||||
// Defined on the base; the credential must not shadow it
|
||||
expect(confluenceOAuth2Api.properties.find((p) => p.name === 'domain')).toBeUndefined();
|
||||
expect(confluenceOAuth2Api.properties.find((p) => p.name === 'siteUrl')).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('OAuth2 flow with default scopes', () => {
|
||||
it('should include every default scope in the authorization URI', () => {
|
||||
const oauthClient = createOAuthClient(defaultScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
for (const scope of defaultScopes) {
|
||||
expect(authUri).toContain(encodeURIComponent(scope));
|
||||
}
|
||||
expect(authUri).toContain(`client_id=${clientId}`);
|
||||
expect(authUri).toContain('response_type=code');
|
||||
});
|
||||
|
||||
it('should retrieve a token carrying the default scopes', async () => {
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, defaultScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(defaultScopes);
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
|
||||
expect(token.data.scope).toBe(defaultScopes.join(' '));
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth2 flow with custom scopes', () => {
|
||||
const customScopes = [
|
||||
'read:page:confluence',
|
||||
'write:page:confluence',
|
||||
'write:space:confluence',
|
||||
'write:attachment:confluence',
|
||||
'offline_access',
|
||||
];
|
||||
|
||||
it('should include custom scopes in authorization URI', () => {
|
||||
const oauthClient = createOAuthClient(customScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
expect(authUri).toContain('write%3Aspace%3Aconfluence');
|
||||
expect(authUri).toContain('write%3Aattachment%3Aconfluence');
|
||||
});
|
||||
|
||||
it('should retrieve token successfully with custom scopes', async () => {
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, customScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(customScopes);
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
|
||||
expect(token.data.scope).toContain('write:space:confluence');
|
||||
expect(token.data.scope).toContain('write:attachment:confluence');
|
||||
});
|
||||
|
||||
it('should handle minimal custom scopes', async () => {
|
||||
const minimalScopes = ['read:page:confluence', 'offline_access'];
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, minimalScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(minimalScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
expect(authUri).toContain('read%3Apage%3Aconfluence');
|
||||
expect(authUri).toContain('offline_access');
|
||||
expect(authUri).not.toContain('write%3Apage%3Aconfluence');
|
||||
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
|
||||
expect(token.data.scope).toContain('read:page:confluence');
|
||||
expect(token.data.scope).not.toContain('write:page:confluence');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,23 +1,29 @@
|
|||
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
import { NodeHelpers } from 'n8n-workflow';
|
||||
import nock from 'nock';
|
||||
|
||||
import { AtlassianOAuth2Api } from '../AtlassianOAuth2Api.credentials';
|
||||
import { JiraSoftwareCloudOAuth2Api } from '../JiraSoftwareCloudOAuth2Api.credentials';
|
||||
import { OAuth2Api } from '../OAuth2Api.credentials';
|
||||
|
||||
describe('JiraSoftwareCloudOAuth2Api Credential', () => {
|
||||
const jiraOAuth2Api = new JiraSoftwareCloudOAuth2Api();
|
||||
const defaultScopes = [
|
||||
'read:jira-user',
|
||||
'read:jira-work',
|
||||
'write:jira-work',
|
||||
'manage:jira-webhook',
|
||||
'manage:jira-user',
|
||||
'offline_access',
|
||||
];
|
||||
const defaultScopes = (
|
||||
jiraOAuth2Api.properties.find((p) => p.name === 'enabledScopes')?.default as string
|
||||
).split(' ');
|
||||
|
||||
// Shared OAuth2 configuration
|
||||
const baseUrl = 'https://auth.atlassian.com';
|
||||
const authorizationUri = `${baseUrl}/authorize`;
|
||||
const accessTokenUri = `${baseUrl}/oauth/token`;
|
||||
// Endpoints derive from the resolved extends chain so base-credential regressions fail here
|
||||
const resolvedProperties: INodeProperties[] = [];
|
||||
NodeHelpers.mergeNodeProperties(resolvedProperties, new OAuth2Api().properties);
|
||||
NodeHelpers.mergeNodeProperties(resolvedProperties, new AtlassianOAuth2Api().properties);
|
||||
NodeHelpers.mergeNodeProperties(resolvedProperties, jiraOAuth2Api.properties);
|
||||
const resolvedDefault = (name: string) =>
|
||||
resolvedProperties.find((p) => p.name === name)?.default as string;
|
||||
|
||||
const authorizationUri = resolvedDefault('authUrl');
|
||||
const accessTokenUri = resolvedDefault('accessTokenUrl');
|
||||
const baseUrl = new URL(accessTokenUri).origin;
|
||||
const redirectUri = 'http://localhost:5678/rest/oauth2-credential/callback';
|
||||
const clientId = 'test-client-id';
|
||||
const clientSecret = 'test-client-secret';
|
||||
|
|
@ -63,58 +69,67 @@ describe('JiraSoftwareCloudOAuth2Api Credential', () => {
|
|||
|
||||
it('should have correct credential metadata', () => {
|
||||
expect(jiraOAuth2Api.name).toBe('jiraSoftwareCloudOAuth2Api');
|
||||
expect(jiraOAuth2Api.extends).toEqual(['oAuth2Api']);
|
||||
expect(jiraOAuth2Api.extends).toEqual(['atlassianOAuth2Api']);
|
||||
|
||||
const enabledScopesProperty = jiraOAuth2Api.properties.find((p) => p.name === 'enabledScopes');
|
||||
expect(enabledScopesProperty?.default).toBe(
|
||||
'read:jira-user read:jira-work write:jira-work manage:jira-webhook manage:jira-user offline_access',
|
||||
expect(defaultScopes).toEqual([
|
||||
'read:jira-user',
|
||||
'read:jira-work',
|
||||
'write:jira-work',
|
||||
'manage:jira-webhook',
|
||||
'offline_access',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should resolve the extends chain', () => {
|
||||
const resolved: INodeProperties[] = [];
|
||||
NodeHelpers.mergeNodeProperties(resolved, new OAuth2Api().properties);
|
||||
NodeHelpers.mergeNodeProperties(resolved, new AtlassianOAuth2Api().properties);
|
||||
NodeHelpers.mergeNodeProperties(resolved, jiraOAuth2Api.properties);
|
||||
|
||||
const byName = (name: string) => resolved.find((p) => p.name === name);
|
||||
|
||||
const domain = byName('domain');
|
||||
expect(domain?.type).toBe('string');
|
||||
expect(domain?.required).toBe(true);
|
||||
expect(domain?.displayName).toBe('Site URL');
|
||||
|
||||
expect(byName('grantType')?.default).toBe('authorizationCode');
|
||||
expect(byName('authUrl')?.default).toBe('https://auth.atlassian.com/authorize');
|
||||
expect(byName('accessTokenUrl')?.default).toBe('https://auth.atlassian.com/oauth/token');
|
||||
expect(byName('authQueryParameters')?.default).toBe(
|
||||
'audience=api.atlassian.com&prompt=consent',
|
||||
);
|
||||
expect(byName('authentication')?.default).toBe('header');
|
||||
expect(byName('customScopes')?.default).toBe(false);
|
||||
expect(byName('enabledScopes')?.default).toBe(defaultScopes.join(' '));
|
||||
|
||||
// Verify Atlassian-specific OAuth2 endpoints
|
||||
const authUrlProperty = jiraOAuth2Api.properties.find((p) => p.name === 'authUrl');
|
||||
expect(authUrlProperty?.default).toBe('https://auth.atlassian.com/authorize');
|
||||
|
||||
const accessTokenUrlProperty = jiraOAuth2Api.properties.find(
|
||||
(p) => p.name === 'accessTokenUrl',
|
||||
const scope = byName('scope');
|
||||
expect(scope?.type).toBe('hidden');
|
||||
expect(scope?.default).toBe(
|
||||
'={{$self["customScopes"] ? $self["enabledScopes"] : "' + defaultScopes.join(' ') + '"}}',
|
||||
);
|
||||
expect(accessTokenUrlProperty?.default).toBe('https://auth.atlassian.com/oauth/token');
|
||||
|
||||
// Verify audience parameter is set (required for Atlassian OAuth2)
|
||||
const authQueryParamsProperty = jiraOAuth2Api.properties.find(
|
||||
(p) => p.name === 'authQueryParameters',
|
||||
);
|
||||
expect(authQueryParamsProperty?.default).toBe('audience=api.atlassian.com&prompt=consent');
|
||||
});
|
||||
|
||||
describe('OAuth2 flow with default scopes', () => {
|
||||
it('should include default scopes in authorization URI', () => {
|
||||
it('should include every default scope in the authorization URI', () => {
|
||||
const oauthClient = createOAuthClient(defaultScopes);
|
||||
const authUri = oauthClient.code.getUri();
|
||||
|
||||
expect(authUri).toContain('scope=');
|
||||
expect(authUri).toContain('read%3Ajira-user');
|
||||
expect(authUri).toContain('read%3Ajira-work');
|
||||
expect(authUri).toContain('write%3Ajira-work');
|
||||
expect(authUri).toContain('manage%3Ajira-webhook');
|
||||
expect(authUri).toContain('manage%3Ajira-user');
|
||||
expect(authUri).toContain('offline_access');
|
||||
for (const scope of defaultScopes) {
|
||||
expect(authUri).toContain(encodeURIComponent(scope));
|
||||
}
|
||||
expect(authUri).toContain(`client_id=${clientId}`);
|
||||
expect(authUri).toContain('response_type=code');
|
||||
});
|
||||
|
||||
it('should retrieve token successfully with default scopes', async () => {
|
||||
it('should retrieve a token carrying the default scopes', async () => {
|
||||
const code = 'test-auth-code';
|
||||
mockTokenEndpoint(code, defaultScopes);
|
||||
|
||||
const oauthClient = createOAuthClient(defaultScopes);
|
||||
const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`);
|
||||
|
||||
expect(token.data.scope).toContain('read:jira-user');
|
||||
expect(token.data.scope).toContain('read:jira-work');
|
||||
expect(token.data.scope).toContain('write:jira-work');
|
||||
expect(token.data.scope).toContain('manage:jira-webhook');
|
||||
expect(token.data.scope).toContain('manage:jira-user');
|
||||
expect(token.data.scope).toContain('offline_access');
|
||||
expect(token.data.scope).toBe(defaultScopes.join(' '));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,28 +3,23 @@ import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
|||
import type { Mock, Mocked } from 'vitest';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import type { AccessibleResource } from '../../transport';
|
||||
import {
|
||||
clearCloudIdCache,
|
||||
confluenceApiRequest,
|
||||
getConfluenceCloudId,
|
||||
matchSiteByHostname,
|
||||
normalizeSiteUrl,
|
||||
} from '../../transport';
|
||||
import { clearAtlassianCloudIdCache } from '@utils/atlassian';
|
||||
|
||||
const accessibleResources: AccessibleResource[] = [
|
||||
import { confluenceApiRequest } from '../../transport';
|
||||
|
||||
const accessibleResources = [
|
||||
{ id: 'cloud-1', url: 'https://example.atlassian.net', name: 'example' },
|
||||
{ id: 'cloud-2', url: 'https://Other.Atlassian.NET' },
|
||||
];
|
||||
|
||||
describe('Confluence Transport', () => {
|
||||
describe('confluenceApiRequest', () => {
|
||||
let ctx: Mocked<IExecuteFunctions>;
|
||||
let mockNode: INode;
|
||||
let mockHttpRequestWithAuthentication: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearCloudIdCache();
|
||||
clearAtlassianCloudIdCache();
|
||||
ctx = mockDeep<IExecuteFunctions>();
|
||||
mockHttpRequestWithAuthentication = vi.fn().mockResolvedValue(accessibleResources);
|
||||
ctx.helpers.httpRequestWithAuthentication = mockHttpRequestWithAuthentication;
|
||||
|
|
@ -41,238 +36,93 @@ describe('Confluence Transport', () => {
|
|||
ctx.getCredentials.mockResolvedValue({ domain: 'https://example.atlassian.net/wiki' });
|
||||
});
|
||||
|
||||
describe('normalizeSiteUrl', () => {
|
||||
it.each([
|
||||
'example.atlassian.net',
|
||||
'http://example.atlassian.net',
|
||||
'https://example.atlassian.net/wiki',
|
||||
'https://example.atlassian.net/wiki/spaces/ENG/pages/123',
|
||||
'HTTPS://Example.Atlassian.NET/wiki/',
|
||||
' example.atlassian.net ',
|
||||
])('reduces "%s" to the lowercased hostname', (input) => {
|
||||
expect(normalizeSiteUrl(input)).toBe('example.atlassian.net');
|
||||
});
|
||||
it('routes requests to https://api.atlassian.com/ex/confluence/{cloudId}', async () => {
|
||||
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
|
||||
|
||||
it('throws on an unparseable site URL', () => {
|
||||
expect(() => normalizeSiteUrl('not a url')).toThrow();
|
||||
});
|
||||
expect(ctx.getCredentials).toHaveBeenCalledWith('confluenceCloudOAuth2Api');
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(2);
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'confluenceCloudOAuth2Api',
|
||||
expect.objectContaining({
|
||||
url: 'https://api.atlassian.com/oauth/token/accessible-resources',
|
||||
}),
|
||||
);
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'confluenceCloudOAuth2Api',
|
||||
expect.objectContaining({
|
||||
url: 'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/pages',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('matchSiteByHostname', () => {
|
||||
it('matches case-insensitively on both sides', () => {
|
||||
expect(matchSiteByHostname(accessibleResources, 'OTHER.atlassian.net')).toBe(
|
||||
accessibleResources[1],
|
||||
);
|
||||
});
|
||||
it('reuses the cached cloudId across requests', async () => {
|
||||
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
|
||||
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/spaces');
|
||||
|
||||
it('returns undefined when nothing matches', () => {
|
||||
expect(matchSiteByHostname(accessibleResources, 'missing.atlassian.net')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips entries with malformed URLs instead of throwing', () => {
|
||||
const withMalformed: AccessibleResource[] = [
|
||||
{ id: 'bad', url: 'not a url' },
|
||||
...accessibleResources,
|
||||
];
|
||||
expect(matchSiteByHostname(withMalformed, 'example.atlassian.net')).toBe(
|
||||
accessibleResources[0],
|
||||
);
|
||||
});
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(3);
|
||||
const urls = mockHttpRequestWithAuthentication.mock.calls.map(([, options]) => options.url);
|
||||
expect(urls).toEqual([
|
||||
'https://api.atlassian.com/oauth/token/accessible-resources',
|
||||
'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/pages',
|
||||
'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/spaces',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('getConfluenceCloudId', () => {
|
||||
it('resolves the cloudId by case-insensitive hostname match', async () => {
|
||||
const cloudId = await getConfluenceCloudId.call(ctx, 'OTHER.atlassian.net/wiki');
|
||||
it('passes method, body and qs through', async () => {
|
||||
const body = { title: 'New page' };
|
||||
const qs = { limit: 25 };
|
||||
|
||||
expect(cloudId).toBe('cloud-2');
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(1);
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'confluenceCloudOAuth2Api',
|
||||
expect.objectContaining({
|
||||
url: 'https://api.atlassian.com/oauth/token/accessible-resources',
|
||||
}),
|
||||
);
|
||||
});
|
||||
await confluenceApiRequest.call(ctx, 'POST', '/wiki/api/v2/pages', body, qs);
|
||||
|
||||
it('serves the second lookup from the cache without refetching', async () => {
|
||||
const first = await getConfluenceCloudId.call(ctx, 'https://example.atlassian.net/wiki');
|
||||
const second = await getConfluenceCloudId.call(ctx, 'example.atlassian.net');
|
||||
|
||||
expect(first).toBe('cloud-1');
|
||||
expect(second).toBe('cloud-1');
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not share the cache across credentials', async () => {
|
||||
ctx.getNode.mockReturnValue({
|
||||
...mockNode,
|
||||
credentials: { confluenceCloudOAuth2Api: { id: 'cred-a', name: 'A' } },
|
||||
});
|
||||
await getConfluenceCloudId.call(ctx, 'example.atlassian.net');
|
||||
|
||||
ctx.getNode.mockReturnValue({
|
||||
...mockNode,
|
||||
credentials: { confluenceCloudOAuth2Api: { id: 'cred-b', name: 'B' } },
|
||||
});
|
||||
await getConfluenceCloudId.call(ctx, 'example.atlassian.net');
|
||||
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('names the input and lists reachable site URLs when nothing matches', async () => {
|
||||
const promise = getConfluenceCloudId.call(ctx, 'foo.atlassian.net');
|
||||
|
||||
await expect(promise).rejects.toThrow(NodeOperationError);
|
||||
await expect(promise).rejects.toThrow(
|
||||
'No Confluence site matched "foo.atlassian.net". This connection can access: https://example.atlassian.net, https://Other.Atlassian.NET',
|
||||
);
|
||||
});
|
||||
|
||||
it('says "no sites" when the connection reaches no Confluence sites', async () => {
|
||||
mockHttpRequestWithAuthentication.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(getConfluenceCloudId.call(ctx, 'foo.atlassian.net')).rejects.toThrow(
|
||||
'can access: no sites',
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a non-array accessible-resources body as no sites', async () => {
|
||||
mockHttpRequestWithAuthentication.mockResolvedValueOnce({ error: 'unexpected' });
|
||||
|
||||
await expect(getConfluenceCloudId.call(ctx, 'foo.atlassian.net')).rejects.toThrow(
|
||||
'can access: no sites',
|
||||
);
|
||||
});
|
||||
|
||||
it('caps the reachable-sites list at 5 entries', async () => {
|
||||
mockHttpRequestWithAuthentication.mockResolvedValueOnce(
|
||||
Array.from({ length: 7 }, (_, i) => ({
|
||||
id: `cloud-${i}`,
|
||||
url: `https://site-${i}.atlassian.net`,
|
||||
})),
|
||||
);
|
||||
|
||||
await expect(getConfluenceCloudId.call(ctx, 'foo.atlassian.net')).rejects.toThrow(
|
||||
'This connection can access: https://site-0.atlassian.net, https://site-1.atlassian.net, https://site-2.atlassian.net, https://site-3.atlassian.net, https://site-4.atlassian.net, and 2 more',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits malformed entries from the reachable-sites list', async () => {
|
||||
mockHttpRequestWithAuthentication.mockResolvedValueOnce([
|
||||
null,
|
||||
{ id: 'no-url' },
|
||||
{ id: 'cloud-1', url: 'https://example.atlassian.net' },
|
||||
]);
|
||||
|
||||
await expect(getConfluenceCloudId.call(ctx, 'foo.atlassian.net')).rejects.toThrow(
|
||||
'This connection can access: https://example.atlassian.net',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a NodeOperationError for an unparseable site URL', async () => {
|
||||
await expect(getConfluenceCloudId.call(ctx, 'not a url')).rejects.toThrow(NodeOperationError);
|
||||
expect(mockHttpRequestWithAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wraps an accessible-resources failure in NodeApiError, keeping status and message', async () => {
|
||||
mockHttpRequestWithAuthentication.mockRejectedValueOnce({
|
||||
message: 'boom',
|
||||
response: { status: 500 },
|
||||
});
|
||||
|
||||
const error = await getConfluenceCloudId
|
||||
.call(ctx, 'example.atlassian.net')
|
||||
.then(() => null)
|
||||
.catch((thrown: NodeApiError) => thrown);
|
||||
|
||||
expect(error).toBeInstanceOf(NodeApiError);
|
||||
expect(error?.httpCode).toBe('500');
|
||||
expect(error?.messages).toContain('boom');
|
||||
});
|
||||
|
||||
it('passes an already-wrapped NodeApiError through unchanged', async () => {
|
||||
// The realistic path: httpRequestWithAuthentication rejects with a NodeApiError already
|
||||
const wrapped = new NodeApiError(mockNode, { message: 'boom' }, { httpCode: '429' });
|
||||
mockHttpRequestWithAuthentication.mockRejectedValueOnce(wrapped);
|
||||
|
||||
await expect(getConfluenceCloudId.call(ctx, 'example.atlassian.net')).rejects.toBe(wrapped);
|
||||
});
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'confluenceCloudOAuth2Api',
|
||||
expect.objectContaining({ method: 'POST', body, qs, json: true }),
|
||||
);
|
||||
});
|
||||
|
||||
describe('confluenceApiRequest', () => {
|
||||
it('routes requests to https://api.atlassian.com/ex/confluence/{cloudId}', async () => {
|
||||
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
|
||||
it('defaults body and qs to empty objects', async () => {
|
||||
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
|
||||
|
||||
expect(ctx.getCredentials).toHaveBeenCalledWith('confluenceCloudOAuth2Api');
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(2);
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'confluenceCloudOAuth2Api',
|
||||
expect.objectContaining({
|
||||
url: 'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/pages',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'confluenceCloudOAuth2Api',
|
||||
expect.objectContaining({ body: {}, qs: {}, json: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses the cached cloudId across requests', async () => {
|
||||
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
|
||||
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/spaces');
|
||||
it('wraps request failures in NodeApiError, keeping status and message', async () => {
|
||||
mockHttpRequestWithAuthentication
|
||||
.mockResolvedValueOnce(accessibleResources)
|
||||
.mockRejectedValueOnce({ message: 'boom', response: { status: 403 } });
|
||||
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(3);
|
||||
const urls = mockHttpRequestWithAuthentication.mock.calls.map(([, options]) => options.url);
|
||||
expect(urls).toEqual([
|
||||
'https://api.atlassian.com/oauth/token/accessible-resources',
|
||||
'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/pages',
|
||||
'https://api.atlassian.com/ex/confluence/cloud-1/wiki/api/v2/spaces',
|
||||
]);
|
||||
});
|
||||
const error = await confluenceApiRequest
|
||||
.call(ctx, 'GET', '/wiki/api/v2/pages')
|
||||
.then(() => null)
|
||||
.catch((thrown: NodeApiError) => thrown);
|
||||
|
||||
it('passes method, body and qs through', async () => {
|
||||
const body = { title: 'New page' };
|
||||
const qs = { limit: 25 };
|
||||
expect(error).toBeInstanceOf(NodeApiError);
|
||||
expect(error?.httpCode).toBe('403');
|
||||
expect(error?.messages).toContain('boom');
|
||||
});
|
||||
|
||||
await confluenceApiRequest.call(ctx, 'POST', '/wiki/api/v2/pages', body, qs);
|
||||
it('surfaces the cloudId lookup error when no site matches', async () => {
|
||||
ctx.getCredentials.mockResolvedValue({ domain: 'https://missing.atlassian.net' });
|
||||
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'confluenceCloudOAuth2Api',
|
||||
expect.objectContaining({ method: 'POST', body, qs, json: true }),
|
||||
);
|
||||
});
|
||||
await expect(confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages')).rejects.toThrow(
|
||||
'No Confluence site matched "https://missing.atlassian.net"',
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults body and qs to empty objects', async () => {
|
||||
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
|
||||
it('throws a NodeOperationError naming the Site URL field when the credential lacks it', async () => {
|
||||
ctx.getCredentials.mockResolvedValue({});
|
||||
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'confluenceCloudOAuth2Api',
|
||||
expect.objectContaining({ body: {}, qs: {}, json: true }),
|
||||
);
|
||||
});
|
||||
const promise = confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
|
||||
|
||||
it('wraps request failures in NodeApiError, keeping status and message', async () => {
|
||||
mockHttpRequestWithAuthentication
|
||||
.mockResolvedValueOnce(accessibleResources)
|
||||
.mockRejectedValueOnce({ message: 'boom', response: { status: 403 } });
|
||||
|
||||
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('403');
|
||||
expect(error?.messages).toContain('boom');
|
||||
});
|
||||
|
||||
it('throws a NodeOperationError naming the Site URL field when the credential lacks it', async () => {
|
||||
ctx.getCredentials.mockResolvedValue({});
|
||||
|
||||
const promise = confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
|
||||
|
||||
await expect(promise).rejects.toThrow(NodeOperationError);
|
||||
await expect(promise).rejects.toThrow('Site URL');
|
||||
expect(mockHttpRequestWithAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
await expect(promise).rejects.toThrow(NodeOperationError);
|
||||
await expect(promise).rejects.toThrow('Site URL');
|
||||
expect(mockHttpRequestWithAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,118 +8,10 @@ import type {
|
|||
} from 'n8n-workflow';
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
// Owned by ENT-128 (parallel PR): coupled by name only, never import the class
|
||||
import { getAtlassianApiBaseUrl, getAtlassianCloudId } from '@utils/atlassian';
|
||||
|
||||
export const CONFLUENCE_CREDENTIAL_NAME = 'confluenceCloudOAuth2Api';
|
||||
|
||||
// Bearer-token origin invariant: every request URL is string-concatenated onto this
|
||||
// constant, so no caller input can change the host. Any future verbatim-URL param
|
||||
// (paginator next-link, uri override) needs an origin check first, see the one in
|
||||
// `microsoftApiRequest` (SharePoint v2 transport). User-supplied path segments in
|
||||
// future endpoints must be encodeURIComponent-ed.
|
||||
const ATLASSIAN_API_BASE = 'https://api.atlassian.com';
|
||||
|
||||
export interface AccessibleResource {
|
||||
id: string;
|
||||
url: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure hostname matcher, exported so ENT-128's credential Test can reuse the exact
|
||||
* matching this node routes with. Case-insensitive, never an exact-string comparison
|
||||
* (the trap in Jira's `getCloudId`).
|
||||
*/
|
||||
export function matchSiteByHostname(
|
||||
resources: AccessibleResource[],
|
||||
hostname: string,
|
||||
): AccessibleResource | undefined {
|
||||
const target = hostname.toLowerCase();
|
||||
// Non-throwing on malformed entries: this is a reuse contract fed with an unvalidated API response
|
||||
return resources.find((resource) => {
|
||||
try {
|
||||
return new URL(resource.url).hostname.toLowerCase() === target;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Module-level cache: credentialId:hostname → cloudId (`_cloudIdCache` in Jira's
|
||||
// GenericFunctions is the precedent, but keyed per credential here: a shared hostname-only
|
||||
// key makes cold vs warm failures distinguishable, letting one user infer that another
|
||||
// credential on the instance reaches a given site).
|
||||
// Lives for the n8n process; a moved/deleted site needs a restart to re-resolve (same ceiling as Jira).
|
||||
// One asymmetry: a warm-cache hit skips the accessible-resources check, so a credential that
|
||||
// lost access to a site it once resolved fails at the API call with Atlassian's 403.
|
||||
const cloudIdCache = new Map<string, string>();
|
||||
|
||||
/** Test-only escape hatch for the module-level cache. */
|
||||
export function clearCloudIdCache(): void {
|
||||
cloudIdCache.clear();
|
||||
}
|
||||
|
||||
/** Reduce any pasted site-URL form (scheme optional, `/wiki` or deeper paths, any casing) to its lowercased hostname. */
|
||||
export function normalizeSiteUrl(siteUrl: string): string {
|
||||
const trimmed = siteUrl.trim();
|
||||
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
return new URL(withScheme).hostname.toLowerCase();
|
||||
}
|
||||
|
||||
export async function getConfluenceCloudId(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
siteUrl: string,
|
||||
): Promise<string> {
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = normalizeSiteUrl(siteUrl);
|
||||
} catch {
|
||||
throw new NodeOperationError(this.getNode(), `"${siteUrl}" is not a valid Confluence site URL`);
|
||||
}
|
||||
|
||||
const credentialId = this.getNode().credentials?.[CONFLUENCE_CREDENTIAL_NAME]?.id ?? '';
|
||||
const cacheKey = `${credentialId}:${hostname}`;
|
||||
const cached = cloudIdCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
let resources: AccessibleResource[];
|
||||
try {
|
||||
resources = await this.helpers.httpRequestWithAuthentication.call(
|
||||
this,
|
||||
CONFLUENCE_CREDENTIAL_NAME,
|
||||
{
|
||||
method: 'GET',
|
||||
url: `${ATLASSIAN_API_BASE}/oauth/token/accessible-resources`,
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||
}
|
||||
|
||||
// A non-array body falls through to the no-match error instead of a raw TypeError
|
||||
if (!Array.isArray(resources)) resources = [];
|
||||
|
||||
const site = matchSiteByHostname(resources, hostname);
|
||||
|
||||
if (!site) {
|
||||
// Same guard class as matchSiteByHostname: malformed entries must not break the error message.
|
||||
// Capped at 5 so the error stays readable and a bogus siteUrl cannot dump the full site list
|
||||
// into persisted execution data.
|
||||
const urls = resources
|
||||
.filter((resource) => typeof resource?.url === 'string' && resource.url !== '')
|
||||
.map((resource) => resource.url);
|
||||
const reachable =
|
||||
urls.slice(0, 5).join(', ') + (urls.length > 5 ? `, and ${urls.length - 5} more` : '');
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`No Confluence site matched "${siteUrl}". This connection can access: ${reachable || 'no sites'}`,
|
||||
);
|
||||
}
|
||||
|
||||
cloudIdCache.set(cacheKey, site.id);
|
||||
return site.id;
|
||||
}
|
||||
|
||||
export async function confluenceApiRequest(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
method: IHttpRequestMethods,
|
||||
|
|
@ -128,8 +20,7 @@ export async function confluenceApiRequest(
|
|||
qs: IDataObject = {},
|
||||
): Promise<IDataObject> {
|
||||
const credentials = await this.getCredentials(CONFLUENCE_CREDENTIAL_NAME);
|
||||
// The key is `domain` (kept from Jira credentials for backwards compatibility)
|
||||
// while the user-facing label is "Site URL"; see AtlassianOAuth2Api (ENT-128)
|
||||
// Keyed `domain` for backwards compatibility with Jira credentials; labeled "Site URL" in the UI
|
||||
const siteUrl = credentials.domain;
|
||||
if (typeof siteUrl !== 'string' || siteUrl === '') {
|
||||
throw new NodeOperationError(
|
||||
|
|
@ -137,11 +28,18 @@ export async function confluenceApiRequest(
|
|||
'The Confluence credential is missing the Site URL field',
|
||||
);
|
||||
}
|
||||
const cloudId = await getConfluenceCloudId.call(this, siteUrl);
|
||||
const cloudId = await getAtlassianCloudId.call(
|
||||
this,
|
||||
CONFLUENCE_CREDENTIAL_NAME,
|
||||
siteUrl,
|
||||
'confluence',
|
||||
);
|
||||
|
||||
// The URL is concatenated onto the api.atlassian.com base, so caller input can't
|
||||
// change the host; a future verbatim-URL param needs an origin check first.
|
||||
const options: IHttpRequestOptions = {
|
||||
method,
|
||||
url: `${ATLASSIAN_API_BASE}/ex/confluence/${encodeURIComponent(cloudId)}${endpoint}`,
|
||||
url: `${getAtlassianApiBaseUrl('confluence', cloudId)}${endpoint}`,
|
||||
body,
|
||||
qs,
|
||||
json: true,
|
||||
|
|
|
|||
|
|
@ -12,36 +12,10 @@ import type {
|
|||
} from 'n8n-workflow';
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { getAtlassianApiBaseUrl, getAtlassianCloudId } from '@utils/atlassian';
|
||||
|
||||
import type { JiraServerInfo, JiraWebhook } from './types';
|
||||
|
||||
// Module-level cache: normalised domain → cloudId (persists for the life of the n8n process)
|
||||
const _cloudIdCache = new Map<string, string>();
|
||||
|
||||
async function getCloudId(
|
||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||
credentialType: string,
|
||||
domain: string,
|
||||
): Promise<string> {
|
||||
const normalizedDomain = domain.replace(/\/$/, '');
|
||||
if (_cloudIdCache.has(normalizedDomain)) return _cloudIdCache.get(normalizedDomain)!;
|
||||
|
||||
const resources = (await this.helpers.requestWithAuthentication.call(this, credentialType, {
|
||||
uri: 'https://api.atlassian.com/oauth/token/accessible-resources',
|
||||
json: true,
|
||||
})) as Array<{ id: string; url: string }>;
|
||||
|
||||
const site = resources.find((r) => r.url === normalizedDomain);
|
||||
if (!site) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`No accessible Jira site found for domain: ${domain}. Make sure the domain matches your Atlassian site URL exactly.`,
|
||||
);
|
||||
}
|
||||
|
||||
_cloudIdCache.set(normalizedDomain, site.id);
|
||||
return site.id;
|
||||
}
|
||||
|
||||
export async function jiraSoftwareCloudApiRequest(
|
||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||
endpoint: string,
|
||||
|
|
@ -63,10 +37,16 @@ export async function jiraSoftwareCloudApiRequest(
|
|||
domain = (await this.getCredentials('jiraSoftwareServerPatApi')).domain as string;
|
||||
credentialType = 'jiraSoftwareServerPatApi';
|
||||
} else if (jiraVersion === 'cloudOAuth2') {
|
||||
const rawDomain = (await this.getCredentials('jiraSoftwareCloudOAuth2Api')).domain as string;
|
||||
const rawDomain = (await this.getCredentials('jiraSoftwareCloudOAuth2Api')).domain;
|
||||
credentialType = 'jiraSoftwareCloudOAuth2Api';
|
||||
const cloudId = await getCloudId.call(this, credentialType, rawDomain);
|
||||
domain = `https://api.atlassian.com/ex/jira/${cloudId}`;
|
||||
if (typeof rawDomain !== 'string' || rawDomain === '') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'The Jira credential is missing the Site URL field',
|
||||
);
|
||||
}
|
||||
const cloudId = await getAtlassianCloudId.call(this, credentialType, rawDomain, 'jira');
|
||||
domain = getAtlassianApiBaseUrl('jira', cloudId);
|
||||
} else {
|
||||
domain = (await this.getCredentials('jiraSoftwareCloudApi')).domain as string;
|
||||
credentialType = 'jiraSoftwareCloudApi';
|
||||
|
|
|
|||
|
|
@ -33,6 +33,20 @@ export const userOperations: INodeProperties[] = [
|
|||
],
|
||||
default: 'create',
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'Creating and deleting users requires the <code>manage:jira-configuration</code> scope, which is not included in the default scopes. Enable it on your OAuth app in the Atlassian developer console, add it in the credential via Custom Scopes, then reconnect.',
|
||||
name: 'userManagementScopeNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['user'],
|
||||
operation: ['create', 'delete'],
|
||||
jiraVersion: ['cloudOAuth2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const userFields: INodeProperties[] = [
|
||||
|
|
|
|||
|
|
@ -98,27 +98,24 @@ describe('Jira -> GenericFunctions', () => {
|
|||
mockExecuteFunctions.getCredentials.mockResolvedValue({
|
||||
domain: 'https://example.atlassian.net',
|
||||
});
|
||||
// First call returns accessible-resources, second call returns the actual API response
|
||||
mockExecuteFunctions.helpers.requestWithAuthentication
|
||||
.mockResolvedValueOnce([{ id: cloudId, url: 'https://example.atlassian.net' }])
|
||||
.mockResolvedValueOnce({});
|
||||
// cloudId lookup uses httpRequestWithAuthentication; the API request uses the legacy helper
|
||||
mockExecuteFunctions.helpers.httpRequestWithAuthentication.mockResolvedValueOnce([
|
||||
{ id: cloudId, url: 'https://example.atlassian.net' },
|
||||
]);
|
||||
mockExecuteFunctions.helpers.requestWithAuthentication.mockResolvedValueOnce({});
|
||||
|
||||
await jiraSoftwareCloudApiRequest.call(mockExecuteFunctions, '/api/2/myself', 'GET');
|
||||
|
||||
expect(mockExecuteFunctions.getCredentials).toHaveBeenCalledWith(
|
||||
'jiraSoftwareCloudOAuth2Api',
|
||||
);
|
||||
// First call must be the accessible-resources lookup
|
||||
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'jiraSoftwareCloudOAuth2Api',
|
||||
expect.objectContaining({
|
||||
uri: 'https://api.atlassian.com/oauth/token/accessible-resources',
|
||||
url: 'https://api.atlassian.com/oauth/token/accessible-resources',
|
||||
}),
|
||||
);
|
||||
// Second call must use the api.atlassian.com base URL with cloudId
|
||||
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith(
|
||||
'jiraSoftwareCloudOAuth2Api',
|
||||
expect.objectContaining({
|
||||
uri: `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/2/myself`,
|
||||
|
|
@ -126,6 +123,21 @@ describe('Jira -> GenericFunctions', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should throw a NodeOperationError naming the Site URL field when the credential lacks it', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValue('cloudOAuth2');
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({});
|
||||
|
||||
const promise = jiraSoftwareCloudApiRequest.call(
|
||||
mockExecuteFunctions,
|
||||
'/api/2/myself',
|
||||
'GET',
|
||||
);
|
||||
|
||||
await expect(promise).rejects.toThrow('Site URL');
|
||||
expect(mockExecuteFunctions.helpers.httpRequestWithAuthentication).not.toHaveBeenCalled();
|
||||
expect(mockExecuteFunctions.helpers.requestWithAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use jiraSoftwareServerApi credential for jiraVersion "server"', async () => {
|
||||
mockExecuteFunctions.getNodeParameter.mockReturnValue('server');
|
||||
mockExecuteFunctions.getCredentials.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { mock, mockDeep } from 'vitest-mock-extended';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
IDataObject,
|
||||
|
|
@ -6,23 +5,31 @@ import type {
|
|||
INode,
|
||||
IWebhookFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { mock, mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { testWebhookTriggerNode } from '@test/nodes/TriggerHelpers';
|
||||
import { clearAtlassianCloudIdCache } from '@utils/atlassian';
|
||||
|
||||
import { JiraTrigger } from '../JiraTrigger.node';
|
||||
import {
|
||||
allEvents,
|
||||
OAUTH2_WEBHOOK_REFRESH_INTERVAL_MS,
|
||||
OAUTH2_WEBHOOK_EXPIRY_BUFFER_MS,
|
||||
OAUTH2_SUPPORTED_WEBHOOK_EVENTS,
|
||||
} from '../GenericFunctions';
|
||||
import { JiraTrigger } from '../JiraTrigger.node';
|
||||
|
||||
describe('JiraTrigger', () => {
|
||||
describe('Webhook lifecycle', () => {
|
||||
let staticData: IDataObject;
|
||||
|
||||
const OAUTH2_DOMAIN = 'https://test-oauth2.atlassian.net';
|
||||
const OAUTH2_CLOUD_ID = 'test-cloud-id';
|
||||
const mockCloudIdLookup = () =>
|
||||
vi.fn().mockResolvedValueOnce([{ id: OAUTH2_CLOUD_ID, url: OAUTH2_DOMAIN }]);
|
||||
|
||||
beforeEach(() => {
|
||||
staticData = {};
|
||||
clearAtlassianCloudIdCache();
|
||||
});
|
||||
|
||||
function mockHookFunctions(
|
||||
|
|
@ -185,6 +192,7 @@ describe('JiraTrigger', () => {
|
|||
|
||||
function mockOAuth2HookFunctions(
|
||||
mockRequest: IHookFunctions['helpers']['requestWithAuthentication'],
|
||||
mockHttpRequest?: IHookFunctions['helpers']['httpRequestWithAuthentication'],
|
||||
) {
|
||||
return mockDeep<IHookFunctions>({
|
||||
getWorkflowStaticData: () => staticData,
|
||||
|
|
@ -198,29 +206,33 @@ describe('JiraTrigger', () => {
|
|||
}),
|
||||
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
|
||||
({ domain }) as T,
|
||||
helpers: { requestWithAuthentication: mockRequest },
|
||||
helpers: {
|
||||
requestWithAuthentication: mockRequest,
|
||||
...(mockHttpRequest ? { httpRequestWithAuthentication: mockHttpRequest } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const baseApiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/webhook`;
|
||||
|
||||
// checkExists — GET /api/3/webhook
|
||||
// checkExists — the cloudId lookup, then GET /api/3/webhook
|
||||
const mockCloudIdRequest = vi.fn().mockResolvedValueOnce(accessibleResources);
|
||||
const mockExistsRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(accessibleResources) // getCloudId call
|
||||
.mockResolvedValueOnce({ isLast: true, maxResults: 50, startAt: 0, total: 0, values: [] });
|
||||
|
||||
const exists = await trigger.webhookMethods.default?.checkExists.call(
|
||||
mockOAuth2HookFunctions(mockExistsRequest),
|
||||
mockOAuth2HookFunctions(mockExistsRequest, mockCloudIdRequest),
|
||||
);
|
||||
|
||||
expect(mockExistsRequest).toHaveBeenCalledTimes(2);
|
||||
expect(mockExistsRequest).toHaveBeenCalledWith(
|
||||
expect(mockCloudIdRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockCloudIdRequest).toHaveBeenCalledWith(
|
||||
'jiraSoftwareCloudOAuth2Api',
|
||||
expect.objectContaining({
|
||||
uri: 'https://api.atlassian.com/oauth/token/accessible-resources',
|
||||
url: 'https://api.atlassian.com/oauth/token/accessible-resources',
|
||||
}),
|
||||
);
|
||||
expect(mockExistsRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockExistsRequest).toHaveBeenCalledWith(
|
||||
'jiraSoftwareCloudOAuth2Api',
|
||||
expect.objectContaining({ uri: baseApiUrl, method: 'GET' }),
|
||||
|
|
@ -274,7 +286,7 @@ describe('JiraTrigger', () => {
|
|||
|
||||
test('should refresh OAuth2 webhook in checkExists when near expiry', async () => {
|
||||
const trigger = new JiraTrigger();
|
||||
const cloudId = 'test-cloud-id'; // already cached from previous OAuth2 test
|
||||
const cloudId = 'test-cloud-id';
|
||||
const domain = 'https://test-oauth2.atlassian.net';
|
||||
const baseApiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/webhook`;
|
||||
|
||||
|
|
@ -311,7 +323,10 @@ describe('JiraTrigger', () => {
|
|||
}),
|
||||
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
|
||||
({ domain }) as T,
|
||||
helpers: { requestWithAuthentication: mockRequest },
|
||||
helpers: {
|
||||
requestWithAuthentication: mockRequest,
|
||||
httpRequestWithAuthentication: mockCloudIdLookup(),
|
||||
},
|
||||
});
|
||||
|
||||
const exists = await trigger.webhookMethods.default?.checkExists.call(hookFns);
|
||||
|
|
@ -361,7 +376,10 @@ describe('JiraTrigger', () => {
|
|||
}),
|
||||
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
|
||||
({ domain }) as T,
|
||||
helpers: { requestWithAuthentication: mockRequest },
|
||||
helpers: {
|
||||
requestWithAuthentication: mockRequest,
|
||||
httpRequestWithAuthentication: mockCloudIdLookup(),
|
||||
},
|
||||
});
|
||||
|
||||
const exists = await trigger.webhookMethods.default?.checkExists.call(hookFns);
|
||||
|
|
@ -393,6 +411,9 @@ describe('JiraTrigger', () => {
|
|||
webhookFns.getBodyData.mockReturnValue({});
|
||||
webhookFns.getQueryData.mockReturnValue({});
|
||||
webhookFns.getCredentials.mockResolvedValue({ domain } as ICredentialDataDecryptedObject);
|
||||
webhookFns.helpers.httpRequestWithAuthentication.mockResolvedValueOnce([
|
||||
{ id: cloudId, url: domain },
|
||||
]);
|
||||
// PUT /api/3/webhook/refresh
|
||||
webhookFns.helpers.requestWithAuthentication.mockResolvedValueOnce({});
|
||||
webhookFns.helpers.returnJsonArray.mockImplementation((data: IDataObject | IDataObject[]) => [
|
||||
|
|
@ -444,7 +465,7 @@ describe('JiraTrigger', () => {
|
|||
|
||||
test('should filter unsupported events when registering OAuth2 webhook', async () => {
|
||||
const trigger = new JiraTrigger();
|
||||
const domain = 'https://test-oauth2.atlassian.net'; // cloudId already cached
|
||||
const domain = 'https://test-oauth2.atlassian.net';
|
||||
const cloudId = 'test-cloud-id';
|
||||
const baseApiUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/webhook`;
|
||||
|
||||
|
|
@ -476,7 +497,10 @@ describe('JiraTrigger', () => {
|
|||
}),
|
||||
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
|
||||
({ domain }) as T,
|
||||
helpers: { requestWithAuthentication: mockRequest },
|
||||
helpers: {
|
||||
requestWithAuthentication: mockRequest,
|
||||
httpRequestWithAuthentication: mockCloudIdLookup(),
|
||||
},
|
||||
});
|
||||
|
||||
await trigger.webhookMethods.default?.create.call(hookFns);
|
||||
|
|
@ -516,7 +540,10 @@ describe('JiraTrigger', () => {
|
|||
}),
|
||||
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
|
||||
({ domain }) as T,
|
||||
helpers: { requestWithAuthentication: mockRequest },
|
||||
helpers: {
|
||||
requestWithAuthentication: mockRequest,
|
||||
httpRequestWithAuthentication: mockCloudIdLookup(),
|
||||
},
|
||||
});
|
||||
|
||||
await trigger.webhookMethods.default?.create.call(hookFns);
|
||||
|
|
@ -559,7 +586,10 @@ describe('JiraTrigger', () => {
|
|||
}),
|
||||
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
|
||||
({ domain }) as T,
|
||||
helpers: { requestWithAuthentication: mockRequest },
|
||||
helpers: {
|
||||
requestWithAuthentication: mockRequest,
|
||||
httpRequestWithAuthentication: mockCloudIdLookup(),
|
||||
},
|
||||
});
|
||||
|
||||
await trigger.webhookMethods.default?.create.call(hookFns);
|
||||
|
|
@ -587,7 +617,10 @@ describe('JiraTrigger', () => {
|
|||
}),
|
||||
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
|
||||
({ domain }) as T,
|
||||
helpers: { requestWithAuthentication: vi.fn().mockResolvedValueOnce([]) },
|
||||
helpers: {
|
||||
requestWithAuthentication: vi.fn().mockResolvedValueOnce([]),
|
||||
httpRequestWithAuthentication: mockCloudIdLookup(),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(trigger.webhookMethods.default?.create.call(hookFns)).rejects.toThrow(
|
||||
|
|
@ -618,7 +651,10 @@ describe('JiraTrigger', () => {
|
|||
}),
|
||||
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
|
||||
({ domain }) as T,
|
||||
helpers: { requestWithAuthentication: mockRequest },
|
||||
helpers: {
|
||||
requestWithAuthentication: mockRequest,
|
||||
httpRequestWithAuthentication: mockCloudIdLookup(),
|
||||
},
|
||||
});
|
||||
|
||||
await trigger.webhookMethods.default?.create.call(hookFns);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@
|
|||
"dist/credentials/ApiTemplateIoApi.credentials.js",
|
||||
"dist/credentials/AsanaApi.credentials.js",
|
||||
"dist/credentials/AsanaOAuth2Api.credentials.js",
|
||||
"dist/credentials/AtlassianOAuth2Api.credentials.js",
|
||||
"dist/credentials/Auth0ManagementApi.credentials.js",
|
||||
"dist/credentials/AutopilotApi.credentials.js",
|
||||
"dist/credentials/Aws.credentials.js",
|
||||
|
|
@ -87,6 +88,7 @@
|
|||
"dist/credentials/ClockifyApi.credentials.js",
|
||||
"dist/credentials/CockpitApi.credentials.js",
|
||||
"dist/credentials/CodaApi.credentials.js",
|
||||
"dist/credentials/ConfluenceCloudOAuth2Api.credentials.js",
|
||||
"dist/credentials/ContentfulApi.credentials.js",
|
||||
"dist/credentials/ConvertApi.credentials.js",
|
||||
"dist/credentials/ConvertKitApi.credentials.js",
|
||||
|
|
|
|||
290
packages/nodes-base/utils/__tests__/atlassian.test.ts
Normal file
290
packages/nodes-base/utils/__tests__/atlassian.test.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
import type { Mock, Mocked } from 'vitest';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import type { AccessibleResource } from '../atlassian';
|
||||
import {
|
||||
clearAtlassianCloudIdCache,
|
||||
extractAtlassianSiteHostname,
|
||||
getAtlassianApiBaseUrl,
|
||||
getAtlassianCloudId,
|
||||
} from '../atlassian';
|
||||
|
||||
describe('extractAtlassianSiteHostname', () => {
|
||||
it.each([
|
||||
['https://example.atlassian.net', 'example.atlassian.net'],
|
||||
['http://example.atlassian.net', 'example.atlassian.net'],
|
||||
['example.atlassian.net', 'example.atlassian.net'],
|
||||
['https://example.atlassian.net/', 'example.atlassian.net'],
|
||||
['https://example.atlassian.net/wiki', 'example.atlassian.net'],
|
||||
['https://example.atlassian.net/wiki/spaces/DOCS/pages/123', 'example.atlassian.net'],
|
||||
['example.atlassian.net/wiki/', 'example.atlassian.net'],
|
||||
['HTTPS://Example.Atlassian.NET/Wiki', 'example.atlassian.net'],
|
||||
[' https://example.atlassian.net ', 'example.atlassian.net'],
|
||||
])('should extract %s → %s', (input, expected) => {
|
||||
expect(extractAtlassianSiteHostname(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([[''], [' '], ['https://']])('should throw on unparseable input %j', (input) => {
|
||||
expect(() => extractAtlassianSiteHostname(input)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAtlassianApiBaseUrl', () => {
|
||||
it('should build the product-scoped API base URL', () => {
|
||||
expect(getAtlassianApiBaseUrl('confluence', 'abc-123')).toBe(
|
||||
'https://api.atlassian.com/ex/confluence/abc-123',
|
||||
);
|
||||
expect(getAtlassianApiBaseUrl('jira', 'abc-123')).toBe(
|
||||
'https://api.atlassian.com/ex/jira/abc-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('should URL-encode the cloudId', () => {
|
||||
expect(getAtlassianApiBaseUrl('jira', 'a/b c')).toBe(
|
||||
'https://api.atlassian.com/ex/jira/a%2Fb%20c',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAtlassianCloudId', () => {
|
||||
const credentialType = 'confluenceCloudOAuth2Api';
|
||||
const accessibleResources: AccessibleResource[] = [
|
||||
{ id: 'cloud-1', url: 'https://example.atlassian.net', name: 'example' },
|
||||
{ id: 'cloud-2', url: 'https://Other.Atlassian.NET' },
|
||||
];
|
||||
|
||||
let ctx: Mocked<IExecuteFunctions>;
|
||||
let mockNode: INode;
|
||||
let mockHttpRequestWithAuthentication: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearAtlassianCloudIdCache();
|
||||
ctx = mockDeep<IExecuteFunctions>();
|
||||
mockHttpRequestWithAuthentication = vi.fn().mockResolvedValue(accessibleResources);
|
||||
ctx.helpers.httpRequestWithAuthentication = mockHttpRequestWithAuthentication;
|
||||
|
||||
mockNode = {
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-base.confluence',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
ctx.getNode.mockReturnValue(mockNode);
|
||||
});
|
||||
|
||||
it('should resolve the cloudId via accessible-resources', async () => {
|
||||
const result = await getAtlassianCloudId.call(
|
||||
ctx,
|
||||
credentialType,
|
||||
'https://example.atlassian.net',
|
||||
'confluence',
|
||||
);
|
||||
|
||||
expect(result).toBe('cloud-1');
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(1);
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
credentialType,
|
||||
expect.objectContaining({
|
||||
url: 'https://api.atlassian.com/oauth/token/accessible-resources',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['example.atlassian.net'],
|
||||
['http://example.atlassian.net'],
|
||||
['https://example.atlassian.net/'],
|
||||
['https://example.atlassian.net/wiki/spaces/DOCS'],
|
||||
['EXAMPLE.Atlassian.NET/wiki'],
|
||||
])('should resolve pasted form %s', async (siteUrl) => {
|
||||
const result = await getAtlassianCloudId.call(ctx, credentialType, siteUrl, 'confluence');
|
||||
|
||||
expect(result).toBe('cloud-1');
|
||||
});
|
||||
|
||||
it('should match hostnames case-insensitively on both sides', async () => {
|
||||
const result = await getAtlassianCloudId.call(
|
||||
ctx,
|
||||
credentialType,
|
||||
'OTHER.atlassian.net/wiki',
|
||||
'confluence',
|
||||
);
|
||||
|
||||
expect(result).toBe('cloud-2');
|
||||
});
|
||||
|
||||
it('should skip entries with malformed URLs instead of throwing', async () => {
|
||||
mockHttpRequestWithAuthentication.mockResolvedValueOnce([
|
||||
{ id: 'bad', url: 'not a url' },
|
||||
...accessibleResources,
|
||||
]);
|
||||
|
||||
const result = await getAtlassianCloudId.call(
|
||||
ctx,
|
||||
credentialType,
|
||||
'example.atlassian.net',
|
||||
'confluence',
|
||||
);
|
||||
|
||||
expect(result).toBe('cloud-1');
|
||||
});
|
||||
|
||||
it('should serve the second lookup from the cache without refetching', async () => {
|
||||
const first = await getAtlassianCloudId.call(
|
||||
ctx,
|
||||
credentialType,
|
||||
'https://example.atlassian.net/wiki',
|
||||
'confluence',
|
||||
);
|
||||
const second = await getAtlassianCloudId.call(
|
||||
ctx,
|
||||
credentialType,
|
||||
'example.atlassian.net',
|
||||
'confluence',
|
||||
);
|
||||
|
||||
expect(first).toBe('cloud-1');
|
||||
expect(second).toBe('cloud-1');
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should share one cache entry across products for the same credential and hostname', async () => {
|
||||
const viaJira = await getAtlassianCloudId.call(
|
||||
ctx,
|
||||
credentialType,
|
||||
'example.atlassian.net',
|
||||
'jira',
|
||||
);
|
||||
const viaConfluence = await getAtlassianCloudId.call(
|
||||
ctx,
|
||||
credentialType,
|
||||
'example.atlassian.net',
|
||||
'confluence',
|
||||
);
|
||||
|
||||
expect(viaJira).toBe('cloud-1');
|
||||
expect(viaConfluence).toBe('cloud-1');
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not share the cache across credentials', async () => {
|
||||
ctx.getNode.mockReturnValue({
|
||||
...mockNode,
|
||||
credentials: { [credentialType]: { id: 'cred-a', name: 'A' } },
|
||||
});
|
||||
await getAtlassianCloudId.call(ctx, credentialType, 'example.atlassian.net', 'confluence');
|
||||
|
||||
ctx.getNode.mockReturnValue({
|
||||
...mockNode,
|
||||
credentials: { [credentialType]: { id: 'cred-b', name: 'B' } },
|
||||
});
|
||||
await getAtlassianCloudId.call(ctx, credentialType, 'example.atlassian.net', 'confluence');
|
||||
|
||||
expect(mockHttpRequestWithAuthentication).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should name the input and list reachable site URLs when nothing matches', async () => {
|
||||
const promise = getAtlassianCloudId.call(
|
||||
ctx,
|
||||
credentialType,
|
||||
'foo.atlassian.net',
|
||||
'confluence',
|
||||
);
|
||||
|
||||
await expect(promise).rejects.toThrow(NodeOperationError);
|
||||
await expect(promise).rejects.toThrow(
|
||||
'No Confluence site matched "foo.atlassian.net". This connection can access: https://example.atlassian.net, https://Other.Atlassian.NET.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should name the product in the error message', async () => {
|
||||
await expect(
|
||||
getAtlassianCloudId.call(
|
||||
ctx,
|
||||
'jiraSoftwareCloudOAuth2Api',
|
||||
'https://unknown.atlassian.net',
|
||||
'jira',
|
||||
),
|
||||
).rejects.toThrow(/No Jira site matched/);
|
||||
});
|
||||
|
||||
it('should say "no sites" when the connection can access none', async () => {
|
||||
mockHttpRequestWithAuthentication.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(
|
||||
getAtlassianCloudId.call(ctx, credentialType, 'foo.atlassian.net', 'confluence'),
|
||||
).rejects.toThrow('can access: no sites');
|
||||
});
|
||||
|
||||
it('should treat a non-array accessible-resources body as no sites', async () => {
|
||||
mockHttpRequestWithAuthentication.mockResolvedValueOnce({ error: 'unexpected' });
|
||||
|
||||
await expect(
|
||||
getAtlassianCloudId.call(ctx, credentialType, 'foo.atlassian.net', 'confluence'),
|
||||
).rejects.toThrow('can access: no sites');
|
||||
});
|
||||
|
||||
it('should cap the reachable-sites list at 5 entries', async () => {
|
||||
mockHttpRequestWithAuthentication.mockResolvedValueOnce(
|
||||
Array.from({ length: 7 }, (_, i) => ({
|
||||
id: `cloud-${i}`,
|
||||
url: `https://site-${i}.atlassian.net`,
|
||||
})),
|
||||
);
|
||||
|
||||
await expect(
|
||||
getAtlassianCloudId.call(ctx, credentialType, 'foo.atlassian.net', 'confluence'),
|
||||
).rejects.toThrow(
|
||||
'This connection can access: https://site-0.atlassian.net, https://site-1.atlassian.net, https://site-2.atlassian.net, https://site-3.atlassian.net, https://site-4.atlassian.net, and 2 more',
|
||||
);
|
||||
});
|
||||
|
||||
it('should omit malformed entries from the reachable-sites list', async () => {
|
||||
mockHttpRequestWithAuthentication.mockResolvedValueOnce([
|
||||
null,
|
||||
{ id: 'no-url' },
|
||||
{ id: 'cloud-1', url: 'https://example.atlassian.net' },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
getAtlassianCloudId.call(ctx, credentialType, 'foo.atlassian.net', 'confluence'),
|
||||
).rejects.toThrow('This connection can access: https://example.atlassian.net');
|
||||
});
|
||||
|
||||
it('should throw a clear NodeOperationError on unparseable site URL', async () => {
|
||||
await expect(getAtlassianCloudId.call(ctx, credentialType, '', 'confluence')).rejects.toThrow(
|
||||
'"" is not a valid Atlassian site URL',
|
||||
);
|
||||
expect(mockHttpRequestWithAuthentication).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should wrap an accessible-resources failure in NodeApiError, keeping status and message', async () => {
|
||||
mockHttpRequestWithAuthentication.mockRejectedValueOnce({
|
||||
message: 'boom',
|
||||
response: { status: 500 },
|
||||
});
|
||||
|
||||
const error = await getAtlassianCloudId
|
||||
.call(ctx, credentialType, 'example.atlassian.net', 'confluence')
|
||||
.then(() => null)
|
||||
.catch((thrown: NodeApiError) => thrown);
|
||||
|
||||
expect(error).toBeInstanceOf(NodeApiError);
|
||||
expect(error?.httpCode).toBe('500');
|
||||
expect(error?.messages).toContain('boom');
|
||||
});
|
||||
|
||||
it('should pass an already-wrapped NodeApiError through unchanged', async () => {
|
||||
const wrapped = new NodeApiError(mockNode, { message: 'boom' }, { httpCode: '429' });
|
||||
mockHttpRequestWithAuthentication.mockRejectedValueOnce(wrapped);
|
||||
|
||||
await expect(
|
||||
getAtlassianCloudId.call(ctx, credentialType, 'example.atlassian.net', 'confluence'),
|
||||
).rejects.toBe(wrapped);
|
||||
});
|
||||
});
|
||||
118
packages/nodes-base/utils/atlassian.ts
Normal file
118
packages/nodes-base/utils/atlassian.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import type {
|
||||
IExecuteFunctions,
|
||||
IHookFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
IPollFunctions,
|
||||
IWebhookFunctions,
|
||||
JsonObject,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeApiError, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
type AtlassianContext =
|
||||
| IHookFunctions
|
||||
| IExecuteFunctions
|
||||
| ILoadOptionsFunctions
|
||||
| IPollFunctions
|
||||
| IWebhookFunctions;
|
||||
|
||||
export type AtlassianProduct = 'jira' | 'confluence';
|
||||
|
||||
const PRODUCT_NAMES: Record<AtlassianProduct, string> = {
|
||||
jira: 'Jira',
|
||||
confluence: 'Confluence',
|
||||
};
|
||||
|
||||
export interface AccessibleResource {
|
||||
id: string;
|
||||
url: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// credentialId:hostname → cloudId, kept for the life of the process. Keyed per
|
||||
// credential so cache timing can't reveal which sites other credentials reach.
|
||||
const cloudIdCache = new Map<string, string>();
|
||||
|
||||
export function clearAtlassianCloudIdCache() {
|
||||
cloudIdCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the hostname from a site URL pasted in any reasonable form: with or
|
||||
* without a scheme, with a trailing slash, with a path like /wiki, any casing.
|
||||
* Throws on input that doesn't contain a parseable hostname.
|
||||
*/
|
||||
export function extractAtlassianSiteHostname(siteUrl: string): string {
|
||||
const trimmed = siteUrl.trim();
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
return new URL(withScheme).hostname.toLowerCase();
|
||||
}
|
||||
|
||||
export function getAtlassianApiBaseUrl(product: AtlassianProduct, cloudId: string): string {
|
||||
return `https://api.atlassian.com/ex/${product}/${encodeURIComponent(cloudId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a site URL to its cloudId by matching its hostname (case-insensitively)
|
||||
* against the sites the OAuth2 connection can access.
|
||||
*/
|
||||
export async function getAtlassianCloudId(
|
||||
this: AtlassianContext,
|
||||
credentialType: string,
|
||||
siteUrl: string,
|
||||
product: AtlassianProduct,
|
||||
): Promise<string> {
|
||||
let hostname: string;
|
||||
try {
|
||||
hostname = extractAtlassianSiteHostname(siteUrl);
|
||||
} catch {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`"${siteUrl}" is not a valid Atlassian site URL. Expected something like https://your-site.atlassian.net.`,
|
||||
);
|
||||
}
|
||||
|
||||
const rawCredentialId = this.getNode().credentials?.[credentialType]?.id;
|
||||
const credentialId = typeof rawCredentialId === 'string' ? rawCredentialId : '';
|
||||
const cacheKey = `${credentialId}:${hostname}`;
|
||||
const cached = cloudIdCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
let resources: AccessibleResource[];
|
||||
try {
|
||||
resources = await this.helpers.httpRequestWithAuthentication.call(this, credentialType, {
|
||||
method: 'GET',
|
||||
url: 'https://api.atlassian.com/oauth/token/accessible-resources',
|
||||
json: true,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new NodeApiError(this.getNode(), error as JsonObject);
|
||||
}
|
||||
|
||||
if (!Array.isArray(resources)) resources = [];
|
||||
|
||||
const site = resources.find((resource) => {
|
||||
try {
|
||||
return new URL(resource.url).hostname.toLowerCase() === hostname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!site) {
|
||||
// Capped at 5 so a bogus siteUrl can't dump the full site list into persisted execution data
|
||||
const urls = resources
|
||||
.filter((resource) => typeof resource?.url === 'string' && resource.url !== '')
|
||||
.map((resource) => resource.url);
|
||||
const reachable =
|
||||
urls.slice(0, 5).join(', ') + (urls.length > 5 ? `, and ${urls.length - 5} more` : '');
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`No ${PRODUCT_NAMES[product]} site matched "${siteUrl}". This connection can access: ${
|
||||
reachable || 'no sites'
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
|
||||
cloudIdCache.set(cacheKey, site.id);
|
||||
return site.id;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user