diff --git a/composer.json b/composer.json index 3200737a..05c386cb 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ "phpcompatibility/phpcompatibility-wp": "dev-master", "phpcompatibility/php-compatibility": "dev-develop as 9.99.99", "automattic/vipwpcs": "^3.0", - "wp-coding-standards/wpcs": "^3.0" + "wp-coding-standards/wpcs": "^3.0", + "phpunit/phpunit": "^9.6" }, "config": { "platform": { @@ -27,6 +28,9 @@ ], "fix": [ "phpcbf" + ], + "test": [ + "phpunit" ] }, "minimum-stability": "dev" diff --git a/php/class-connect.php b/php/class-connect.php index d35bb2a8..025e3594 100644 --- a/php/class-connect.php +++ b/php/class-connect.php @@ -93,6 +93,22 @@ class Connect extends Settings_Component implements Config, Setup, Notice { */ const CLOUDINARY_VARIABLE_REGEX = '^(?:CLOUDINARY_URL=)?cloudinary://[0-9]+:[A-Za-z_\-0-9]+@[A-Za-z]+'; + /** + * Sanitize a raw connection URL input. + * + * Strips leading/trailing whitespace and the optional CLOUDINARY_URL= prefix + * (case-insensitive), which users sometimes copy verbatim from their dashboard. + * + * @param string $url The raw URL string. + * + * @return string + */ + public static function sanitize_connection_url( $url ) { + $url = trim( (string) $url ); + $url = preg_replace( '/^CLOUDINARY_URL=/i', '', $url ); + return trim( $url ); + } + /** * Initiate the plugin resources. * @@ -150,7 +166,7 @@ public function rest_endpoints( $endpoints ) { */ public function rest_test_connection( WP_REST_Request $request ) { - $url = $request->get_param( 'cloudinary_url' ); + $url = self::sanitize_connection_url( (string) $request->get_param( 'cloudinary_url' ) ); $result = $this->test_connection( $url ); return rest_ensure_response( $result ); @@ -273,7 +289,7 @@ public function verify_connection( $data ) { return $data; } - $data['cloudinary_url'] = str_replace( 'CLOUDINARY_URL=', '', $data['cloudinary_url'] ); + $data['cloudinary_url'] = self::sanitize_connection_url( $data['cloudinary_url'] ); $current = $this->plugin->settings->find_setting( 'connect' )->get_value(); // Same URL, return original data. @@ -904,7 +920,7 @@ public function upgrade_connection( $old_version ) { } // Test upgraded details. - $data['cloudinary_url'] = str_replace( 'CLOUDINARY_URL=', '', $data['cloudinary_url'] ); + $data['cloudinary_url'] = self::sanitize_connection_url( $data['cloudinary_url'] ); $test = $this->test_connection( $data['cloudinary_url'] ); if ( 'connection_success' === $test['type'] ) { @@ -943,7 +959,7 @@ public function maybe_connection_string_constant( $value, $setting ) { static $url = null; if ( empty( $url ) ) { - $url = str_replace( 'CLOUDINARY_URL=', '', CLOUDINARY_CONNECTION_STRING ); + $url = self::sanitize_connection_url( CLOUDINARY_CONNECTION_STRING ); } if ( 'cloudinary_url' === $setting ) { diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..0ce2fb58 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,19 @@ + + + + + tests/php + + + + + php + + + diff --git a/src/js/components/wizard.js b/src/js/components/wizard.js index 2d93d9c3..343724a4 100644 --- a/src/js/components/wizard.js +++ b/src/js/components/wizard.js @@ -28,6 +28,7 @@ const Wizard = { error: document.getElementById( 'connection-error' ), success: document.getElementById( 'connection-success' ), working: document.getElementById( 'connection-working' ), + formatHint: document.getElementById( 'connection-format-hint' ), }, debounceConnect: null, updateConnection: document.getElementById( 'update-connection' ), @@ -93,13 +94,13 @@ const Wizard = { } ); connectionInput.addEventListener( 'input', ( ev ) => { this.lockNext(); - const value = connectionInput.value.replace( - 'CLOUDINARY_URL=', - '' - ); + const value = connectionInput.value + .replace( /^CLOUDINARY_URL=/i, '' ) + .trim(); this.connection.error.classList.remove( 'active' ); this.connection.success.classList.remove( 'active' ); this.connection.working.classList.remove( 'active' ); + this.connection.formatHint.classList.add( 'hidden' ); if ( value.length ) { this.testing = value; if ( this.debounceConnect ) { @@ -258,10 +259,12 @@ const Wizard = { showError() { this.connection.error.classList.add( 'active' ); this.connection.success.classList.remove( 'active' ); + this.connection.formatHint.classList.remove( 'hidden' ); }, showSuccess() { this.connection.error.classList.remove( 'active' ); this.connection.success.classList.add( 'active' ); + this.connection.formatHint.classList.add( 'hidden' ); }, show( item ) { item.classList.remove( 'hidden' ); diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php new file mode 100644 index 00000000..82d0f6e7 --- /dev/null +++ b/tests/php/bootstrap.php @@ -0,0 +1,118 @@ +code = $code; + $this->message = $message; + } + + /** @return string */ + public function get_error_code() { + return $this->code; + } + + /** @return string */ + public function get_error_message() { + return $this->message; + } + } +} diff --git a/tests/php/test-class-connect.php b/tests/php/test-class-connect.php new file mode 100644 index 00000000..dd3211b9 --- /dev/null +++ b/tests/php/test-class-connect.php @@ -0,0 +1,159 @@ +assertSame( $url, Cloudinary\Connect::sanitize_connection_url( $url ) ); + } + + /** @test */ + public function it_strips_uppercase_cloudinary_url_prefix() { + $raw = 'CLOUDINARY_URL=cloudinary://123:secret@cloud'; + $expected = 'cloudinary://123:secret@cloud'; + $this->assertSame( $expected, Cloudinary\Connect::sanitize_connection_url( $raw ) ); + } + + /** @test */ + public function it_strips_lowercase_cloudinary_url_prefix() { + $raw = 'cloudinary_url=cloudinary://123:secret@cloud'; + $expected = 'cloudinary://123:secret@cloud'; + $this->assertSame( $expected, Cloudinary\Connect::sanitize_connection_url( $raw ) ); + } + + /** @test */ + public function it_strips_mixed_case_cloudinary_url_prefix() { + $raw = 'Cloudinary_URL=cloudinary://123:secret@cloud'; + $expected = 'cloudinary://123:secret@cloud'; + $this->assertSame( $expected, Cloudinary\Connect::sanitize_connection_url( $raw ) ); + } + + /** @test */ + public function it_trims_leading_and_trailing_whitespace() { + $raw = ' cloudinary://123:secret@cloud '; + $expected = 'cloudinary://123:secret@cloud'; + $this->assertSame( $expected, Cloudinary\Connect::sanitize_connection_url( $raw ) ); + } + + /** @test */ + public function it_trims_whitespace_around_a_prefixed_url() { + $raw = " CLOUDINARY_URL=cloudinary://123:secret@cloud\n"; + $expected = 'cloudinary://123:secret@cloud'; + $this->assertSame( $expected, Cloudinary\Connect::sanitize_connection_url( $raw ) ); + } + + /** @test */ + public function it_returns_an_empty_string_for_empty_input() { + $this->assertSame( '', Cloudinary\Connect::sanitize_connection_url( '' ) ); + } + + /** @test */ + public function it_casts_non_string_input_to_string() { + // null cast to (string) is ''; prefix-only strings become empty. + $this->assertSame( '', Cloudinary\Connect::sanitize_connection_url( null ) ); + } + + /** @test */ + public function it_does_not_strip_prefix_that_appears_mid_string() { + // The regex is anchored to ^, so an embedded occurrence is left alone. + $raw = 'cloudinary://123:CLOUDINARY_URL=secret@cloud'; + $this->assertSame( $raw, Cloudinary\Connect::sanitize_connection_url( $raw ) ); + } + + // ----------------------------------------------------------------------- + // CLOUDINARY_VARIABLE_REGEX + // ----------------------------------------------------------------------- + + /** + * Helper: run the regex against a (pre-sanitized) URL. + * + * @param string $url URL to test. + * @return bool + */ + private function matches_regex( $url ) { + return (bool) preg_match( + '~' . Cloudinary\Connect::CLOUDINARY_VARIABLE_REGEX . '~', + $url + ); + } + + /** @test */ + public function regex_accepts_a_valid_url_without_prefix() { + $this->assertTrue( $this->matches_regex( 'cloudinary://123456:AbC-dEf_0@mycloud' ) ); + } + + /** @test */ + public function regex_accepts_a_valid_url_with_uppercase_prefix() { + // The regex itself still allows the prefix; sanitize_connection_url() removes + // it before storage, but the regex must tolerate it during the transition. + $this->assertTrue( $this->matches_regex( 'CLOUDINARY_URL=cloudinary://123456:AbC@mycloud' ) ); + } + + /** @test */ + public function regex_rejects_a_url_missing_the_cloudinary_scheme() { + $this->assertFalse( $this->matches_regex( 'https://123456:secret@mycloud' ) ); + } + + /** @test */ + public function regex_rejects_a_url_with_non_numeric_api_key() { + $this->assertFalse( $this->matches_regex( 'cloudinary://NOT_A_NUMBER:secret@mycloud' ) ); + } + + /** @test */ + public function regex_rejects_a_url_missing_the_cloud_name() { + // Missing host part – '@' at end with nothing following. + $this->assertFalse( $this->matches_regex( 'cloudinary://123:secret@' ) ); + } + + /** @test */ + public function regex_rejects_an_empty_string() { + $this->assertFalse( $this->matches_regex( '' ) ); + } + + // ----------------------------------------------------------------------- + // Integration: sanitize then validate + // ----------------------------------------------------------------------- + + /** @test */ + public function sanitized_prefixed_url_passes_regex_validation() { + $raw = 'CLOUDINARY_URL=cloudinary://987654321:MySecret_Key-01@production'; + $sanitized = Cloudinary\Connect::sanitize_connection_url( $raw ); + $this->assertTrue( $this->matches_regex( $sanitized ) ); + } + + /** @test */ + public function sanitized_whitespace_padded_url_passes_regex_validation() { + $raw = " cloudinary://111222333:pass@staging \n"; + $sanitized = Cloudinary\Connect::sanitize_connection_url( $raw ); + $this->assertTrue( $this->matches_regex( $sanitized ) ); + } + + /** @test */ + public function malformed_url_fails_even_after_sanitization() { + // Missing API secret. + $raw = 'CLOUDINARY_URL=cloudinary://123456@cloud'; + $sanitized = Cloudinary\Connect::sanitize_connection_url( $raw ); + $this->assertFalse( $this->matches_regex( $sanitized ) ); + } +} diff --git a/ui-definitions/components/wizard.php b/ui-definitions/components/wizard.php index 832cd6c3..d43fd27a 100644 --- a/ui-definitions/components/wizard.php +++ b/ui-definitions/components/wizard.php @@ -149,6 +149,9 @@ +