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 @@
+
+ cloudinary://API_KEY:API_SECRET@CLOUD_NAME
+