mirror of
https://github.com/WordPress/create-block-theme.git
synced 2026-07-26 21:42:48 +08:00
* 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>
257 lines
9.7 KiB
PHP
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 );
|
|
}
|
|
}
|