From 4724719eaa36295733955d47ad98c43e06ebc1f7 Mon Sep 17 00:00:00 2001 From: Theofanis Despoudis <328805+theodesp@users.noreply.github.com> Date: Thu, 7 Mar 2024 14:19:08 +0000 Subject: [PATCH] Feature: Error when NEXT_PUBLIC_WORDPRESS_URL same as headless site URL (#1809) * Feature: Error when NEXT_PUBLIC_WORDPRESS_URL pointing to headless site * Update packages/faustwp-cli/src/healthCheck/validateNextWordPressUrl.ts Co-authored-by: John Parris * Chore: Fix PHP Lint issues * Chore: PHP Lint issues * Feature: handle older versions of FaustWP Plugin * Tests: Add unit tests for domains_match * PHPCS: Fix * PHPCS: Lint issues * Chore: Remove WP_Rest_Response messages. * Feat: Only perform the check on valid secret key --------- Co-authored-by: John Parris --- .changeset/modern-tools-collect.md | 6 + package-lock.json | 25 +++++ packages/faustwp-cli/package.json | 3 +- .../src/healthCheck/validateFaustEnvVars.ts | 2 + .../healthCheck/validateNextWordPressUrl.ts | 41 +++++++ .../healthCheck/validateFaustEnvVars.test.ts | 20 ++-- .../validateNextWordPressUrl.test.ts | 103 ++++++++++++++++++ .../healthCheck/verifyGraphQLEndpoint.test.ts | 2 +- plugins/faustwp/includes/rest/callbacks.php | 51 +++++++++ .../faustwp/includes/utilities/functions.php | 24 ++++ plugins/faustwp/tests/unit/FunctionsTests.php | 24 ++++ 11 files changed, 290 insertions(+), 11 deletions(-) create mode 100644 .changeset/modern-tools-collect.md create mode 100644 packages/faustwp-cli/src/healthCheck/validateNextWordPressUrl.ts create mode 100644 packages/faustwp-cli/tests/healthCheck/validateNextWordPressUrl.test.ts create mode 100644 plugins/faustwp/tests/unit/FunctionsTests.php diff --git a/.changeset/modern-tools-collect.md b/.changeset/modern-tools-collect.md new file mode 100644 index 000000000..d05963486 --- /dev/null +++ b/.changeset/modern-tools-collect.md @@ -0,0 +1,6 @@ +--- +'@faustwp/cli': patch +'@faustwp/wordpress-plugin': patch +--- + +Faust now errors if the NEXT_PUBLIC_WORDPRESS_URL matches the Headless URL in Faust Plugin settings. diff --git a/package-lock.json b/package-lock.json index 31b6840d5..e00602473 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15136,6 +15136,30 @@ } } }, + "node_modules/fetch-mock-jest": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fetch-mock-jest/-/fetch-mock-jest-1.5.1.tgz", + "integrity": "sha512-+utwzP8C+Pax1GSka3nFXILWMY3Er2L+s090FOgqVNrNCPp0fDqgXnAHAJf12PLHi0z4PhcTaZNTz8e7K3fjqQ==", + "dev": true, + "dependencies": { + "fetch-mock": "^9.11.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "charity", + "url": "https://www.justgiving.com/refugee-support-europe" + }, + "peerDependencies": { + "node-fetch": "*" + }, + "peerDependenciesMeta": { + "node-fetch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "dev": true, @@ -31596,6 +31620,7 @@ "@types/jest": "^29.5.5", "@types/node": "^18.15.11", "@types/prompt": "1.1.2", + "fetch-mock-jest": "^1.5.1", "jest-environment-jsdom": "29.6.4", "rimraf": "5.0.5", "ts-jest": "^29.1.1", diff --git a/packages/faustwp-cli/package.json b/packages/faustwp-cli/package.json index 6ba5ea5f0..c71d3917f 100644 --- a/packages/faustwp-cli/package.json +++ b/packages/faustwp-cli/package.json @@ -20,7 +20,8 @@ "jest-environment-jsdom": "29.6.4", "rimraf": "5.0.5", "ts-jest": "^29.1.1", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "fetch-mock-jest": "^1.5.1" }, "dependencies": { "archiver": "^6.0.1", diff --git a/packages/faustwp-cli/src/healthCheck/validateFaustEnvVars.ts b/packages/faustwp-cli/src/healthCheck/validateFaustEnvVars.ts index 439bcac10..2e66b97bc 100644 --- a/packages/faustwp-cli/src/healthCheck/validateFaustEnvVars.ts +++ b/packages/faustwp-cli/src/healthCheck/validateFaustEnvVars.ts @@ -1,5 +1,6 @@ import { getWpSecret, getWpUrl } from '../utils/index.js'; import { errorLog, infoLog, warnLog } from '../stdout/index.js'; +import { validateNextWordPressUrl } from './validateNextWordPressUrl.js'; export function isWPEngineComSubdomain(url: string) { const regex = /\b\w+\.wpengine\.com\b/; @@ -63,6 +64,7 @@ export const validateFaustEnvVars = async () => { ); process.exit(1); } + await validateNextWordPressUrl(); } catch (error) { console.log('error', error); } diff --git a/packages/faustwp-cli/src/healthCheck/validateNextWordPressUrl.ts b/packages/faustwp-cli/src/healthCheck/validateNextWordPressUrl.ts new file mode 100644 index 000000000..6584ff69b --- /dev/null +++ b/packages/faustwp-cli/src/healthCheck/validateNextWordPressUrl.ts @@ -0,0 +1,41 @@ +import { getWpSecret, getWpUrl } from '../utils/index.js'; +import { errorLog, warnLog } from '../stdout/index.js'; + +/** + * Validates the NEXT_PUBLIC_WORDPRESS_URL environment variable by sending a POST request to the Faust Plugin API. + * If the URL matches the Faust Plugin Headless URL, the validation fails, and an error is logged. + */ +export async function validateNextWordPressUrl(): Promise { + const apiUrl = `${getWpUrl()}/wp-json/faustwp/v1/validate_public_wordpress_url`; + const headers = { + 'Content-Type': 'application/json', + 'x-faustwp-secret': getWpSecret() || '', + }; + + const postData = { + public_wordpress_url: getWpUrl(), + }; + try { + const response = await fetch(apiUrl, { + method: 'POST', + headers, + body: JSON.stringify(postData), + }); + + if (!response.ok) { + if (response.status === 404) { + // Handle the case when the route does not exist + warnLog( + 'Route not found: Please update your FaustWP plugin to the latest version.', + ); + } else { + errorLog( + 'Validation Failed: Your Faust front-end site URL value is misconfigured. It should NOT match the `NEXT_PUBLIC_WORDPRESS_URL.`', + ); + process.exit(1); + } + } + } catch (error) { + console.log('error', error); + } +} diff --git a/packages/faustwp-cli/tests/healthCheck/validateFaustEnvVars.test.ts b/packages/faustwp-cli/tests/healthCheck/validateFaustEnvVars.test.ts index 5a010cac0..32625c6f5 100644 --- a/packages/faustwp-cli/tests/healthCheck/validateFaustEnvVars.test.ts +++ b/packages/faustwp-cli/tests/healthCheck/validateFaustEnvVars.test.ts @@ -2,8 +2,7 @@ import { isWPEngineComSubdomain, validateFaustEnvVars, } from '../../src/healthCheck/validateFaustEnvVars'; -import fetchMock from 'fetch-mock'; - +import fetchMock from 'fetch-mock-jest'; /** * @jest-environment jsdom */ @@ -56,19 +55,22 @@ describe('healthCheck/validateFaustEnvVars', () => { }); it('logs an error when the secret key validation fails', async () => { - process.env.NEXT_PUBLIC_WORDPRESS_URL = 'https://headless.local'; process.env.FAUST_SECRET_KEY = 'invalid-secret-key'; - fetchMock.post('https://headless.local/wp-json/faustwp/v1/validate_secret_key', { - status: 401, - }); - + fetchMock.post( + 'https://headless.local/wp-json/faustwp/v1/validate_secret_key', + { + status: 401, + }, + ); + await validateFaustEnvVars(); - return expect(Promise.resolve(validateFaustEnvVars())).toMatchSnapshot(`Ensure your FAUST_SECRET_KEY environment variable matches your Secret Key in the Faust WordPress plugin settings`); + return expect(Promise.resolve(validateFaustEnvVars())).toMatchSnapshot( + `Ensure your FAUST_SECRET_KEY environment variable matches your Secret Key in the Faust WordPress plugin settings`, + ); }); - }); describe('isWPEngineComTLD', () => { diff --git a/packages/faustwp-cli/tests/healthCheck/validateNextWordPressUrl.test.ts b/packages/faustwp-cli/tests/healthCheck/validateNextWordPressUrl.test.ts new file mode 100644 index 000000000..c539fabc0 --- /dev/null +++ b/packages/faustwp-cli/tests/healthCheck/validateNextWordPressUrl.test.ts @@ -0,0 +1,103 @@ +import fetchMock from 'fetch-mock-jest'; +import { validateNextWordPressUrl } from '../../src/healthCheck/validateNextWordPressUrl'; +/** + * @jest-environment jsdom + */ +describe('healthCheck/validateNextWordPressUrl', () => { + const envBackup = process.env; + + beforeEach(() => { + process.env = { ...envBackup }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + process.env = envBackup; + }); + + it('exits with a 1 exit code when the WordPress URL matches the Headless URL', async () => { + // @ts-ignore + const mockExit = jest.spyOn(process, 'exit').mockImplementation((code) => { + if (code && code !== 0) { + throw new Error(`Exit code: ${code}`); + } + }); + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + process.env.NEXT_PUBLIC_WORDPRESS_URL = 'https://headless.local'; + process.env.FAUST_SECRET_KEY = 'invalid-secret-key'; + + const headers = { + 'Content-Type': 'application/json', + 'x-faustwp-secret': process.env.FAUST_SECRET_KEY, + }; + + fetchMock.post( + 'https://headless.local/wp-json/faustwp/v1/validate_public_wordpress_url', + { + headers, + body: JSON.stringify({ + public_wordpress_url: process.env.NEXT_PUBLIC_WORDPRESS_URL, + }), + status: 400, + }, + ); + + await validateNextWordPressUrl(); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Validation Failed: Your Faust front-end site URL value is misconfigured. It should NOT match the `NEXT_PUBLIC_WORDPRESS_URL.', + ), + ); + expect(mockExit).toHaveBeenCalledWith(1); + expect(fetchMock).toHaveFetched( + 'https://headless.local/wp-json/faustwp/v1/validate_public_wordpress_url', + ); + + consoleLogSpy.mockClear(); + }); + + it('continues silently when the route does not exist', async () => { + // @ts-ignore + const mockExit = jest.spyOn(process, 'exit').mockImplementation((code) => { + if (code && code !== 0) { + throw new Error(`Exit code: ${code}`); + } + }); + // Mock environment variables + process.env.NEXT_PUBLIC_WORDPRESS_URL = 'http://mysite.local'; + process.env.FAUST_SECRET_KEY = 'e9d5963e-bb41-4c94-a3f3-292e8903d5ea'; + + const headers = { + 'Content-Type': 'application/json', + 'x-faustwp-secret': process.env.FAUST_SECRET_KEY, + }; + + fetchMock.postOnce( + 'http://mysite.local/wp-json/faustwp/v1/validate_public_wordpress_url', + { + status: 404, + }, + ); + + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + await validateNextWordPressUrl(); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Route not found: Please update your FaustWP plugin to the latest version.', + ), + ); + + expect(mockExit).not.toHaveBeenCalled() + expect(fetchMock).toHaveFetched( + 'http://mysite.local/wp-json/faustwp/v1/validate_public_wordpress_url', + ); + + consoleLogSpy.mockRestore(); + }); +}); diff --git a/packages/faustwp-cli/tests/healthCheck/verifyGraphQLEndpoint.test.ts b/packages/faustwp-cli/tests/healthCheck/verifyGraphQLEndpoint.test.ts index 052f0d4be..69a48dec0 100644 --- a/packages/faustwp-cli/tests/healthCheck/verifyGraphQLEndpoint.test.ts +++ b/packages/faustwp-cli/tests/healthCheck/verifyGraphQLEndpoint.test.ts @@ -2,7 +2,7 @@ * @jest-environment jsdom */ import 'isomorphic-fetch'; -import fetchMock from 'fetch-mock'; +import fetchMock from 'fetch-mock-jest'; import { verifyGraphQLEndpoint } from '../../src/healthCheck/verifyGraphQLEndpoint.js'; import { getGraphqlEndpoint } from '../../src/utils/index.js'; diff --git a/plugins/faustwp/includes/rest/callbacks.php b/plugins/faustwp/includes/rest/callbacks.php index 1c4bf23c4..6951d2acc 100644 --- a/plugins/faustwp/includes/rest/callbacks.php +++ b/plugins/faustwp/includes/rest/callbacks.php @@ -26,6 +26,7 @@ use function WPE\FaustWP\Settings\faustwp_get_setting; use function WPE\FaustWP\Settings\faustwp_update_setting; use function WPE\FaustWP\Settings\is_telemetry_enabled; +use function WPE\FaustWP\Utilities\domains_match; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -139,6 +140,16 @@ function register_rest_routes() { ) ); + register_rest_route( + 'faustwp/v1', + '/validate_public_wordpress_url', + array( + 'methods' => 'POST', + 'callback' => __NAMESPACE__ . '\\handle_rest_validate_public_wordpress_url_callback', + 'permission_callback' => __NAMESPACE__ . '\\rest_authorize_permission_callback', + ) + ); + /** * Faust.js packages now use `faustwp/v1/authorize`. * @@ -523,3 +534,43 @@ function handle_rest_validate_secret_key_callback( \WP_REST_Request $request ) { function rest_validate_secret_key_permission_callback( \WP_REST_Request $request ) { return rest_authorize_permission_callback( $request ); } + +/** + * Callback for WordPress register_rest_route() 'callback' parameter. + * + * Handle POST /faustwp/v1/validate_public_wordpress_url response. + * + * @link https://developer.wordpress.org/reference/functions/register_rest_route/ + * @link https://developer.wordpress.org/rest-api/extending-the-rest-api/routes-and-endpoints/#endpoint-callback + * + * @param \WP_REST_Request $request Current \WP_REST_Request object. + * + * @return mixed A \WP_REST_Response, or \WP_Error. + */ +function handle_rest_validate_public_wordpress_url_callback( \WP_REST_Request $request ) { + // Get the frontend URI setting from WordPress. + $frontend_uri = faustwp_get_setting( 'frontend_uri' ); + + // Retrieve the parameters from the request. + $parameters = $request->get_params(); + + // Check if the public_wordpress_url parameter is present in the request. + if ( isset( $parameters['public_wordpress_url'] ) ) { + // Retrieve the value of the public_wordpress_url parameter. + $public_wordpress_url = $parameters['public_wordpress_url']; + + // Check if the provided WordPress URL does not match the frontend URI. + if ( ! domains_match( $public_wordpress_url, $frontend_uri ) ) { + // Return 200 OK if the URLs do not match. + $response = new \WP_REST_Response( 'OK', 200 ); + } else { + // Return 400 Bad Request if the URLs match. + $response = new \WP_REST_Response( 'Bad Request', 400 ); + } + } else { + // Return 400 Bad Request if the public_wordpress_url parameter is missing. + $response = new \WP_REST_Response( 'Bad Request', 400 ); + } + + return $response; +} diff --git a/plugins/faustwp/includes/utilities/functions.php b/plugins/faustwp/includes/utilities/functions.php index 0cc189b8e..ac699e0cc 100644 --- a/plugins/faustwp/includes/utilities/functions.php +++ b/plugins/faustwp/includes/utilities/functions.php @@ -43,3 +43,27 @@ function plugin_version() { return $plugin['Version']; } + +/** + * Checks if two domain strings represent the same domain. + * + * @param string $domain1 The first domain string. + * @param string $domain2 The second domain string. + * @return bool True if the domains match, false otherwise. + */ +function domains_match( $domain1, $domain2 ) { + // Extract the domain part. + $extract_domain = function ( $url ) { + $parsed_url = wp_parse_url( $url, PHP_URL_HOST ); + return $parsed_url ? $parsed_url : null; + }; + + $domain1 = $extract_domain( $domain1 ); + $domain2 = $extract_domain( $domain2 ); + + // Remove "www" prefix from domain if present. + $domain1 = preg_replace( '/^www\./i', '', $domain1 ); + $domain2 = preg_replace( '/^www\./i', '', $domain2 ); + + return null !== $domain1 && null !== $domain2 && $domain1 === $domain2; +} diff --git a/plugins/faustwp/tests/unit/FunctionsTests.php b/plugins/faustwp/tests/unit/FunctionsTests.php new file mode 100644 index 000000000..66bd9ab45 --- /dev/null +++ b/plugins/faustwp/tests/unit/FunctionsTests.php @@ -0,0 +1,24 @@ +assertTrue(domains_match("http://example.com", "https://example.com")); + + // Test case 2: Same domains with trailing slashes + $this->assertTrue(domains_match("http://example.com/", "http://example.com")); + + // Test case 3: Same domains with www prefix + $this->assertTrue(domains_match("http://www.example.com", "http://example.com")); + + // Test case 4: Different domains + $this->assertFalse(domains_match("http://example1.com", "http://example2.com")); + + // Test case 5: Same domains with different subdomains + $this->assertFalse(domains_match("http://1.example.com", "http://2.example.com")); + } +}