feat: guard admin package imports
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin file import controller.
|
||||
*
|
||||
* @package WPContentSync
|
||||
*/
|
||||
|
||||
namespace WPContentSync\Admin;
|
||||
|
||||
use WPContentSync\Logging\LoggerInterface;
|
||||
use WPContentSync\Transport\FileTransportInterface;
|
||||
|
||||
final class FileImportController {
|
||||
private FileTransportInterface $transport;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct( FileTransportInterface $transport, LoggerInterface $logger ) {
|
||||
$this->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' )
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace WPContentSync\Tests\Unit\Admin;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use WPContentSync\Admin\FileImportController;
|
||||
use WPContentSync\Logging\LoggerInterface;
|
||||
use WPContentSync\Package\PackageChecksum;
|
||||
use WPContentSync\Package\PackageValidator;
|
||||
use WPContentSync\Transport\JsonFileTransport;
|
||||
|
||||
class FileImportControllerTest extends TestCase {
|
||||
/** @var array<int, string> */
|
||||
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<string, mixed> $context Context.
|
||||
*/
|
||||
public function error( string $message, array $context = array() ): void {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context Context.
|
||||
*/
|
||||
public function warning( string $message, array $context = array() ): void {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context Context.
|
||||
*/
|
||||
public function info( string $message, array $context = array() ): void {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
+69
-1
@@ -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<string, string> $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 );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user