From b3473fd75da1b75cbb462be94492cd1237eb2ac3 Mon Sep 17 00:00:00 2001 From: Jonathan Bennetts Date: Mon, 16 Mar 2026 19:30:54 +0000 Subject: [PATCH 1/4] feat(Wordpress Node): Add support for OAuth2 --- .../WordpressOAuth2Api.credentials.ts | 112 ++++++++++++ .../WordpressOAuth2Api.credentials.test.ts | 159 ++++++++++++++++++ .../nodes/Wordpress/GenericFunctions.ts | 27 ++- .../nodes/Wordpress/Wordpress.node.ts | 31 ++++ .../__tests__/GenericFunctions.test.ts | 75 +++++++++ packages/nodes-base/package.json | 1 + 6 files changed, 401 insertions(+), 4 deletions(-) create mode 100644 packages/nodes-base/credentials/WordpressOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/credentials/test/WordpressOAuth2Api.credentials.test.ts diff --git a/packages/nodes-base/credentials/WordpressOAuth2Api.credentials.ts b/packages/nodes-base/credentials/WordpressOAuth2Api.credentials.ts new file mode 100644 index 000000000000..1adc24091d47 --- /dev/null +++ b/packages/nodes-base/credentials/WordpressOAuth2Api.credentials.ts @@ -0,0 +1,112 @@ +import type { ICredentialType, INodeProperties } from 'n8n-workflow'; + +export class WordpressOAuth2Api implements ICredentialType { + name = 'wordpressOAuth2Api'; + + displayName = 'WordPress OAuth2 API'; + + extends = ['oAuth2Api']; + + documentationUrl = 'wordpress'; + + properties: INodeProperties[] = [ + { + displayName: + 'OAuth2 authentication works with WordPress.com-hosted sites only. For self-hosted WordPress, use the WordPress API (Basic Auth) credential instead.', + name: 'wordpressComNotice', + type: 'notice', + default: '', + }, + { + displayName: 'Grant Type', + name: 'grantType', + type: 'hidden', + default: 'authorizationCode', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: 'https://public-api.wordpress.com/oauth2/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: 'https://public-api.wordpress.com/oauth2/token', + required: true, + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden', + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden', + default: 'header', + }, + { + displayName: 'Use Custom Domain', + name: 'customDomain', + type: 'boolean', + default: false, + description: + 'Whether your WordPress.com site uses a custom domain instead of a .wordpress.com subdomain', + }, + { + displayName: 'Custom Domain', + name: 'customDomainUrl', + type: 'string', + displayOptions: { + show: { + customDomain: [true], + }, + }, + default: '', + placeholder: 'myblog.com', + description: + "Your WordPress.com site's custom domain. Used as the site identifier in API requests — calls still route through public-api.wordpress.com.", + }, + { + displayName: 'Custom Scopes', + name: 'customScopes', + type: 'boolean', + default: false, + description: 'Whether to define custom OAuth2 scopes instead of the defaults', + }, + { + 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: 'global', + description: 'Space-separated list of OAuth2 scopes to request', + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: '={{$self["customScopes"] ? $self["enabledScopes"] : "global"}}', + }, + ]; +} diff --git a/packages/nodes-base/credentials/test/WordpressOAuth2Api.credentials.test.ts b/packages/nodes-base/credentials/test/WordpressOAuth2Api.credentials.test.ts new file mode 100644 index 000000000000..a03314b8c84a --- /dev/null +++ b/packages/nodes-base/credentials/test/WordpressOAuth2Api.credentials.test.ts @@ -0,0 +1,159 @@ +import { ClientOAuth2 } from '@n8n/client-oauth2'; +import nock from 'nock'; + +import { WordpressOAuth2Api } from '../WordpressOAuth2Api.credentials'; + +describe('WordpressOAuth2Api Credential', () => { + const credential = new WordpressOAuth2Api(); + + // Shared OAuth2 configuration + const authorizationUri = 'https://public-api.wordpress.com/oauth2/authorize'; + const accessTokenUri = 'https://public-api.wordpress.com/oauth2/token'; + 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('https://public-api.wordpress.com') + .post('/oauth2/token', (body: Record) => { + 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: 64800, + scope: responseScopes.join(' '), + blog_id: '12345', + blog_url: 'https://myblog.wordpress.com', + }); + }; + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('should have correct credential metadata', () => { + expect(credential.name).toBe('wordpressOAuth2Api'); + expect(credential.extends).toEqual(['oAuth2Api']); + + const authUrlProperty = credential.properties.find((p) => p.name === 'authUrl'); + expect(authUrlProperty?.default).toBe('https://public-api.wordpress.com/oauth2/authorize'); + + const accessTokenUrlProperty = credential.properties.find((p) => p.name === 'accessTokenUrl'); + expect(accessTokenUrlProperty?.default).toBe('https://public-api.wordpress.com/oauth2/token'); + }); + + it('should have a notice about WordPress.com-only support', () => { + const noticeProperty = credential.properties.find((p) => p.name === 'wordpressComNotice'); + expect(noticeProperty).toBeDefined(); + expect(noticeProperty?.type).toBe('notice'); + expect(noticeProperty?.default).toBe(''); + }); + + it('should have custom domain toggle defaulting to false', () => { + const customDomainProperty = credential.properties.find((p) => p.name === 'customDomain'); + expect(customDomainProperty?.default).toBe(false); + }); + + it('should have custom scopes toggle defaulting to false', () => { + const customScopesProperty = credential.properties.find((p) => p.name === 'customScopes'); + expect(customScopesProperty?.default).toBe(false); + }); + + it('should have default scope of global', () => { + const enabledScopesProperty = credential.properties.find((p) => p.name === 'enabledScopes'); + expect(enabledScopesProperty?.default).toBe('global'); + }); + + describe('OAuth2 flow with default scope', () => { + it('should include global scope in authorization URI', () => { + const oauthClient = createOAuthClient(['global']); + const authUri = oauthClient.code.getUri(); + + expect(authUri).toContain('scope='); + expect(authUri).toContain('global'); + expect(authUri).toContain(`client_id=${clientId}`); + expect(authUri).toContain('response_type=code'); + }); + + it('should retrieve token successfully with global scope', async () => { + const code = 'test-auth-code'; + mockTokenEndpoint(code, ['global']); + + const oauthClient = createOAuthClient(['global']); + const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`); + + expect(token.data.scope).toContain('global'); + expect(token.data.blog_id).toBe('12345'); + expect(token.data.blog_url).toBe('https://myblog.wordpress.com'); + }); + }); + + describe('OAuth2 flow with custom scopes', () => { + const customScopes = ['posts', 'media']; + + it('should include custom scopes in authorization URI', () => { + const oauthClient = createOAuthClient(customScopes); + const authUri = oauthClient.code.getUri(); + + expect(authUri).toContain('scope='); + expect(authUri).toContain('posts'); + expect(authUri).toContain('media'); + expect(authUri).not.toContain('global'); + }); + + 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('posts'); + expect(token.data.scope).toContain('media'); + }); + + it('should handle completely different custom scopes', async () => { + const differentScopes = ['comments', 'stats']; + const code = 'test-auth-code'; + mockTokenEndpoint(code, differentScopes); + + const oauthClient = createOAuthClient(differentScopes); + const authUri = oauthClient.code.getUri(); + + expect(authUri).toContain('comments'); + expect(authUri).toContain('stats'); + expect(authUri).not.toContain('global'); + expect(authUri).not.toContain('posts'); + + const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`); + + expect(token.data.scope).toContain('comments'); + expect(token.data.scope).toContain('stats'); + expect(token.data.scope).not.toContain('global'); + expect(token.data.scope).not.toContain('posts'); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts b/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts index ac0feb564cd7..ab3e16173a5a 100644 --- a/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts @@ -18,7 +18,27 @@ export async function wordpressApiRequest( uri?: string, option: IDataObject = {}, ): Promise { - const credentials = await this.getCredentials('wordpressApi'); + const authType = this.getNodeParameter('authType', 0, 'basicAuth') as string; + const isOAuth2 = authType === 'oAuth2'; + + let baseUri: string; + let credentialType: string; + let rejectUnauthorized: boolean | undefined; + + if (isOAuth2) { + const credentials = await this.getCredentials('wordpressOAuth2Api'); + credentialType = 'wordpressOAuth2Api'; + const sitePrefix = + credentials.customDomain && credentials.customDomainUrl + ? `/sites/${credentials.customDomainUrl as string}` + : ''; + baseUri = `https://public-api.wordpress.com/wp/v2${sitePrefix}`; + } else { + const credentials = await this.getCredentials('wordpressApi'); + credentialType = 'wordpressApi'; + baseUri = `${credentials.url as string}/wp-json/wp/v2`; + rejectUnauthorized = !(credentials.allowUnauthorizedCerts as boolean); + } let options: IRequestOptions = { headers: { @@ -29,8 +49,8 @@ export async function wordpressApiRequest( method, qs, body, - uri: uri || `${credentials.url}/wp-json/wp/v2${resource}`, - rejectUnauthorized: !credentials.allowUnauthorizedCerts, + uri: uri ?? `${baseUri}${resource}`, + ...(rejectUnauthorized !== undefined ? { rejectUnauthorized } : {}), json: true, }; options = Object.assign({}, options, option); @@ -38,7 +58,6 @@ export async function wordpressApiRequest( delete options.body; } try { - const credentialType = 'wordpressApi'; return await this.helpers.requestWithAuthentication.call(this, credentialType, options); } catch (error) { throw new NodeApiError(this.getNode(), error as JsonObject); diff --git a/packages/nodes-base/nodes/Wordpress/Wordpress.node.ts b/packages/nodes-base/nodes/Wordpress/Wordpress.node.ts index 24e2bfb1e6d8..ec7c5446b772 100644 --- a/packages/nodes-base/nodes/Wordpress/Wordpress.node.ts +++ b/packages/nodes-base/nodes/Wordpress/Wordpress.node.ts @@ -36,9 +36,40 @@ export class Wordpress implements INodeType { { name: 'wordpressApi', required: true, + displayOptions: { + show: { + authType: ['basicAuth'], + }, + }, + }, + { + name: 'wordpressOAuth2Api', + required: true, + displayOptions: { + show: { + authType: ['oAuth2'], + }, + }, }, ], properties: [ + { + displayName: 'Authentication', + name: 'authType', + type: 'options', + options: [ + { + name: 'Basic Auth', + value: 'basicAuth', + }, + { + name: 'OAuth2 (WordPress.com)', + value: 'oAuth2', + }, + ], + default: 'basicAuth', + description: 'The authentication method to use', + }, { displayName: 'Resource', name: 'resource', diff --git a/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts b/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts index b46cecedad40..d01dda980551 100644 --- a/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts @@ -12,10 +12,16 @@ describe('Wordpress > GenericFunctions', () => { allowUnauthorizedCerts: false, }), getNode: jest.fn(), + getNodeParameter: jest.fn().mockReturnValue('basicAuth'), }; beforeEach(() => { jest.clearAllMocks(); + mockFunctions.getNodeParameter.mockReturnValue('basicAuth'); + mockFunctions.getCredentials.mockResolvedValue({ + url: 'http://example.com', + allowUnauthorizedCerts: false, + }); }); describe('wordpressApiRequest', () => { @@ -26,6 +32,20 @@ describe('Wordpress > GenericFunctions', () => { expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalled(); }); + it('should use wordpressApi credential for basic auth', async () => { + mockFunctions.helpers.requestWithAuthentication.mockResolvedValue({ data: 'testData' }); + await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}); + + expect(mockFunctions.getCredentials).toHaveBeenCalledWith('wordpressApi'); + expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'wordpressApi', + expect.objectContaining({ + uri: 'http://example.com/wp-json/wp/v2/posts', + rejectUnauthorized: true, + }), + ); + }); + it('should throw NodeApiError on failure', async () => { mockFunctions.helpers.requestWithAuthentication.mockRejectedValue({ message: 'fail' }); await expect( @@ -34,6 +54,61 @@ describe('Wordpress > GenericFunctions', () => { }); }); + describe('wordpressApiRequest with OAuth2', () => { + beforeEach(() => { + mockFunctions.getNodeParameter.mockReturnValue('oAuth2'); + mockFunctions.getCredentials.mockResolvedValue({ + customDomain: false, + customDomainUrl: '', + }); + mockFunctions.helpers.requestWithAuthentication.mockResolvedValue({ data: 'testData' }); + }); + + it('should use wordpressOAuth2Api credential', async () => { + await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}); + + expect(mockFunctions.getCredentials).toHaveBeenCalledWith('wordpressOAuth2Api'); + expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'wordpressOAuth2Api', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('should use public-api.wordpress.com base URL without custom domain', async () => { + await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}); + + expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'wordpressOAuth2Api', + expect.objectContaining({ + uri: 'https://public-api.wordpress.com/wp/v2/posts', + }), + ); + }); + + it('should include site prefix when custom domain is set', async () => { + mockFunctions.getCredentials.mockResolvedValue({ + customDomain: true, + customDomainUrl: 'myblog.com', + }); + + await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}); + + expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'wordpressOAuth2Api', + expect.objectContaining({ + uri: 'https://public-api.wordpress.com/wp/v2/sites/myblog.com/posts', + }), + ); + }); + + it('should not set rejectUnauthorized for OAuth2 path', async () => { + await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}); + + const callArgs = mockFunctions.helpers.requestWithAuthentication.mock.calls[0][1]; + expect(callArgs).not.toHaveProperty('rejectUnauthorized'); + }); + }); + describe('wordpressApiRequestAllItems', () => { it('should accumulate all pages', async () => { mockFunctions.helpers.requestWithAuthentication diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index b28d1a0f779d..2db5f027785b 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -397,6 +397,7 @@ "dist/credentials/WiseApi.credentials.js", "dist/credentials/WooCommerceApi.credentials.js", "dist/credentials/WordpressApi.credentials.js", + "dist/credentials/WordpressOAuth2Api.credentials.js", "dist/credentials/WorkableApi.credentials.js", "dist/credentials/WufooApi.credentials.js", "dist/credentials/XeroOAuth2Api.credentials.js", From 04099597ac280226030c31ca8930fdeda372490c Mon Sep 17 00:00:00 2001 From: Jonathan Bennetts Date: Tue, 17 Mar 2026 08:41:11 +0000 Subject: [PATCH 2/4] Add to custom scopes array and tidy up --- packages/cli/src/constants.ts | 1 + .../src/oauth/__tests__/oauth.service.test.ts | 25 +++++++++++++++++++ .../nodes/Wordpress/GenericFunctions.ts | 14 ++++++++--- .../__tests__/GenericFunctions.test.ts | 24 ++++++++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 413bdf9397a4..a47ae5bbf720 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -94,6 +94,7 @@ export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [ 'microsoftOAuth2Api', 'highLevelOAuth2Api', 'mcpOAuth2Api', + 'wordpressOAuth2Api', ]; export const ARTIFICIAL_TASK_DATA = { diff --git a/packages/cli/src/oauth/__tests__/oauth.service.test.ts b/packages/cli/src/oauth/__tests__/oauth.service.test.ts index 942665ee3710..411fb6fee26c 100644 --- a/packages/cli/src/oauth/__tests__/oauth.service.test.ts +++ b/packages/cli/src/oauth/__tests__/oauth.service.test.ts @@ -1130,6 +1130,31 @@ describe('OauthService', () => { ); }); + it('should not delete scope for wordpressOAuth2Api credentials', async () => { + const credential = mock({ + id: '1', + type: 'wordpressOAuth2Api', + }); + const mockDecryptedData = { clientId: 'client-id', scope: 'custom-scope' }; + const mockOAuthCredentials = { clientId: 'client-id', scope: 'custom-scope' }; + const mockAdditionalData = mock(); + + jest.mocked(WorkflowExecuteAdditionalData.getBase).mockResolvedValue(mockAdditionalData); + credentialsHelper.getDecrypted.mockResolvedValue(mockDecryptedData); + credentialsHelper.applyDefaultsAndOverwrites.mockResolvedValue(mockOAuthCredentials); + + await service.getOAuthCredentials(credential); + + expect(credentialsHelper.applyDefaultsAndOverwrites).toHaveBeenCalledWith( + mockAdditionalData, + { clientId: 'client-id', scope: 'custom-scope' }, + credential.type, + 'internal', + undefined, + undefined, + ); + }); + it('should not delete scope for non-OAuth2 credentials', async () => { const credential = mock({ id: '1', diff --git a/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts b/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts index ab3e16173a5a..488a73bfff81 100644 --- a/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts @@ -28,10 +28,16 @@ export async function wordpressApiRequest( if (isOAuth2) { const credentials = await this.getCredentials('wordpressOAuth2Api'); credentialType = 'wordpressOAuth2Api'; - const sitePrefix = - credentials.customDomain && credentials.customDomainUrl - ? `/sites/${credentials.customDomainUrl as string}` - : ''; + let sitePrefix = ''; + if (credentials.customDomain && credentials.customDomainUrl) { + let domain = credentials.customDomainUrl as string; + try { + domain = new URL(domain).hostname; + } catch { + domain = domain.replace(/\/+$/, ''); + } + sitePrefix = `/sites/${domain}`; + } baseUri = `https://public-api.wordpress.com/wp/v2${sitePrefix}`; } else { const credentials = await this.getCredentials('wordpressApi'); diff --git a/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts b/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts index d01dda980551..89cc301edd3e 100644 --- a/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts @@ -101,6 +101,30 @@ describe('Wordpress > GenericFunctions', () => { ); }); + it.each([ + ['https://myblog.com/', 'myblog.com'], + ['https://myblog.com', 'myblog.com'], + ['http://myblog.com/', 'myblog.com'], + ['myblog.com/', 'myblog.com'], + ])( + 'should normalize custom domain input "%s" to "%s" in site prefix', + async (input, expected) => { + mockFunctions.getCredentials.mockResolvedValue({ + customDomain: true, + customDomainUrl: input, + }); + + await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}); + + expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'wordpressOAuth2Api', + expect.objectContaining({ + uri: `https://public-api.wordpress.com/wp/v2/sites/${expected}/posts`, + }), + ); + }, + ); + it('should not set rejectUnauthorized for OAuth2 path', async () => { await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}); From 32f924543b3cfe40b3d24d02ca04af968826300f Mon Sep 17 00:00:00 2001 From: Jonathan Bennetts Date: Tue, 17 Mar 2026 09:24:19 +0000 Subject: [PATCH 3/4] Fix review issue --- .../nodes-base/credentials/WordpressOAuth2Api.credentials.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nodes-base/credentials/WordpressOAuth2Api.credentials.ts b/packages/nodes-base/credentials/WordpressOAuth2Api.credentials.ts index 1adc24091d47..47927dde8146 100644 --- a/packages/nodes-base/credentials/WordpressOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/WordpressOAuth2Api.credentials.ts @@ -67,6 +67,7 @@ export class WordpressOAuth2Api implements ICredentialType { }, }, default: '', + required: true, placeholder: 'myblog.com', description: "Your WordPress.com site's custom domain. Used as the site identifier in API requests — calls still route through public-api.wordpress.com.", From c315ebed3fbe0f3f53983544112bbd0ba4eaff22 Mon Sep 17 00:00:00 2001 From: Jonathan Bennetts Date: Mon, 23 Mar 2026 17:03:47 +0000 Subject: [PATCH 4/4] Updates from review feedback --- .../WordpressOAuth2Api.credentials.ts | 21 +-- .../WordpressOAuth2Api.credentials.test.ts | 135 ++---------------- .../nodes/Wordpress/GenericFunctions.ts | 16 +-- .../__tests__/GenericFunctions.test.ts | 42 +++--- 4 files changed, 41 insertions(+), 173 deletions(-) diff --git a/packages/nodes-base/credentials/WordpressOAuth2Api.credentials.ts b/packages/nodes-base/credentials/WordpressOAuth2Api.credentials.ts index 47927dde8146..a050cc76d38f 100644 --- a/packages/nodes-base/credentials/WordpressOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/WordpressOAuth2Api.credentials.ts @@ -50,27 +50,14 @@ export class WordpressOAuth2Api implements ICredentialType { default: 'header', }, { - displayName: 'Use Custom Domain', - name: 'customDomain', - type: 'boolean', - default: false, - description: - 'Whether your WordPress.com site uses a custom domain instead of a .wordpress.com subdomain', - }, - { - displayName: 'Custom Domain', - name: 'customDomainUrl', + displayName: 'WordPress.com Site', + name: 'wordpressSite', type: 'string', - displayOptions: { - show: { - customDomain: [true], - }, - }, default: '', required: true, - placeholder: 'myblog.com', + placeholder: 'myblog.wordpress.com', description: - "Your WordPress.com site's custom domain. Used as the site identifier in API requests — calls still route through public-api.wordpress.com.", + 'Your WordPress.com site identifier — either a .wordpress.com subdomain (e.g. myblog.wordpress.com) or a custom domain (e.g. myblog.com). Used in API requests that still route through public-api.wordpress.com.', }, { displayName: 'Custom Scopes', diff --git a/packages/nodes-base/credentials/test/WordpressOAuth2Api.credentials.test.ts b/packages/nodes-base/credentials/test/WordpressOAuth2Api.credentials.test.ts index a03314b8c84a..c9b002ac6076 100644 --- a/packages/nodes-base/credentials/test/WordpressOAuth2Api.credentials.test.ts +++ b/packages/nodes-base/credentials/test/WordpressOAuth2Api.credentials.test.ts @@ -1,59 +1,8 @@ -import { ClientOAuth2 } from '@n8n/client-oauth2'; -import nock from 'nock'; - import { WordpressOAuth2Api } from '../WordpressOAuth2Api.credentials'; describe('WordpressOAuth2Api Credential', () => { const credential = new WordpressOAuth2Api(); - // Shared OAuth2 configuration - const authorizationUri = 'https://public-api.wordpress.com/oauth2/authorize'; - const accessTokenUri = 'https://public-api.wordpress.com/oauth2/token'; - 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('https://public-api.wordpress.com') - .post('/oauth2/token', (body: Record) => { - 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: 64800, - scope: responseScopes.join(' '), - blog_id: '12345', - blog_url: 'https://myblog.wordpress.com', - }); - }; - - beforeAll(() => { - nock.disableNetConnect(); - }); - - afterAll(() => { - nock.restore(); - }); - - afterEach(() => { - nock.cleanAll(); - }); - it('should have correct credential metadata', () => { expect(credential.name).toBe('wordpressOAuth2Api'); expect(credential.extends).toEqual(['oAuth2Api']); @@ -72,9 +21,12 @@ describe('WordpressOAuth2Api Credential', () => { expect(noticeProperty?.default).toBe(''); }); - it('should have custom domain toggle defaulting to false', () => { - const customDomainProperty = credential.properties.find((p) => p.name === 'customDomain'); - expect(customDomainProperty?.default).toBe(false); + it('should have a required wordpressSite field with no default', () => { + const siteProperty = credential.properties.find((p) => p.name === 'wordpressSite'); + expect(siteProperty).toBeDefined(); + expect(siteProperty?.type).toBe('string'); + expect(siteProperty?.required).toBe(true); + expect(siteProperty?.default).toBe(''); }); it('should have custom scopes toggle defaulting to false', () => { @@ -82,78 +34,15 @@ describe('WordpressOAuth2Api Credential', () => { expect(customScopesProperty?.default).toBe(false); }); - it('should have default scope of global', () => { + it('should have enabledScopes defaulting to "global"', () => { const enabledScopesProperty = credential.properties.find((p) => p.name === 'enabledScopes'); expect(enabledScopesProperty?.default).toBe('global'); }); - describe('OAuth2 flow with default scope', () => { - it('should include global scope in authorization URI', () => { - const oauthClient = createOAuthClient(['global']); - const authUri = oauthClient.code.getUri(); - - expect(authUri).toContain('scope='); - expect(authUri).toContain('global'); - expect(authUri).toContain(`client_id=${clientId}`); - expect(authUri).toContain('response_type=code'); - }); - - it('should retrieve token successfully with global scope', async () => { - const code = 'test-auth-code'; - mockTokenEndpoint(code, ['global']); - - const oauthClient = createOAuthClient(['global']); - const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`); - - expect(token.data.scope).toContain('global'); - expect(token.data.blog_id).toBe('12345'); - expect(token.data.blog_url).toBe('https://myblog.wordpress.com'); - }); - }); - - describe('OAuth2 flow with custom scopes', () => { - const customScopes = ['posts', 'media']; - - it('should include custom scopes in authorization URI', () => { - const oauthClient = createOAuthClient(customScopes); - const authUri = oauthClient.code.getUri(); - - expect(authUri).toContain('scope='); - expect(authUri).toContain('posts'); - expect(authUri).toContain('media'); - expect(authUri).not.toContain('global'); - }); - - 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('posts'); - expect(token.data.scope).toContain('media'); - }); - - it('should handle completely different custom scopes', async () => { - const differentScopes = ['comments', 'stats']; - const code = 'test-auth-code'; - mockTokenEndpoint(code, differentScopes); - - const oauthClient = createOAuthClient(differentScopes); - const authUri = oauthClient.code.getUri(); - - expect(authUri).toContain('comments'); - expect(authUri).toContain('stats'); - expect(authUri).not.toContain('global'); - expect(authUri).not.toContain('posts'); - - const token = await oauthClient.code.getToken(redirectUri + `?code=${code}`); - - expect(token.data.scope).toContain('comments'); - expect(token.data.scope).toContain('stats'); - expect(token.data.scope).not.toContain('global'); - expect(token.data.scope).not.toContain('posts'); - }); + it('should use enabledScopes when customScopes is true, otherwise fall back to "global"', () => { + const scopeProperty = credential.properties.find((p) => p.name === 'scope'); + expect(scopeProperty?.default).toBe( + '={{$self["customScopes"] ? $self["enabledScopes"] : "global"}}', + ); }); }); diff --git a/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts b/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts index 488a73bfff81..3362e4e66240 100644 --- a/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Wordpress/GenericFunctions.ts @@ -28,17 +28,13 @@ export async function wordpressApiRequest( if (isOAuth2) { const credentials = await this.getCredentials('wordpressOAuth2Api'); credentialType = 'wordpressOAuth2Api'; - let sitePrefix = ''; - if (credentials.customDomain && credentials.customDomainUrl) { - let domain = credentials.customDomainUrl as string; - try { - domain = new URL(domain).hostname; - } catch { - domain = domain.replace(/\/+$/, ''); - } - sitePrefix = `/sites/${domain}`; + let site = credentials.wordpressSite as string; + try { + site = new URL(site).hostname; + } catch { + site = site.split('/')[0]; } - baseUri = `https://public-api.wordpress.com/wp/v2${sitePrefix}`; + baseUri = `https://public-api.wordpress.com/wp/v2/sites/${site}`; } else { const credentials = await this.getCredentials('wordpressApi'); credentialType = 'wordpressApi'; diff --git a/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts b/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts index 89cc301edd3e..fa5d946865fb 100644 --- a/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts +++ b/packages/nodes-base/nodes/Wordpress/__tests__/GenericFunctions.test.ts @@ -58,8 +58,7 @@ describe('Wordpress > GenericFunctions', () => { beforeEach(() => { mockFunctions.getNodeParameter.mockReturnValue('oAuth2'); mockFunctions.getCredentials.mockResolvedValue({ - customDomain: false, - customDomainUrl: '', + wordpressSite: 'myblog.wordpress.com', }); mockFunctions.helpers.requestWithAuthentication.mockResolvedValue({ data: 'testData' }); }); @@ -74,21 +73,20 @@ describe('Wordpress > GenericFunctions', () => { ); }); - it('should use public-api.wordpress.com base URL without custom domain', async () => { + it('should always include /sites/{site} in the URL', async () => { await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}); expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( 'wordpressOAuth2Api', expect.objectContaining({ - uri: 'https://public-api.wordpress.com/wp/v2/posts', + uri: 'https://public-api.wordpress.com/wp/v2/sites/myblog.wordpress.com/posts', }), ); }); - it('should include site prefix when custom domain is set', async () => { + it('should use /sites/{site} for a custom domain site', async () => { mockFunctions.getCredentials.mockResolvedValue({ - customDomain: true, - customDomainUrl: 'myblog.com', + wordpressSite: 'myblog.com', }); await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}); @@ -106,24 +104,22 @@ describe('Wordpress > GenericFunctions', () => { ['https://myblog.com', 'myblog.com'], ['http://myblog.com/', 'myblog.com'], ['myblog.com/', 'myblog.com'], - ])( - 'should normalize custom domain input "%s" to "%s" in site prefix', - async (input, expected) => { - mockFunctions.getCredentials.mockResolvedValue({ - customDomain: true, - customDomainUrl: input, - }); + ['myblog.com/path', 'myblog.com'], + ['myblog.wordpress.com', 'myblog.wordpress.com'], + ])('should normalize wordpressSite input "%s" to "%s" in the URL', async (input, expected) => { + mockFunctions.getCredentials.mockResolvedValue({ + wordpressSite: input, + }); - await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}); + await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {}); - expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( - 'wordpressOAuth2Api', - expect.objectContaining({ - uri: `https://public-api.wordpress.com/wp/v2/sites/${expected}/posts`, - }), - ); - }, - ); + expect(mockFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'wordpressOAuth2Api', + expect.objectContaining({ + uri: `https://public-api.wordpress.com/wp/v2/sites/${expected}/posts`, + }), + ); + }); it('should not set rejectUnauthorized for OAuth2 path', async () => { await wordpressApiRequest.call(mockFunctions, 'GET', '/posts', {}, {});