create-block-theme/tests/test-class-create-block-theme-api.php
Sarah Norris b27577b6b1
Require super-admin for theme-modifying REST routes on multisite (#850)
* Add can_modify_theme() permission helper with DISALLOW_FILE_* respect

* Wire 9 mutating REST routes to can_modify_theme()

* Add multisite permission tests (skip when not in multisite env)

* Keep DISALLOW_FILE_* authoritative over the cbt_file_mods_allowed filter

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Assert /font-families returns 200 SUCCESS, not just not-403

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Avoid constructor side effects in invoke_private test helper

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Grant super-admin on multisite to isolate file-mod hardening assertion

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Update docblock

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Restore docblock close marker dropped by Copilot suggestion

* Delegate file-mod check to WP Core's wp_is_file_mod_allowed

scruffian flagged that the previous implementation bypassed WordPress
Core's canonical `file_mod_allowed` filter — the mechanism hosts and
security plugins use to disable file modifications globally.

Now delegates the DISALLOW_FILE_MODS / file_mod_allowed branch to
wp_is_file_mod_allowed( 'create_block_theme_modify_theme' ). The
explicit DISALLOW_FILE_EDIT check is kept on top because Core's
helper does NOT cover that constant (it's specifically for the theme
file editor UI).

The cbt_file_mods_allowed filter remains as a test seam — it can only
further restrict, never re-enable, the policy decided by core.

* Add regression test for cbt filter not overriding core file_mod_allowed

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Use edit_themes + wp_is_file_mod_allowed for permission check

Refactors can_modify_theme() to compose two WordPress Core primitives
instead of reimplementing the same checks:

  current_user_can( 'edit_themes' )
    && wp_is_file_mod_allowed( 'create_block_theme_modify_theme' )

The edit_themes capability already encodes the matrix this plugin
needs: held by Administrators on single-site, super-admins on multisite
(NOT sub-site admins), and automatically denied by Core when
DISALLOW_FILE_EDIT is defined. wp_is_file_mod_allowed handles
DISALLOW_FILE_MODS and the canonical file_mod_allowed filter.

That removes:
  - the explicit is_multisite() / is_super_admin() branch
  - the explicit DISALLOW_FILE_EDIT constant check
  - the file_mods_allowed() helper
  - the cbt_file_mods_allowed filter (was a test seam; tests now use
    the canonical file_mod_allowed filter instead)

Functional matrix is unchanged.

* Document /reset-theme cap reuse for permission-surface consistency

* Hide UI when REST capability gate denies

Sub-site admins on multisite, DISALLOW_FILE_EDIT/DISALLOW_FILE_MODS
sites, and sites that deny file_mod_allowed for the
create_block_theme_modify_theme context now get 403s from every
mutating REST route — but the admin landing page and editor sidebar
were still registered behind edit_theme_options, so those users could
open the UI and click primary actions that the API rejects.

Make can_modify_theme() public static on CBT_Theme_API and gate
- the Appearance > Create Block Theme menu entry, and
- the site-editor plugin sidebar enqueue
on the same predicate, so the UI surface matches the REST surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add multisite specific test runs

* Activate a block theme in UI-gate tests

Without an active block theme, both create_admin_menu() and
create_block_theme_sidebar_enqueue() return early on the
wp_is_block_theme() check — so the prior negative-only assertions
would have passed even if the cap gate did nothing. Activate a blank
block theme via the plugin's /create-blank endpoint (mirroring the
helper used in test-theme-fonts.php), then assert both directions:
menu/script register with the gate open, and drop out once
file_mod_allowed denies. This proves the cap gate (not the block-theme
guard) is what hides the UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Don't invoke sidebar enqueue under gate-open in test

The sidebar enqueue includes build/plugin-sidebar.asset.php on the
gate-open path; the CI test job doesn't build the plugin before
running PHP tests, so the previous version of this test errored on
that include. The cap-gate behaviour we care about is the gate-denied
path — that short-circuits before the include.

Drop the gate-open enqueue call and replace it with direct asserts on
the two preconditions (wp_is_block_theme() is true, $pagenow is
site-editor.php). That still proves the cap gate (not one of the
earlier guards) is what dropped the script, without depending on
build artefacts that CI doesn't produce.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-19 14:13:28 +01:00

257 lines
9.7 KiB
PHP

<?php
/**
* @package Create_Block_Theme
*/
class Test_Create_Block_Theme_Api extends WP_UnitTestCase {
public function test_admin_can_modify_theme_on_single_site() {
if ( is_multisite() ) {
$this->markTestSkipped( 'single-site only' );
}
$admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $admin );
$this->assertTrue( CBT_Theme_API::can_modify_theme() );
}
public function test_editor_cannot_modify_theme_on_single_site() {
if ( is_multisite() ) {
$this->markTestSkipped( 'single-site only' );
}
$editor = $this->factory->user->create( array( 'role' => 'editor' ) );
wp_set_current_user( $editor );
$this->assertFalse( CBT_Theme_API::can_modify_theme() );
}
public function test_anonymous_cannot_modify_theme() {
wp_set_current_user( 0 );
$this->assertFalse( CBT_Theme_API::can_modify_theme() );
}
public function test_admin_blocked_by_core_file_mod_allowed_filter() {
// WordPress Core's canonical `file_mod_allowed` filter is the
// mechanism hosts and security plugins use to disable file
// modifications globally. can_modify_theme() must honour it.
if ( is_multisite() ) {
$this->markTestSkipped( 'single-site only' );
}
$admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $admin );
add_filter( 'file_mod_allowed', '__return_false' );
$result = CBT_Theme_API::can_modify_theme();
remove_filter( 'file_mod_allowed', '__return_false' );
$this->assertFalse( $result );
}
public function test_save_endpoint_rejects_when_file_mod_allowed_filter_returns_false() {
$admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $admin );
if ( is_multisite() ) {
grant_super_admin( $admin );
}
add_filter( 'file_mod_allowed', '__return_false' );
$request = new WP_REST_Request( 'POST', '/create-block-theme/v1/save' );
$response = rest_get_server()->dispatch( $request );
remove_filter( 'file_mod_allowed', '__return_false' );
if ( is_multisite() ) {
revoke_super_admin( $admin );
}
$this->assertSame( 403, $response->get_status() );
$this->assertSame( 'rest_forbidden', $response->get_data()['code'] );
}
public function test_save_endpoint_rejects_anonymous() {
wp_set_current_user( 0 );
$request = new WP_REST_Request( 'POST', '/create-block-theme/v1/save' );
$response = rest_get_server()->dispatch( $request );
$this->assertSame( 401, $response->get_status() );
}
public function test_font_families_endpoint_still_accessible_to_admin() {
$admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $admin );
// /font-families is a GET and is NOT gated by can_modify_theme().
// Even with file mods disallowed by the canonical filter, it should
// still respond (not 403).
add_filter( 'file_mod_allowed', '__return_false' );
$request = new WP_REST_Request( 'GET', '/create-block-theme/v1/font-families' );
$response = rest_get_server()->dispatch( $request );
remove_filter( 'file_mod_allowed', '__return_false' );
$this->assertSame( 200, $response->get_status() );
$this->assertSame( 'SUCCESS', $response->get_data()['status'] );
}
public function test_super_admin_can_modify_theme_on_multisite() {
if ( ! is_multisite() ) {
$this->markTestSkipped( 'requires multisite — set WP_TESTS_MULTISITE=1' );
}
$super = $this->factory->user->create( array( 'role' => 'administrator' ) );
grant_super_admin( $super );
wp_set_current_user( $super );
$this->assertTrue( CBT_Theme_API::can_modify_theme() );
revoke_super_admin( $super );
}
public function test_subsite_admin_cannot_modify_theme_on_multisite() {
if ( ! is_multisite() ) {
$this->markTestSkipped( 'requires multisite — set WP_TESTS_MULTISITE=1' );
}
$admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
// Explicitly NOT a super-admin — `edit_themes` is super-admin-only on multisite.
wp_set_current_user( $admin );
$this->assertFalse( CBT_Theme_API::can_modify_theme() );
}
public function test_save_endpoint_rejects_subsite_admin_on_multisite() {
if ( ! is_multisite() ) {
$this->markTestSkipped( 'requires multisite — set WP_TESTS_MULTISITE=1' );
}
$admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $admin );
$request = new WP_REST_Request( 'POST', '/create-block-theme/v1/save' );
$response = rest_get_server()->dispatch( $request );
$this->assertSame( 403, $response->get_status() );
$this->assertSame( 'rest_forbidden', $response->get_data()['code'] );
}
public function test_admin_landing_menu_gated_by_can_modify_theme() {
// CBT_Admin_Landing::create_admin_menu() also returns early when
// wp_is_block_theme() is false. To prove the cap gate (and not the
// block-theme guard) is what hides the menu, activate a block theme
// first, then assert the menu appears when can_modify_theme() passes
// and disappears once file_mod_allowed denies.
if ( is_multisite() ) {
$this->markTestSkipped( 'single-site only — multisite gate is covered by edit_themes super-admin check' );
}
$admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $admin );
$saved_stylesheet = get_stylesheet();
$test_theme_slug = $this->create_blank_theme();
set_current_screen( 'themes.php' );
$landing = new CBT_Admin_Landing();
global $submenu;
try {
// Gate open: menu IS registered (proves we cleared wp_is_block_theme()).
$submenu = array();
$landing->create_admin_menu();
$open_slugs = array_column( isset( $submenu['themes.php'] ) ? $submenu['themes.php'] : array(), 2 );
$this->assertContains(
'create-block-theme-landing',
$open_slugs,
'Landing-page menu should register on a block theme when the user can modify the theme.'
);
// Gate denied: menu is NOT registered.
add_filter( 'file_mod_allowed', '__return_false' );
$submenu = array();
$landing->create_admin_menu();
$denied_slugs = array_column( isset( $submenu['themes.php'] ) ? $submenu['themes.php'] : array(), 2 );
remove_filter( 'file_mod_allowed', '__return_false' );
$this->assertNotContains(
'create-block-theme-landing',
$denied_slugs,
'Landing-page menu must NOT register when the user cannot modify the theme.'
);
} finally {
$this->uninstall_theme( $test_theme_slug, $saved_stylesheet );
}
}
public function test_editor_sidebar_enqueue_gated_by_can_modify_theme() {
// CBT_Editor_Tools::create_block_theme_sidebar_enqueue() has two
// early-return guards before the cap check: wp_is_block_theme() and
// $pagenow === 'site-editor.php'. Prove the cap gate (and not one of
// those guards) is what drops the script by:
// 1. Activating a block theme (so wp_is_block_theme() would pass)
// 2. Setting $pagenow = 'site-editor.php' (so the pagenow guard would pass)
// 3. Denying file_mod_allowed and calling the enqueue
// The enqueue must short-circuit at the cap check WITHOUT touching
// the asset include further down — we don't run the gate-open path
// because the test runner doesn't build the plugin assets.
if ( is_multisite() ) {
$this->markTestSkipped( 'single-site only — multisite gate is covered by edit_themes super-admin check' );
}
$admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $admin );
$saved_stylesheet = get_stylesheet();
$test_theme_slug = $this->create_blank_theme();
global $pagenow;
$saved_pagenow = $pagenow;
$pagenow = 'site-editor.php';
try {
// Sanity-check both early-return guards would NOT trigger — this
// proves the cap check is the only thing that can drop the script.
$this->assertTrue( wp_is_block_theme(), 'wp_is_block_theme() must be true so the block-theme guard would not drop the enqueue.' );
$this->assertSame( 'site-editor.php', $pagenow, '$pagenow must be site-editor.php so the pagenow guard would not drop the enqueue.' );
$tools = new CBT_Editor_Tools();
wp_dequeue_script( 'create-block-theme-slot-fill' );
wp_deregister_script( 'create-block-theme-slot-fill' );
add_filter( 'file_mod_allowed', '__return_false' );
$tools->create_block_theme_sidebar_enqueue();
$enqueued_after_deny = wp_script_is( 'create-block-theme-slot-fill', 'enqueued' );
remove_filter( 'file_mod_allowed', '__return_false' );
$this->assertFalse(
$enqueued_after_deny,
'Sidebar script must NOT enqueue when the user cannot modify the theme.'
);
} finally {
$pagenow = $saved_pagenow;
wp_dequeue_script( 'create-block-theme-slot-fill' );
wp_deregister_script( 'create-block-theme-slot-fill' );
$this->uninstall_theme( $test_theme_slug, $saved_stylesheet );
}
}
/**
* Create + activate a blank block theme using the plugin's own
* /create-blank endpoint, so wp_is_block_theme() returns true. Mirrors
* the helper in tests/test-theme-fonts.php.
*/
private function create_blank_theme() {
$test_theme_slug = 'cbt-api-test-theme';
delete_theme( $test_theme_slug );
$request = new WP_REST_Request( 'POST', '/create-block-theme/v1/create-blank' );
$request->set_param( 'name', $test_theme_slug );
$request->set_param( 'description', '' );
$request->set_param( 'uri', '' );
$request->set_param( 'author', '' );
$request->set_param( 'author_uri', '' );
$request->set_param( 'tags_custom', '' );
$request->set_param( 'recommended_plugins', '' );
rest_do_request( $request );
CBT_Theme_JSON_Resolver::clean_cached_data();
return $test_theme_slug;
}
private function uninstall_theme( $theme_slug, $restore_stylesheet = null ) {
CBT_Theme_JSON_Resolver::write_user_settings( array() );
if ( $restore_stylesheet ) {
switch_theme( $restore_stylesheet );
}
delete_theme( $theme_slug );
}
}