Skip to content
14 changes: 7 additions & 7 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 53 additions & 2 deletions includes/classes/wp-maintenance-mode-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,46 @@ public function display_plugin_settings() {
}
}

/**
* Whether a submitted settings tab has the shape the save handler expects:
* list fields hold arrays of scalars, every other field is a scalar.
*
* @param string $tab Settings tab slug.
* @param string|array<array-key, mixed>|null $options Submitted options for the tab.
* @return bool
*/
private function is_valid_settings_payload( string $tab, $options ): bool {
if ( ! is_array( $options ) ) {
return false;
}

// the classic Modules save maps over countdown_details unconditionally
if ( 'modules' === $tab && ! get_option( 'wpmm_new_look' ) && ! isset( $options['countdown_details'] ) ) {
return false;
}

$list_fields = array(
'general' => array( 'backend_role', 'frontend_role' ),
'modules' => array( 'countdown_details' ),
'bot' => array( 'messages', 'responses' ),
);
$tab_lists = isset( $list_fields[ $tab ] ) ? $list_fields[ $tab ] : array();

foreach ( $options as $field => $value ) {
$is_list = in_array( $field, $tab_lists, true );
if ( $is_list !== is_array( $value ) ) {
return false;
}

$items = $is_list ? $value : array( $value );
if ( count( array_filter( $items, 'is_scalar' ) ) !== count( $items ) ) {
return false;
}
}

return true;
}

/**
* Save settings
*
Expand Down Expand Up @@ -416,6 +456,10 @@ public function save_plugin_settings() {
die( esc_html__( 'The tab slug must exist.', 'wp-maintenance-mode' ) );
}

if ( ! $this->is_valid_settings_payload( $tab, isset( $_POST['options'][ $tab ] ) ? $_POST['options'][ $tab ] : null ) ) {
wp_die( esc_html__( 'The submitted settings are invalid.', 'wp-maintenance-mode' ), '', array( 'response' => 400 ) );
}

// Do some sanitizations
switch ( $tab ) {
case 'general':
Expand Down Expand Up @@ -928,7 +972,14 @@ public function change_template_category() {
* @return void
*/
public function toggle_gutenberg() {
if ( empty( $_POST['source'] ) ) {
// check capabilities
if ( ! current_user_can( wpmm_get_capability( 'settings' ) ) ) {
wp_send_json_error( __( 'You do not have access to this resource.', 'wp-maintenance-mode' ) );
}

// only the migration notices may toggle; other notice nonces reach every role.
$source = isset( $_POST['source'] ) && in_array( $_POST['source'], array( 'migration', 'rollback' ), true ) ? $_POST['source'] : '';
if ( empty( $source ) ) {
die( esc_html__( 'The source filed must not be empty.', 'wp-maintenance-mode' ) );
}

Expand All @@ -938,7 +989,7 @@ public function toggle_gutenberg() {
}

// check nonce validation
if ( ! wp_verify_nonce( $_POST['_wpnonce'], 'notice_nonce_' . $_POST['source'] ) ) {
if ( ! wp_verify_nonce( $_POST['_wpnonce'], 'notice_nonce_' . $source ) ) {
die( esc_html__( 'Security check.', 'wp-maintenance-mode' ) );
}

Expand Down
19 changes: 17 additions & 2 deletions tests/ajax-api-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -358,8 +358,8 @@ public function test_toggle_gutenberg_flips_the_new_look_option() {
delete_option( 'wpmm_new_look' );
delete_option( 'wpmm_migration_time' );

$_POST['source'] = 'dashboard';
$_POST['_wpnonce'] = wp_create_nonce( 'notice_nonce_dashboard' );
$_POST['source'] = 'migration';
$_POST['_wpnonce'] = wp_create_nonce( 'notice_nonce_migration' );

$response = $this->handle( 'wpmm_toggle_gutenberg' );

Expand All @@ -368,6 +368,21 @@ public function test_toggle_gutenberg_flips_the_new_look_option() {
$this->assertNotEmpty( get_option( 'wpmm_migration_time' ) );
}

public function test_toggle_gutenberg_requires_capability_even_with_a_notice_nonce() {
$this->_setRole( 'subscriber' );
update_option( 'wpmm_new_look', '1' );

// Every role sees the rebrand notice, so a subscriber holds its nonce.
$_POST['source'] = 'rebrand';
$_POST['_wpnonce'] = wp_create_nonce( 'notice_nonce_rebrand' );

$response = $this->handle( 'wpmm_toggle_gutenberg' );

$this->assertFalse( $response['success'] );
$this->assertSame( 'You do not have access to this resource.', $response['data'] );
$this->assertSame( '1', get_option( 'wpmm_new_look' ) );
}

/*
* wpmm_subscribe (plugin newsletter, external HTTP)
*/
Expand Down
162 changes: 162 additions & 0 deletions tests/settings-save-test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php
/**
* Tests for the settings form save handler.
*/

/**
* Test_Settings_Save.
*/
class Test_Settings_Save extends WP_UnitTestCase {

/**
* Seeded settings, built once: defaults embed the current time.
*
* @var array<string, array<string, mixed>>
*/
private $settings;

public function set_up(): void {
parent::set_up();

$this->settings = WP_Maintenance_Mode::get_instance()->default_settings();
update_option( 'wpmm_settings', $this->settings );
update_option( 'wpmm_new_look', '0' );
}

public function tear_down(): void {
$_POST = array();
wp_set_current_user( 0 );
parent::tear_down();
}

/**
* Submit a settings tab form as an administrator.
*
* @param 'general'|'design'|'modules'|'bot' $tab Settings tab slug.
* @param array{options?: string|array<string, mixed>} $fields Submitted fields besides nonce and tab.
* @return string The message the request ended with.
*/
private function save_settings( string $tab, array $fields ): string {
$admin = wpmm_test_boot_admin();

wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );

$_POST = array_merge(
array(
'_wpnonce' => wp_create_nonce( 'tab-' . $tab ),
'tab' => $tab,
),
$fields
);

$redirect_catcher = function (): void {
// the handler ends with wp_safe_redirect() + exit
throw new WPDieException( 'redirected' );
};
add_filter( 'wp_redirect', $redirect_catcher );

try {
$admin->save_plugin_settings();
$message = 'no response';
} catch ( WPDieException $e ) {
$message = $e->getMessage();
} finally {
remove_filter( 'wp_redirect', $redirect_catcher );
}

return $message;
}

/**
* @return array<string, array{0: 'general'|'design', 1: array{options?: string|array{general?: string, design?: string}}}>
*/
public function malformed_payloads(): array {
return array(
'options is a string' => array( 'general', array( 'options' => 'status' ) ),
'options is an empty string' => array( 'general', array( 'options' => '' ) ),
'options[general] is a string' => array( 'general', array( 'options' => array( 'general' => 'status' ) ) ),
'options is missing' => array( 'general', array() ),
'design options is a string' => array( 'design', array( 'options' => 'title' ) ),
'options[design] is a string' => array( 'design', array( 'options' => array( 'design' => 'title' ) ) ),
);
}

/**
* @dataProvider malformed_payloads
*
* @param 'general'|'design' $tab Settings tab slug.
* @param array{options?: string|array{general?: string, design?: string}} $fields Submitted fields besides nonce and tab.
*/
public function test_malformed_payload_is_rejected_without_saving( string $tab, array $fields ): void {
$message = $this->save_settings( $tab, $fields );

$this->assertSame( 'The submitted settings are invalid.', $message );
$this->assertSame( $this->settings, get_option( 'wpmm_settings' ) );
}

/**
* @return array<string, array{0: 'general'|'design'|'modules'|'bot', 1: string, 2: string|array<array-key, string|list<string>>}>
*/
public function malformed_fields(): array {
return array(
'heading_color is an array' => array( 'design', 'heading_color', array( '#000000' ) ),
'bg_custom is an array' => array( 'design', 'bg_custom', array( 'https://example.org/bg.jpg' ) ),
'backend_role is a string' => array( 'general', 'backend_role', 'administrator' ),
'frontend_role is a string' => array( 'general', 'frontend_role', 'editor' ),
'exclude is an array' => array( 'general', 'exclude', array( 'feed' ) ),
'redirection is an array' => array( 'general', 'redirection', array( 'https://example.org' ) ),
'countdown_details is a string' => array( 'modules', 'countdown_details', '1' ),
'countdown day is an array' => array( 'modules', 'countdown_details', array( 'days' => array( '1' ), 'hours' => '1', 'minutes' => '0' ) ),
'bot messages is a string' => array( 'bot', 'messages', 'hello' ),
);
}

/**
* Submit an otherwise complete form for the tab with one field of the wrong type.
*
* @dataProvider malformed_fields
*
* @param 'general'|'design'|'modules'|'bot' $tab Settings tab slug.
* @param string $field Field name inside the tab.
* @param string|array<array-key, string|list<string>> $value Submitted value.
*/
public function test_malformed_field_is_rejected_without_saving( string $tab, string $field, $value ): void {
// the stored general defaults keep exclude as a list, while the form posts a string
$form = 'general' === $tab ? array( 'status' => '0' ) : $this->settings[ $tab ];
$form[ $field ] = $value;

$message = $this->save_settings( $tab, array( 'options' => array( $tab => $form ) ) );

$this->assertSame( 'The submitted settings are invalid.', $message );
$this->assertSame( $this->settings, get_option( 'wpmm_settings' ) );
}

public function test_classic_modules_save_without_countdown_details_is_rejected(): void {
$form = $this->settings['modules'];
unset( $form['countdown_details'] );

$message = $this->save_settings( 'modules', array( 'options' => array( 'modules' => $form ) ) );

$this->assertSame( 'The submitted settings are invalid.', $message );
$this->assertSame( $this->settings, get_option( 'wpmm_settings' ) );
}

public function test_new_look_modules_save_without_countdown_details_is_saved(): void {
update_option( 'wpmm_new_look', '1' );
$form = $this->settings['modules'];
unset( $form['countdown_details'] );
$form['ga_status'] = '1';

$message = $this->save_settings( 'modules', array( 'options' => array( 'modules' => $form ) ) );

$this->assertSame( 'redirected', $message );
$this->assertSame( 1, get_option( 'wpmm_settings' )['modules']['ga_status'] );
}

public function test_general_form_save_stores_status(): void {
$message = $this->save_settings( 'general', array( 'options' => array( 'general' => array( 'status' => '1' ) ) ) );

$this->assertSame( 'redirected', $message );
$this->assertSame( 1, get_option( 'wpmm_settings' )['general']['status'] );
}
}
Loading