Commit b77fc8fcf3 for woocommerce
commit b77fc8fcf3bb235af79e5fbe119accef92113578
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date: Fri Apr 25 19:20:49 2025 +0800
[Blueprint] Enhance SQL execution safety (#57344)
* Enhance SQL execution safety in ImportRunSql class
* Introduced validation checks for allowed SQL query types.
* Added mechanisms to detect SQL injection patterns and prevent unauthorized modifications to protected user tables and capabilities.
* Implemented SQL normalization to standardize query format.
* Enhanced error handling during SQL execution with transaction support.
* Add unit tests for ImportRunSql class
* Implemented comprehensive tests for SQL query processing, including valid INSERT, UPDATE, and REPLACE queries.
* Added tests to ensure invalid query types are correctly rejected and appropriate error messages are returned.
* Included tests for SQL normalization, handling of SQL injection patterns, and protection against modifications to user roles and capabilities.
* Ensured proper error handling for SQL execution failures and multiple statement queries.
* Add changelog
* Improve error handling in ImportRunSql class during SQL execution
* Updated error suppression to be enabled explicitly.
* Refactored error handling to store the last error in a variable for clarity.
* Enhanced error messages to provide more informative feedback on SQL execution failures.
* Refactor SQL handling in ImportRunSql class for improved security and clarity
* Removed the normalize_sql method and replaced it with direct SQL trimming.
* Added checks for suspicious SQL comments to enhance security against hidden malicious code.
* Updated related tests to validate detection of suspicious comments and ensure proper error handling for SQL queries.
* Simplified SQL validation logic by directly using trimmed SQL content in various checks.
* Fix tests
* Refactor SQL query preparation in ExportWCTaxRates class for improved security
* Updated the SQL query in generateSteps method to use prepared statements, enhancing security against SQL injection.
* Removed direct table name interpolation to ensure safer query execution.
* Update table name handling in ExportWCTaxRates class for improved SQL query preparation
* Modified the generateSteps method to prepend the table prefix directly to the table name variable, ensuring consistent and secure SQL query execution.
* This change enhances clarity and maintains the integrity of the SQL preparation process.
* Reformat
* Add changelog
* Remove unused imports
* [Blueprint] Add security restriction to only allow blueprint imports in Coming Soon mode (#57382)
* Implement import permission check for blueprint uploads
- Added a new API endpoint to check if blueprint imports are allowed based on site status.
- Integrated the import permission check into the file upload state machine.
- Updated the UI to display a notice when imports are disabled, guiding users to enable the "Coming Soon" mode or set a constant for live sites.
- Enhanced styling for error and notice components related to blueprint uploads.
This change improves user experience by providing clear feedback on import permissions.
* Add changelog
* Implement import permission check in the RestApi class
- Added a check in the import_step method to verify if blueprint imports are allowed.
- Returns an error message if imports are disabled, enhancing user feedback during the import process.
* Fix lints
* Add unit tests for blueprint import functionality
- Introduced tests to validate the import_step endpoint, including checks for file size limits and import permissions based on site status.
- Enhanced the setup and teardown methods for better resource management during tests.
- Ensured that appropriate error messages are returned when imports are disabled or when file size exceeds the limit, improving feedback for users.
* Fix lint
diff --git a/packages/php/blueprint/changelog/woo6-10-blueprints-security-audit-determine-the-scope-of-allowed-sql b/packages/php/blueprint/changelog/woo6-10-blueprints-security-audit-determine-the-scope-of-allowed-sql
new file mode 100644
index 0000000000..0e916f97af
--- /dev/null
+++ b/packages/php/blueprint/changelog/woo6-10-blueprints-security-audit-determine-the-scope-of-allowed-sql
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Enhance SQL execution safety in ImportRunSql class
diff --git a/packages/php/blueprint/src/Importers/ImportRunSql.php b/packages/php/blueprint/src/Importers/ImportRunSql.php
index 3a2fb889bb..2b4bc3fbcd 100644
--- a/packages/php/blueprint/src/Importers/ImportRunSql.php
+++ b/packages/php/blueprint/src/Importers/ImportRunSql.php
@@ -9,7 +9,10 @@ use Automattic\WooCommerce\Blueprint\UsePluginHelpers;
use Automattic\WooCommerce\Blueprint\UseWPFunctions;
/**
- * Class ImportRunSql
+ * Processes SQL execution steps in the Blueprint.
+ *
+ * Handles the execution of SQL queries with safety checks to prevent
+ * unauthorized modifications to sensitive WordPress data.
*
* @package Automattic\WooCommerce\Blueprint\Importers
*/
@@ -18,22 +21,86 @@ class ImportRunSql implements StepProcessor {
use UseWPFunctions;
/**
- * Process the step.
+ * List of allowed SQL query types.
*
- * @param object $schema The schema for the step.
+ * @var array
+ */
+ private const ALLOWED_QUERY_TYPES = array(
+ 'INSERT',
+ 'UPDATE',
+ 'REPLACE INTO',
+ );
+
+
+ /**
+ * Process the SQL execution step.
*
- * @return StepProcessorResult
+ * Validates and executes the SQL query while ensuring:
+ * 1. Only allowed query types are executed
+ * 2. No modifications to admin users or roles
+ * 3. No unauthorized changes to user capabilities
+ *
+ * @param object $schema The schema containing the SQL query to execute.
+ * @return StepProcessorResult The result of the SQL execution.
*/
public function process( $schema ): StepProcessorResult {
global $wpdb;
$result = StepProcessorResult::success( RunSql::get_step_name() );
- // Security check: Check if we can use prepared statements.
- $wpdb->query( $schema->sql->contents ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
- if ( $wpdb->last_error ) {
- $result->add_error( "Error executing SQL: {$wpdb->last_error}" );
- } else {
- $result->add_debug( "Executed SQL ({$schema->sql->name}): {$schema->sql->contents}" );
+ $sql = trim( $schema->sql->contents );
+
+ // Check if the query type is allowed.
+ if ( ! $this->is_allowed_query_type( $sql ) ) {
+ $result->add_error(
+ sprintf(
+ 'Only %s queries are allowed.',
+ implode( ', ', self::ALLOWED_QUERY_TYPES )
+ )
+ );
+ return $result;
+ }
+
+ // Check for SQL comments that might be hiding malicious code.
+ if ( $this->contains_suspicious_comments( $sql ) ) {
+ $result->add_error( 'SQL query contains suspicious comment patterns.' );
+ return $result;
+ }
+
+ // Detect SQL injection patterns.
+ if ( $this->contains_sql_injection_patterns( $sql ) ) {
+ $result->add_error( 'SQL query contains potential injection patterns.' );
+ return $result;
+ }
+
+ // Check if the query affects protected tables.
+ if ( $this->affects_protected_tables( $sql ) ) {
+ $result->add_error( 'Modifications to admin users or roles are not allowed.' );
+ return $result;
+ }
+
+ // Check if the query affects user capabilities in wp_options.
+ if ( $this->affects_user_capabilities( $sql ) ) {
+ $result->add_error( 'Modifications to user roles or capabilities are not allowed.' );
+ return $result;
+ }
+
+ $wpdb->suppress_errors( true );
+ $wpdb->query( 'START TRANSACTION' );
+
+ try {
+ $query_result = $wpdb->query( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
+
+ $last_error = $wpdb->last_error;
+ if ( $last_error ) {
+ $wpdb->query( 'ROLLBACK' );
+ $result->add_error( 'Error executing SQL: ' . $last_error );
+ } else {
+ $wpdb->query( 'COMMIT' );
+ $result->add_debug( "Executed SQL ({$schema->sql->name}): Affected {$query_result} rows" );
+ }
+ } catch ( \Throwable $e ) {
+ $wpdb->query( 'ROLLBACK' );
+ $result->add_error( "Exception executing SQL: {$e->getMessage()}" );
}
return $result;
@@ -70,4 +137,175 @@ class ImportRunSql implements StepProcessor {
return true;
}
+ /**
+ * Check if the SQL query type is allowed.
+ *
+ * @param string $sql_content The SQL query to check.
+ * @return bool True if the query type is allowed, false otherwise.
+ */
+ private function is_allowed_query_type( string $sql_content ): bool {
+ $uppercase_sql_content = strtoupper( trim( $sql_content ) );
+
+ foreach ( self::ALLOWED_QUERY_TYPES as $query_type ) {
+ if ( 0 === stripos( $uppercase_sql_content, $query_type ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Check for suspicious comment patterns that might hide malicious code.
+ *
+ * This method detects various types of SQL comments that might be used
+ * to hide malicious SQL commands or bypass security filters.
+ *
+ * @param string $sql_content The SQL query to check.
+ * @return bool True if suspicious comments found, false otherwise.
+ */
+ private function contains_suspicious_comments( string $sql_content ): bool {
+ // Quick check if there are any comments at all before running regex.
+ if (
+ strpos( $sql_content, '--' ) === false &&
+ strpos( $sql_content, '/*' ) === false &&
+ strpos( $sql_content, '#' ) === false
+ ) {
+ return false;
+ }
+
+ // List of potentially dangerous SQL commands to check for in comments.
+ $dangerous_commands = array(
+ 'DELETE',
+ 'DROP',
+ 'ALTER',
+ 'CREATE',
+ 'TRUNCATE',
+ 'GRANT',
+ 'REVOKE',
+ 'EXEC',
+ 'EXECUTE',
+ 'CALL',
+ 'INTO OUTFILE',
+ 'INTO DUMPFILE',
+ 'LOAD_FILE',
+ 'LOAD DATA',
+ 'BENCHMARK',
+ 'SLEEP',
+ 'INFORMATION_SCHEMA',
+ 'USER\\(',
+ 'DATABASE\\(',
+ 'SCHEMA\\(',
+ );
+
+ $dangerous_pattern = implode( '|', $dangerous_commands );
+
+ // Check for SQL comments that might be hiding malicious code.
+ $patterns = array(
+ // Single-line comments (-- style) containing dangerous commands.
+ '/--.*?(' . $dangerous_pattern . ')/i',
+ // Single-line comments (# style) containing dangerous commands.
+ '/#.*?(' . $dangerous_pattern . ')/i',
+ // Multi-line comments hiding dangerous commands.
+ '/\/\*.*?(' . $dangerous_pattern . ').*?\*\//is',
+ // MySQL-specific execution comments (version-specific code execution).
+ '/\/\*![0-9]*.*?\*\//',
+ );
+
+ foreach ( $patterns as $pattern ) {
+ if ( preg_match( $pattern, $sql_content ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+
+ /**
+ * Check for common SQL injection patterns.
+ *
+ * @param string $sql_content The SQL query to check.
+ * @return bool True if potential injection patterns found, false otherwise.
+ */
+ private function contains_sql_injection_patterns( string $sql_content ): bool {
+ $patterns = array(
+ '/UNION\s+(?:ALL\s+)?SELECT/i', // UNION-based injections.
+ '/OR\s+1\s*=\s*1/i', // OR 1=1 condition.
+ '/AND\s+0\s*=\s*0/i', // AND 0=0 condition.
+ '/;\s*--/i', // Inline comment terminations.
+ '/SLEEP\s*\(/i', // Time-based injections.
+ '/BENCHMARK\s*\(/i', // Benchmark-based injections.
+ '/LOAD_FILE\s*\(/i', // File access.
+ '/INTO\s+OUTFILE/i', // File write.
+ '/INTO\s+DUMPFILE/i', // File dump.
+ '/CREATE\s+(?:TEMPORARY\s+)?TABLE/i', // Table creation.
+ '/DROP\s+TABLE/i', // Table deletion.
+ '/ALTER\s+TABLE/i', // Table alteration.
+ '/INFORMATION_SCHEMA/i', // Database metadata access.
+ '/EXEC\s*\(/i', // Stored procedure execution.
+ '/SCHEMA_NAME/i', // Schema access.
+ '/DATABASE\(\)/i', // Current database name.
+ '/CHR\s*\(/i', // Character function for evasion.
+ '/CHAR\s*\(/i', // Character function for evasion.
+ '/FROM\s+mysql\./i', // Direct MySQL system table access.
+ '/FROM\s+information_schema\./i', // Direct information schema access.
+ );
+ foreach ( $patterns as $pattern ) {
+ if ( preg_match( $pattern, $sql_content ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+
+ /**
+ * Check if the SQL query affects protected user tables.
+ *
+ * @param string $sql_content The SQL query to check.
+ * @return bool True if the query affects protected tables, false otherwise.
+ */
+ private function affects_protected_tables( string $sql_content ): bool {
+ global $wpdb;
+ $protected_tables = array(
+ $wpdb->users,
+ $wpdb->usermeta,
+ );
+
+ foreach ( $protected_tables as $table ) {
+ if ( preg_match( '/\b' . preg_quote( $table, '/' ) . '\b/i', $sql_content ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if the SQL query affects user capabilities in wp_options.
+ *
+ * @param string $sql_content The SQL query to check.
+ * @return bool True if the query affects user capabilities, false otherwise.
+ */
+ private function affects_user_capabilities( string $sql_content ): bool {
+ global $wpdb;
+
+ // Check if the query affects user capabilities in wp_options.
+ if ( stripos( $sql_content, $wpdb->prefix . 'options' ) !== false ) {
+ $option_patterns = array(
+ 'user_roles',
+ 'capabilities',
+ 'wp_user_',
+ 'role_',
+ 'administrator',
+ );
+
+ foreach ( $option_patterns as $pattern ) {
+ if ( stripos( $sql_content, $pattern ) !== false ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
}
diff --git a/packages/php/blueprint/tests/Unit/Importers/ImportRunSqlTest.php b/packages/php/blueprint/tests/Unit/Importers/ImportRunSqlTest.php
new file mode 100644
index 0000000000..ce703e900d
--- /dev/null
+++ b/packages/php/blueprint/tests/Unit/Importers/ImportRunSqlTest.php
@@ -0,0 +1,289 @@
+<?php
+
+namespace Automattic\WooCommerce\Blueprint\Tests\Unit\Importers;
+
+use Automattic\WooCommerce\Blueprint\Importers\ImportRunSql;
+use Automattic\WooCommerce\Blueprint\Steps\RunSql;
+use PHPUnit\Framework\TestCase;
+use stdClass;
+
+/**
+ * Tests for ImportRunSql.
+ */
+class ImportRunSqlTest extends TestCase {
+ /**
+ * The importer instance being tested.
+ *
+ * @var ImportRunSql
+ */
+ private $importer;
+
+ /**
+ * Set up the test case.
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $this->importer = new ImportRunSql();
+ }
+
+ /**
+ * Test that the importer returns the correct step class.
+ */
+ public function test_get_step_class(): void {
+ $this->assertEquals( RunSql::class, $this->importer->get_step_class() );
+ }
+
+ /**
+ * Test that valid INSERT query is processed successfully.
+ */
+ public function test_process_valid_insert_query(): void {
+ $schema = $this->create_sql_schema(
+ 'INSERT INTO wp_posts (post_title) VALUES (\'Test Post\')',
+ 'test_insert'
+ );
+
+ $result = $this->importer->process( $schema );
+
+ $this->assertTrue( $result->is_success() );
+ }
+
+ /**
+ * Test that valid UPDATE query is processed successfully.
+ */
+ public function test_process_valid_update_query(): void {
+ $schema = $this->create_sql_schema(
+ 'UPDATE wp_posts SET post_title = \'Updated Title\' WHERE ID = 1',
+ 'test_update'
+ );
+
+ $result = $this->importer->process( $schema );
+
+ $this->assertTrue( $result->is_success() );
+ }
+
+ /**
+ * Test that REPLACE INTO query is processed successfully.
+ */
+ public function test_process_valid_replace_query(): void {
+ $schema = $this->create_sql_schema(
+ 'REPLACE INTO wp_options (option_name, option_value) VALUES (\'test_option\', \'test_value\')',
+ 'test_replace'
+ );
+
+ $result = $this->importer->process( $schema );
+
+ $this->assertTrue( $result->is_success() );
+ }
+
+ /**
+ * Test that invalid query types are rejected.
+ *
+ * @param string $query The query to test.
+ *
+ * @dataProvider invalid_queries_provider
+ */
+ public function test_process_invalid_query_types( string $query ): void {
+ $schema = $this->create_sql_schema( $query, 'test_invalid_query' );
+
+ $result = $this->importer->process( $schema );
+
+ $this->assertFalse( $result->is_success() );
+ $error_messages = $result->get_messages( 'error' );
+ $this->assertNotEmpty( $error_messages );
+ $this->assertStringContainsString( 'Only INSERT, UPDATE, REPLACE INTO queries are allowed', $error_messages[0]['message'] );
+ }
+
+ /**
+ * Data provider for invalid query types.
+ *
+ * @return array
+ */
+ public function invalid_queries_provider(): array {
+ return array(
+ array( 'DELETE FROM wp_posts WHERE ID = 1' ),
+ array( 'SELECT * FROM wp_posts' ),
+ array( 'CREATE TABLE test_table (id INT)' ),
+ array( 'DROP TABLE IF EXISTS test_table' ),
+ array( 'ALTER TABLE wp_posts ADD COLUMN new_column INT' ),
+ array( 'TRUNCATE TABLE wp_posts' ),
+ array( 'GRANT ALL PRIVILEGES ON wp_posts TO \'user\'@\'localhost\'' ),
+ array( 'REVOKE ALL PRIVILEGES ON wp_posts FROM \'user\'@\'localhost\'' ),
+ );
+ }
+
+
+ /**
+ * Test detection of suspicious SQL comments.
+ *
+ * @dataProvider suspicious_comments_provider
+ *
+ * @param string $name The name of the test case.
+ * @param string $sql The SQL query to test.
+ */
+ public function test_contains_suspicious_comments( string $name, string $sql ): void {
+ $schema = $this->create_sql_schema( $sql, $name );
+ $importer = new ImportRunSql();
+ $result = $importer->process( $schema );
+
+ $this->assertFalse( $result->is_success() );
+ $error_messages = $result->get_messages( 'error' );
+ $this->assertNotEmpty( $error_messages );
+ $this->assertStringContainsString( 'SQL query contains suspicious comment patterns.', $error_messages[0]['message'], $name );
+ }
+
+
+ /**
+ * Data provider for suspicious SQL comments.
+ *
+ * @return array[] Test cases with SQL queries containing suspicious comments.
+ */
+ public function suspicious_comments_provider(): array {
+ return array(
+ array( 'single line comment with dangerous command', "UPDATE wp_posts SET post_status = 'draft' -- DROP TABLE wp_posts" ),
+ array( 'hash comment with dangerous command', "UPDATE wp_posts SET post_status = 'draft' # DELETE FROM wp_posts" ),
+ array( 'multi-line comment with dangerous command', "UPDATE wp_posts SET post_status = 'draft' /* ALTER TABLE wp_posts DROP COLUMN post_content */" ),
+ array( 'MySQL version specific comment', "UPDATE wp_posts SET post_status = 'draft' /*!40000 DROP TABLE wp_posts */" ),
+ array( 'comment after SQL keyword', "UPDATE/*! dangerous */wp_posts SET post_status = 'draft'" ),
+ array( 'comment with system table access', "UPDATE wp_posts SET post_status = 'draft' /* SELECT * FROM information_schema.tables */" ),
+ array( 'comment with function calls', "UPDATE wp_posts SET post_status = 'draft' /* SLEEP(10) */" ),
+ );
+ }
+
+
+ /**
+ * Test that SQL injection patterns are detected and rejected.
+ *
+ * @param string $query The query to test.
+ *
+ * @dataProvider sql_injection_patterns_provider
+ */
+ public function test_process_sql_injection_patterns( string $query ): void {
+ $schema = $this->create_sql_schema( $query, 'test_sql_injection' );
+
+ $result = $this->importer->process( $schema );
+
+ $this->assertFalse( $result->is_success() );
+ $error_messages = $result->get_messages( 'error' );
+ $this->assertNotEmpty( $error_messages );
+
+ $expected_message = 'SQL query contains potential injection patterns.';
+ $actual_message = $error_messages[0]['message'];
+ $this->assertEquals( $expected_message, $actual_message );
+ }
+
+ /**
+ * Data provider for SQL injection patterns.
+ *
+ * @return array
+ */
+ public function sql_injection_patterns_provider(): array {
+ return array(
+ array( 'INSERT INTO wp_posts (post_title) VALUES (\'test\') UNION SELECT * FROM wp_users' ),
+ array( 'UPDATE wp_posts SET post_title = \'test\' WHERE 1=1 OR 1=1' ),
+ array( 'UPDATE wp_posts SET post_title = \'test\' WHERE ID = 1 AND 0=0' ),
+ array( 'INSERT INTO wp_posts (post_title) VALUES (\'test\') UNION ALL SELECT user_login FROM wp_users' ),
+ array( 'UPDATE wp_posts SET post_title = (SELECT SLEEP(5)) WHERE ID = 1' ),
+ array( 'UPDATE wp_posts SET post_title = (SELECT BENCHMARK(1000000,MD5(\'test\'))) WHERE ID = 1' ),
+ array( 'INSERT INTO wp_posts (post_title) VALUES ((SELECT LOAD_FILE(\'/etc/passwd\')))' ),
+ );
+ }
+
+
+ /**
+ * Test that queries affecting protected tables are rejected.
+ */
+ public function test_protected_tables_access(): void {
+ global $wpdb;
+ $protected_tables = array(
+ $wpdb->prefix . 'users',
+ $wpdb->prefix . 'usermeta',
+ );
+
+ foreach ( $protected_tables as $table ) {
+ $schema = $this->create_sql_schema(
+ "INSERT INTO $table (user_login) VALUES ('test_user')",
+ 'test_protected_table'
+ );
+
+ $result = $this->importer->process( $schema );
+
+ $this->assertFalse( $result->is_success() );
+ $error_messages = $result->get_messages( 'error' );
+ $this->assertNotEmpty( $error_messages );
+ $this->assertStringContainsString( 'Modifications to admin users or roles are not allowed', $error_messages[0]['message'], $table );
+ }
+ }
+
+ /**
+ * Test that queries affecting user capabilities are rejected.
+ */
+ public function test_user_capabilities_protection(): void {
+ global $wpdb;
+ $queries = array(
+ "INSERT INTO {$wpdb->prefix}options (option_name, option_value) VALUES ('wp_user_roles', 'test')",
+ "UPDATE {$wpdb->prefix}options SET option_value = 'test' WHERE option_name LIKE '%capabilities%'",
+ "REPLACE INTO {$wpdb->prefix}options (option_name, option_value) VALUES ('role_administrator', 'test')",
+ );
+
+ foreach ( $queries as $query ) {
+ $schema = $this->create_sql_schema( $query, 'test_capabilities' );
+
+ $result = $this->importer->process( $schema );
+
+ $this->assertFalse( $result->is_success() );
+ $error_messages = $result->get_messages( 'error' );
+ $this->assertNotEmpty( $error_messages );
+ $this->assertStringContainsString( 'Modifications to user roles or capabilities are not allowed', $error_messages[0]['message'] );
+ }
+ }
+
+
+ /**
+ * Test that SQL execution error is handled properly.
+ */
+ public function test_process_sql_execution_error(): void {
+ $schema = $this->create_sql_schema(
+ 'INSERT INTO wp_test_table (test_column) VALUES (\'Test Value\')',
+ 'test_error'
+ );
+
+ $result = $this->importer->process( $schema );
+
+ $this->assertFalse( $result->is_success() );
+ $error_messages = $result->get_messages( 'error' );
+ $this->assertNotEmpty( $error_messages );
+ $this->assertStringContainsString( 'Error executing SQL', $error_messages[0]['message'] );
+ }
+
+ /**
+ * Test that queries with multiple statements are rejected.
+ */
+ public function test_process_multiple_statements_rejected(): void {
+ $schema = $this->create_sql_schema(
+ 'INSERT INTO wp_posts (post_title) VALUES (\'Test Post\'); UPDATE wp_posts SET post_status = \'publish\'',
+ 'test_multiple_statements'
+ );
+
+ $result = $this->importer->process( $schema );
+
+ $this->assertFalse( $result->is_success() );
+ $error_messages = $result->get_messages( 'error' );
+ $this->assertNotEmpty( $error_messages );
+ $this->assertStringContainsString( 'Error executing SQL', $error_messages[0]['message'] );
+ }
+
+ /**
+ * Create a schema object for SQL testing.
+ *
+ * @param string $sql_contents The SQL query.
+ * @param string $name The name of the SQL step.
+ * @return stdClass
+ */
+ private function create_sql_schema( string $sql_contents, string $name ): stdClass {
+ $schema = new stdClass();
+ $schema->sql = new stdClass();
+ $schema->sql->contents = $sql_contents;
+ $schema->sql->name = $name;
+ return $schema;
+ }
+}
diff --git a/plugins/woocommerce/changelog/woo6-10-blueprints-security-audit-determine-the-scope-of-allowed-sql b/plugins/woocommerce/changelog/woo6-10-blueprints-security-audit-determine-the-scope-of-allowed-sql
new file mode 100644
index 0000000000..073e2812a2
--- /dev/null
+++ b/plugins/woocommerce/changelog/woo6-10-blueprints-security-audit-determine-the-scope-of-allowed-sql
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix blueprint export tax sql command
diff --git a/plugins/woocommerce/changelog/wooplug-3965-blueprint-restrict-blueprint-imports-to-coming-soon-mode b/plugins/woocommerce/changelog/wooplug-3965-blueprint-restrict-blueprint-imports-to-coming-soon-mode
new file mode 100644
index 0000000000..9c4d91105a
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-3965-blueprint-restrict-blueprint-imports-to-coming-soon-mode
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Restrict blueprint imports to coming soon mode
diff --git a/plugins/woocommerce/client/admin/client/blueprint/components/BlueprintUploadDropzone.tsx b/plugins/woocommerce/client/admin/client/blueprint/components/BlueprintUploadDropzone.tsx
index 9d3f8a4e90..d963a6bb0e 100644
--- a/plugins/woocommerce/client/admin/client/blueprint/components/BlueprintUploadDropzone.tsx
+++ b/plugins/woocommerce/client/admin/client/blueprint/components/BlueprintUploadDropzone.tsx
@@ -22,6 +22,8 @@ import {
import apiFetch from '@wordpress/api-fetch';
import { dispatch } from '@wordpress/data';
import { recordEvent } from '@woocommerce/tracks';
+import { createInterpolateElement } from '@wordpress/element';
+import { getAdminLink } from '@woocommerce/settings';
/**
* Internal dependencies
@@ -147,11 +149,26 @@ const importBlueprint = async ( steps: BlueprintStep[] ) => {
}
};
+const checkImportAllowed = async (): Promise< boolean > => {
+ try {
+ const response = await apiFetch< { import_allowed: boolean } >( {
+ path: 'wc-admin/blueprint/import-allowed',
+ method: 'GET',
+ } );
+ return response.import_allowed;
+ } catch ( error ) {
+ throw new Error(
+ __( 'Failed to check if imports are allowed.', 'woocommerce' )
+ );
+ }
+};
+
interface FileUploadContext {
file?: File;
steps?: BlueprintStep[];
error?: Error;
settings_to_overwrite?: string[];
+ import_allowed?: boolean;
}
type FileUploadEvents =
@@ -223,6 +240,7 @@ export const fileUploadMachine = setup( {
stepsParser: fromPromise( ( { input }: { input: { file: File } } ) =>
parseBlueprintSteps( input.file )
),
+ importAllowedChecker: fromPromise( () => checkImportAllowed() ),
},
guards: {
hasSettingsToOverwrite: ( { context } ) =>
@@ -233,13 +251,32 @@ export const fileUploadMachine = setup( {
},
} ).createMachine( {
id: 'fileUpload',
- initial: 'idle',
+ initial: 'checkingImportAllowed',
context: () => ( {} ),
states: {
+ checkingImportAllowed: {
+ invoke: {
+ src: 'importAllowedChecker',
+ onDone: {
+ target: 'idle',
+ actions: assign( {
+ import_allowed: ( { event } ) => event.output,
+ error: () => undefined,
+ } ),
+ },
+ onError: {
+ target: 'error',
+ actions: assign( {
+ error: ( { event } ) => event.error as Error,
+ } ),
+ },
+ },
+ },
idle: {
on: {
UPLOAD: {
target: 'parsingSteps',
+ guard: ( { context } ) => context.import_allowed === true,
actions: assign( {
file: ( { event } ) => event.file,
error: () => undefined,
@@ -393,6 +430,39 @@ export const BlueprintUploadDropzone = () => {
return (
<>
+ { state.matches( 'checkingImportAllowed' ) && (
+ <div className="blueprint-upload-form">
+ <div className="blueprint-upload-dropzone-uploading">
+ <Spinner />
+ </div>
+ </div>
+ ) }
+ { state.context.import_allowed === false &&
+ ! state.context.error && (
+ <Notice
+ status="warning"
+ isDismissible={ false }
+ className="blueprint-upload-dropzone-notice"
+ >
+ { createInterpolateElement(
+ __(
+ 'Blueprint imports are disabled by default for live sites. <br/>Enable <link>Coming Soon mode</link> or define "ALLOW_BLUEPRINT_IMPORT_IN_LIVE_MODE" as true.',
+ 'woocommerce'
+ ),
+ {
+ br: <br />,
+ link: (
+ // eslint-disable-next-line jsx-a11y/anchor-has-content, jsx-a11y/control-has-associated-label
+ <a
+ href={ getAdminLink(
+ 'admin.php?page=wc-settings&tab=site-visibility'
+ ) }
+ />
+ ),
+ }
+ ) }
+ </Notice>
+ ) }
{ state.context.error && (
<div className="blueprint-upload-dropzone-error">
<Notice
@@ -405,49 +475,50 @@ export const BlueprintUploadDropzone = () => {
</Notice>
</div>
) }
- { ( state.matches( 'idle' ) ||
- state.matches( 'error' ) ||
- state.matches( 'parsingSteps' ) ) && (
- <div className="blueprint-upload-form">
- <FormFileUpload
- className="blueprint-upload-field"
- accept="application/json, application/zip"
- multiple={ false }
- onChange={ ( evt ) => {
- const file = evt.target.files?.[ 0 ]; // since multiple is disabled it has to be in 0
- if ( file ) {
- send( { type: 'UPLOAD', file } );
- }
- } }
- >
- <div className="blueprint-upload-dropzone">
- <Icon icon={ upload } />
- <p className="blueprint-upload-dropzone-text">
- { __( 'Drag and drop or ', 'woocommerce' ) }
- <span>
- { __( 'choose a file', 'woocommerce' ) }
- </span>
- </p>
- <DropZone
- onFilesDrop={ ( files ) => {
- if ( files.length > 1 ) {
+ { state.context.import_allowed &&
+ ( state.matches( 'idle' ) ||
+ state.matches( 'error' ) ||
+ state.matches( 'parsingSteps' ) ) && (
+ <div className="blueprint-upload-form">
+ <FormFileUpload
+ className="blueprint-upload-field"
+ accept="application/json, application/zip"
+ multiple={ false }
+ onChange={ ( evt ) => {
+ const file = evt.target.files?.[ 0 ]; // since multiple is disabled it has to be in 0
+ if ( file ) {
+ send( { type: 'UPLOAD', file } );
+ }
+ } }
+ >
+ <div className="blueprint-upload-dropzone">
+ <Icon icon={ upload } />
+ <p className="blueprint-upload-dropzone-text">
+ { __( 'Drag and drop or ', 'woocommerce' ) }
+ <span>
+ { __( 'choose a file', 'woocommerce' ) }
+ </span>
+ </p>
+ <DropZone
+ onFilesDrop={ ( files ) => {
+ if ( files.length > 1 ) {
+ send( {
+ type: 'ERROR',
+ error: new Error(
+ 'Only one file can be uploaded at a time'
+ ),
+ } );
+ }
send( {
- type: 'ERROR',
- error: new Error(
- 'Only one file can be uploaded at a time'
- ),
+ type: 'UPLOAD',
+ file: files[ 0 ],
} );
- }
- send( {
- type: 'UPLOAD',
- file: files[ 0 ],
- } );
- } }
- ></DropZone>
- </div>
- </FormFileUpload>
- </div>
- ) }
+ } }
+ ></DropZone>
+ </div>
+ </FormFileUpload>
+ </div>
+ ) }
{ state.matches( 'importing' ) && (
<div className="blueprint-upload-form">
<div className="blueprint-upload-dropzone-uploading">
@@ -480,6 +551,7 @@ export const BlueprintUploadDropzone = () => {
<Button
className="woocommerce-blueprint-import-button"
variant="primary"
+ disabled={ ! state.context.import_allowed }
onClick={ () => {
send( { type: 'IMPORT' } );
} }
diff --git a/plugins/woocommerce/client/admin/client/blueprint/components/style.scss b/plugins/woocommerce/client/admin/client/blueprint/components/style.scss
index 22c34a6429..5902671e0c 100644
--- a/plugins/woocommerce/client/admin/client/blueprint/components/style.scss
+++ b/plugins/woocommerce/client/admin/client/blueprint/components/style.scss
@@ -1,4 +1,4 @@
-
+.blueprint-upload-dropzone-notice,
.blueprint-upload-dropzone-error {
margin-top: 16px;
}
diff --git a/plugins/woocommerce/client/admin/client/blueprint/settings/style.scss b/plugins/woocommerce/client/admin/client/blueprint/settings/style.scss
index 1012d76164..eea1a1ee93 100644
--- a/plugins/woocommerce/client/admin/client/blueprint/settings/style.scss
+++ b/plugins/woocommerce/client/admin/client/blueprint/settings/style.scss
@@ -161,9 +161,7 @@
margin-top: 14px;
}
- .components-notice.is-error {
- background: #fce2e4;
- border-left-color: #cc1818;
+ .components-notice {
padding: 12px;
button.components-notice__dismiss {
@@ -174,17 +172,19 @@
}
}
- .components-notice__content {
+ .components-notice__content,
+ &.is-error .components-notice__content pre {
margin: 0;
-
- pre {
- color: $gray-900;
- line-height: 24px; /* 184.615% */
- margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
- }
+ color: $gray-900;
+ line-height: 24px; /* 184.615% */
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
}
}
+
+ .components-notice.is-error {
+ background: #fce2e4;
+ border-left-color: #cc1818;
+ }
}
// Show the notice at the bottom right of the screen
diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCTaxRates.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCTaxRates.php
index 74e6ca4d42..432d7e0a18 100644
--- a/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCTaxRates.php
+++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Exporters/ExportWCTaxRates.php
@@ -41,8 +41,7 @@ class ExportWCTaxRates implements StepExporter, HasAlias {
$table = $wpdb->prefix . $table;
return array_map(
fn( $record ) => new RunSql( Util::array_to_insert_sql( $record, $table, 'replace into' ) ),
- // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- $wpdb->get_results( "SELECT * FROM {$table}", ARRAY_A )
+ $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i', $table ), ARRAY_A ),
);
}
diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/Init.php b/plugins/woocommerce/src/Admin/Features/Blueprint/Init.php
index 1681137e88..f212bbf040 100644
--- a/plugins/woocommerce/src/Admin/Features/Blueprint/Init.php
+++ b/plugins/woocommerce/src/Admin/Features/Blueprint/Init.php
@@ -4,7 +4,6 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Admin\Features\Blueprint;
-use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCCoreProfilerOptions;
use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCPaymentGateways;
use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCSettingsAccount;
use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCSettingsAdvanced;
@@ -14,15 +13,10 @@ use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCSettingsIn
use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCSettingsProducts;
use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCSettingsSiteVisibility;
use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCShipping;
-use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCTaskOptions;
use Automattic\WooCommerce\Admin\Features\Blueprint\Exporters\ExportWCTaxRates;
-use Automattic\WooCommerce\Admin\Features\Blueprint\Importers\ImportSetWCPaymentGateways;
-use Automattic\WooCommerce\Admin\Features\Blueprint\Importers\ImportSetWCShipping;
-use Automattic\WooCommerce\Admin\Features\Blueprint\Importers\ImportSetWCTaxRates;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Blueprint\Exporters\HasAlias;
use Automattic\WooCommerce\Blueprint\Exporters\StepExporter;
-use Automattic\WooCommerce\Blueprint\StepProcessor;
use Automattic\WooCommerce\Blueprint\UseWPFunctions;
/**
diff --git a/plugins/woocommerce/src/Admin/Features/Blueprint/RestApi.php b/plugins/woocommerce/src/Admin/Features/Blueprint/RestApi.php
index 52e27da5c2..03b309db8c 100644
--- a/plugins/woocommerce/src/Admin/Features/Blueprint/RestApi.php
+++ b/plugins/woocommerce/src/Admin/Features/Blueprint/RestApi.php
@@ -8,7 +8,8 @@ use Automattic\WooCommerce\Blueprint\Exporters\ExportInstallPluginSteps;
use Automattic\WooCommerce\Blueprint\Exporters\ExportInstallThemeSteps;
use Automattic\WooCommerce\Blueprint\ExportSchema;
use Automattic\WooCommerce\Blueprint\ImportStep;
-use Automattic\WooCommerce\Blueprint\ZipExportedSchema;
+use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonHelper;
+use WP_Error;
/**
* Class RestApi
@@ -30,6 +31,20 @@ class RestApi {
*/
protected $namespace = 'wc-admin';
+ /**
+ * ComingSoonHelper instance.
+ *
+ * @var ComingSoonHelper
+ */
+ protected $coming_soon_helper;
+
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ $this->coming_soon_helper = new ComingSoonHelper();
+ }
+
/**
* Get maximum allowed file size for blueprint uploads.
*
@@ -110,6 +125,21 @@ class RestApi {
'schema' => array( $this, 'get_import_step_response_schema' ),
)
);
+
+ register_rest_route(
+ $this->namespace,
+ '/blueprint/import-allowed',
+ array(
+ array(
+ 'methods' => \WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_import_allowed' ),
+ 'permission_callback' => function () {
+ return current_user_can( 'manage_woocommerce' );
+ },
+ ),
+ 'schema' => array( $this, 'get_import_allowed_schema' ),
+ )
+ );
}
/**
@@ -235,6 +265,17 @@ class RestApi {
* @return array
*/
public function import_step( \WP_REST_Request $request ) {
+ if ( ! $this->can_import_blueprint() ) {
+ return array(
+ 'success' => false,
+ 'messages' => array(
+ array(
+ 'message' => __( 'Blueprint imports are disabled', 'woocommerce' ),
+ 'type' => 'error',
+ ),
+ ),
+ );
+ }
// Get the raw body size.
$body_size = strlen( $request->get_body() );
if ( $body_size > $this->get_max_file_size() ) {
@@ -264,6 +305,63 @@ class RestApi {
);
}
+
+ /**
+ * Check if blueprint imports are allowed based on site status and configuration.
+ *
+ * @return bool Returns true if imports are allowed, false otherwise.
+ */
+ private function can_import_blueprint() {
+ // Check if override constant is defined and true.
+ if ( defined( 'ALLOW_BLUEPRINT_IMPORT_IN_LIVE_MODE' ) && ALLOW_BLUEPRINT_IMPORT_IN_LIVE_MODE ) {
+ return true;
+ }
+
+ // Only allow imports in coming soon mode.
+ if ( $this->coming_soon_helper->is_site_live() ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get whether blueprint imports are allowed.
+ *
+ * @return \WP_REST_Response
+ */
+ public function get_import_allowed() {
+ $can_import = $this->can_import_blueprint();
+
+ return rest_ensure_response(
+ array(
+ 'import_allowed' => $can_import,
+ )
+ );
+ }
+
+ /**
+ * Get the schema for the import-allowed endpoint.
+ *
+ * @return array
+ */
+ public function get_import_allowed_schema() {
+ return array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'blueprint-import-allowed',
+ 'type' => 'object',
+ 'properties' => array(
+ 'import_allowed' => array(
+ 'description' => __( 'Whether blueprint imports are currently allowed', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ ),
+ );
+ }
+
+
/**
* Get the schema for the import-step endpoint.
*
diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/Blueprint/RestApiTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/Blueprint/RestApiTest.php
index e2e8e519bc..98a7687276 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Features/Blueprint/RestApiTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/Blueprint/RestApiTest.php
@@ -3,6 +3,8 @@
* Unit tests for RestApi class.
*/
+declare(strict_types=1);
+
namespace Automattic\WooCommerce\Tests\Admin\Features\Blueprint;
use Automattic\WooCommerce\Admin\Features\Blueprint\RestApi;
@@ -23,64 +25,168 @@ class RestApiTest extends WP_Test_REST_TestCase {
*/
private $temp_file;
+ /**
+ * @var int User ID with administrator role.
+ */
+ private $user;
+
/**
* Setup test case.
*/
public function setUp(): void {
parent::setUp();
$this->rest_api = new RestApi();
+ $this->useAdmin();
+
+ // Create a temporary test file with valid Blueprint schema.
+ $this->temp_file = wp_tempnam( 'blueprint_test_' );
+ $blueprint_content = wp_json_encode(
+ array(
+ 'steps' => array(
+ array(
+ 'step' => 'setWCSettings',
+ 'settings' => array(
+ 'woocommerce_store_address' => '123 Test St',
+ 'woocommerce_store_city' => 'Test City',
+ ),
+ ),
+ ),
+ )
+ );
+ global $wp_filesystem;
+ WP_Filesystem();
+ $wp_filesystem->put_contents( $this->temp_file, $blueprint_content );
+ }
+
+ /**
+ * Use a user with administrator role.
+ *
+ * @return void
+ */
+ public function useAdmin() {
+ // Register an administrator user and log in.
+ $this->user = $this->factory->user->create(
+ array(
+ 'role' => 'administrator',
+ )
+ );
+ wp_set_current_user( $this->user );
+ }
+
+ /**
+ * Clean up after each test.
+ */
+ public function tearDown(): void {
+ parent::tearDown();
+ // Clean up global state.
+ unset( $_FILES['file'] );
+
+ // Clean up temporary file.
+ if ( file_exists( $this->temp_file ) ) {
+ wp_delete_file( $this->temp_file );
+ }
+
+ remove_all_filters( 'pre_option_woocommerce_coming_soon' );
+ }
+
+ /**
+ * Test that blueprint imports are disabled in live mode.
+ */
+ public function test_cannot_import_blueprint_in_live_mode() {
+ add_filter(
+ 'pre_option_woocommerce_coming_soon',
+ function () {
+ return 'no';
+ }
+ );
+
+ $request = new \WP_REST_Request( 'POST', '/wc-admin/blueprint/import-step' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'step_definition' => array(
+ 'step' => 'setSiteOptions',
+ 'options' => array(
+ 'woocommerce_store_address' => '123 Test St',
+ ),
+ ),
+ )
+ )
+ );
+
+ $response = $this->rest_api->import_step( $request );
- // Create a temporary test file with valid Blueprint schema
- $this->temp_file = wp_tempnam('blueprint_test_');
- $blueprint_content = json_encode([
- 'steps' => [
- [
- 'step' => 'setWCSettings',
- 'settings' => [
- 'woocommerce_store_address' => '123 Test St',
- 'woocommerce_store_city' => 'Test City'
- ]
- ]
- ]
- ]);
- file_put_contents($this->temp_file, $blueprint_content);
+ $this->assertFalse( $response['success'] );
+ $this->assertCount( 1, $response['messages'] );
+ $this->assertEquals( 'error', $response['messages'][0]['type'] );
+ $this->assertStringContainsString( 'Blueprint imports are disabled', $response['messages'][0]['message'] );
}
/**
* Test file size validation in import_step endpoint.
*/
public function test_import_step_file_size_validation() {
- // Create a large request body
- $large_value = str_repeat('X', RestApi::MAX_FILE_SIZE + 1024); // Slightly over limit
- $request = new \WP_REST_Request('POST', '/wc-admin/blueprint/import-step');
- $request->set_body(json_encode(array(
- 'step_definition' => array(
- 'step' => 'setWCSettings',
- 'settings' => array(
- 'large_setting' => $large_value
+ add_filter(
+ 'pre_option_woocommerce_coming_soon',
+ function () {
+ return 'yes';
+ }
+ );
+
+ // Create a large request body.
+ $large_value = str_repeat( 'X', RestApi::MAX_FILE_SIZE + 1024 ); // Slightly over limit.
+ $request = new \WP_REST_Request( 'POST', '/wc-admin/blueprint/import-step' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'step_definition' => array(
+ 'step' => 'setSiteOptions',
+ 'options' => array(
+ 'large_setting' => $large_value,
+ ),
+ ),
)
)
- )));
+ );
- $response = $this->rest_api->import_step($request);
+ $response = $this->rest_api->import_step( $request );
- $this->assertFalse($response['success']);
- $this->assertCount(1, $response['messages']);
- $this->assertEquals('error', $response['messages'][0]['type']);
- $this->assertStringContainsString('50 MB', $response['messages'][0]['message']);
+ $this->assertFalse( $response['success'] );
+ $this->assertCount( 1, $response['messages'] );
+ $this->assertEquals( 'error', $response['messages'][0]['type'] );
+ $this->assertStringContainsString( '50 MB', $response['messages'][0]['message'] );
}
/**
- * Clean up after each test.
+ * Test that import blueprint endpoint is working.
*/
- public function tearDown(): void {
- parent::tearDown();
- // Clean up global state
- unset($_FILES['file']);
+ public function test_import_blueprint() {
+ add_filter(
+ 'pre_option_woocommerce_coming_soon',
+ function () {
+ return 'yes';
+ }
+ );
- // Clean up temporary file
- if (file_exists($this->temp_file)) {
- unlink($this->temp_file);
- }
+ $request = new \WP_REST_Request( 'POST', '/wc-admin/blueprint/import-step' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'step_definition' => array(
+ 'step' => 'setSiteOptions',
+ 'options' => array(
+ 'woocommerce_store_address' => '123 Test St',
+ ),
+ ),
+ )
+ )
+ );
+ $request->set_header( 'Content-Type', 'application/json' );
+
+ $response = $this->rest_api->import_step( $request );
+
+ $this->assertTrue( $response['success'], $response['messages'][0]['message'] );
+ $this->assertCount( 1, $response['messages'] );
+ $this->assertStringContainsString( 'woocommerce_store_address has been updated', $response['messages'][0]['message'] );
}
-}
\ No newline at end of file
+}