From 76b614e9e3e5eca861afe13f7a716a43546f9d32 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 26 Apr 2026 20:32:04 -0500 Subject: [PATCH] feat: guard admin package imports --- src/Admin/FileImportController.php | 69 ++++++++ tests/Unit/Admin/FileImportControllerTest.php | 165 ++++++++++++++++++ tests/bootstrap.php | 70 +++++++- 3 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 src/Admin/FileImportController.php create mode 100644 tests/Unit/Admin/FileImportControllerTest.php diff --git a/src/Admin/FileImportController.php b/src/Admin/FileImportController.php new file mode 100644 index 0000000..5f36908 --- /dev/null +++ b/src/Admin/FileImportController.php @@ -0,0 +1,69 @@ +transport = $transport; + $this->logger = $logger; + } + + public function register(): void { + add_action( 'admin_post_wpcs_import_package', array( $this, 'handleImport' ) ); + } + + public function handleImport(): void { + if ( ! current_user_can( 'manage_options' ) ) { + throw new \RuntimeException( 'You do not have permission to import content packages.' ); + } + + if ( ! check_admin_referer( 'wpcs_import_package', 'wpcs_import_package_nonce' ) ) { + throw new \RuntimeException( 'The import request could not be verified.' ); + } + + if ( ! isset( $_FILES['wpcs_package_file']['tmp_name'], $_FILES['wpcs_package_file']['error'] ) ) { + throw new \RuntimeException( 'Choose a package JSON file before importing.' ); + } + + if ( UPLOAD_ERR_OK !== (int) $_FILES['wpcs_package_file']['error'] ) { + throw new \RuntimeException( 'The package file could not be uploaded.' ); + } + + $uploaded_file = sanitize_text_field( (string) $_FILES['wpcs_package_file']['tmp_name'] ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a validated local upload temp file. + $contents = file_get_contents( $uploaded_file ); + + if ( false === $contents ) { + throw new \RuntimeException( 'The package file could not be read.' ); + } + + $package = $this->transport->import( $contents ); + + $this->logger->info( + 'Validated imported content package.', + array( + 'schema_version' => $package->schemaVersion(), + 'manifest' => $package->manifest(), + ) + ); + + wp_safe_redirect( + add_query_arg( + array( 'wpcs_imported' => '1' ), + admin_url( 'admin.php?page=wp-content-sync' ) + ) + ); + } +} diff --git a/tests/Unit/Admin/FileImportControllerTest.php b/tests/Unit/Admin/FileImportControllerTest.php new file mode 100644 index 0000000..f1e962e --- /dev/null +++ b/tests/Unit/Admin/FileImportControllerTest.php @@ -0,0 +1,165 @@ + */ + private array $temporary_files = array(); + + protected function tearDown(): void { + foreach ( $this->temporary_files as $file ) { + if ( is_file( $file ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removing a PHPUnit temp file. + unlink( $file ); + } + } + + unset( $GLOBALS['wpcs_current_user_can'], $GLOBALS['wpcs_nonce_valid'], $GLOBALS['wpcs_redirect_location'] ); + $_FILES = array(); + + parent::tearDown(); + } + + public function test_it_rejects_users_without_manage_options(): void { + $GLOBALS['wpcs_current_user_can']['manage_options'] = false; + $controller = $this->controller(); + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'You do not have permission to import content packages.' ); + + $controller->handleImport(); + } + + public function test_it_rejects_invalid_nonces(): void { + $GLOBALS['wpcs_nonce_valid']['wpcs_import_package']['wpcs_import_package_nonce'] = false; + $controller = $this->controller(); + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'The import request could not be verified.' ); + + $controller->handleImport(); + } + + public function test_it_rejects_missing_uploads(): void { + $controller = $this->controller(); + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'Choose a package JSON file before importing.' ); + + $controller->handleImport(); + } + + public function test_it_rejects_failed_uploads(): void { + $_FILES['wpcs_package_file'] = array( + 'tmp_name' => '', + 'error' => UPLOAD_ERR_INI_SIZE, + ); + $controller = $this->controller(); + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'The package file could not be uploaded.' ); + + $controller->handleImport(); + } + + public function test_it_imports_valid_uploaded_packages_without_mutating_content(): void { + $file = $this->createTemporaryPackageFile( $this->validJson() ); + + $_FILES['wpcs_package_file'] = array( + 'tmp_name' => $file, + 'error' => UPLOAD_ERR_OK, + ); + + $this->controller()->handleImport(); + + self::assertStringContainsString( 'wpcs_imported=1', $GLOBALS['wpcs_redirect_location'] ); + } + + private function controller(): FileImportController { + return new FileImportController( + new JsonFileTransport( new PackageValidator() ), + new class() implements LoggerInterface { + /** + * @param array $context Context. + */ + public function error( string $message, array $context = array() ): void {} + + /** + * @param array $context Context. + */ + public function warning( string $message, array $context = array() ): void {} + + /** + * @param array $context Context. + */ + public function info( string $message, array $context = array() ): void {} + + /** + * @param array $context Context. + */ + public function debug( string $message, array $context = array() ): void {} + } + ); + } + + private function validJson(): string { + $records = array( + 'posts' => array(), + 'terms' => array(), + 'media' => array(), + 'custom_post_types' => array(), + ); + + $json = wp_json_encode( + array( + 'schema_version' => '1.0', + 'generated_at' => '2026-04-26T20:30:00+00:00', + 'source' => array( + 'site_url' => 'https://example.test', + 'name' => 'Example', + ), + 'destination' => array( + 'site_url' => 'https://staging.example.test', + 'name' => 'Staging', + ), + 'manifest' => array( + 'posts' => 0, + 'terms' => 0, + 'media' => 0, + 'custom_post_types' => 0, + ), + 'records' => $records, + 'checksums' => array( + 'records' => PackageChecksum::records( $records ), + ), + ) + ); + + if ( false === $json ) { + throw new \RuntimeException( 'Unable to create package JSON fixture.' ); + } + + return $json; + } + + private function createTemporaryPackageFile( string $contents ): string { + $file = tempnam( sys_get_temp_dir(), 'wpcs-package-' ); + + if ( false === $file ) { + throw new \RuntimeException( 'Unable to create temporary package file.' ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Creating a PHPUnit temp fixture. + file_put_contents( $file, $contents ); + $this->temporary_files[] = $file; + + return $file; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 3479c6e..1b9282d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -47,6 +47,22 @@ if ( ! function_exists( 'wp_strip_all_tags' ) ) { } } +if ( ! function_exists( 'wp_unslash' ) ) { + /** + * Minimal slashes remover for unit tests. + * + * @param mixed $value Value to unslash. + * @return mixed + */ + function wp_unslash( $value ) { + if ( is_array( $value ) ) { + return array_map( 'wp_unslash', $value ); + } + + return is_string( $value ) ? stripslashes( $value ) : $value; + } +} + if ( ! function_exists( 'esc_html' ) ) { /** * Minimal HTML escaper for unit tests. @@ -323,7 +339,59 @@ if ( ! function_exists( 'current_user_can' ) ) { * @return bool */ function current_user_can( $capability ) { - return 'manage_options' === $capability; + return $GLOBALS['wpcs_current_user_can'][ $capability ] ?? 'manage_options' === $capability; + } +} + +if ( ! function_exists( 'check_admin_referer' ) ) { + /** + * Minimal nonce checker for unit tests. + * + * @param string $action Nonce action. + * @param string $query_arg Nonce request field. + * @return bool + */ + function check_admin_referer( $action, $query_arg = '_wpnonce' ) { + return $GLOBALS['wpcs_nonce_valid'][ $action ][ $query_arg ] ?? true; + } +} + +if ( ! function_exists( 'wp_safe_redirect' ) ) { + /** + * Minimal safe redirect helper for unit tests. + * + * @param string $location Redirect location. + * @return bool + */ + function wp_safe_redirect( $location ) { + $GLOBALS['wpcs_redirect_location'] = $location; + + return true; + } +} + +if ( ! function_exists( 'admin_url' ) ) { + /** + * Minimal admin URL helper for unit tests. + * + * @param string $path Admin path. + * @return string + */ + function admin_url( $path = '' ) { + return 'https://example.test/wp-admin/' . ltrim( $path, '/' ); + } +} + +if ( ! function_exists( 'add_query_arg' ) ) { + /** + * Minimal query arg helper for unit tests. + * + * @param array $args Query args. + * @param string $url URL. + * @return string + */ + function add_query_arg( array $args, $url ) { + return $url . ( false === strpos( $url, '?' ) ? '?' : '&' ) . http_build_query( $args ); } }