From d38aca8004be6a2677400a4ea77f556191700a18 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Thu, 3 Sep 2026 20:32:22 +0100 Subject: [PATCH 1/7] fix: let the release gate check out its action and read check runs --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1da8d726..7f847239a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,8 @@ jobs: runs-on: ubuntu-latest permissions: statuses: write + contents: read + checks: read steps: - name: Checkout gate action uses: actions/checkout@v4 From b4c3dea76be528825e1ab8d5c091368d595f0fe9 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Thu, 3 Sep 2026 20:32:22 +0100 Subject: [PATCH 2/7] fix: say execution is disabled when Run Once is blocked, not only safe mode --- src/js/components/ManageMenu/ManageMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/components/ManageMenu/ManageMenu.tsx b/src/js/components/ManageMenu/ManageMenu.tsx index 462106379..fae66f145 100644 --- a/src/js/components/ManageMenu/ManageMenu.tsx +++ b/src/js/components/ManageMenu/ManageMenu.tsx @@ -40,7 +40,7 @@ const getNotice = (result: string): { text: string, type: NoticeType } | undefin case 'run-once-safe-mode': return { - text: __('Safe mode is active, so the snippet was not run.', 'code-snippets'), + text: __('Snippet execution is disabled on this site, so the snippet was not run.', 'code-snippets'), type: 'warning' } From 362880edd1824bd8f116313876dc1d74429ed292 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Thu, 3 Sep 2026 20:32:34 +0100 Subject: [PATCH 3/7] fix: treat the same name in different namespaces as different declarations --- src/php/Utils/Validator.php | 80 +++++++++++++++++-- tests/unit/Snippets/Batch_Activation_Test.php | 15 ++++ tests/unit/Utils/Validator_Test.php | 70 ++++++++++++++++ 3 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 tests/unit/Utils/Validator_Test.php diff --git a/src/php/Utils/Validator.php b/src/php/Utils/Validator.php index 324605fb3..1f075b088 100644 --- a/src/php/Utils/Validator.php +++ b/src/php/Utils/Validator.php @@ -64,6 +64,13 @@ class Validator { */ private array $claimed_identifiers = []; + /** + * Namespace the code being read currently declares, lower-cased, or empty for the global namespace. + * + * @var string + */ + private string $namespace = ''; + /** * Class constructor. * @@ -130,15 +137,18 @@ private function next() { */ private function check_duplicate_identifier( string $type, string $identifier ): bool { $identifier = strtolower( ltrim( $identifier, '\\' ) ); + + // PHP keeps declared names fully qualified, so that is the form compared + // and claimed: the same short name in two namespaces is two names. + $qualified = '' === $this->namespace ? $identifier : $this->namespace . '\\' . $identifier; $namespaced_identifier = 'code_snippets\\' . $identifier; if ( ! isset( $this->defined_identifiers[ $type ] ) ) { switch ( $type ) { case T_FUNCTION: - $defined_functions = get_defined_functions(); $this->defined_identifiers[ T_FUNCTION ] = array_map( 'strtolower', - array_merge( $defined_functions['internal'], $defined_functions['user'] ) + array_merge( get_defined_functions()['internal'], get_defined_functions()['user'] ) ); break; @@ -160,18 +170,67 @@ private function check_duplicate_identifier( string $type, string $identifier ): $this->claimed_identifiers[ $type ] ?? [] ); - $duplicate_identifier = in_array( $identifier, $known, true ); - $duplicate_namespaced = in_array( $namespaced_identifier, $known, true ); + $duplicate_identifier = in_array( $qualified, $known, true ); + $duplicate_namespaced = '' === $this->namespace && in_array( $namespaced_identifier, $known, true ); $exceptions = $this->exceptions[ $type ] ?? []; - $exception_identifier = in_array( $identifier, $exceptions, true ); - $exception_namespaced = in_array( $namespaced_identifier, $exceptions, true ); + $exception_identifier = in_array( $identifier, $exceptions, true ) || in_array( $qualified, $exceptions, true ); + $exception_namespaced = in_array( $identifier, $exceptions, true ) || in_array( $namespaced_identifier, $exceptions, true ); - array_unshift( $this->defined_identifiers[ $type ], $identifier ); - $this->claimed_identifiers[ $type ][] = $identifier; + array_unshift( $this->defined_identifiers[ $type ], $qualified ); + $this->claimed_identifiers[ $type ][] = $qualified; return ( $duplicate_identifier && ! $exception_identifier ) || ( $duplicate_namespaced && ! $exception_namespaced ); } + /** + * Read the name a namespace declaration introduces, leaving the cursor after it. + * + * A bare "namespace {" opens the global namespace; "namespace\\foo()" is a + * relative name rather than a declaration and is left alone. + * + * @return string Lower-cased namespace, or empty for the global namespace. + */ + private function read_namespace_declaration(): string { + $name = ''; + + while ( ! $this->end() ) { + $token = $this->peek(); + + if ( is_array( $token ) ) { + if ( T_WHITESPACE === $token[0] || T_COMMENT === $token[0] || T_DOC_COMMENT === $token[0] ) { + $this->next(); + continue; + } + + if ( T_NS_SEPARATOR === $token[0] && '' === $name ) { + return $this->namespace; + } + + if ( defined( 'T_NAME_RELATIVE' ) && T_NAME_RELATIVE === $token[0] ) { + return $this->namespace; + } + + if ( T_STRING === $token[0] || T_NS_SEPARATOR === $token[0] + || ( defined( 'T_NAME_QUALIFIED' ) && T_NAME_QUALIFIED === $token[0] ) ) { + $name .= $token[1]; + $this->next(); + continue; + } + + return $this->namespace; + } + + if ( ';' === $token || '{' === $token ) { + $this->next(); + return strtolower( trim( $name, '\\' ) ); + } + + return $this->namespace; + } + + return strtolower( trim( $name, '\\' ) ); + } + /** * Validate the given PHP code and return the result. * @@ -187,6 +246,11 @@ public function validate() { continue; } + if ( T_NAMESPACE === $token[0] ) { + $this->namespace = $this->read_namespace_declaration(); + continue; + } + // If this is a function or class exists check, then allow this function or class to be defined. if ( T_STRING === $token[0] && ( 'function_exists' === $token[1] || 'class_exists' === $token[1] ) ) { $type = 'function_exists' === $token[1] ? T_FUNCTION : T_CLASS; diff --git a/tests/unit/Snippets/Batch_Activation_Test.php b/tests/unit/Snippets/Batch_Activation_Test.php index fa1f4dc8d..a03061af8 100644 --- a/tests/unit/Snippets/Batch_Activation_Test.php +++ b/tests/unit/Snippets/Batch_Activation_Test.php @@ -141,4 +141,19 @@ public function test_anonymous_functions_do_not_collide(): void { $this->assertTrue( $this->is_active( $first->id ) ); $this->assertTrue( $this->is_active( $second->id ) ); } + + /** + * The same function name in two namespaces is not a collision. + * + * @return void + */ + public function test_same_name_in_different_namespaces_activates_both(): void { + $alpha = $this->make_snippet( 'global', "namespace Batch\\Alpha;\nfunction batch_shared() { return 'a'; }" ); + $beta = $this->make_snippet( 'global', "namespace Batch\\Beta;\nfunction batch_shared() { return 'b'; }" ); + + activate_snippets( [ $alpha->id, $beta->id ] ); + + $this->assertTrue( $this->is_active( $alpha->id ) ); + $this->assertTrue( $this->is_active( $beta->id ), 'a different namespace makes it a different function' ); + } } diff --git a/tests/unit/Utils/Validator_Test.php b/tests/unit/Utils/Validator_Test.php new file mode 100644 index 000000000..01e04191a --- /dev/null +++ b/tests/unit/Utils/Validator_Test.php @@ -0,0 +1,70 @@ +assertFalse( $first->validate() ); + + $second = new Validator( "namespace Acme\\Beta;\nfunction shared_name() {}", $first->get_claimed_identifiers() ); + $this->assertFalse( $second->validate(), 'a different namespace is a different function' ); + + $third = new Validator( "namespace Acme\\Alpha;\nfunction shared_name() {}", $second->get_claimed_identifiers() ); + $this->assertIsArray( $third->validate(), 'the same namespace and name is a redeclaration' ); + } + + /** + * Claimed names are stored fully qualified. + * + * @return void + */ + public function test_claims_are_fully_qualified(): void { + $validator = new Validator( "namespace Acme\\Alpha;\nfunction shared_name() {}\nclass Widget {}" ); + $validator->validate(); + + $claimed = $validator->get_claimed_identifiers(); + $this->assertContains( 'acme\\alpha\\shared_name', $claimed[ T_FUNCTION ] ); + $this->assertContains( 'acme\\alpha\\widget', $claimed[ T_CLASS ] ); + } + + /** + * Un-namespaced code still collides with itself, and a namespace block scopes only what it wraps. + * + * @return void + */ + public function test_global_and_braced_namespaces(): void { + $first = new Validator( 'function plain_name() {}' ); + $this->assertFalse( $first->validate() ); + + $duplicate = new Validator( 'function plain_name() {}', $first->get_claimed_identifiers() ); + $this->assertIsArray( $duplicate->validate() ); + + $braced = new Validator( "namespace Acme {\n\tfunction plain_name() {}\n}\nnamespace {\n\tfunction other_name() {}\n}", $first->get_claimed_identifiers() ); + $this->assertFalse( $braced->validate(), 'a braced namespace scopes its function, and the global block after it is global again' ); + $this->assertContains( 'acme\\plain_name', $braced->get_claimed_identifiers()[ T_FUNCTION ] ); + $this->assertContains( 'other_name', $braced->get_claimed_identifiers()[ T_FUNCTION ] ); + } + + /** + * A relative name such as namespace\foo() is a call, not a declaration. + * + * @return void + */ + public function test_relative_namespace_names_are_not_declarations(): void { + $validator = new Validator( "namespace Acme;\nnamespace\\helper();\nfunction helper() {}" ); + $this->assertFalse( $validator->validate() ); + $this->assertContains( 'acme\\helper', $validator->get_claimed_identifiers()[ T_FUNCTION ] ); + } +} From 61a906fdf6a856ac5e0d643455b9e76e486988be Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Thu, 3 Sep 2026 20:32:41 +0100 Subject: [PATCH 4/7] fix: delete the known cache keys where the cache cannot flush a group --- src/php/snippet-ops.php | 22 ++++++++++++- tests/unit/Core/Versioned_Cache_Test.php | 41 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index d487c62e2..efda85ee5 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -128,7 +128,27 @@ function flush_versioned_cache_groups( string $previous_version ): void { // still be waiting to break its next rollback. flush_cache_group( CACHE_GROUP_BASE ); - flush_cache_group( CACHE_GROUP ); + // Where the cache cannot flush a whole group, the keys this plugin writes + // are deleted one by one instead, so an uninstall followed by a reinstall + // of the same version cannot read snippets that no longer exist. + if ( ! flush_cache_group( CACHE_GROUP ) ) { + flush_known_cache_keys(); + } +} + +/** + * Delete every key this plugin is known to write in its current cache group. + * + * @return void + */ +function flush_known_cache_keys(): void { + clean_snippets_cache( code_snippets()->db->get_table_name( false ) ); + + if ( is_multisite() ) { + clean_snippets_cache( code_snippets()->db->get_table_name( true ) ); + } + + wp_cache_delete( Settings\CACHE_KEY, CACHE_GROUP ); } /** diff --git a/tests/unit/Core/Versioned_Cache_Test.php b/tests/unit/Core/Versioned_Cache_Test.php index 17ea1da29..acbd30e36 100644 --- a/tests/unit/Core/Versioned_Cache_Test.php +++ b/tests/unit/Core/Versioned_Cache_Test.php @@ -3,7 +3,9 @@ namespace Code_Snippets\Core; use Code_Snippets\UnitTestCase; +use function Code_Snippets\code_snippets; use function Code_Snippets\flush_cache_group; +use function Code_Snippets\flush_known_cache_keys; use function Code_Snippets\flush_versioned_cache_groups; use const Code_Snippets\CACHE_GROUP; use const Code_Snippets\CACHE_GROUP_BASE; @@ -106,4 +108,43 @@ public function test_empty_previous_version_is_tolerated(): void { $this->assertTrue( true ); } + + /** + * Every key the plugin writes is deleted individually, for caches that cannot flush a group. + * + * @return void + */ + public function test_known_keys_are_deleted_without_a_group_flush(): void { + $table = code_snippets()->db->get_table_name( false ); + $keys = [ + "all_snippets_$table", + "all_snippet_tags_$table", + 'active_snippets_global_single-use_front-end_' . $table, + \Code_Snippets\Settings\CACHE_KEY, + ]; + + foreach ( $keys as $key ) { + wp_cache_set( $key, 'stale', CACHE_GROUP ); + } + + flush_known_cache_keys(); + + foreach ( $keys as $key ) { + $this->assertFalse( wp_cache_get( $key, CACHE_GROUP ), $key ); + } + } + + /** + * Flushing the versioned groups leaves no snippet data behind, whichever path the cache supports. + * + * @return void + */ + public function test_versioned_flush_leaves_no_snippet_data(): void { + $table = code_snippets()->db->get_table_name( false ); + wp_cache_set( "all_snippets_$table", 'stale', CACHE_GROUP ); + + flush_versioned_cache_groups( '' ); + + $this->assertFalse( wp_cache_get( "all_snippets_$table", CACHE_GROUP ) ); + } } From e3998db336bd864641fa280eb82111510d9f3e5b Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Thu, 3 Sep 2026 20:32:47 +0100 Subject: [PATCH 5/7] fix: anchor the vendor prefix check in the autoloader test --- tests/unit/Core/Autoloader_Prefixes_Test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/Core/Autoloader_Prefixes_Test.php b/tests/unit/Core/Autoloader_Prefixes_Test.php index 84be398e9..928af5a10 100644 --- a/tests/unit/Core/Autoloader_Prefixes_Test.php +++ b/tests/unit/Core/Autoloader_Prefixes_Test.php @@ -52,7 +52,7 @@ public function test_no_unprefixed_vendor_namespace_remains_registered(): void { $leftovers = []; foreach ( array_keys( $prefixes ) as $namespace ) { - if ( false === strpos( $namespace, $vendor_prefix ) && + if ( 0 !== strpos( $namespace, $vendor_prefix ) && isset( $prefixes[ $vendor_prefix . $namespace ] ) && ! empty( $prefixes[ $namespace ] ) ) { $leftovers[] = $namespace; From ceec0f2c05b270958ee1f689617b04a7893d92dc Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Thu, 3 Sep 2026 20:32:54 +0100 Subject: [PATCH 6/7] fix: refresh the Run Once nonce with the Heartbeat --- .../ManageMenu/SnippetsTable/TableColumns.tsx | 22 +- src/js/utils/restAPI.ts | 10 +- src/php/Admin/Menus/Manage/Manage_Menu.php | 19 ++ .../Manage/Manage_Menu_Run_Once_Test.php | 190 ++++++++++++++++++ 4 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 tests/unit/Admin/Menus/Manage/Manage_Menu_Run_Once_Test.php diff --git a/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx b/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx index c6026d95a..53e3c0dd1 100644 --- a/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx @@ -7,6 +7,7 @@ import { useSnippetsList } from '../../../hooks/useSnippetsList' import { handleUnknownError } from '../../../utils/errors' import { isNetworkAdmin } from '../../../utils/screen' import { getSnippetDisplayName, getSnippetEditUrl, getSnippetType } from '../../../utils/snippets/snippets' +import { getRunOnceNonce } from '../../../utils/restAPI' import { buildUrl } from '../../../utils/urls' import { Badge } from '../../common/Badge' import { SnippetPriorityInput } from '../../common/snippets/SnippetPriorityInput' @@ -22,16 +23,25 @@ interface ColumnProps { snippet: Snippet } +const runOnceUrl = (snippet: Snippet, nonce: string): string => + buildUrl(window.location.href, { + action: 'run-once', + snippet: snippet.id, + network: snippet.network ? 'true' : 'false', + _wpnonce: nonce + }) + +// The rendered link carries the nonce from page load; the click reads the one +// the Heartbeat has refreshed since, so a page left open still works. const RunOnceButton: React.FC = ({ snippet }) => { + event.preventDefault() + window.location.assign(runOnceUrl(snippet, getRunOnceNonce())) + }} > {__('Run Once', 'code-snippets')} diff --git a/src/js/utils/restAPI.ts b/src/js/utils/restAPI.ts index 3fe39470a..0d6109635 100644 --- a/src/js/utils/restAPI.ts +++ b/src/js/utils/restAPI.ts @@ -56,6 +56,10 @@ export const applyMethodOverride = (config: InternalAxiosRequestConfig): Interna * reloading the page, which loses whatever was being written. */ let restNonce = window.CODE_SNIPPETS?.restAPI.nonce +let runOnceNonce = window.CODE_SNIPPETS_MANAGE?.runOnceNonce + +/** The Run Once nonce as last refreshed by the Heartbeat, or the one rendered with the page. */ +export const getRunOnceNonce = (): string => runOnceNonce ?? '' /** * Keep the REST nonce current for as long as the page is open. @@ -71,10 +75,14 @@ export const listenForNonceRefresh = () => { window.wp.hooks?.addAction( 'heartbeat.tick', 'code-snippets/refresh-rest-nonce', - (data: { rest_nonce?: string }) => { + (data: { rest_nonce?: string, code_snippets_run_once_nonce?: string }) => { if (data.rest_nonce) { restNonce = data.rest_nonce } + + if (data.code_snippets_run_once_nonce) { + runOnceNonce = data.code_snippets_run_once_nonce + } } ) } diff --git a/src/php/Admin/Menus/Manage/Manage_Menu.php b/src/php/Admin/Menus/Manage/Manage_Menu.php index 8d9d48b42..089177d07 100644 --- a/src/php/Admin/Menus/Manage/Manage_Menu.php +++ b/src/php/Admin/Menus/Manage/Manage_Menu.php @@ -48,6 +48,7 @@ public function __construct() { new Manage_Menu_Bulk_Download(); add_action( 'admin_menu', array( $this, 'register_upgrade_menu' ), 500 ); + add_filter( 'heartbeat_received', [ $this, 'refresh_run_once_nonce' ] ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_menu_css' ) ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_menu_css' ] ); } @@ -255,6 +256,24 @@ private function handle_run_once(): void { exit; } + /** + * Send a fresh Run Once nonce with each Heartbeat, so a page left open past + * the nonce lifetime can still run a snippet. + * + * @param mixed $response Heartbeat response. + * + * @return array + */ + public function refresh_run_once_nonce( $response ): array { + $response = is_array( $response ) ? $response : []; + + if ( code_snippets()->current_user_can() ) { + $response['code_snippets_run_once_nonce'] = wp_create_nonce( self::RUN_ONCE_NONCE ); + } + + return $response; + } + /** * Executed when the admin page is loaded. */ diff --git a/tests/unit/Admin/Menus/Manage/Manage_Menu_Run_Once_Test.php b/tests/unit/Admin/Menus/Manage/Manage_Menu_Run_Once_Test.php new file mode 100644 index 000000000..c7e60c117 --- /dev/null +++ b/tests/unit/Admin/Menus/Manage/Manage_Menu_Run_Once_Test.php @@ -0,0 +1,190 @@ +user->create( [ 'role' => 'administrator' ] ) ); + $this->redirected_to = ''; + add_filter( 'wp_redirect', [ $this, 'capture_redirect' ] ); + } + + /** + * Clean up after each test. + * + * @return void + */ + public function tear_down() { + remove_filter( 'wp_redirect', [ $this, 'capture_redirect' ] ); + remove_all_filters( 'code_snippets/execute_snippets' ); + $_REQUEST = []; + parent::tear_down(); + } + + /** + * Record the redirect and stop the handler before it exits. + * + * @param string $location Redirect target. + * + * @return never + * @throws RuntimeException Always; the location is kept on the test, not in the message. + */ + public function capture_redirect( string $location ) { + $this->redirected_to = $location; + throw new RuntimeException( 'redirect captured' ); + } + + /** + * Run the handler for a snippet id with a given nonce, returning the result query value. + * + * @param int $snippet_id Snippet to run. + * @param string $nonce Nonce to present. + * + * @return string|null The result parameter of the redirect, or null when the handler returned without redirecting. + */ + private function run_once_request( int $snippet_id, string $nonce ): ?string { + $_REQUEST['action'] = 'run-once'; + $_REQUEST['snippet'] = (string) $snippet_id; + $_REQUEST['_wpnonce'] = $nonce; + + // The admin bootstrap does not run under PHPUnit, so the menu is built here. + $menu = new Manage_Menu(); + $method = new ReflectionMethod( $menu, 'handle_run_once' ); + $method->setAccessible( true ); + + try { + $method->invoke( $menu ); + } catch ( RuntimeException $e ) { + $query = []; + wp_parse_str( (string) wp_parse_url( $this->redirected_to, PHP_URL_QUERY ), $query ); + return $query['result'] ?? ''; + } + + return null; + } + + /** + * Store a single-use snippet. + * + * @param string $code Snippet code. + * + * @return Snippet + */ + private function single_use( string $code ): Snippet { + $snippet = new Snippet(); + $snippet->name = 'Run once'; + $snippet->scope = 'single-use'; + $snippet->code = $code; + $snippet->active = false; + + return save_snippet( $snippet ); + } + + /** + * A bad nonce does nothing at all. + * + * @return void + */ + public function test_bad_nonce_is_ignored(): void { + $snippet = $this->single_use( 'update_option( "run_once_ran", "yes" );' ); + + $this->assertNull( $this->run_once_request( $snippet->id, 'not-a-nonce' ) ); + $this->assertFalse( (bool) get_snippet( $snippet->id )->active ); + } + + /** + * A valid snippet runs and reports it. + * + * @return void + */ + public function test_valid_snippet_is_executed(): void { + $snippet = $this->single_use( 'update_option( "run_once_ran", "yes" );' ); + + $this->assertSame( 'executed', $this->run_once_request( $snippet->id, wp_create_nonce( Manage_Menu::RUN_ONCE_NONCE ) ) ); + $this->assertTrue( (bool) get_snippet( $snippet->id )->active ); + } + + /** + * Code that fails validation is reported as a failure and stays inactive. + * + * @return void + */ + public function test_invalid_snippet_reports_failure(): void { + $snippet = $this->single_use( 'function wp_head() { return "redeclared"; }' ); + + $this->assertSame( 'run-once-failed', $this->run_once_request( $snippet->id, wp_create_nonce( Manage_Menu::RUN_ONCE_NONCE ) ) ); + $this->assertFalse( (bool) get_snippet( $snippet->id )->active ); + } + + /** + * With execution disabled nothing runs, and the result says so. + * + * @return void + */ + public function test_disabled_execution_is_reported(): void { + add_filter( 'code_snippets/execute_snippets', '__return_false' ); + $snippet = $this->single_use( 'update_option( "run_once_ran", "yes" );' ); + + $this->assertSame( 'run-once-safe-mode', $this->run_once_request( $snippet->id, wp_create_nonce( Manage_Menu::RUN_ONCE_NONCE ) ) ); + $this->assertFalse( (bool) get_snippet( $snippet->id )->active ); + } + + /** + * Only single-use snippets can be run this way. + * + * @return void + */ + public function test_other_scopes_are_refused(): void { + $snippet = new Snippet(); + $snippet->name = 'Global'; + $snippet->scope = 'global'; + $snippet->code = 'update_option( "run_once_ran", "yes" );'; + $snippet->active = false; + $saved = save_snippet( $snippet ); + + $this->assertSame( '', $this->run_once_request( $saved->id, wp_create_nonce( Manage_Menu::RUN_ONCE_NONCE ) ), 'redirected back with no result' ); + $this->assertFalse( (bool) get_snippet( $saved->id )->active ); + } + + /** + * The Heartbeat carries a fresh Run Once nonce for people who may run snippets, and nothing for others. + * + * @return void + */ + public function test_heartbeat_refreshes_the_nonce(): void { + $menu = new Manage_Menu(); + $this->assertNotFalse( has_filter( 'heartbeat_received', [ $menu, 'refresh_run_once_nonce' ] ), 'the menu listens to the Heartbeat' ); + + $response = $menu->refresh_run_once_nonce( [ 'other' => 'kept' ] ); + $this->assertSame( 'kept', $response['other'] ); + $this->assertArrayHasKey( 'code_snippets_run_once_nonce', $response ); + $this->assertNotFalse( wp_verify_nonce( $response['code_snippets_run_once_nonce'], Manage_Menu::RUN_ONCE_NONCE ) ); + + wp_set_current_user( self::factory()->user->create( [ 'role' => 'subscriber' ] ) ); + $this->assertArrayNotHasKey( 'code_snippets_run_once_nonce', $menu->refresh_run_once_nonce( [] ) ); + } +} From 470b60b7bbb72fc57f77d14b3dca335ac3a1d0c1 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Thu, 3 Sep 2026 20:34:46 +0100 Subject: [PATCH 7/7] chore: drop comments that restate what the linter helpers do --- scripts/linters/lint-changelog.ts | 5 ----- scripts/linters/lint-readme.ts | 5 ----- 2 files changed, 10 deletions(-) diff --git a/scripts/linters/lint-changelog.ts b/scripts/linters/lint-changelog.ts index 4acaf4741..0fc6aaf81 100644 --- a/scripts/linters/lint-changelog.ts +++ b/scripts/linters/lint-changelog.ts @@ -60,17 +60,12 @@ const collapseBlankLines = (lines: string[]): string[] => { return out } -/** Remove blank lines before the first content line. */ const stripLeadingBlanks = (lines: string[]): string[] => { let i = 0 while (i < lines.length && '' === lines[i].trim()) {i += 1} return lines.slice(i) } -/** - * Drop blank lines that sit between two list items. Bullets accumulate stray - * blank lines over successive edits; consecutive items should be contiguous. - */ const removeBlanksBetweenListItems = (lines: string[]): string[] => { const isItem = (l: string): boolean => /^\s*[*-] /.test(l) const out: string[] = [] diff --git a/scripts/linters/lint-readme.ts b/scripts/linters/lint-readme.ts index 9d10fa6a4..d24f1a9be 100644 --- a/scripts/linters/lint-readme.ts +++ b/scripts/linters/lint-readme.ts @@ -100,7 +100,6 @@ const collapseBlankLines = (lines: string[]): string[] => { return out } -/** Remove blank lines before the first content line. */ const stripLeadingBlanks = (lines: string[]): string[] => { let i = 0 while (i < lines.length && '' === lines[i].trim()) { @@ -109,10 +108,6 @@ const stripLeadingBlanks = (lines: string[]): string[] => { return lines.slice(i) } -/** - * Drop blank lines that sit between two list items. Bullets accumulate stray - * blank lines over successive edits; consecutive items should be contiguous. - */ const removeBlanksBetweenListItems = (lines: string[]): string[] => { const isItem = (l: string): boolean => /^\s*[*-] /.test(l) const out: string[] = []