From 59f946c6d307638f587e6892ddb03b487002ca97 Mon Sep 17 00:00:00 2001 From: coraislovely-code <302675651+coraislovely-code@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:12:07 -0400 Subject: [PATCH 1/2] Add a PHPUnit harness that runs without WordPress The theme has never had a test of any kind. The obstacle has always been that testing a WordPress theme appears to require WordPress, and therefore a database. It does not, for the part that matters. The theme's logic -- escaping decisions, SQL construction, nonce and capability checks on the options screen -- depends only on its arguments plus a small set of WordPress helpers. Stubbing those gives a suite that runs in milliseconds with no database, no Docker and no WordPress checkout. Several of the stubs have to be faithful or the tests they support quietly stop meaning anything, and this is documented at the top of tests/stubs.php: - esc_html()/esc_attr() call _wp_specialchars() with $double_encode = false, so they leave existing entities alone, while esc_textarea() double-encodes. Much of this theme's escaping behaviour turns on that difference, so a naive htmlspecialchars() stub would give the wrong answer. - esc_url() drops a disallowed scheme and esc_attr() does not. An href escaped with esc_attr() is well-formed HTML and a working javascript: URL, and several places in the theme do exactly that, so the two stubs must not be interchangeable. - wp_filter_nohtml_kses() is the only sanitiser the options screen uses. WordPress defines it as addslashes( wp_kses( stripslashes( $data ), array() ) ): it removes every tag and leaves quotes ALONE. A stub that also escaped quotes would make unescaped attribute output look safe. - The nonce functions are a test-driven seam, not a reimplementation. A test can make verification pass, make it fail, and -- the part that matters -- see whether it was attempted at all, because a handler with no nonce check passes every happy-path test. - current_user_can() defaults to false, so a missing capability check fails a test instead of sailing through one. Also included is a $wpdb spy that records the SQL it is handed, which is what allows query construction to be tested without a database. It answers $wpdb->escape() as well, removed from WordPress in 5.3 but still called by widgets/calendar.php. Loading theme code needs one wrinkle. functions.php cannot be required from a test: it calls easel_themeinfo() before defining it, glob-autoloads all of functions/ and widgets/ through get_template_part(), and pulls in options.php under is_admin(). So tests require one file at a time, and easel_themeinfo()/easel_load_options() are stubbed in the bootstrap. Those two are worth testing themselves, so the stubs are dispatchers: Easel_TestCase::loadRealThemeInfo() lifts the real bodies out of functions.php verbatim and switches both over together. No theme file is modified -- the harness has to be able to test the theme as shipped. CI runs php -l across 7.4 through 8.4, PHPUnit on 8.2 through 8.4, and an advisory PHPCS job scoped to the lines a pull request adds rather than the files it touches. The theme carries roughly six hundred pre-existing findings; a per-file run would be red by default, and a job that is always red is a job nobody reads. HarnessTest.php asserts these properties of the harness itself. If it fails, nothing else in the suite should be trusted. Co-Authored-By: Claude Fable 5 --- .github/scripts/phpcs-added-lines.py | 120 ++++ .github/workflows/ci.yml | 104 +++ .gitignore | 7 + composer.json | 26 + phpcs.xml.dist | 30 + phpunit.xml.dist | 16 + tests/Easel_TestCase.php | 130 ++++ tests/HarnessTest.php | 230 +++++++ tests/bootstrap.php | 66 ++ tests/stubs.php | 983 +++++++++++++++++++++++++++ 10 files changed, 1712 insertions(+) create mode 100755 .github/scripts/phpcs-added-lines.py create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 phpcs.xml.dist create mode 100644 phpunit.xml.dist create mode 100644 tests/Easel_TestCase.php create mode 100644 tests/HarnessTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/stubs.php diff --git a/.github/scripts/phpcs-added-lines.py b/.github/scripts/phpcs-added-lines.py new file mode 100755 index 0000000..ed1c855 --- /dev/null +++ b/.github/scripts/phpcs-added-lines.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Report only the PHPCS findings that sit on lines a pull request added. + +This theme predates the WordPress coding standards by roughly a decade, so its files carry +hundreds of pre-existing findings each. Running PHPCS over whole files would therefore fail +every pull request that touches legacy code, whether or not it made anything worse -- and a +job that is always red is a job nobody reads. + +Filtering to added lines keeps the signal: a finding is reported when this change introduced +it, and the historical backlog stays a separate, deliberate piece of work. + +Even scoped this way it is advisory, not a gate. Some of what it reports cannot reasonably +be fixed: the sniffs cannot see through `apply_filters()` to an escaper inside it, cannot +know that an interpolated ORDER BY direction was whitelisted, and object to the +`$before_title` / `$after_title` arguments that every WordPress widget emits. Failing a build +on those would mean scattering `phpcs:ignore` annotations through unrelated changes, so the +job reports and moves on. Pass --strict to exit non-zero instead. + +Usage: phpcs-added-lines.py [--strict] +""" + +import collections +import json +import os +import re +import sys + +HUNK = re.compile(r'^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@') + + +def added_lines(diff_path): + """Map each file to the set of line numbers this diff adds to it.""" + added = collections.defaultdict(set) + current = None + new_line = 0 + with open(diff_path, encoding='utf-8', errors='replace') as fh: + for line in fh: + if line.startswith('+++ '): + path = line[4:].strip() + current = None if path == '/dev/null' else re.sub(r'^b/', '', path) + continue + if line.startswith('@@'): + m = HUNK.match(line) + if m: + new_line = int(m.group(1)) + continue + if current is None: + continue + if line.startswith('+'): + added[current].add(new_line) + new_line += 1 + elif not line.startswith('-'): + # Context line. With --unified=0 these are rare, but count them anyway so + # the line numbering stays correct if the diff is ever generated with context. + new_line += 1 + return added + + +def main(): + args = [a for a in sys.argv[1:] if a != '--strict'] + strict = '--strict' in sys.argv[1:] + if len(args) != 2: + print(__doc__, file=sys.stderr) + return 2 + + report_path, diff_path = args + if not os.path.exists(report_path) or os.path.getsize(report_path) == 0: + print('PHPCS produced no report; nothing to check.') + return 0 + + with open(report_path, encoding='utf-8', errors='replace') as fh: + report = json.load(fh) + + added = added_lines(diff_path) + + def to_repo_path(reported_path): + """Map a path in the PHPCS report onto the repo-relative path used by the diff. + + PHPCS reports absolute paths. Those normally sit under the working directory, but + not always -- so fall back to matching the longest path suffix that the diff knows + about, rather than silently treating every finding as untouched and reporting a + false all-clear. + """ + rel = os.path.relpath(reported_path, os.getcwd()) + if not rel.startswith('..'): + return rel + norm = reported_path.replace(os.sep, '/') + for candidate in added: + if norm.endswith('/' + candidate) or norm == candidate: + return candidate + return rel + + reported = 0 + suppressed = 0 + for abs_path, data in report.get('files', {}).items(): + rel = to_repo_path(abs_path) + touched = added.get(rel, set()) + for msg in data.get('messages', []): + if msg.get('type') != 'ERROR': + continue + if msg.get('line') in touched: + reported += 1 + print('{}:{}:{} {}\n {}'.format( + rel, msg.get('line'), msg.get('column'), msg.get('source'), msg.get('message'))) + else: + suppressed += 1 + + print() + if suppressed: + print('{} pre-existing finding(s) on untouched lines were not reported.'.format(suppressed)) + if reported: + print('{} finding(s) on lines this change adds -- worth a look, but note that some ' + 'are unavoidable; see the header of this script.'.format(reported)) + return 1 if strict else 0 + print('No PHPCS findings on added lines.') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..01015cd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + +permissions: + contents: read + +jobs: + # Syntax check across every PHP version we claim to support. This is the cheapest useful + # signal in the repo: the theme's recent history is PHP 8 compatibility work, and until + # now nothing verified it on more than one interpreter. + lint-php: + name: php -l (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ '7.4', '8.0', '8.1', '8.2', '8.3', '8.4' ] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - name: Lint every PHP file + run: | + find . -name '*.php' -not -path './vendor/*' -print0 \ + | xargs -0 -n1 -P4 php -l + + test: + name: PHPUnit (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ '8.2', '8.3', '8.4' ] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - name: Install dependencies + run: composer update --no-interaction --prefer-dist + - name: Run tests + run: composer test + + # Scoped to the LINES a pull request actually adds, not merely the files it touches. + # + # Scoping by file is not enough here. This theme predates the WordPress coding standards + # by about a decade, and its files carry hundreds of pre-existing findings apiece -- so a + # per-file run goes red the moment a PR edits a legacy file, regardless of whether the PR + # made anything worse. A job that is red by default is a job everyone learns to scroll + # past, which is worse than not having it. + # + # Reporting only on added lines means the job stays quiet unless someone introduces a new + # finding, and cleaning up the historical ones stays an independent choice. + # + # Advisory, not a gate. Some of what it reports cannot reasonably be fixed -- the sniffs + # cannot see through apply_filters() to an escaper inside it, cannot know an interpolated + # ORDER BY direction was whitelisted, and object to the $before_title/$after_title that + # every WordPress widget emits. Gating on those would mean scattering phpcs:ignore + # annotations through unrelated changes. Add --strict below to make it blocking. + phpcs: + name: PHPCS (advisory, changed lines) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + - name: Install dependencies + run: composer update --no-interaction --prefer-dist + - name: Report PHPCS findings on added lines + run: | + BASE="${{ github.event.pull_request.base.sha }}" + if [ -n "$BASE" ]; then + # Compare against the merge base, so a base branch that has moved on since the + # pull request opened does not drag unrelated commits into the diff. + BASE="$(git merge-base "$BASE" HEAD)" + else + BASE="$(git rev-parse HEAD~1 2>/dev/null || true)" + fi + if [ -z "$BASE" ]; then + echo "No base commit to compare against; skipping." + exit 0 + fi + echo "Comparing against $BASE" + git diff --unified=0 "$BASE" HEAD -- '*.php' > /tmp/pr.diff + FILES="$(git diff --name-only --diff-filter=ACMR "$BASE" HEAD -- '*.php' | grep -v '^vendor/' || true)" + if [ -z "$FILES" ]; then + echo "No PHP files changed; nothing to check." + exit 0 + fi + echo "$FILES" | tr '\n' ' '; echo + # -q so the progress output does not corrupt the JSON report. + echo "$FILES" | xargs ./vendor/bin/phpcs -q --report=json > /tmp/phpcs.json || true + python3 .github/scripts/phpcs-added-lines.py /tmp/phpcs.json /tmp/pr.diff diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +/vendor/ +/.phpunit.cache/ +/.phpcs-cache +phpunit.xml +phpcs.xml +composer.lock diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c24414b --- /dev/null +++ b/composer.json @@ -0,0 +1,26 @@ +{ + "name": "frumph/easel", + "description": "Easel — a WordPress theme for publishing a webcomic.", + "type": "wordpress-theme", + "license": "GPL-3.0-or-later", + "require": { + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^11.5", + "squizlabs/php_codesniffer": "^3.10", + "wp-coding-standards/wpcs": "^3.1", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "scripts": { + "test": "phpunit", + "lint": "phpcs", + "lint:fix": "phpcbf", + "lint:php": "find . -name '*.php' -not -path './vendor/*' -not -path './.git/*' -print0 | xargs -0 -n1 php -l" + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..2e714d1 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,30 @@ + + + + Escaping and security sniffs for the Easel theme. + + Scoped deliberately narrow. The theme predates these standards by a decade and a + full WordPress-Extra run reports thousands of pre-existing findings, which would + bury the ones that matter. Start with the sniffs that catch the bug classes this + theme has actually had -- unescaped output in templates, unprepared SQL, missing + nonce and capability checks -- and widen later if anyone has the appetite. + + + . + /vendor/* + /tests/* + + /js/* + + + + + + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..275f922 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,16 @@ + + + + + tests + + + diff --git a/tests/Easel_TestCase.php b/tests/Easel_TestCase.php new file mode 100644 index 0000000..a8443b8 --- /dev/null +++ b/tests/Easel_TestCase.php @@ -0,0 +1,130 @@ + theme files already required in this process */ + private static $loaded = array(); + + protected function setUp(): void { + parent::setUp(); + Easel_Test_State::reset(); + // widgets/calendar.php reads $wp_locale straight off the globals, so give every + // test a fresh one rather than making each remember to. + $GLOBALS['wp_locale'] = new Easel_Locale(); + } + + /** + * Require a theme file, relative to the theme root, at most once per process. + * + * One file at a time and never functions.php: that file autoloads all of functions/ + * and widgets/ and calls easel_themeinfo() before defining it. See tests/bootstrap.php. + */ + protected static function loadThemeFile( $relative ) { + if ( isset( self::$loaded[ $relative ] ) ) { + return; + } + self::$loaded[ $relative ] = true; + require_once EASEL_THEME_DIR . '/' . $relative; + } + + /** + * Switch easel_themeinfo()/easel_load_options() over to the theme's real code. + * + * The two functions are extracted from functions.php by brace matching and eval'd + * under prefixed names; only the `function (` line differs from what ships. + * Their bodies still call easel_load_options() unqualified, which lands back on the + * dispatcher in tests/bootstrap.php -- and since that is now in real mode, the real + * implementation answers. Ugly, but it beats either duplicating the defaults into the + * test suite (where they would drift) or editing functions.php to suit the tests. + */ + protected static function loadRealThemeInfo() { + if ( ! function_exists( 'easel_test_real_easel_themeinfo' ) ) { + $source = file_get_contents( EASEL_THEME_DIR . '/functions.php' ); + $code = ''; + foreach ( array( 'easel_load_options', 'easel_themeinfo' ) as $name ) { + $body = self::extractFunction( $source, $name ); + if ( null === $body ) { + throw new RuntimeException( "Could not find function $name() in functions.php" ); + } + $code .= preg_replace( '/^function\s+' . $name . '\s*\(/', 'function easel_test_real_' . $name . '(', $body ) . "\n"; + } + eval( $code ); + } + Easel_Test_State::$use_real_themeinfo = true; + // The real accessor memoises into a global, so clear it or the previous test's + // values leak into this one. + $GLOBALS['easel_themeinfo'] = array(); + } + + /** + * Return the source text of a top-level function declaration, braces matched. + */ + private static function extractFunction( $source, $name ) { + if ( ! preg_match( '/^function\s+' . preg_quote( $name, '/' ) . '\s*\(/m', $source, $m, PREG_OFFSET_CAPTURE ) ) { + return null; + } + $start = $m[0][1]; + $open = strpos( $source, '{', $start ); + $depth = 0; + for ( $i = $open, $len = strlen( $source ); $i < $len; $i++ ) { + if ( '{' === $source[ $i ] ) { + $depth++; + } elseif ( '}' === $source[ $i ] ) { + $depth--; + if ( 0 === $depth ) { + return substr( $source, $start, $i - $start + 1 ); + } + } + } + return null; + } + + /** + * Install a fresh $wpdb spy as the global and return it. + */ + protected function useWpdbSpy() { + $spy = new Easel_WPDB_Spy(); + $GLOBALS['wpdb'] = $spy; + return $spy; + } + + /** + * Set the global $post to a lightweight stand-in. The functions under test only ever + * read a couple of properties, so a real WP_Post is unnecessary. + */ + protected function setGlobalPost( $id = 1, $type = 'post' ) { + $post = new stdClass(); + $post->ID = $id; + $post->post_author = 0; + $post->post_type = $type; + $post->post_status = 'publish'; + $post->post_title = 'Title'; + $GLOBALS['post'] = $post; + return $post; + } + + protected function setThemeInfo( array $values ) { + Easel_Test_State::$themeinfo = $values; + } + + protected function setPostMeta( $post_id, $key, $value ) { + Easel_Test_State::$post_meta[ $post_id . ':' . $key ] = $value; + } + + protected function grantCap( $cap ) { + Easel_Test_State::$user_caps[ $cap ] = true; + } + + /** + * Make wp_verify_nonce() accept this exact (nonce, action) pair and nothing else. + */ + protected function allowNonce( $nonce, $action ) { + Easel_Test_State::$valid_nonces[ $action . '|' . $nonce ] = true; + } +} diff --git a/tests/HarnessTest.php b/tests/HarnessTest.php new file mode 100644 index 0000000..84ca5cc --- /dev/null +++ b/tests/HarnessTest.php @@ -0,0 +1,230 @@ +assertTrue( function_exists( 'easel_get_calendar' ) ); + $this->assertTrue( class_exists( 'easel_calendar_widget' ) ); + $this->assertTrue( function_exists( 'easel_show_mood_in_post' ) ); + $this->assertTrue( function_exists( 'easel_handle_edit_post_mood_save' ) ); + $this->assertTrue( function_exists( 'easel_save_page_editor_options' ) ); + $this->assertTrue( function_exists( 'easel_copyright_text' ) ); + } + + /** + * The stubs answer with the checkout itself, so file_exists() and glob() inside the + * theme (moods, avatars) see the directories that actually ship. + */ + public function testThemeLocationStubsPointAtTheRepo() { + $this->assertSame( EASEL_THEME_DIR, get_template_directory() ); + $this->assertSame( EASEL_THEME_DIR, get_stylesheet_directory() ); + $this->assertFileExists( get_template_directory() . '/style.css' ); + + $this->assertSame( EASEL_TEST_THEME_URI, get_template_directory_uri() ); + $this->assertSame( EASEL_TEST_THEME_URI, get_stylesheet_directory_uri() ); + } + + /** + * esc_html()/esc_attr() must NOT re-encode existing entities; esc_textarea() must. + * Several tests distinguish correct from incorrect behaviour purely on this, so if the + * stubs get it wrong those tests silently stop meaning anything. + */ + public function testEscapingStubsMirrorWordPressDoubleEncodeSemantics() { + $this->assertSame( '<b>', esc_html( '<b>' ), 'esc_html must not double-encode' ); + $this->assertSame( '<b>', esc_attr( '<b>' ), 'esc_attr must not double-encode' ); + $this->assertSame( '&lt;b&gt;', esc_textarea( '<b>' ), 'esc_textarea must double-encode' ); + + $this->assertSame( '<b>', esc_html( '' ) ); + $this->assertSame( '"x"', esc_attr( '"x"' ) ); + } + + /** + * The distinction a number of real findings turn on: esc_url() refuses a javascript: + * URL, esc_attr() is perfectly happy to hand one back. Escaping an href with esc_attr() + * therefore produces valid HTML and a working XSS, and only esc_url() closes it. + */ + public function testEscUrlRejectsJavascriptSchemeAndEscAttrDoesNot() { + $this->assertSame( '', esc_url( 'javascript:alert(1)' ) ); + $this->assertSame( '', esc_url_raw( 'javascript:alert(1)' ) ); + $this->assertSame( 'https://example.test/x', esc_url_raw( 'https://example.test/x' ) ); + + $this->assertSame( 'javascript:alert(1)', esc_attr( 'javascript:alert(1)' ) ); + $this->assertStringContainsString( 'javascript:', esc_attr( 'javascript:alert(1)' ) ); + } + + /** + * wp_filter_nohtml_kses() is the theme's only sanitiser on the options screen. It + * removes every tag and leaves quotes alone -- so its output is safe to print as text + * and still needs esc_attr() before it goes near an attribute. + */ + public function testNohtmlKsesStripsTagsButNotQuotes() { + $this->assertSame( 'alert(1)', wp_filter_nohtml_kses( '' ) ); + $this->assertSame( 'bold', wp_filter_nohtml_kses( 'bold' ) ); + + // Quotes survive: addslashes() on the way out is undone by wp_unslash(), the same + // round trip a $_REQUEST value makes in WordPress. + $this->assertSame( '\"x\"', wp_filter_nohtml_kses( '"x"' ) ); + $this->assertSame( + '" onerror=alert(1)', + wp_unslash( wp_filter_nohtml_kses( addslashes( '" onerror=alert(1)' ) ) ), + 'a quote must survive sanitising, or attribute-injection tests would prove nothing' + ); + } + + /** + * The CSRF seam: verification can be made to succeed, made to fail, and -- most + * importantly -- observed, so a test can tell "checked and passed" from "never checked". + */ + public function testNonceStubCanPassCanFailAndRecordsTheAttempt() { + $this->assertFalse( wp_verify_nonce( 'forged', 'easel_post_options-7' ) ); + $this->assertCount( 1, Easel_Test_State::$nonce_checks ); + $this->assertFalse( Easel_Test_State::$nonce_checks[0]['valid'] ); + $this->assertSame( 'easel_post_options-7', Easel_Test_State::$nonce_checks[0]['action'] ); + + $this->allowNonce( 'good', 'easel_post_options-7' ); + $this->assertSame( 1, wp_verify_nonce( 'good', 'easel_post_options-7' ) ); + $this->assertCount( 2, Easel_Test_State::$nonce_checks ); + $this->assertTrue( Easel_Test_State::$nonce_checks[1]['valid'] ); + + // A nonce is scoped to its action, so the same string must not open another door. + $this->assertFalse( wp_verify_nonce( 'good', 'some_other_action' ) ); + } + + public function testNonceFieldEmitsAVerifiableNonce() { + $field = wp_nonce_field( 'update-options', '_wpnonce', true, false ); + $this->assertMatchesRegularExpression( '/name="_wpnonce" value="([^"]+)"/', $field ); + preg_match( '/value="([^"]+)"/', $field, $m ); + $this->assertSame( 1, wp_verify_nonce( $m[1], 'update-options' ) ); + } + + public function testCurrentUserCanDefaultsToFalse() { + $this->assertFalse( current_user_can( 'edit_page' ) ); + $this->assertFalse( current_user_can( 'manage_options' ) ); + + $this->grantCap( 'edit_page' ); + $this->assertTrue( current_user_can( 'edit_page' ) ); + $this->assertFalse( current_user_can( 'manage_options' ), 'granting one capability must not grant the rest' ); + } + + public function testFilterStubIsOverridable() { + $this->assertSame( 'default', apply_filters( 'easel_copyright_text', 'default' ) ); + Easel_Test_State::$filters['easel_copyright_text'] = 'overridden'; + $this->assertSame( 'overridden', apply_filters( 'easel_copyright_text', 'default' ) ); + } + + public function testTranslationStubIsOverridable() { + $this->assertSame( 'Original', __( 'Original', 'easel' ) ); + Easel_Test_State::$translations['Original'] = 'Origineel'; + $this->assertSame( 'Origineel', __( 'Original', 'easel' ) ); + } + + public function testThemeModsAndOptionsAreTestDriven() { + $this->assertFalse( get_theme_mod( 'easel-customize-select-layout', false ) ); + set_theme_mod( 'easel-customize-select-layout', '2cl' ); + $this->assertSame( '2cl', get_theme_mod( 'easel-customize-select-layout', false ) ); + remove_theme_mod( 'easel-customize-select-layout' ); + $this->assertFalse( get_theme_mod( 'easel-customize-select-layout', false ) ); + + update_option( 'easel-options', array( 'home_post_count' => 3 ) ); + delete_option( 'easel-options' ); + $this->assertSame( array( 'easel-options' ), Easel_Test_State::$deleted_options ); + } + + public function testPostMetaDeletionIsRecorded() { + $this->setPostMeta( 7, 'disable-sidebars', '1' ); + $this->assertSame( '1', get_post_meta( 7, 'disable-sidebars', true ) ); + delete_post_meta( 7, 'disable-sidebars' ); + $this->assertSame( '', get_post_meta( 7, 'disable-sidebars', true ) ); + $this->assertSame( array( array( 7, 'disable-sidebars' ) ), Easel_Test_State::$deleted_meta ); + } + + /** + * $wp_locale is a global that widgets/calendar.php reaches for directly. + */ + public function testLocaleStubAnswersWhatTheCalendarAsksFor() { + $locale = $GLOBALS['wp_locale']; + $this->assertSame( 'January', $locale->get_month( '01' ) ); + $this->assertSame( 'Jan', $locale->get_month_abbrev( $locale->get_month( '01' ) ) ); + $this->assertSame( 'Monday', $locale->get_weekday( 1 ) ); + $this->assertSame( 'M', $locale->get_weekday_initial( 'Monday' ) ); + $this->assertSame( 'Mon', $locale->get_weekday_abbrev( 'Monday' ) ); + } + + public function testWpdbSpyRecordsPreparedSql() { + $wpdb = $this->useWpdbSpy(); + $sql = $wpdb->prepare( 'SELECT * FROM t WHERE id = %d AND name = %s', '7 OR 1=1', "o'brien" ); + $this->assertSame( "SELECT * FROM t WHERE id = 7 AND name = 'o\\'brien'", $sql ); + $this->assertTrue( $wpdb->sawSql( 'id = 7' ) ); + } + + /** + * widgets/calendar.php still calls $wpdb->escape(), removed from WordPress in 5.3. The + * spy answers so the widget can be exercised at all -- and answers with addslashes(), + * which is what it always was and why it never made anything safe. + */ + public function testWpdbSpyStillAnswersDeprecatedEscape() { + $wpdb = $this->useWpdbSpy(); + $this->assertSame( "o\\'brien", $wpdb->escape( "o'brien" ) ); + } + + /** + * The dispatcher in tests/bootstrap.php can hand easel_themeinfo() over to the theme's + * real implementation, extracted from functions.php without editing it. + * + * Note the seeded option. With 'easel-options' unset, the real easel_load_options() + * assigns into the `false` that get_option() returns, which PHP 8.1 deprecates -- and + * this suite runs with failOnDeprecation. That default-install path deserves a test + * that asserts the deprecation deliberately (#[IgnoreDeprecations]), not one that + * trips over it here. + */ + public function testRealThemeInfoCanBeLoadedFromFunctionsPhp() { + update_option( 'easel-options', array( 'home_post_count' => '9' ) ); + self::loadRealThemeInfo(); + + $this->assertSame( array( 'home_post_count' => '9' ), easel_load_options() ); + $this->assertSame( '9', easel_themeinfo( 'home_post_count' ) ); + // From $easel_addinfo inside the real easel_themeinfo(), not from the option. + $this->assertSame( '4.4.0 Beta', easel_themeinfo( 'version' ) ); + // The real accessor forces a layout when the option does not carry one. + $this->assertSame( '3c', easel_themeinfo( 'layout' ) ); + $this->assertFalse( easel_themeinfo( 'no_such_setting' ) ); + } + + /** + * And back again: without loadRealThemeInfo(), easel_themeinfo() is the cheap stub the + * rest of the suite drives. Runs after the test above, which proves the switch is per + * test rather than sticky for the process. + */ + public function testThemeInfoStubIsTheDefault() { + $this->assertFalse( Easel_Test_State::$use_real_themeinfo ); + $this->assertFalse( easel_themeinfo( 'moods_directory' ) ); + $this->setThemeInfo( array( 'moods_directory' => 'kelly' ) ); + $this->assertSame( 'kelly', easel_themeinfo( 'moods_directory' ) ); + $this->assertFalse( easel_themeinfo( 'still_missing' ) ); + } + + /** + * Every test above dirtied something. None of it should be visible here. + */ + public function testStateResetsBetweenTests() { + $this->assertSame( array(), Easel_Test_State::$nonce_checks ); + $this->assertSame( array(), Easel_Test_State::$theme_mods ); + $this->assertSame( array(), Easel_Test_State::$user_caps ); + $this->assertSame( array(), Easel_Test_State::$deleted_options ); + $this->assertSame( array(), Easel_Test_State::$deleted_meta ); + $this->assertSame( array(), Easel_Test_State::$themeinfo ); + $this->assertFalse( get_option( 'easel-options' ) ); + $this->assertSame( 'UTF-8', get_option( 'blog_charset' ) ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..2dd63e1 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,66 @@ +get_results(), so they have to exist even though the spy ignores them. +if ( ! defined( 'OBJECT' ) ) { + define( 'OBJECT', 'OBJECT' ); + define( 'ARRAY_A', 'ARRAY_A' ); + define( 'ARRAY_N', 'ARRAY_N' ); +} + +class Easel_Test_State { + /** @var array option name => value */ + public static $options = array(); + /** @var array "postid:metakey" => value */ + public static $post_meta = array(); + /** @var array theme mod name => value */ + public static $theme_mods = array(); + /** @var array capability => bool, consulted by current_user_can() */ + public static $user_caps = array(); + /** @var array filter tag => value to return */ + public static $filters = array(); + /** @var array msgid => translation */ + public static $translations = array(); + /** @var array easel_themeinfo() key => value */ + public static $themeinfo = array(); + /** @var bool route easel_themeinfo()/easel_load_options() to the theme's real code */ + public static $use_real_themeinfo = false; + /** @var array "action|nonce" => true; anything else fails verification */ + public static $valid_nonces = array(); + /** @var array every wp_verify_nonce()/check_admin_referer() call, in order */ + public static $nonce_checks = array(); + /** @var string[] names passed to delete_option() */ + public static $deleted_options = array(); + /** @var array [post_id, key] pairs passed to delete_post_meta() */ + public static $deleted_meta = array(); + /** @var array recorded update_post_meta() calls */ + public static $meta_writes = array(); + + public static function reset() { + self::$options = array( + 'blog_charset' => 'UTF-8', + 'start_of_week' => 1, + ); + self::$post_meta = array(); + self::$theme_mods = array(); + self::$user_caps = array(); + self::$filters = array(); + self::$translations = array(); + self::$themeinfo = array(); + self::$use_real_themeinfo = false; + self::$valid_nonces = array(); + self::$nonce_checks = array(); + self::$deleted_options = array(); + self::$deleted_meta = array(); + self::$meta_writes = array(); + } +} + +/* -------------------------------------------------------------------------- * + * Escaping — must mirror WordPress's double_encode semantics. See header. + * -------------------------------------------------------------------------- */ + +if ( ! function_exists( 'esc_html' ) ) { + function esc_html( $text ) { + return htmlspecialchars( (string) $text, ENT_QUOTES, 'UTF-8', false ); + } +} + +if ( ! function_exists( 'esc_attr' ) ) { + function esc_attr( $text ) { + return htmlspecialchars( (string) $text, ENT_QUOTES, 'UTF-8', false ); + } +} + +if ( ! function_exists( 'esc_textarea' ) ) { + function esc_textarea( $text ) { + // Note the missing 4th argument: htmlspecialchars() defaults to double_encode = true, + // which is what WordPress's esc_textarea() does too. + return htmlspecialchars( (string) $text, ENT_QUOTES, 'UTF-8' ); + } +} + +if ( ! function_exists( 'esc_js' ) ) { + function esc_js( $text ) { + return addslashes( (string) $text ); + } +} + +/** + * Approximation of esc_url(). The behaviour the tests rely on is that a disallowed scheme + * yields an empty string and that ampersands are entity-encoded. + */ +if ( ! function_exists( 'esc_url' ) ) { + function esc_url( $url, $protocols = null ) { + $url = (string) $url; + if ( '' === $url ) { + return ''; + } + $allowed = null === $protocols + ? array( 'http', 'https', 'mailto', 'ftp', 'ftps', 'news', 'irc', 'tel' ) + : $protocols; + if ( preg_match( '#^([a-z0-9+.-]+):#i', $url, $m ) && ! in_array( strtolower( $m[1] ), $allowed, true ) ) { + return ''; + } + $url = str_replace( array( '"', "'", '<', '>' ), '', $url ); + return str_replace( '&', '&', htmlspecialchars( $url, ENT_NOQUOTES, 'UTF-8', false ) ); + } +} + +if ( ! function_exists( 'esc_url_raw' ) ) { + function esc_url_raw( $url, $protocols = null ) { + $url = (string) $url; + if ( '' === $url ) { + return ''; + } + $allowed = null === $protocols + ? array( 'http', 'https', 'mailto', 'ftp', 'ftps', 'news', 'irc', 'tel' ) + : $protocols; + if ( preg_match( '#^([a-z0-9+.-]+):#i', $url, $m ) && ! in_array( strtolower( $m[1] ), $allowed, true ) ) { + return ''; + } + return $url; + } +} + +/** + * Only the empty-allowlist case is modelled, which is the only way the theme calls kses. + * WordPress additionally normalises bare ampersands to &; nothing here depends on + * that, and strip_tags() matches on everything that does. + */ +if ( ! function_exists( 'wp_kses' ) ) { + function wp_kses( $content, $allowed_html = array(), $allowed_protocols = array() ) { + return strip_tags( (string) $content ); + } +} + +/** + * Written out the long way on purpose, because the shape is the point. + * + * This is the theme's only sanitiser on the options screen, and what it does NOT do is + * as load-bearing as what it does: tags go, quotes stay. Anything that lands in an HTML + * attribute still needs esc_attr() on the way out, and several tests exist to say so. + */ +if ( ! function_exists( 'wp_filter_nohtml_kses' ) ) { + function wp_filter_nohtml_kses( $data ) { + return addslashes( wp_kses( stripslashes( (string) $data ), array() ) ); + } +} + +if ( ! function_exists( 'sanitize_text_field' ) ) { + function sanitize_text_field( $str ) { + $str = strip_tags( (string) $str ); + $str = preg_replace( '/[\r\n\t ]+/', ' ', $str ); + return trim( str_replace( "\0", '', $str ) ); + } +} + +if ( ! function_exists( 'sanitize_html_class' ) ) { + function sanitize_html_class( $class, $fallback = '' ) { + $sanitized = preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $class ); + return '' === $sanitized ? $fallback : $sanitized; + } +} + +if ( ! function_exists( 'sanitize_key' ) ) { + function sanitize_key( $key ) { + return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( (string) $key ) ); + } +} + +if ( ! function_exists( 'sanitize_title' ) ) { + function sanitize_title( $title, $fallback_title = '', $context = 'save' ) { + $title = strtolower( strip_tags( (string) $title ) ); + $title = preg_replace( '/[^a-z0-9_\-\s]/', '', $title ); + $title = preg_replace( '/[\s_]+/', '-', trim( $title ) ); + return '' === $title ? $fallback_title : $title; + } +} + +if ( ! function_exists( 'wp_unslash' ) ) { + function wp_unslash( $value ) { + return is_array( $value ) ? array_map( 'wp_unslash', $value ) : stripslashes( (string) $value ); + } +} + +/* -------------------------------------------------------------------------- * + * Nonces — the seam the CSRF tests hang on. + * + * Not a reimplementation: a real nonce depends on the user, the session token and the + * clock, none of which exist here, and reproducing the hash would prove nothing anyway. + * What the tests need is control and visibility. + * + * Control Easel_Test_State::$valid_nonces holds "action|nonce" keys. A test that + * wants verification to succeed calls Easel_TestCase::allowNonce(); a test + * that wants to model an attacker simply does not, and wp_verify_nonce() + * returns false the way it does for a forged request. + * Visibility every call is appended to Easel_Test_State::$nonce_checks, so a test can + * assert that a save handler CHECKED at all. That matters more than the + * verdict: a handler with no nonce check passes any test that only looks at + * the happy path, and only an empty $nonce_checks reveals it. + * -------------------------------------------------------------------------- */ + +if ( ! function_exists( 'wp_create_nonce' ) ) { + function wp_create_nonce( $action = -1 ) { + $nonce = 'nonce-' . md5( (string) $action ); + Easel_Test_State::$valid_nonces[ $action . '|' . $nonce ] = true; + return $nonce; + } +} + +if ( ! function_exists( 'wp_verify_nonce' ) ) { + function wp_verify_nonce( $nonce, $action = -1 ) { + $nonce = (string) $nonce; + $valid = ! empty( Easel_Test_State::$valid_nonces[ $action . '|' . $nonce ] ); + Easel_Test_State::$nonce_checks[] = array( + 'nonce' => $nonce, + 'action' => $action, + 'valid' => $valid, + ); + return $valid ? 1 : false; + } +} + +if ( ! function_exists( 'wp_nonce_field' ) ) { + function wp_nonce_field( $action = -1, $name = '_wpnonce', $referer = true, $display = true ) { + $field = ''; + if ( $display ) { + echo $field; + } + return $field; + } +} + +/** + * Records like wp_verify_nonce() does, and returns rather than dying so a test can assert + * on the false branch instead of watching the process exit. + */ +if ( ! function_exists( 'check_admin_referer' ) ) { + function check_admin_referer( $action = -1, $query_arg = '_wpnonce' ) { + $nonce = isset( $_REQUEST[ $query_arg ] ) ? $_REQUEST[ $query_arg ] : ''; + return wp_verify_nonce( $nonce, $action ); + } +} + +/* -------------------------------------------------------------------------- * + * Options, meta, theme mods, capabilities, filters — all test-driven. + * -------------------------------------------------------------------------- */ + +if ( ! function_exists( 'get_option' ) ) { + function get_option( $name, $default = false ) { + return array_key_exists( $name, Easel_Test_State::$options ) ? Easel_Test_State::$options[ $name ] : $default; + } +} + +if ( ! function_exists( 'update_option' ) ) { + function update_option( $name, $value, $autoload = null ) { + Easel_Test_State::$options[ $name ] = $value; + return true; + } +} + +if ( ! function_exists( 'delete_option' ) ) { + function delete_option( $name ) { + Easel_Test_State::$deleted_options[] = $name; + unset( Easel_Test_State::$options[ $name ] ); + return true; + } +} + +if ( ! function_exists( 'get_post_meta' ) ) { + function get_post_meta( $post_id, $key = '', $single = false ) { + $k = $post_id . ':' . $key; + if ( ! array_key_exists( $k, Easel_Test_State::$post_meta ) ) { + return $single ? '' : array(); + } + return Easel_Test_State::$post_meta[ $k ]; + } +} + +if ( ! function_exists( 'update_post_meta' ) ) { + function update_post_meta( $post_id, $key, $value, $prev = '' ) { + Easel_Test_State::$post_meta[ $post_id . ':' . $key ] = $value; + Easel_Test_State::$meta_writes[] = array( $post_id, $key, $value ); + return true; + } +} + +if ( ! function_exists( 'delete_post_meta' ) ) { + function delete_post_meta( $post_id, $key, $value = '' ) { + Easel_Test_State::$deleted_meta[] = array( $post_id, $key ); + unset( Easel_Test_State::$post_meta[ $post_id . ':' . $key ] ); + return true; + } +} + +if ( ! function_exists( 'get_theme_mod' ) ) { + function get_theme_mod( $name, $default = false ) { + return array_key_exists( $name, Easel_Test_State::$theme_mods ) ? Easel_Test_State::$theme_mods[ $name ] : $default; + } +} + +if ( ! function_exists( 'set_theme_mod' ) ) { + function set_theme_mod( $name, $value ) { + Easel_Test_State::$theme_mods[ $name ] = $value; + return true; + } +} + +if ( ! function_exists( 'remove_theme_mod' ) ) { + function remove_theme_mod( $name ) { + unset( Easel_Test_State::$theme_mods[ $name ] ); + } +} + +/** + * Defaults to false on purpose. A save handler that forgets its capability check should + * fail a test, not sail through one; if every capability were granted by default the + * missing check would be invisible. + */ +if ( ! function_exists( 'current_user_can' ) ) { + function current_user_can( $capability, ...$args ) { + return ! empty( Easel_Test_State::$user_caps[ $capability ] ); + } +} + +if ( ! function_exists( 'user_can' ) ) { + function user_can( $user, $capability, ...$args ) { + return ! empty( Easel_Test_State::$user_caps[ $capability ] ); + } +} + +if ( ! function_exists( 'is_user_logged_in' ) ) { + function is_user_logged_in() { + return ! empty( Easel_Test_State::$user_caps ); + } +} + +if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( $tag, $value = null, ...$args ) { + return array_key_exists( $tag, Easel_Test_State::$filters ) ? Easel_Test_State::$filters[ $tag ] : $value; + } +} + +/* -------------------------------------------------------------------------- * + * Translation and number formatting + * -------------------------------------------------------------------------- */ + +if ( ! function_exists( '__' ) ) { + function __( $text, $domain = 'default' ) { + return array_key_exists( $text, Easel_Test_State::$translations ) ? Easel_Test_State::$translations[ $text ] : $text; + } +} + +if ( ! function_exists( '_e' ) ) { + function _e( $text, $domain = 'default' ) { + echo __( $text, $domain ); + } +} + +if ( ! function_exists( '_x' ) ) { + function _x( $text, $context, $domain = 'default' ) { + return __( $text, $domain ); + } +} + +if ( ! function_exists( '_n' ) ) { + function _n( $single, $plural, $number, $domain = 'default' ) { + return __( 1 === (int) $number ? $single : $plural, $domain ); + } +} + +if ( ! function_exists( 'esc_html__' ) ) { + function esc_html__( $text, $domain = 'default' ) { + return esc_html( __( $text, $domain ) ); + } +} + +if ( ! function_exists( 'esc_attr__' ) ) { + function esc_attr__( $text, $domain = 'default' ) { + return esc_attr( __( $text, $domain ) ); + } +} + +if ( ! function_exists( 'esc_html_e' ) ) { + function esc_html_e( $text, $domain = 'default' ) { + echo esc_html( __( $text, $domain ) ); + } +} + +if ( ! function_exists( 'esc_attr_e' ) ) { + function esc_attr_e( $text, $domain = 'default' ) { + echo esc_attr( __( $text, $domain ) ); + } +} + +if ( ! function_exists( 'number_format_i18n' ) ) { + function number_format_i18n( $number, $decimals = 0 ) { + return number_format( (float) $number, (int) $decimals ); + } +} + +if ( ! function_exists( 'load_theme_textdomain' ) ) { + function load_theme_textdomain( $domain, $path = false ) { + return true; + } +} + +/* -------------------------------------------------------------------------- * + * Theme locations — every stub answers with the repo itself, so file_exists() and + * glob() calls inside the theme (moods, avatars) see the real shipped directories. + * -------------------------------------------------------------------------- */ + +if ( ! function_exists( 'get_template_directory' ) ) { + function get_template_directory() { + return EASEL_THEME_DIR; + } +} + +if ( ! function_exists( 'get_stylesheet_directory' ) ) { + function get_stylesheet_directory() { + return EASEL_THEME_DIR; + } +} + +if ( ! function_exists( 'get_template_directory_uri' ) ) { + function get_template_directory_uri() { + return EASEL_TEST_THEME_URI; + } +} + +if ( ! function_exists( 'get_stylesheet_directory_uri' ) ) { + function get_stylesheet_directory_uri() { + return EASEL_TEST_THEME_URI; + } +} + +if ( ! function_exists( 'get_stylesheet_uri' ) ) { + function get_stylesheet_uri() { + return EASEL_TEST_THEME_URI . '/style.css'; + } +} + +if ( ! function_exists( 'home_url' ) ) { + function home_url( $path = '' ) { + return 'https://example.test' . $path; + } +} + +if ( ! function_exists( 'site_url' ) ) { + function site_url( $path = '' ) { + return 'https://example.test' . $path; + } +} + +if ( ! function_exists( 'get_bloginfo' ) ) { + function get_bloginfo( $show = '', $filter = 'raw' ) { + $defaults = array( + 'name' => 'Example Comic', + 'description' => 'Just another WordPress site', + 'url' => home_url(), + 'wpurl' => home_url(), + 'rss2_url' => home_url( '/feed/' ), + 'charset' => get_option( 'blog_charset' ), + 'version' => '6.4', + 'language' => 'en-US', + ); + return array_key_exists( $show, $defaults ) ? $defaults[ $show ] : ''; + } +} + +if ( ! function_exists( 'bloginfo' ) ) { + function bloginfo( $show = '' ) { + echo get_bloginfo( $show ); + } +} + +if ( ! function_exists( 'add_query_arg' ) ) { + function add_query_arg( ...$args ) { + if ( is_array( $args[0] ) ) { + $params = $args[0]; + $url = isset( $args[1] ) ? $args[1] : ''; + } else { + $params = array( $args[0] => $args[1] ); + $url = isset( $args[2] ) ? $args[2] : ''; + } + $sep = false === strpos( $url, '?' ) ? '?' : '&'; + return $url . $sep . http_build_query( $params ); + } +} + +/* -------------------------------------------------------------------------- * + * Posts, users, dates and archive links + * -------------------------------------------------------------------------- */ + +if ( ! function_exists( 'get_post' ) ) { + function get_post( $post = null, $output = OBJECT ) { + if ( is_object( $post ) ) { + return $post; + } + if ( null === $post || 0 === (int) $post ) { + return isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null; + } + $stub = new stdClass(); + $stub->ID = (int) $post; + $stub->post_author = 0; + $stub->post_type = 'post'; + $stub->post_status = 'publish'; + $stub->post_title = 'Title'; + return $stub; + } +} + +if ( ! function_exists( 'get_the_ID' ) ) { + function get_the_ID() { + return isset( $GLOBALS['post'] ) ? $GLOBALS['post']->ID : 0; + } +} + +if ( ! function_exists( 'get_userdata' ) ) { + function get_userdata( $user_id ) { + $user = new stdClass(); + $user->ID = (int) $user_id; + $user->display_name = 'User ' . (int) $user_id; + $user->user_login = 'user' . (int) $user_id; + $user->user_url = ''; + return $user; + } +} + +if ( ! function_exists( 'get_permalink' ) ) { + function get_permalink( $post = 0 ) { + return 'https://example.test/?p=' . ( is_object( $post ) ? $post->ID : (int) $post ); + } +} + +if ( ! function_exists( 'get_the_title' ) ) { + function get_the_title( $post = 0 ) { + return 'Title'; + } +} + +if ( ! function_exists( 'get_the_time' ) ) { + function get_the_time( $format = '', $post = null ) { + return '2020-01-01'; + } +} + +/** + * Fixed clock. Dates in the calendar widget are compared against this, so a test asserting + * on "today" stays green in December. + */ +if ( ! function_exists( 'current_time' ) ) { + function current_time( $type, $gmt = 0 ) { + $ts = 1577880000; // 2020-01-01 12:00:00 UTC + if ( 'timestamp' === $type || 'U' === $type ) { + return $ts; + } + if ( 'mysql' === $type ) { + return gmdate( 'Y-m-d H:i:s', $ts ); + } + return gmdate( $type, $ts ); + } +} + +if ( ! function_exists( 'get_month_link' ) ) { + function get_month_link( $year, $month ) { + return sprintf( 'https://example.test/%04d/%02d/', (int) $year, (int) $month ); + } +} + +if ( ! function_exists( 'get_day_link' ) ) { + function get_day_link( $year, $month, $day ) { + return sprintf( 'https://example.test/%04d/%02d/%02d/', (int) $year, (int) $month, (int) $day ); + } +} + +if ( ! function_exists( 'zeroise' ) ) { + function zeroise( $number, $threshold ) { + return sprintf( '%0' . (int) $threshold . 's', $number ); + } +} + +if ( ! function_exists( 'calendar_week_mod' ) ) { + function calendar_week_mod( $num ) { + $base = 7; + return $num - $base * floor( $num / $base ); + } +} + +/* -------------------------------------------------------------------------- * + * $wp_locale stand-in. widgets/calendar.php reaches for the global directly, so the base + * test case installs one of these before every test. + * -------------------------------------------------------------------------- */ + +class Easel_Locale { + public $weekday = array( 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' ); + public $weekday_initial = array( + 'Sunday' => 'S', + 'Monday' => 'M', + 'Tuesday' => 'T', + 'Wednesday' => 'W', + 'Thursday' => 'T', + 'Friday' => 'F', + 'Saturday' => 'S', + ); + public $weekday_abbrev = array( + 'Sunday' => 'Sun', + 'Monday' => 'Mon', + 'Tuesday' => 'Tue', + 'Wednesday' => 'Wed', + 'Thursday' => 'Thu', + 'Friday' => 'Fri', + 'Saturday' => 'Sat', + ); + public $month = array( + '01' => 'January', + '02' => 'February', + '03' => 'March', + '04' => 'April', + '05' => 'May', + '06' => 'June', + '07' => 'July', + '08' => 'August', + '09' => 'September', + '10' => 'October', + '11' => 'November', + '12' => 'December', + ); + + public function get_month( $month_number ) { + $key = zeroise( (int) $month_number, 2 ); + return isset( $this->month[ $key ] ) ? $this->month[ $key ] : ''; + } + + public function get_month_abbrev( $month_name ) { + return substr( (string) $month_name, 0, 3 ); + } + + public function get_weekday( $weekday_number ) { + return $this->weekday[ (int) $weekday_number ]; + } + + public function get_weekday_initial( $weekday_name ) { + return isset( $this->weekday_initial[ $weekday_name ] ) ? $this->weekday_initial[ $weekday_name ] : ''; + } + + public function get_weekday_abbrev( $weekday_name ) { + return isset( $this->weekday_abbrev[ $weekday_name ] ) ? $this->weekday_abbrev[ $weekday_name ] : ''; + } +} + +/* -------------------------------------------------------------------------- * + * Hook, widget and asset registration — no-ops, so theme files can simply be require'd. + * -------------------------------------------------------------------------- */ + +if ( ! function_exists( 'add_action' ) ) { + function add_action( ...$args ) { + return true; + } +} + +if ( ! function_exists( 'add_filter' ) ) { + function add_filter( ...$args ) { + return true; + } +} + +if ( ! function_exists( 'remove_action' ) ) { + function remove_action( ...$args ) { + return true; + } +} + +if ( ! function_exists( 'remove_filter' ) ) { + function remove_filter( ...$args ) { + return true; + } +} + +if ( ! function_exists( 'do_action' ) ) { + function do_action( ...$args ) { + return true; + } +} + +if ( ! function_exists( 'register_widget' ) ) { + function register_widget( $widget ) { + return true; + } +} + +if ( ! function_exists( 'register_sidebar' ) ) { + function register_sidebar( $args = array() ) { + return isset( $args['id'] ) ? $args['id'] : 'sidebar-1'; + } +} + +if ( ! function_exists( 'register_nav_menus' ) ) { + function register_nav_menus( $locations = array() ) { + return true; + } +} + +if ( ! function_exists( 'add_theme_support' ) ) { + function add_theme_support( $feature, ...$args ) { + return true; + } +} + +if ( ! function_exists( 'add_image_size' ) ) { + function add_image_size( $name, $width = 0, $height = 0, $crop = false ) { + return true; + } +} + +if ( ! function_exists( 'add_meta_box' ) ) { + function add_meta_box( $id, $title, $callback, $screen = null, $context = 'advanced', $priority = 'default', $callback_args = null ) { + return true; + } +} + +if ( ! function_exists( 'add_theme_page' ) ) { + function add_theme_page( $page_title, $menu_title, $capability, $menu_slug, $callback = '' ) { + return 'appearance_page_' . $menu_slug; + } +} + +if ( ! function_exists( 'wp_enqueue_script' ) ) { + function wp_enqueue_script( $handle, $src = '', $deps = array(), $ver = false, $in_footer = false ) { + return true; + } +} + +if ( ! function_exists( 'wp_enqueue_style' ) ) { + function wp_enqueue_style( $handle, $src = '', $deps = array(), $ver = false, $media = 'all' ) { + return true; + } +} + +if ( ! function_exists( 'wp_admin_css' ) ) { + function wp_admin_css( $file = 'wp-admin', $force_echo = false ) { + return true; + } +} + +if ( ! function_exists( 'get_template_part' ) ) { + function get_template_part( $slug, $name = null ) { + return false; + } +} + +if ( ! function_exists( 'wp_parse_args' ) ) { + function wp_parse_args( $args, $defaults = array() ) { + return array_merge( $defaults, (array) $args ); + } +} + +if ( ! function_exists( 'checked' ) ) { + function checked( $checked, $current = true, $display = true ) { + $out = ( (string) $checked === (string) $current ) ? ' checked="checked"' : ''; + if ( $display ) { + echo $out; + } + return $out; + } +} + +if ( ! function_exists( 'selected' ) ) { + function selected( $selected, $current = true, $display = true ) { + $out = ( (string) $selected === (string) $current ) ? ' selected="selected"' : ''; + if ( $display ) { + echo $out; + } + return $out; + } +} + +if ( ! function_exists( 'is_admin' ) ) { + function is_admin() { + return false; + } +} + +if ( ! function_exists( 'is_multisite' ) ) { + function is_multisite() { + return false; + } +} + +if ( ! function_exists( 'wp_cache_get' ) ) { + function wp_cache_get( $key, $group = '' ) { + return false; + } +} + +if ( ! function_exists( 'wp_cache_set' ) ) { + function wp_cache_set( $key, $data, $group = '', $expire = 0 ) { + return true; + } +} + +if ( ! function_exists( 'wp_cache_delete' ) ) { + function wp_cache_delete( $key, $group = '' ) { + return true; + } +} + +if ( ! class_exists( 'WP_Error' ) ) { + class WP_Error { + public $message; + public function __construct( $code = '', $message = '' ) { + $this->message = $message; + } + public function get_error_message() { + return $this->message; + } + } +} + +if ( ! function_exists( 'is_wp_error' ) ) { + function is_wp_error( $thing ) { + return $thing instanceof WP_Error; + } +} + +/* -------------------------------------------------------------------------- * + * Widgets + * -------------------------------------------------------------------------- */ + +if ( ! class_exists( 'WP_Widget' ) ) { + class WP_Widget { + public $id_base; + public $name; + public function __construct( $id_base = '', $name = '', $widget_options = array(), $control_options = array() ) { + $this->id_base = $id_base; + $this->name = $name; + } + public function get_field_id( $field ) { + return $this->id_base . '-' . $field; + } + public function get_field_name( $field ) { + return $this->id_base . '[' . $field . ']'; + } + } +} + +/* -------------------------------------------------------------------------- * + * $wpdb spy — records the SQL it is handed so tests can assert on the query text + * without a database. This is what stands in for MySQL when checking that untrusted + * input never reaches the SQL string. + * -------------------------------------------------------------------------- */ + +class Easel_WPDB_Spy { + public $posts = 'wp_posts'; + public $postmeta = 'wp_postmeta'; + public $terms = 'wp_terms'; + public $term_relationships = 'wp_term_relationships'; + public $term_taxonomy = 'wp_term_taxonomy'; + public $prefix = 'wp_'; + + /** @var string[] every SQL string handed to prepare() or a get_*() method */ + public $queries = array(); + /** @var mixed value the next get_col()/get_results()/get_var()/get_row() returns */ + public $result = array(); + + public function prepare( $query, ...$args ) { + // Close enough to $wpdb::prepare for assertion purposes: %d becomes an integer, + // %s becomes a single-quoted escaped string. + $i = 0; + $out = preg_replace_callback( + '/%[dsf]/', + function ( $m ) use ( &$i, $args ) { + $arg = array_key_exists( $i, $args ) ? $args[ $i ] : ''; + $i++; + if ( '%d' === $m[0] ) { + return (string) (int) $arg; + } + if ( '%f' === $m[0] ) { + return (string) (float) $arg; + } + return "'" . addslashes( (string) $arg ) . "'"; + }, + $query + ); + $this->queries[] = $out; + return $out; + } + + /** + * $wpdb::escape() was deprecated in WordPress 3.6 and removed in 5.3, but + * widgets/calendar.php still calls it, so the spy has to answer. Behaves like the + * addslashes() it always was -- which is exactly why it is not a sanitiser. + */ + public function escape( $data ) { + return is_array( $data ) ? array_map( array( $this, 'escape' ), $data ) : addslashes( (string) $data ); + } + + public function get_col( $query = null, $x = 0 ) { + if ( null !== $query ) { + $this->queries[] = $query; + } + return $this->result; + } + + public function get_results( $query = null, $output = null ) { + if ( null !== $query ) { + $this->queries[] = $query; + } + return $this->result; + } + + public function get_var( $query = null, $x = 0, $y = 0 ) { + if ( null !== $query ) { + $this->queries[] = $query; + } + return is_array( $this->result ) ? null : $this->result; + } + + public function get_row( $query = null, $output = null, $y = 0 ) { + if ( null !== $query ) { + $this->queries[] = $query; + } + return null; + } + + public function query( $query ) { + $this->queries[] = $query; + return true; + } + + public function update( $table, $data, $where, ...$rest ) { + return 1; + } + + /** The SQL from the most recent call. */ + public function last() { + return end( $this->queries ); + } + + /** True when any recorded query contains $needle. */ + public function sawSql( $needle ) { + foreach ( $this->queries as $q ) { + if ( false !== strpos( $q, $needle ) ) { + return true; + } + } + return false; + } +} From 52a1597388877bb36fa4572036c1a0bb3264bb35 Mon Sep 17 00:00:00 2001 From: coraislovely-code <302675651+coraislovely-code@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:27:01 -0400 Subject: [PATCH 2/2] Add characterization tests for the code about to be changed These pin the theme's current behaviour rather than the behaviour it ought to have, so that when the escaping, the request handling and the option loading are changed the assertions have to be inverted rather than merely kept passing. Every test says in its docblock what it is recording and what the change will turn it into. Covered: how author contact details and the menubar social links are concatenated into markup, the order in which the options screen reads the request against its form token, the sanitize_callback names the customizer registers and the type of its three width settings, the default array built on a site that has never pressed Save, and two $_SERVER reads that assume the key is present. The harness grows three things the new tests needed: a recorder that stands in for $wp_customize, a helper that lifts a single function out of functions.php, and a helper that buffers output and collects the diagnostics a call raises so a test can assert on them instead of the run failing on them. No theme file is touched. Co-Authored-By: Claude Fable 5 --- tests/AuthorContactEscapingTest.php | 118 +++++++++++++++++++ tests/CustomizerRangeValueTest.php | 102 +++++++++++++++++ tests/CustomizerSanitizeCallbackTest.php | 112 ++++++++++++++++++ tests/Easel_TestCase.php | 78 ++++++++++++- tests/HarnessTest.php | 24 ++-- tests/LoadOptionsDefaultsTest.php | 99 ++++++++++++++++ tests/OptionsRequestHandlingTest.php | 124 ++++++++++++++++++++ tests/SocialIconEscapingTest.php | 104 +++++++++++++++++ tests/SuperglobalAccessTest.php | 129 +++++++++++++++++++++ tests/stubs.php | 140 ++++++++++++++++++++++- 10 files changed, 1010 insertions(+), 20 deletions(-) create mode 100644 tests/AuthorContactEscapingTest.php create mode 100644 tests/CustomizerRangeValueTest.php create mode 100644 tests/CustomizerSanitizeCallbackTest.php create mode 100644 tests/LoadOptionsDefaultsTest.php create mode 100644 tests/OptionsRequestHandlingTest.php create mode 100644 tests/SocialIconEscapingTest.php create mode 100644 tests/SuperglobalAccessTest.php diff --git a/tests/AuthorContactEscapingTest.php b/tests/AuthorContactEscapingTest.php new file mode 100644 index 0000000..a40c53a --- /dev/null +++ b/tests/AuthorContactEscapingTest.php @@ -0,0 +1,118 @@ +assertSame( self::QUOTE_PROBE, sanitize_text_field( self::QUOTE_PROBE ) ); + $this->assertSame( self::SCHEME_PROBE, sanitize_text_field( self::SCHEME_PROBE ) ); + + // It is not a no-op -- it does strip tags. It just does not touch either probe. + $this->assertSame( 'x', sanitize_text_field( 'x' ) ); + } + + /** + * esc_attr() is the wrong tool for an href: it closes the quote hole and leaves the + * scheme. Pinned because the fix uses esc_url() instead, and this is why. + */ + public function testEscAttrEncodesQuotesButKeepsTheJavascriptScheme() { + $this->assertSame( '" onmouseover="x', esc_attr( self::QUOTE_PROBE ) ); + $this->assertSame( self::SCHEME_PROBE, esc_attr( self::SCHEME_PROBE ) ); + } + + /** + * esc_url() closes both: the scheme is not on its allow list, and the quote does not + * survive its character filtering either. + */ + public function testEscUrlDropsTheJavascriptSchemeAndTheQuote() { + $this->assertSame( '', esc_url( self::SCHEME_PROBE ) ); + $this->assertStringNotContainsString( '"', esc_url( self::QUOTE_PROBE ) ); + } + + /** + * The row the template builds today, for a contact method that is emitted raw into both + * the href and the link text. + * + * After the fix the row is built as + * '' . esc_html( $value ) . '' + * and the expected strings become the ones in testEscapedContactRowIsWhatTheFixEmits() + * below: one href attribute, and no attribute boundary anywhere in the text. + */ + public function testContactRowIsBuiltWithoutEscapingToday() { + $this->assertSame( + '" onmouseover="x', + $this->contactRow( self::QUOTE_PROBE ), + 'the stored value ends the href early and adds a second attribute' + ); + + $this->assertSame( + 'javascript:x', + $this->contactRow( self::SCHEME_PROBE ), + 'the stored scheme reaches the href unchanged' + ); + } + + /** + * The same two values once the row is built with esc_url() and esc_html(). + * + * This test passes today as well -- it is describing the escaping functions, not the + * template -- and it exists so the expected post-fix output is written down next to the + * pre-fix output rather than only in a comment. + */ + public function testEscapedContactRowIsWhatTheFixEmits() { + $this->assertSame( + '" onmouseover="x', + $this->escapedContactRow( self::QUOTE_PROBE ) + ); + + $this->assertSame( + 'javascript:x', + $this->escapedContactRow( self::SCHEME_PROBE ) + ); + } + + /** The concatenation the template performs for a contact row, as it stands. */ + private function contactRow( $value ) { + return '' . $value . ''; + } + + /** The same concatenation with the escaping the fix adds. */ + private function escapedContactRow( $value ) { + return '' . esc_html( $value ) . ''; + } +} diff --git a/tests/CustomizerRangeValueTest.php b/tests/CustomizerRangeValueTest.php new file mode 100644 index 0000000..02425fe --- /dev/null +++ b/tests/CustomizerRangeValueTest.php @@ -0,0 +1,102 @@ +customize = new Easel_Customize_Recorder(); + easel_Customize::register( $this->customize ); + } + + /** + * After the fix the expected value here is the theme's integer sanitiser rather than + * 'wp_filter_nohtml_kses'. + */ + public function testWidthSettingsAreSanitizedAsTextRatherThanAsNumbers() { + foreach ( self::RANGE_SETTINGS as $id ) { + $this->assertArrayHasKey( $id, $this->customize->settings ); + $this->assertSame( + 'wp_filter_nohtml_kses', + $this->customize->settings[ $id ]['sanitize_callback'], + "$id is registered with a text sanitiser" + ); + $this->assertSame( + 'theme_mod', + $this->customize->settings[ $id ]['type'], + "$id is stored as a theme mod, so get_theme_mod() is what reads it back" + ); + } + } + + /** + * The text sanitiser hands back both an empty string and a non-numeric one unchanged, + * so either can end up stored against a width setting. + */ + public function testTextSanitizerPassesThroughValuesThatAreNotNumbers() { + $this->assertSame( '', wp_filter_nohtml_kses( '' ) ); + $this->assertSame( 'abc', wp_filter_nohtml_kses( 'abc' ) ); + $this->assertSame( '200', wp_filter_nohtml_kses( '200' ) ); + } + + /** + * The addition the header CSS performs on the two sidebar widths. + * + * After the fix the stored value is an integer and this test asserts a sum instead: + * an unset or empty setting falls back to the default and 200 + 4 is 204. + */ + public function testAddingFourToANonNumericWidthIsATypeError() { + if ( PHP_VERSION_ID < 80000 ) { + $this->markTestSkipped( 'PHP 7 coerces a non-numeric string instead of throwing' ); + } + + foreach ( array( '', 'abc' ) as $stored ) { + set_theme_mod( 'easel-customize-range-left-sidebar-width', $stored ); + try { + $width = get_theme_mod( 'easel-customize-range-left-sidebar-width', 200 ) + 4; + $this->fail( 'expected a TypeError, got ' . var_export( $width, true ) ); + } catch ( TypeError $e ) { + $this->assertStringContainsString( 'Unsupported operand types', $e->getMessage() ); + } + } + } + + /** + * And the happy path, which is what makes the test above about the stored value rather + * than about the arithmetic being wrong in general. + */ + public function testAddingFourToANumericWidthWorks() { + set_theme_mod( 'easel-customize-range-left-sidebar-width', '200' ); + $this->assertSame( 204, get_theme_mod( 'easel-customize-range-left-sidebar-width', 200 ) + 4 ); + + // Unset, the default answers and the addition is int + int. + remove_theme_mod( 'easel-customize-range-left-sidebar-width' ); + $this->assertSame( 204, get_theme_mod( 'easel-customize-range-left-sidebar-width', 200 ) + 4 ); + } +} diff --git a/tests/CustomizerSanitizeCallbackTest.php b/tests/CustomizerSanitizeCallbackTest.php new file mode 100644 index 0000000..4321cde --- /dev/null +++ b/tests/CustomizerSanitizeCallbackTest.php @@ -0,0 +1,112 @@ +customize = new Easel_Customize_Recorder(); + easel_Customize::register( $this->customize ); + } + + /** + * Proof that register() actually ran, so the assertions below are about the theme's + * registrations rather than about an empty recorder. + */ + public function testRecorderCapturedTheRegistration() { + $this->assertContains( 'colors', $this->customize->removed_sections ); + $this->assertArrayHasKey( 'easel-scheme-options', $this->customize->sections ); + $this->assertArrayHasKey( 'easel-customize-select-layout', $this->customize->settings ); + $this->assertNotEmpty( $this->customize->controls ); + } + + /** + * The function exists under the transposed name and not under the name the settings use. + * + * After the fix both assertions swap: eaeel_sanitize_checkbox() is gone and + * easel_sanitize_checkbox() is defined. + */ + public function testCheckboxSanitizerIsDefinedUnderADifferentNameThanItIsReferencedBy() { + $this->assertTrue( + function_exists( 'eaeel_sanitize_checkbox' ), + 'the definition currently carries the transposed name' + ); + $this->assertFalse( + function_exists( 'easel_sanitize_checkbox' ), + 'the name the settings reference is currently undefined' + ); + } + + /** + * Every setting does declare a sanitize_callback, which is what the theme directory + * review asks for. Recorded so that a setting added later without one shows up here. + */ + public function testEverySettingDeclaresASanitizeCallback() { + $without = array_keys( + array_filter( + $this->customize->sanitizeCallbacks(), + function ( $callback ) { + return null === $callback || '' === $callback; + } + ) + ); + + $this->assertSame( array(), $without ); + } + + /** + * Current behaviour: three settings name a callback that cannot be called, and every + * other setting names one that can. + * + * After the fix this becomes assertSame( array(), $missing ) -- the rename makes the + * whole list callable. + */ + public function testOnlyTheCheckboxSettingsNameASanitizerThatDoesNotExist() { + $missing = array(); + foreach ( $this->customize->sanitizeCallbacks() as $id => $callback ) { + if ( ! is_callable( $callback ) ) { + $missing[] = $id; + } + } + + $this->assertSame( self::SETTINGS_WITH_A_MISSING_SANITIZER, $missing ); + + foreach ( $missing as $id ) { + $this->assertSame( + 'easel_sanitize_checkbox', + $this->customize->settings[ $id ]['sanitize_callback'], + "$id names the sanitiser that is not defined" + ); + } + } +} diff --git a/tests/Easel_TestCase.php b/tests/Easel_TestCase.php index a8443b8..82ab9bd 100644 --- a/tests/Easel_TestCase.php +++ b/tests/Easel_TestCase.php @@ -52,7 +52,7 @@ protected static function loadRealThemeInfo() { if ( null === $body ) { throw new RuntimeException( "Could not find function $name() in functions.php" ); } - $code .= preg_replace( '/^function\s+' . $name . '\s*\(/', 'function easel_test_real_' . $name . '(', $body ) . "\n"; + $code .= preg_replace( '/^\s*function\s+' . $name . '\s*\(/', 'function easel_test_real_' . $name . '(', $body ) . "\n"; } eval( $code ); } @@ -63,10 +63,32 @@ protected static function loadRealThemeInfo() { } /** - * Return the source text of a top-level function declaration, braces matched. + * Lift one function out of a theme file and define it, under its own name, verbatim. + * + * Same trick as loadRealThemeInfo() and for the same reason: functions.php cannot be + * require'd, but several of the functions in it are worth exercising directly. Because + * the file is never loaded the real name is free, so nothing has to be renamed here. + */ + protected static function loadThemeFunction( $name, $relative = 'functions.php' ) { + if ( function_exists( $name ) ) { + return; + } + $source = file_get_contents( EASEL_THEME_DIR . '/' . $relative ); + $body = self::extractFunction( $source, $name ); + if ( null === $body ) { + throw new RuntimeException( "Could not find function $name() in $relative" ); + } + eval( $body ); + } + + /** + * Return the source text of a function declaration, braces matched. + * + * Leading whitespace is allowed because some of the theme's functions sit inside an + * `if (!function_exists(...))` guard and are therefore indented. */ private static function extractFunction( $source, $name ) { - if ( ! preg_match( '/^function\s+' . preg_quote( $name, '/' ) . '\s*\(/m', $source, $m, PREG_OFFSET_CAPTURE ) ) { + if ( ! preg_match( '/^[ \t]*function\s+' . preg_quote( $name, '/' ) . '\s*\(/m', $source, $m, PREG_OFFSET_CAPTURE ) ) { return null; } $start = $m[0][1]; @@ -85,6 +107,56 @@ private static function extractFunction( $source, $name ) { return null; } + /** + * Run $callable with its output buffered and PHP's diagnostics intercepted. + * + * Returns array( 'output' => string, 'diagnostics' => array of errno/message pairs ). + * + * Some of the code under test emits notices, warnings or deprecations as it runs, and + * that emission is exactly what a few of these tests are pinning. The suite runs with + * failOnWarning/failOnNotice/failOnDeprecation, so rather than loosening the config the + * handler is swapped for the duration of the call: the diagnostics are collected and + * returned as ordinary data, which a test can then assert on precisely. Returning true + * from the handler stops PHP passing the event on to PHPUnit's own handler. + */ + protected function runCapturing( callable $callable ) { + $diagnostics = array(); + ob_start(); + set_error_handler( + function ( $errno, $errstr ) use ( &$diagnostics ) { + $diagnostics[] = array( + 'errno' => $errno, + 'message' => $errstr, + ); + return true; + } + ); + try { + $callable(); + } finally { + restore_error_handler(); + $output = ob_get_clean(); + } + return array( + 'output' => $output, + 'diagnostics' => $diagnostics, + ); + } + + /** + * The subset of runCapturing()'s diagnostics whose message contains $needle. + */ + protected static function diagnosticsMatching( array $diagnostics, $needle ) { + return array_values( + array_filter( + $diagnostics, + function ( $entry ) use ( $needle ) { + return false !== strpos( $entry['message'], $needle ); + } + ) + ); + } + /** * Install a fresh $wpdb spy as the global and return it. */ diff --git a/tests/HarnessTest.php b/tests/HarnessTest.php index 84ca5cc..d5f00fb 100644 --- a/tests/HarnessTest.php +++ b/tests/HarnessTest.php @@ -50,17 +50,17 @@ public function testEscapingStubsMirrorWordPressDoubleEncodeSemantics() { } /** - * The distinction a number of real findings turn on: esc_url() refuses a javascript: - * URL, esc_attr() is perfectly happy to hand one back. Escaping an href with esc_attr() - * therefore produces valid HTML and a working XSS, and only esc_url() closes it. + * The distinction a number of the tests turn on: esc_url() refuses a javascript: URL, + * esc_attr() is perfectly happy to hand one back. Escaping an href with esc_attr() + * therefore produces valid HTML that still carries the scheme; only esc_url() drops it. */ public function testEscUrlRejectsJavascriptSchemeAndEscAttrDoesNot() { - $this->assertSame( '', esc_url( 'javascript:alert(1)' ) ); - $this->assertSame( '', esc_url_raw( 'javascript:alert(1)' ) ); + $this->assertSame( '', esc_url( 'javascript:x' ) ); + $this->assertSame( '', esc_url_raw( 'javascript:x' ) ); $this->assertSame( 'https://example.test/x', esc_url_raw( 'https://example.test/x' ) ); - $this->assertSame( 'javascript:alert(1)', esc_attr( 'javascript:alert(1)' ) ); - $this->assertStringContainsString( 'javascript:', esc_attr( 'javascript:alert(1)' ) ); + $this->assertSame( 'javascript:x', esc_attr( 'javascript:x' ) ); + $this->assertStringContainsString( 'javascript:', esc_attr( 'javascript:x' ) ); } /** @@ -69,21 +69,21 @@ public function testEscUrlRejectsJavascriptSchemeAndEscAttrDoesNot() { * and still needs esc_attr() before it goes near an attribute. */ public function testNohtmlKsesStripsTagsButNotQuotes() { - $this->assertSame( 'alert(1)', wp_filter_nohtml_kses( '' ) ); + $this->assertSame( 'x', wp_filter_nohtml_kses( '' ) ); $this->assertSame( 'bold', wp_filter_nohtml_kses( 'bold' ) ); // Quotes survive: addslashes() on the way out is undone by wp_unslash(), the same // round trip a $_REQUEST value makes in WordPress. $this->assertSame( '\"x\"', wp_filter_nohtml_kses( '"x"' ) ); $this->assertSame( - '" onerror=alert(1)', - wp_unslash( wp_filter_nohtml_kses( addslashes( '" onerror=alert(1)' ) ) ), - 'a quote must survive sanitising, or attribute-injection tests would prove nothing' + '" onerror=x', + wp_unslash( wp_filter_nohtml_kses( addslashes( '" onerror=x' ) ) ), + 'a quote must survive sanitising, or the attribute-escaping tests would prove nothing' ); } /** - * The CSRF seam: verification can be made to succeed, made to fail, and -- most + * The nonce seam: verification can be made to succeed, made to fail, and -- most * importantly -- observed, so a test can tell "checked and passed" from "never checked". */ public function testNonceStubCanPassCanFailAndRecordsTheAttempt() { diff --git a/tests/LoadOptionsDefaultsTest.php b/tests/LoadOptionsDefaultsTest.php new file mode 100644 index 0000000..c25d869 --- /dev/null +++ b/tests/LoadOptionsDefaultsTest.php @@ -0,0 +1,99 @@ +assertFalse( get_option( 'easel-options' ) ); + } + + /** + * The defaults come back correctly populated, which is the behaviour the fix has to + * preserve. The suite runs with failOnDeprecation, so the attribute is what lets this + * test exercise the real path rather than tiptoe around it. + */ + #[IgnoreDeprecations] + public function testDefaultsAreReturnedForASiteThatHasNeverSaved() { + $options = easel_load_options(); + + $this->assertIsArray( $options ); + $this->assertSame( '5', $options['home_post_count'] ); + $this->assertSame( 500, $options['content_width'] ); + $this->assertSame( 700, $options['content_width_disabled_sidebars'] ); + $this->assertSame( 'none', $options['moods_directory'] ); + $this->assertSame( 'none', $options['avatar_directory'] ); + $this->assertSame( 'excerpt', $options['excerpt_or_content_in_archive'] ); + $this->assertSame( 'DESC', $options['archive_display_order'] ); + $this->assertTrue( $options['enable_avatar_trick'] ); + $this->assertFalse( $options['disable_post_titles'] ); + $this->assertArrayHasKey( 'enable_jetpack_infinite_scrolling', $options ); + $this->assertSame( '', $options['menubar_social_twitter'] ); + } + + /** + * And it is not written back, so the array is rebuilt on every call. + */ + #[IgnoreDeprecations] + public function testDefaultsAreNotPersisted() { + easel_load_options(); + + $this->assertFalse( get_option( 'easel-options' ) ); + $this->assertArrayNotHasKey( 'easel-options', Easel_Test_State::$options ); + } + + /** + * The deprecation itself, pinned by message. One notice per call: the first assignment + * converts the false, and the remaining keys go into an array. + */ + public function testBuildingDefaultsRaisesTheFalseToArrayDeprecation() { + $run = $this->runCapturing( + function () { + easel_load_options(); + } + ); + + $matches = self::diagnosticsMatching( $run['diagnostics'], 'Automatic conversion of false to array' ); + + $this->assertCount( 1, $matches ); + $this->assertSame( E_DEPRECATED, $matches[0]['errno'] ); + $this->assertSame( '', $run['output'], 'the function must not print anything' ); + } + + /** + * Nothing happens when the option does exist, which is what the rest of the suite has + * been relying on by seeding it. + */ + public function testASavedOptionIsReturnedUntouched() { + update_option( 'easel-options', array( 'home_post_count' => '9' ) ); + + $run = $this->runCapturing( + function () { + $this->assertSame( array( 'home_post_count' => '9' ), easel_load_options() ); + } + ); + + $this->assertSame( array(), $run['diagnostics'] ); + } +} diff --git a/tests/OptionsRequestHandlingTest.php b/tests/OptionsRequestHandlingTest.php new file mode 100644 index 0000000..7864056 --- /dev/null +++ b/tests/OptionsRequestHandlingTest.php @@ -0,0 +1,124 @@ + '5' ) ); + $_REQUEST['action'] = 'easel_reset'; + + $run = $this->runCapturing( 'easel_admin_options' ); + + $this->assertContains( 'easel-options', Easel_Test_State::$deleted_options ); + $this->assertFalse( get_option( 'easel-options' ) ); + $this->assertStringContainsString( 'Easel Settings RESET!', $run['output'] ); + $this->assertSame( + array(), + Easel_Test_State::$nonce_checks, + 'the reset ran without wp_verify_nonce() being called at any point' + ); + } + + /** + * Same shape for the customizer reset, which drops the theme mod and the theme_mods + * option. + * + * After the fix: 'easel-customize' is still in $theme_mods, 'theme_mods_easel' is not in + * $deleted_options, and $nonce_checks is not empty. + */ + public function testResetCustomizeActionClearsThemeModsAndChecksNoNonce() { + set_theme_mod( 'easel-customize', array( 'page_background' => '#ffffff' ) ); + update_option( 'theme_mods_easel', array( 'easel-customize' => array() ) ); + $_REQUEST['action'] = 'easel_reset_customize'; + + $run = $this->runCapturing( 'easel_admin_options' ); + + $this->assertArrayNotHasKey( 'easel-customize', Easel_Test_State::$theme_mods ); + $this->assertContains( 'theme_mods_easel', Easel_Test_State::$deleted_options ); + $this->assertStringContainsString( 'Easel Customizer Colors RESET!', $run['output'] ); + $this->assertSame( + array(), + Easel_Test_State::$nonce_checks, + 'the customizer reset ran without wp_verify_nonce() being called at any point' + ); + } + + /** + * The control. Without an action nothing is deleted, which is what makes the two tests + * above about the action rather than about merely rendering the screen. + */ + public function testRenderingTheScreenWithNoActionDeletesNothing() { + update_option( 'easel-options', array( 'home_post_count' => '5' ) ); + + $this->runCapturing( 'easel_admin_options' ); + + $this->assertSame( array(), Easel_Test_State::$deleted_options ); + $this->assertSame( array( 'home_post_count' => '5' ), get_option( 'easel-options' ) ); + } + + /** + * Inside the nonce block the action is read as $_REQUEST['action'] rather than + * isset($_REQUEST['action']), once per save handler. + * + * After the fix the same call raises no 'action' warnings at all, so this becomes + * assertCount( 0, ... ) -- or assertSame( array(), ... ). + */ + public function testActionIsReadWithoutIssetOnceTheNonceIsAccepted() { + $_POST['_wpnonce'] = wp_create_nonce( 'update-options' ); + + $run = $this->runCapturing( 'easel_admin_options' ); + + $this->assertNotEmpty( + Easel_Test_State::$nonce_checks, + 'this test only means something if the nonce block was actually entered' + ); + + $warnings = self::diagnosticsMatching( $run['diagnostics'], 'Undefined array key "action"' ); + $this->assertCount( 6, $warnings, 'one warning per save handler that compares the action' ); + $this->assertSame( E_WARNING, $warnings[0]['errno'] ); + } +} diff --git a/tests/SocialIconEscapingTest.php b/tests/SocialIconEscapingTest.php new file mode 100644 index 0000000..79b2a50 --- /dev/null +++ b/tests/SocialIconEscapingTest.php @@ -0,0 +1,104 @@ +setThemeInfo( array( 'menubar_social_twitter' => self::QUOTE_PROBE ) ); + + $run = $this->runCapturing( 'easel_display_social_icons' ); + + $this->assertStringContainsString( 'href="" onmouseover="x"', $run['output'] ); + $this->assertStringContainsString( self::QUOTE_PROBE, $run['output'] ); + } + + /** + * Current behaviour: the scheme reaches the href unchanged. + * + * After the fix esc_url() returns an empty string for it and the attribute is href="". + */ + public function testStoredLinkKeepsItsSchemeInTheHref() { + $this->setThemeInfo( array( 'menubar_social_facebook' => self::SCHEME_PROBE ) ); + + $run = $this->runCapturing( 'easel_display_social_icons' ); + + $this->assertStringContainsString( 'href="javascript:x"', $run['output'] ); + } + + /** + * All eleven links go through the same concatenation, so the property is not specific to + * one network. + */ + public function testEverySocialLinkIsEmittedTheSameWay() { + $networks = array( + 'twitter', + 'facebook', + 'googleplus', + 'linkedin', + 'pinterest', + 'youtube', + 'flickr', + 'tumblr', + 'deviantart', + 'myspace', + 'email', + ); + + foreach ( $networks as $network ) { + $this->setThemeInfo( array( 'menubar_social_' . $network => self::QUOTE_PROBE ) ); + + $run = $this->runCapturing( 'easel_display_social_icons' ); + + $this->assertStringContainsString( + 'href="" onmouseover="x"', + $run['output'], + "menubar_social_$network is emitted unescaped" + ); + } + } + + /** + * The control: with nothing stored the wrapper is still printed but no links are, so the + * assertions above are about the stored value rather than about the surrounding markup. + */ + public function testNoLinksAreEmittedWhenNothingIsStored() { + $run = $this->runCapturing( 'easel_display_social_icons' ); + + $this->assertStringContainsString( 'menunav-social-wrapper', $run['output'] ); + $this->assertStringNotContainsString( 'assertSame( array(), $run['diagnostics'] ); + } +} diff --git a/tests/SuperglobalAccessTest.php b/tests/SuperglobalAccessTest.php new file mode 100644 index 0000000..0c3e5ab --- /dev/null +++ b/tests/SuperglobalAccessTest.php @@ -0,0 +1,129 @@ +server_backup = $_SERVER; + self::loadThemeFunction( 'easel_is_signup' ); + self::loadThemeFile( 'widgets/calendar.php' ); + } + + protected function tearDown(): void { + $_SERVER = $this->server_backup; + unset( $GLOBALS['posts'] ); + parent::tearDown(); + } + + /** + * Current behaviour: one warning per read, and the line reads SCRIPT_NAME twice. + */ + public function testSignupCheckWarnsWhenScriptNameIsAbsent() { + unset( $_SERVER['SCRIPT_NAME'] ); + + $run = $this->runCapturing( + function () { + $this->assertFalse( easel_is_signup() ); + } + ); + + $warnings = self::diagnosticsMatching( $run['diagnostics'], 'Undefined array key "SCRIPT_NAME"' ); + $this->assertCount( 2, $warnings, 'the condition reads SCRIPT_NAME once per branch' ); + $this->assertSame( E_WARNING, $warnings[0]['errno'] ); + } + + /** + * With the key present the function answers, and the answer is right for the usual case + * where SCRIPT_NAME carries a leading path. + */ + public function testSignupCheckMatchesAScriptNameWithALeadingPath() { + $_SERVER['SCRIPT_NAME'] = '/wp-signup.php'; + $this->assertTrue( easel_is_signup() ); + + $_SERVER['SCRIPT_NAME'] = '/wp-activate.php'; + $this->assertTrue( easel_is_signup() ); + + $_SERVER['SCRIPT_NAME'] = '/index.php'; + $this->assertFalse( easel_is_signup() ); + } + + /** + * Current behaviour: a bare filename is missed, because strpos() returns position 0 and + * the function tests it for truth rather than for false. + * + * After the fix both of these assert true. + */ + public function testSignupCheckMissesABareScriptName() { + // The reason, stated on its own so a failure here is unambiguous. + $this->assertSame( 0, strpos( 'wp-signup.php', 'wp-signup.php' ) ); + + $_SERVER['SCRIPT_NAME'] = 'wp-signup.php'; + $this->assertFalse( easel_is_signup() ); + + $_SERVER['SCRIPT_NAME'] = 'wp-activate.php'; + $this->assertFalse( easel_is_signup() ); + } + + /** + * The calendar widget reads HTTP_USER_AGENT three times on one line while picking a + * title separator, so a request with no User-Agent header raises three warnings. + * + * $posts is set so the widget gets past its "no posts at all, abort" shortcut; the $wpdb + * spy answers everything after that with empty results. + */ + public function testCalendarWarnsWhenUserAgentIsAbsent() { + $this->useWpdbSpy(); + $GLOBALS['posts'] = array( 1 ); + unset( $_SERVER['HTTP_USER_AGENT'] ); + + $run = $this->runCapturing( + function () { + easel_get_calendar( true, false ); + } + ); + + $warnings = self::diagnosticsMatching( $run['diagnostics'], 'Undefined array key "HTTP_USER_AGENT"' ); + $this->assertCount( 3, $warnings, 'three reads on one line' ); + $this->assertSame( E_WARNING, $warnings[0]['errno'] ); + } + + /** + * The control: with the header present the same call raises no HTTP_USER_AGENT warning, + * so the test above is about the missing key rather than about the widget in general. + */ + public function testCalendarIsQuietWhenUserAgentIsPresent() { + $this->useWpdbSpy(); + $GLOBALS['posts'] = array( 1 ); + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0'; + + $run = $this->runCapturing( + function () { + easel_get_calendar( true, false ); + } + ); + + $this->assertSame( + array(), + self::diagnosticsMatching( $run['diagnostics'], 'HTTP_USER_AGENT' ) + ); + } +} diff --git a/tests/stubs.php b/tests/stubs.php index 8b91266..39a98f2 100644 --- a/tests/stubs.php +++ b/tests/stubs.php @@ -20,8 +20,8 @@ * it removes every tag and leaves quotes ALONE. Tests turn on that second half -- a * stub that also escaped quotes would make unescaped attribute output look safe. * - The nonce functions are a test-driven seam rather than a reimplementation, because - * the CSRF tests need to make verification both pass and fail, and need to prove it - * was attempted at all. See the block below for the contract. + * the tests that cover request handling need to make verification both pass and fail, + * and need to prove it was attempted at all. See the block below for the contract. * - current_user_can() defaults to FALSE, so a missing capability check shows up as a * failing test rather than as a silent pass. * - apply_filters() passes through by default but is overridable, because the filters @@ -196,6 +196,19 @@ function sanitize_html_class( $class, $fallback = '' ) { } } +/** + * functions/customize.php names this as the sanitize_callback for every colour setting, so + * it has to exist for a test that asks whether the named callbacks are callable. + */ +if ( ! function_exists( 'sanitize_hex_color' ) ) { + function sanitize_hex_color( $color ) { + if ( '' === $color || null === $color ) { + return ''; + } + return preg_match( '|^#([A-Fa-f0-9]{3}){1,2}$|', (string) $color ) ? $color : null; + } +} + if ( ! function_exists( 'sanitize_key' ) ) { function sanitize_key( $key ) { return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( (string) $key ) ); @@ -218,7 +231,7 @@ function wp_unslash( $value ) { } /* -------------------------------------------------------------------------- * - * Nonces — the seam the CSRF tests hang on. + * Nonces — the seam the request-handling tests hang on. * * Not a reimplementation: a real nonce depends on the user, the session token and the * clock, none of which exist here, and reproducing the hash would prove nothing anyway. @@ -226,8 +239,8 @@ function wp_unslash( $value ) { * * Control Easel_Test_State::$valid_nonces holds "action|nonce" keys. A test that * wants verification to succeed calls Easel_TestCase::allowNonce(); a test - * that wants to model an attacker simply does not, and wp_verify_nonce() - * returns false the way it does for a forged request. + * that wants the failing branch simply does not, and wp_verify_nonce() + * returns false the way it does for a request with no valid token. * Visibility every call is appended to Easel_Test_State::$nonce_checks, so a test can * assert that a save handler CHECKED at all. That matters more than the * verdict: a handler with no nonce check passes any test that only looks at @@ -857,6 +870,123 @@ function is_wp_error( $thing ) { } } +/* -------------------------------------------------------------------------- * + * Customizer + * + * functions/customize.php only defines its two range controls when WP_Customize_Control + * already exists, so the base class has to be here before that file is loaded. The three + * classes are shells: the theme instantiates them inside register() and the recorder below + * simply stores whatever it is handed, so no rendering ever happens in a test. + * -------------------------------------------------------------------------- */ + +if ( ! class_exists( 'WP_Customize_Control' ) ) { + class WP_Customize_Control { + // The properties WordPress declares that this theme actually passes through to a + // control constructor. Declared rather than left dynamic so that PHP 8.2 does not + // deprecate the assignment loop below. + public $manager; + public $id; + public $label = ''; + public $description = ''; + public $section = ''; + public $settings = ''; + public $type = 'text'; + public $priority = 10; + public $capability; + public $choices = array(); + public $input_attrs = array(); + + public function __construct( $manager = null, $id = '', $args = array() ) { + $this->manager = $manager; + $this->id = $id; + foreach ( $args as $key => $value ) { + $this->$key = $value; + } + } + + public function link( $setting_key = 'default' ) { + return ''; + } + + public function value( $setting_key = 'default' ) { + return ''; + } + + public function input_attrs() { + return ''; + } + } + + class WP_Customize_Color_Control extends WP_Customize_Control { + public $type = 'color'; + } + + class WP_Customize_Image_Control extends WP_Customize_Control { + public $type = 'image'; + } +} + +/** + * Stands in for $wp_customize and writes down every registration instead of performing it. + * + * easel_Customize::register() is a long list of add_setting()/add_control() calls, and the + * interesting questions about it are all questions about the arguments -- does every + * setting declare a sanitize_callback, does the named callback exist, is a numeric setting + * sanitised as a number. Recording the calls answers all of those without a Customizer. + */ +class Easel_Customize_Recorder { + /** @var array section id => args */ + public $sections = array(); + /** @var array setting id => args */ + public $settings = array(); + /** @var array list of array( $id_or_control, $args ) */ + public $controls = array(); + /** @var string[] ids passed to remove_section() */ + public $removed_sections = array(); + /** @var array ids handed out by get_setting() */ + private $setting_objects = array(); + + public function add_section( $id, $args = array() ) { + $this->sections[ $id ] = $args; + } + + public function add_setting( $id, $args = array() ) { + $this->settings[ $id ] = $args; + } + + public function add_control( $id, $args = array() ) { + $this->controls[] = array( $id, $args ); + } + + public function remove_section( $id ) { + $this->removed_sections[] = $id; + unset( $this->sections[ $id ] ); + } + + /** + * The theme reaches for core's own 'blogname'/'blogdescription' settings to change + * their transport, so this has to answer with an object for ids nobody registered. + */ + public function get_setting( $id ) { + if ( ! isset( $this->setting_objects[ $id ] ) ) { + $setting = new stdClass(); + $setting->id = $id; + $setting->transport = 'refresh'; + $this->setting_objects[ $id ] = $setting; + } + return $this->setting_objects[ $id ]; + } + + /** setting id => the sanitize_callback it was registered with, or null if it has none. */ + public function sanitizeCallbacks() { + $out = array(); + foreach ( $this->settings as $id => $args ) { + $out[ $id ] = isset( $args['sanitize_callback'] ) ? $args['sanitize_callback'] : null; + } + return $out; + } +} + /* -------------------------------------------------------------------------- * * Widgets * -------------------------------------------------------------------------- */