From 19ceb7b9d77142606df5ba3998bc8e14cf23ddf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 22 Apr 2026 15:03:26 +0200 Subject: [PATCH 1/4] Add real git CLI end-to-end test --- bin/test-git-cli-e2e.sh | 4 + components/Git/Tests/GitCliEndToEndTest.php | 280 ++++++++++++++++++ components/Git/Tests/GitRepositoryTest.php | 16 + .../fixtures/git-http-endpoint-router.php | 54 ++++ components/Git/class-gitrepository.php | 6 +- 5 files changed, 357 insertions(+), 3 deletions(-) create mode 100755 bin/test-git-cli-e2e.sh create mode 100644 components/Git/Tests/GitCliEndToEndTest.php create mode 100644 components/Git/Tests/fixtures/git-http-endpoint-router.php diff --git a/bin/test-git-cli-e2e.sh b/bin/test-git-cli-e2e.sh new file mode 100755 index 000000000..4500d1d1e --- /dev/null +++ b/bin/test-git-cli-e2e.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -eu + +docker compose run --rm sandbox vendor/bin/phpunit components/Git/Tests/GitCliEndToEndTest.php diff --git a/components/Git/Tests/GitCliEndToEndTest.php b/components/Git/Tests/GitCliEndToEndTest.php new file mode 100644 index 000000000..3494f1c3f --- /dev/null +++ b/components/Git/Tests/GitCliEndToEndTest.php @@ -0,0 +1,280 @@ +markTestSkipped( 'proc_open() is required for the Git CLI end-to-end test.' ); + } + if ( ! $this->command_is_available( 'git' ) ) { + $this->markTestSkipped( 'The git command is required for the Git CLI end-to-end test.' ); + } + + $this->temp_dir = sys_get_temp_dir() . '/php-toolkit-git-cli-' . uniqid(); + $this->git_home = $this->temp_dir . '/home'; + $this->remote_repository_path = $this->temp_dir . '/remote-repository'; + $this->working_copy_path = $this->temp_dir . '/working-copy'; + $this->router_script_path = dirname( __FILE__ ) . '/fixtures/git-http-endpoint-router.php'; + + mkdir( $this->temp_dir, 0777, true ); + mkdir( $this->git_home, 0777, true ); + + $this->initialize_remote_repository(); + $this->start_http_server(); + } + + /** + * @after + */ + public function tear_down_environment() { + if ( is_resource( $this->server_process ) ) { + @proc_terminate( $this->server_process ); + @proc_close( $this->server_process ); + } + + $this->delete_directory( $this->temp_dir ); + } + + public function test_real_git_cli_can_clone_push_and_pull() { + $remote_url = sprintf( 'http://127.0.0.1:%d/repo.git', $this->server_port ); + $this->assert_command_succeeds( + sprintf( + 'git -c protocol.version=2 clone %s %s', + escapeshellarg( $remote_url ), + escapeshellarg( $this->working_copy_path ) + ) + ); + + $this->assertSame( + "Hello from the server\n", + file_get_contents( $this->working_copy_path . '/README.md' ) + ); + + $this->assert_command_succeeds( 'git config user.name "PHP Toolkit"', $this->working_copy_path ); + $this->assert_command_succeeds( 'git config user.email "php-toolkit@example.com"', $this->working_copy_path ); + + file_put_contents( $this->working_copy_path . '/README.md', "Updated from clone\n" ); + $this->assert_command_succeeds( 'git add README.md', $this->working_copy_path ); + $this->assert_command_succeeds( 'git commit -m "Update README"', $this->working_copy_path ); + $this->assert_command_succeeds( 'git push origin trunk', $this->working_copy_path ); + + $remote_repository = $this->open_remote_repository(); + $this->assertSame( + "Updated from clone\n", + $remote_repository->read_object_by_path( '/README.md' )->consume_all() + ); + + $remote_repository->set_branch_tip( 'HEAD', 'ref: refs/heads/trunk' ); + $remote_repository->commit( + array( + 'updates' => array( + 'README.md' => "Updated on the server\n", + ), + ) + ); + + $this->assert_command_succeeds( 'git pull --ff-only origin trunk', $this->working_copy_path ); + $this->assertSame( + "Updated on the server\n", + file_get_contents( $this->working_copy_path . '/README.md' ) + ); + } + + private function initialize_remote_repository() { + $repository = $this->open_remote_repository(); + $repository->set_config_value( 'user.name', 'PHP Toolkit' ); + $repository->set_config_value( 'user.email', 'php-toolkit@example.com' ); + $repository->set_branch_tip( 'HEAD', 'ref: refs/heads/trunk' ); + $repository->commit( + array( + 'updates' => array( + 'README.md' => "Hello from the server\n", + ), + ) + ); + } + + private function open_remote_repository() { + return new GitRepository( + LocalFilesystem::create( $this->remote_repository_path ), + array( + 'default_branch' => 'trunk', + ) + ); + } + + private function start_http_server() { + $this->server_port = $this->find_available_port(); + + $command = sprintf( + '%s -S 127.0.0.1:%d %s', + escapeshellarg( PHP_BINARY ), + $this->server_port, + escapeshellarg( $this->router_script_path ) + ); + + $descriptor_spec = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'file', $this->temp_dir . '/server.stdout.log', 'a' ), + 2 => array( 'file', $this->temp_dir . '/server.stderr.log', 'a' ), + ); + + $this->server_process = proc_open( + $command, + $descriptor_spec, + $pipes, + dirname( dirname( dirname( dirname( __FILE__ ) ) ) ), + array( + 'PHP_TOOLKIT_GIT_E2E_REPOSITORY_PATH' => $this->remote_repository_path, + ) + ); + + if ( ! is_resource( $this->server_process ) ) { + $this->fail( 'Failed to start the PHP built-in server for the Git CLI end-to-end test.' ); + } + + fclose( $pipes[0] ); + + $started = false; + for ( $attempt = 0; $attempt < 50; $attempt++ ) { + $socket = @fsockopen( '127.0.0.1', $this->server_port ); + if ( false !== $socket ) { + fclose( $socket ); + $started = true; + break; + } + usleep( 100000 ); + } + + if ( ! $started ) { + $this->fail( + "Failed to start the PHP built-in server.\n" . + $this->get_server_logs() + ); + } + } + + private function find_available_port() { + $server = stream_socket_server( 'tcp://127.0.0.1:0', $errno, $errstr ); + if ( false === $server ) { + $this->fail( sprintf( 'Failed to find an available port: %s', $errstr ) ); + } + + $server_name = stream_socket_get_name( $server, false ); + fclose( $server ); + + return intval( substr( strrchr( $server_name, ':' ), 1 ) ); + } + + private function command_is_available( $command ) { + $result = $this->run_command( sprintf( 'command -v %s', escapeshellarg( $command ) ) ); + + return 0 === $result['exit_code']; + } + + private function assert_command_succeeds( $command, $cwd = null ) { + $result = $this->run_command( $command, $cwd ); + + $this->assertSame( + 0, + $result['exit_code'], + sprintf( + "Command failed: %s\nstdout:\n%s\nstderr:\n%s\nserver logs:\n%s", + $command, + $result['stdout'], + $result['stderr'], + $this->get_server_logs() + ) + ); + + return $result; + } + + private function run_command( $command, $cwd = null ) { + $descriptor_spec = array( + 0 => array( 'pipe', 'r' ), + 1 => array( 'pipe', 'w' ), + 2 => array( 'pipe', 'w' ), + ); + + $process = proc_open( + $command, + $descriptor_spec, + $pipes, + $cwd, + array( + 'GIT_CONFIG_NOSYSTEM' => '1', + 'GIT_TERMINAL_PROMPT' => '0', + 'HOME' => $this->git_home, + ) + ); + + if ( ! is_resource( $process ) ) { + $this->fail( sprintf( 'Failed to start command: %s', $command ) ); + } + + fclose( $pipes[0] ); + $stdout = stream_get_contents( $pipes[1] ); + $stderr = stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + + return array( + 'exit_code' => proc_close( $process ), + 'stdout' => $stdout, + 'stderr' => $stderr, + ); + } + + private function get_server_logs() { + $stdout_log = $this->temp_dir . '/server.stdout.log'; + $stderr_log = $this->temp_dir . '/server.stderr.log'; + + return "stdout:\n" . + ( is_file( $stdout_log ) ? file_get_contents( $stdout_log ) : '' ) . + "\nstderr:\n" . + ( is_file( $stderr_log ) ? file_get_contents( $stderr_log ) : '' ); + } + + private function delete_directory( $path ) { + if ( ! $path || ! file_exists( $path ) ) { + return; + } + + if ( is_file( $path ) || is_link( $path ) ) { + @unlink( $path ); + return; + } + + $entries = scandir( $path ); + if ( false === $entries ) { + return; + } + + foreach ( $entries as $entry ) { + if ( '.' === $entry || '..' === $entry ) { + continue; + } + $this->delete_directory( $path . '/' . $entry ); + } + + @rmdir( $path ); + } +} diff --git a/components/Git/Tests/GitRepositoryTest.php b/components/Git/Tests/GitRepositoryTest.php index 7601f90a0..e690f9b85 100644 --- a/components/Git/Tests/GitRepositoryTest.php +++ b/components/Git/Tests/GitRepositoryTest.php @@ -173,6 +173,22 @@ public function test_commit() { $this->assertEquals( $commit_oid, $repo->get_branch_tip() ); } + public function test_initial_commit_has_no_null_parent() { + $repo = new GitRepository( InMemoryFilesystem::create() ); + $repo->set_branch_tip( 'refs/heads/trunk', Commit::NULL_HASH ); + $repo->set_branch_tip( 'HEAD', 'ref: refs/heads/trunk' ); + + $commit_oid = $repo->commit( + array( + 'updates' => array( + 'README.md' => 'Hello, world!', + ), + ) + ); + + $this->assertSame( array(), $repo->read_object( $commit_oid )->as_commit()->parents ); + } + public function test_find_path_descendants() { $repo = new GitRepository( InMemoryFilesystem::create() ); $repo->set_branch_tip( 'refs/heads/trunk', Commit::NULL_HASH ); diff --git a/components/Git/Tests/fixtures/git-http-endpoint-router.php b/components/Git/Tests/fixtures/git-http-endpoint-router.php new file mode 100644 index 000000000..c4e43bd58 --- /dev/null +++ b/components/Git/Tests/fixtures/git-http-endpoint-router.php @@ -0,0 +1,54 @@ + 'trunk', + ) +); +$endpoint = new GitEndpoint( $repository ); +$response = new BufferingResponseWriter(); + +try { + $endpoint->handle_request( + $git_path, + file_get_contents( 'php://input' ), + $response + ); +} catch ( Exception $exception ) { + http_response_code( 500 ); + header( 'Content-Type: text/plain' ); + echo $exception->getMessage(); +} diff --git a/components/Git/class-gitrepository.php b/components/Git/class-gitrepository.php index c0b81287e..42b24b30b 100644 --- a/components/Git/class-gitrepository.php +++ b/components/Git/class-gitrepository.php @@ -727,7 +727,7 @@ public function commit( $options = array() ) { // Create a new commit object. $commit_options = $options['commit'] ?? array(); $commit_options['tree'] = $root_tree_oid; - if ( ! isset( $commit_options['parents'] ) && $this->get_branch_tip( 'HEAD' ) ) { + if ( ! isset( $commit_options['parents'] ) && ! Commit::is_null_hash( $head ) ) { $commit_options['parents'] = array( $head ); } @@ -919,8 +919,8 @@ public function get_commits_range( string $head_oid, string $last_ancestor_oid, continue; } $visited[ $current_oid ] = true; - $commits[] = $current_oid; - $commit = $this->read_object( $current_oid )->as_commit(); + $commits[] = $current_oid; + $commit = $this->read_object( $current_oid )->as_commit(); foreach ( $commit->parents as $parent_hash ) { $queue[] = $parent_hash; } From c157dca62383ecba5fc09ac08aef1de6b6bfa95a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Wed, 22 Apr 2026 15:29:11 +0200 Subject: [PATCH 2/4] WP Origin: expose WordPress as a Git remote for Markdown content Adds the WP Origin plugin, which makes WordPress posts and pages available as a Git remote. Users and coding agents can clone, pull, and push Markdown files using standard Git tooling, with WordPress as the source of truth. The plugin exposes a Git Smart HTTP endpoint at /wp-json/git/v1/md.git, backed by HTTP Basic Auth with application passwords. Posts and pages are serialized as Markdown with YAML front matter. Pushes validate staleness before applying changes and move deleted files to trash rather than destroying content. This commit also renames the Markdown fenced-code block language from `block` to `gutenberg` (the old name is still accepted on import), and adds a real end-to-end test script that boots a WordPress Playground instance, clones the repo, edits and pushes a post, and verifies that a stale push is rejected. --- Dockerfile | 5 + bin/run-server-dev.sh | 3 +- bin/test-wp-origin-git-actions.sh | 118 +++++ .../Markdown/Tests/MarkdownConsumerTest.php | 15 + .../Markdown/Tests/MarkdownProducerTest.php | 11 + .../Markdown/class-markdownconsumer.php | 3 +- .../Markdown/class-markdownproducer.php | 2 +- package.json | 3 +- plugins/wp-origin/blueprint-e2e.json | 22 + .../class-wp-origin-buffering-response.php | 33 ++ plugins/wp-origin/class-wp-origin-plugin.php | 443 ++++++++++++++++++ plugins/wp-origin/wp-origin-dev-bootstrap.php | 38 ++ plugins/wp-origin/wp-origin.php | 19 + 13 files changed, 710 insertions(+), 5 deletions(-) create mode 100755 bin/test-wp-origin-git-actions.sh create mode 100644 plugins/wp-origin/blueprint-e2e.json create mode 100644 plugins/wp-origin/class-wp-origin-buffering-response.php create mode 100644 plugins/wp-origin/class-wp-origin-plugin.php create mode 100644 plugins/wp-origin/wp-origin-dev-bootstrap.php create mode 100644 plugins/wp-origin/wp-origin.php diff --git a/Dockerfile b/Dockerfile index 121704726..4c9bb416a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libsqlite3-dev \ libonig-dev \ locales \ + nodejs \ + npm \ unzip \ git \ && sed -i 's/# en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen \ @@ -20,6 +22,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Install Composer COPY --from=composer:2 /usr/bin/composer /usr/bin/composer +# Install Playground CLI for WordPress integration smoke tests. +RUN npm install -g @wp-playground/cli@latest + WORKDIR /app # Install dependencies first (cached layer for faster rebuilds) diff --git a/bin/run-server-dev.sh b/bin/run-server-dev.sh index 48e82482d..1f39d8738 100644 --- a/bin/run-server-dev.sh +++ b/bin/run-server-dev.sh @@ -10,5 +10,6 @@ npx @wp-playground/cli@latest \ --mount=`pwd`/plugins/static-files-editor:/wordpress/wp-content/plugins/static-files-editor \ --mount=`pwd`/plugins/url-updater:/wordpress/wp-content/plugins/url-updater \ --mount=`pwd`/plugins/git-repo:/wordpress/wp-content/plugins/git-repo \ + --mount=`pwd`/plugins/wp-origin:/wordpress/wp-content/plugins/wp-origin \ --mount=`pwd`/.my-notes-git:/wordpress/wp-content/uploads \ - --blueprint=./blueprint-dev.json \ No newline at end of file + --blueprint=./blueprint-dev.json diff --git a/bin/test-wp-origin-git-actions.sh b/bin/test-wp-origin-git-actions.sh new file mode 100755 index 000000000..178341f83 --- /dev/null +++ b/bin/test-wp-origin-git-actions.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -eu + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +PORT="${WP_ORIGIN_E2E_PORT:-9409}" +PLAYGROUND_LOG="$ROOT_DIR/.context/wp-origin-playground.log" +CREDENTIALS_FILE="$ROOT_DIR/.context/wp-origin-e2e.json" +WORK_DIR="$(mktemp -d)" + +if command -v wp-playground >/dev/null 2>&1; then + PLAYGROUND_CMD="wp-playground" +else + PLAYGROUND_CMD="npx @wp-playground/cli" +fi + +cleanup() { + if [ -n "${PLAYGROUND_PID:-}" ] && kill -0 "$PLAYGROUND_PID" 2>/dev/null; then + kill "$PLAYGROUND_PID" 2>/dev/null || true + wait "$PLAYGROUND_PID" 2>/dev/null || true + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT INT TERM + +mkdir -p "$ROOT_DIR/.context" +rm -f "$PLAYGROUND_LOG" "$CREDENTIALS_FILE" + +cd "$ROOT_DIR" + +$PLAYGROUND_CMD server \ + --port="$PORT" \ + --blueprint="$ROOT_DIR/plugins/wp-origin/blueprint-e2e.json" \ + --mount="$ROOT_DIR:/workspace" \ + --mount="$ROOT_DIR/vendor:/wordpress/wp-content/vendor" \ + --mount="$ROOT_DIR/components:/wordpress/wp-content/components" \ + --mount="$ROOT_DIR/plugins/wp-origin:/wordpress/wp-content/plugins/wp-origin" \ + >"$PLAYGROUND_LOG" 2>&1 & +PLAYGROUND_PID=$! + +for _ in $(seq 1 120); do + if [ -f "$CREDENTIALS_FILE" ]; then + break + fi + sleep 1 +done + +if [ ! -f "$CREDENTIALS_FILE" ]; then + cat "$PLAYGROUND_LOG" + echo "WP Origin e2e setup did not produce credentials." >&2 + exit 1 +fi + +USERNAME="$(php -r 'echo json_decode(file_get_contents($argv[1]), true)["username"];' "$CREDENTIALS_FILE")" +PASSWORD="$(php -r 'echo json_decode(file_get_contents($argv[1]), true)["password"];' "$CREDENTIALS_FILE")" + +AUTH_HEADER="$(php -r 'echo base64_encode($argv[1] . ":" . $argv[2]);' "$USERNAME" "$PASSWORD")" +BASE_URL="http://127.0.0.1:$PORT" +REMOTE_AUTH_URL="http://$USERNAME:$PASSWORD@127.0.0.1:$PORT/wp-json/git/v1/md.git" +REMOTE_URL="$BASE_URL/wp-json/git/v1/md.git" +CLONE_DIR="$WORK_DIR/clone" + +git -c protocol.version=2 clone "$REMOTE_AUTH_URL" "$CLONE_DIR" + +test -f "$CLONE_DIR/post/hello-world.md" +test -f "$CLONE_DIR/page/sample-page.md" +grep -q 'Hello from WordPress' "$CLONE_DIR/post/hello-world.md" + +cd "$CLONE_DIR" +git config user.name "WP Origin E2E" +git config user.email "wp-origin-e2e@example.com" + +php -r ' +$path = $argv[1]; +$contents = file_get_contents($path); +$contents = str_replace("Hello from WordPress", "Updated from Git", $contents); +file_put_contents($path, $contents); +' "$CLONE_DIR/post/hello-world.md" + +git add post/hello-world.md +git commit -m "Update hello world from Git" +git push origin trunk + +POST_ID="$(curl -sS -H "Authorization: Basic $AUTH_HEADER" "$BASE_URL/wp-json/wp/v2/posts?slug=hello-world&context=edit" | php -r ' +$posts = json_decode(stream_get_contents(STDIN), true); +echo $posts[0]["id"]; +')" + +UPDATED_CONTENT="$(curl -sS -H "Authorization: Basic $AUTH_HEADER" "$BASE_URL/wp-json/wp/v2/posts/$POST_ID?context=edit" | php -r ' +$post = json_decode(stream_get_contents(STDIN), true); +echo $post["content"]["raw"]; +')" +printf '%s' "$UPDATED_CONTENT" | grep -q 'Updated from Git' + +php -r ' +$path = $argv[1]; +$contents = file_get_contents($path); +$contents = str_replace("Updated from Git", "Stale local edit", $contents); +file_put_contents($path, $contents); +' "$CLONE_DIR/post/hello-world.md" + +git add post/hello-world.md +git commit -m "Create stale local edit" + +UPDATE_PAYLOAD='{"content":"

Updated in WordPress

"}' +curl -sS \ + -X POST \ + -H "Authorization: Basic $AUTH_HEADER" \ + -H "Content-Type: application/json" \ + -d "$UPDATE_PAYLOAD" \ + "$BASE_URL/wp-json/wp/v2/posts/$POST_ID?context=edit" >/dev/null + +if git push origin trunk; then + echo "Expected stale push to fail." >&2 + exit 1 +fi + +git pull --rebase origin trunk +grep -q 'Updated in WordPress' "$CLONE_DIR/post/hello-world.md" diff --git a/components/Markdown/Tests/MarkdownConsumerTest.php b/components/Markdown/Tests/MarkdownConsumerTest.php index 1283d72f4..36f066958 100644 --- a/components/Markdown/Tests/MarkdownConsumerTest.php +++ b/components/Markdown/Tests/MarkdownConsumerTest.php @@ -204,4 +204,19 @@ public function test_frontmatter_extraction() { ); $this->assertEquals( $expected_metadata, $metadata ); } + + public function test_gutenberg_fence_is_preserved_as_block_markup() { + $markdown = <<
Roses are red
+``` +MD; + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + + $this->assertEquals( + '
Roses are red
', + trim( $result->get_block_markup() ) + ); + } } diff --git a/components/Markdown/Tests/MarkdownProducerTest.php b/components/Markdown/Tests/MarkdownProducerTest.php index cafb49151..738e2a5cf 100644 --- a/components/Markdown/Tests/MarkdownProducerTest.php +++ b/components/Markdown/Tests/MarkdownProducerTest.php @@ -147,6 +147,17 @@ public static function provider_test_conversion() { | Cell 3 | Cell 4 | +MD + , + ), + 'Unsupported blocks are preserved as gutenberg fences' => array( + 'blocks' => '
Roses are red
', + 'expected' => <<
Roses are red
+``` + MD , ), diff --git a/components/Markdown/class-markdownconsumer.php b/components/Markdown/class-markdownconsumer.php index c49a41459..4a4339a0c 100644 --- a/components/Markdown/class-markdownconsumer.php +++ b/components/Markdown/class-markdownconsumer.php @@ -181,7 +181,7 @@ private function convert_markdown_to_blocks() { if ( method_exists( $node, 'getInfo' ) && $node->getInfo() ) { $attrs['language'] = preg_replace( '/[ \t\r\n\f].*/', '', $node->getInfo() ); } - if ( 'block' === $attrs['language'] ) { + if ( 'block' === $attrs['language'] || 'gutenberg' === $attrs['language'] ) { // This is a special case for preserving block literals that could not be expressed as markdown. $this->append_content( "\n" . $node->getLiteral() . "\n" ); } else { @@ -287,7 +287,6 @@ private function convert_markdown_to_blocks() { break; default: - error_log( 'Unhandled node type: ' . get_class( $node ) ); return null; } } else { diff --git a/components/Markdown/class-markdownproducer.php b/components/Markdown/class-markdownproducer.php index 1e20cf429..b88fa04a8 100644 --- a/components/Markdown/class-markdownproducer.php +++ b/components/Markdown/class-markdownproducer.php @@ -282,7 +282,7 @@ function ( $cell, $width ) { } $markdown = array(); $markdown[] = ''; - $markdown[] = '```block'; + $markdown[] = '```gutenberg'; $markdown[] = \serialize_block( $block ); $markdown[] = '```'; $markdown[] = ''; diff --git a/package.json b/package.json index d340c9b20..e12587c15 100644 --- a/package.json +++ b/package.json @@ -21,5 +21,6 @@ "bugs": { "url": "https://github.com/WordPress/php-toolkit/issues" }, - "homepage": "https://github.com/WordPress/php-toolkit#readme" + "homepage": "https://github.com/WordPress/php-toolkit#readme", + "packageManager": "npm@11.12.1+sha512.cdca14b85d647b3192028d02aadbe82d75f79a446aceea9874be98e6d768f20ebd3555770a48d0e9906106007877bbc690f715e9372f2e2dc644a3c3157fb14c" } diff --git a/plugins/wp-origin/blueprint-e2e.json b/plugins/wp-origin/blueprint-e2e.json new file mode 100644 index 000000000..29c98a3f0 --- /dev/null +++ b/plugins/wp-origin/blueprint-e2e.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json", + "meta": { + "title": "WP Origin E2E", + "description": "Boot a WordPress site with WP Origin active and seeded content.", + "author": "adamziel" + }, + "preferredVersions": { + "php": "8.1", + "wp": "latest" + }, + "steps": [ + { + "step": "activatePlugin", + "pluginPath": "/wordpress/wp-content/plugins/wp-origin" + }, + { + "step": "wp-cli", + "command": "wp eval '$admins = get_users( array( \"role\" => \"Administrator\", \"number\" => 1 ) ); $admin = $admins[0]; update_option( \"permalink_structure\", \"/%postname%/\" ); flush_rewrite_rules(); $hello = get_page_by_path( \"hello-world\", OBJECT, \"post\" ); if ( $hello ) { wp_update_post( array( \"ID\" => $hello->ID, \"post_title\" => \"Hello World\", \"post_content\" => \"

Hello from WordPress

\", \"post_status\" => \"publish\" ) ); } $page = get_page_by_path( \"sample-page\", OBJECT, \"page\" ); if ( $page ) { wp_update_post( array( \"ID\" => $page->ID, \"post_title\" => \"Sample Page\", \"post_content\" => \"

Page from WordPress

\", \"post_status\" => \"publish\" ) ); } list( $raw_password ) = WP_Application_Passwords::create_new_application_password( $admin->ID, array( \"name\" => \"WP Origin E2E\" ) ); $password = preg_replace( \"/\\\\s+/\", \"\", $raw_password ); file_put_contents( \"/workspace/.context/wp-origin-e2e.json\", wp_json_encode( array( \"username\" => $admin->user_login, \"password\" => $password, \"post_slug\" => \"hello-world\", \"page_slug\" => \"sample-page\" ) ) );'" + } + ] +} diff --git a/plugins/wp-origin/class-wp-origin-buffering-response.php b/plugins/wp-origin/class-wp-origin-buffering-response.php new file mode 100644 index 000000000..514d23130 --- /dev/null +++ b/plugins/wp-origin/class-wp-origin-buffering-response.php @@ -0,0 +1,33 @@ +http_code = $code; + } + + public function send_header( $name, $value ) { + $this->headers[ $name ] = $value; + } + + public function append_bytes( $body ): void { + $this->body .= $body; + } + + public function close_writing(): void { + } + + public function to_rest_response() { + $response = new WP_REST_Response( $this->body, $this->http_code ); + foreach ( $this->headers as $name => $value ) { + $response->header( $name, $value ); + } + + return $response; + } +} diff --git a/plugins/wp-origin/class-wp-origin-plugin.php b/plugins/wp-origin/class-wp-origin-plugin.php new file mode 100644 index 000000000..649fd6799 --- /dev/null +++ b/plugins/wp-origin/class-wp-origin-plugin.php @@ -0,0 +1,443 @@ +/.*)?'; + + public static function bootstrap() { + add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) ); + } + + public static function register_routes() { + register_rest_route( + self::ROUTE_NAMESPACE, + self::ROUTE_PATTERN, + array( + 'methods' => WP_REST_Server::ALLMETHODS, + 'callback' => array( __CLASS__, 'handle_rest_request' ), + 'permission_callback' => array( __CLASS__, 'check_permissions' ), + ) + ); + } + + public static function check_permissions( WP_REST_Request $request ) { + $git_path = self::build_git_path( $request ); + if ( self::is_push_request( $git_path ) ) { + return current_user_can( 'edit_posts' ); + } + + return is_user_logged_in(); + } + + public static function handle_rest_request( WP_REST_Request $request ) { + try { + $repository = self::open_repository(); + self::sync_repository_from_wordpress( $repository ); + + $git_path = self::build_git_path( $request ); + $request_body = file_get_contents( 'php://input' ); + + $push_header = null; + if ( self::is_push_request( $git_path ) ) { + $push_header = self::parse_push_header( $request_body ); + if ( false === $push_header ) { + return self::build_protocol_error_response( + 'git-receive-pack', + 'Invalid push request.' + ); + } + + $current_head = $repository->get_branch_tip( 'refs/heads/' . self::DEFAULT_BRANCH ); + if ( $push_header['old_oid'] !== $current_head ) { + return self::build_protocol_error_response( + 'git-receive-pack', + 'Push rejected because the remote changed. Pull the latest changes and try again.' + ); + } + } + + $response = new WP_Origin_Buffering_Response(); + $endpoint = new GitEndpoint( $repository ); + $endpoint->handle_request( $git_path, $request_body, $response ); + + if ( self::is_push_request( $git_path ) ) { + try { + self::apply_repository_changes_to_wordpress( + $repository, + $push_header['old_oid'], + $push_header['new_oid'] + ); + self::sync_repository_from_wordpress( $repository ); + } catch ( Exception $exception ) { + $repository->set_branch_tip( 'refs/heads/' . self::DEFAULT_BRANCH, $push_header['old_oid'] ); + $repository->set_branch_tip( 'HEAD', 'ref: refs/heads/' . self::DEFAULT_BRANCH ); + + return self::build_protocol_error_response( + 'git-receive-pack', + $exception->getMessage() + ); + } + } + + return $response->to_rest_response(); + } catch ( Exception $exception ) { + return new WP_Error( + 'wp_origin_error', + $exception->getMessage(), + array( 'status' => 500 ) + ); + } + } + + private static function build_protocol_error_response( $service, $message ) { + $response = new WP_Origin_Buffering_Response(); + $response->send_header( 'Content-Type', 'application/x-' . $service . '-result' ); + $response->send_header( 'Cache-Control', 'no-cache' ); + $response->send_header( 'Git-Protocol', 'version=2' ); + $response->append_bytes( + WordPress\Git\Protocol\GitProtocolEncoderPipe::encode_packet_line( + 'error ' . rtrim( $message ) . "\n", + "\x03" + ) . '0000' + ); + + return $response->to_rest_response(); + } + + private static function build_git_path( WP_REST_Request $request ) { + $path = $request->get_param( 'path' ); + if ( ! is_string( $path ) || '' === $path ) { + $path = '/'; + } + + $query_params = $request->get_query_params(); + if ( '/info/refs' === $path && isset( $query_params['service'] ) ) { + $path .= '?service=' . $query_params['service']; + } + + return $path; + } + + private static function is_push_request( $git_path ) { + return '/git-receive-pack' === $git_path; + } + + private static function open_repository() { + $repository = new GitRepository( + LocalFilesystem::create( self::get_repository_path() ), + array( + 'default_branch' => self::DEFAULT_BRANCH, + ) + ); + + if ( ! $repository->get_config_value( 'user.name' ) ) { + $repository->set_config_value( 'user.name', get_option( 'blogname', 'WP Origin' ) ); + } + if ( ! $repository->get_config_value( 'user.email' ) ) { + $repository->set_config_value( 'user.email', get_option( 'admin_email', 'wp-origin@example.com' ) ); + } + $repository->set_branch_tip( 'HEAD', 'ref: refs/heads/' . self::DEFAULT_BRANCH ); + + return $repository; + } + + private static function get_repository_path() { + $upload_dir = wp_upload_dir(); + + return $upload_dir['basedir'] . '/wp-origin/repository'; + } + + private static function sync_repository_from_wordpress( GitRepository $repository ) { + $exported_files = self::export_wordpress_content(); + $current_files = array(); + + try { + $current_head = $repository->get_branch_tip( 'refs/heads/' . self::DEFAULT_BRANCH ); + if ( ! Commit::is_null_hash( $current_head ) ) { + $current_files = self::read_markdown_files_from_commit( $repository, $current_head ); + } + } catch ( GitException $exception ) { + $current_head = Commit::NULL_HASH; + } + + $updates = array(); + foreach ( $exported_files as $path => $contents ) { + if ( ! isset( $current_files[ $path ] ) || $current_files[ $path ] !== $contents ) { + $updates[ $path ] = $contents; + } + } + + $deletes = array(); + foreach ( $current_files as $path => $contents ) { + if ( ! isset( $exported_files[ $path ] ) ) { + $deletes[] = $path; + } + } + + if ( empty( $updates ) && empty( $deletes ) ) { + return; + } + + $commit_timestamp = time(); + foreach ( $exported_files as $path => $contents ) { + $metadata = self::parse_markdown_metadata( $contents ); + if ( isset( $metadata['modified_gmt'] ) ) { + $maybe_timestamp = strtotime( $metadata['modified_gmt'] . ' UTC' ); + if ( false !== $maybe_timestamp ) { + $commit_timestamp = max( $commit_timestamp, $maybe_timestamp ); + } + } + } + + $repository->commit( + array( + 'updates' => $updates, + 'deletes' => $deletes, + 'commit' => array( + 'message' => 'Sync from WordPress', + 'author_date' => gmdate( Commit::DATE_FORMAT, $commit_timestamp ), + 'committer_date' => gmdate( Commit::DATE_FORMAT, $commit_timestamp ), + ), + ) + ); + } + + private static function export_wordpress_content() { + $posts = get_posts( + array( + 'post_type' => array( 'post', 'page' ), + 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ), + 'posts_per_page' => -1, + 'orderby' => 'ID', + 'order' => 'ASC', + ) + ); + + $files = array(); + foreach ( $posts as $post ) { + if ( ! current_user_can( 'read_post', $post->ID ) ) { + continue; + } + + $metadata = array( + 'id' => (string) $post->ID, + 'type' => $post->post_type, + 'slug' => $post->post_name, + 'status' => $post->post_status, + 'title' => $post->post_title, + 'date_gmt' => $post->post_date_gmt, + 'modified_gmt' => $post->post_modified_gmt, + ); + + $producer = new MarkdownProducer( + new BlocksWithMetadata( + $post->post_content, + $metadata + ) + ); + $path = self::build_markdown_path( $post->post_type, $post->post_name ); + $files[ $path ] = $producer->produce(); + } + + ksort( $files ); + + return $files; + } + + private static function build_markdown_path( $post_type, $slug ) { + return ltrim( $post_type . '/' . $slug . '.md', '/' ); + } + + private static function apply_repository_changes_to_wordpress( GitRepository $repository, $old_commit, $new_commit ) { + $old_files = Commit::is_null_hash( $old_commit ) ? array() : self::read_markdown_files_from_commit( $repository, $old_commit ); + $new_files = self::read_markdown_files_from_commit( $repository, $new_commit ); + + $updated_post_ids = array(); + foreach ( $new_files as $path => $contents ) { + if ( isset( $old_files[ $path ] ) && $old_files[ $path ] === $contents ) { + continue; + } + + $post_id = self::upsert_post_from_markdown( $path, $contents ); + if ( $post_id ) { + $updated_post_ids[ $post_id ] = true; + } + } + + foreach ( $old_files as $path => $contents ) { + if ( isset( $new_files[ $path ] ) ) { + continue; + } + + $metadata = self::parse_markdown_metadata( $contents ); + $post_id = isset( $metadata['id'] ) ? intval( $metadata['id'] ) : 0; + if ( ! $post_id ) { + $post_id = self::find_post_id_by_path_metadata( $path, $metadata ); + } + if ( ! $post_id || isset( $updated_post_ids[ $post_id ] ) ) { + continue; + } + + if ( isset( $metadata['modified_gmt'] ) ) { + $current_modified = get_post_field( 'post_modified_gmt', $post_id ); + if ( $current_modified && $current_modified !== $metadata['modified_gmt'] ) { + throw new Exception( 'Push rejected because a deleted post changed in WordPress. Pull the latest changes and try again.' ); + } + } + + wp_trash_post( $post_id ); + } + } + + private static function upsert_post_from_markdown( $path, $markdown ) { + $post_type = self::path_to_post_type( $path ); + $slug = self::path_to_slug( $path ); + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + $metadata = array(); + foreach ( $result->get_all_metadata() as $key => $value ) { + $metadata[ $key ] = is_array( $value ) ? reset( $value ) : $value; + } + + if ( isset( $metadata['type'] ) && $metadata['type'] !== $post_type ) { + throw new Exception( 'Push rejected because the file post type does not match its directory.' ); + } + + $post_id = isset( $metadata['id'] ) ? intval( $metadata['id'] ) : 0; + if ( $post_id && get_post( $post_id ) ) { + $existing_post = get_post( $post_id ); + } else { + $post_id = self::find_post_id_by_path_metadata( $path, $metadata ); + $existing_post = $post_id ? get_post( $post_id ) : null; + } + + if ( $existing_post && isset( $metadata['modified_gmt'] ) && $existing_post->post_modified_gmt !== $metadata['modified_gmt'] ) { + throw new Exception( 'Push rejected because WordPress content changed since the last pull.' ); + } + + $postarr = array( + 'post_type' => $post_type, + 'post_name' => isset( $metadata['slug'] ) ? $metadata['slug'] : $slug, + 'post_title' => isset( $metadata['title'] ) ? $metadata['title'] : ucwords( str_replace( '-', ' ', $slug ) ), + 'post_status' => isset( $metadata['status'] ) ? $metadata['status'] : 'draft', + 'post_content' => $result->get_block_markup(), + ); + + if ( isset( $metadata['date_gmt'] ) && '' !== $metadata['date_gmt'] ) { + $postarr['post_date_gmt'] = $metadata['date_gmt']; + } + + if ( $existing_post ) { + $postarr['ID'] = $existing_post->ID; + $post_id = wp_update_post( wp_slash( $postarr ), true ); + } else { + $post_id = wp_insert_post( wp_slash( $postarr ), true ); + } + + if ( is_wp_error( $post_id ) ) { + throw new Exception( $post_id->get_error_message() ); + } + + return $post_id; + } + + private static function find_post_id_by_path_metadata( $path, $metadata ) { + $post_type = self::path_to_post_type( $path ); + $slug = isset( $metadata['slug'] ) ? $metadata['slug'] : self::path_to_slug( $path ); + $posts = get_posts( + array( + 'post_type' => $post_type, + 'name' => $slug, + 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future', 'trash' ), + 'posts_per_page' => 1, + 'fields' => 'ids', + ) + ); + + if ( empty( $posts ) ) { + return 0; + } + + return intval( $posts[0] ); + } + + private static function path_to_post_type( $path ) { + $segments = explode( '/', ltrim( $path, '/' ) ); + if ( empty( $segments[0] ) || ! in_array( $segments[0], array( 'post', 'page' ), true ) ) { + throw new Exception( 'Push rejected because the file path is outside the supported post type directories.' ); + } + + return $segments[0]; + } + + private static function path_to_slug( $path ) { + $basename = basename( $path ); + if ( 'md' !== pathinfo( $basename, PATHINFO_EXTENSION ) ) { + throw new Exception( 'Push rejected because only Markdown files are supported.' ); + } + + return pathinfo( $basename, PATHINFO_FILENAME ); + } + + private static function parse_markdown_metadata( $markdown ) { + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + $metadata = array(); + foreach ( $result->get_all_metadata() as $key => $value ) { + $metadata[ $key ] = is_array( $value ) ? reset( $value ) : $value; + } + + return $metadata; + } + + private static function read_markdown_files_from_commit( GitRepository $repository, $commit_hash ) { + $commit = $repository->read_object( $commit_hash )->as_commit(); + $files = array(); + + if ( Commit::is_null_hash( $commit->tree ) ) { + return $files; + } + + self::collect_tree_files( $repository, $commit->tree, '', $files ); + ksort( $files ); + + return $files; + } + + private static function collect_tree_files( GitRepository $repository, $tree_hash, $prefix, &$files ) { + $tree = $repository->read_object( $tree_hash )->as_tree(); + foreach ( $tree->entries as $entry ) { + $path = ltrim( $prefix . '/' . $entry->name, '/' ); + if ( TreeEntry::FILE_MODE_DIRECTORY === $entry->get_mode_bucket() ) { + self::collect_tree_files( $repository, $entry->hash, $path, $files ); + continue; + } + if ( 'md' !== pathinfo( $path, PATHINFO_EXTENSION ) ) { + continue; + } + $files[ $path ] = $repository->read_object( $entry->hash )->consume_all(); + } + } + + private static function parse_push_header( $request_bytes ) { + if ( ! preg_match( '/([0-9a-f]{40}) ([0-9a-f]{40}) refs\\/heads\\/' . self::DEFAULT_BRANCH . "\0/", $request_bytes, $matches ) ) { + return false; + } + + return array( + 'old_oid' => $matches[1], + 'new_oid' => $matches[2], + ); + } +} diff --git a/plugins/wp-origin/wp-origin-dev-bootstrap.php b/plugins/wp-origin/wp-origin-dev-bootstrap.php new file mode 100644 index 000000000..ffa05d1a9 --- /dev/null +++ b/plugins/wp-origin/wp-origin-dev-bootstrap.php @@ -0,0 +1,38 @@ +addClassMap( $wp_origin_classmap ); + +$wp_origin_psr4 = require __DIR__ . '/../../vendor/composer/autoload_psr4.php'; +foreach ( $wp_origin_psr4 as $prefix => $paths ) { + $wp_origin_loader->setPsr4( $prefix, $paths ); +} + +$wp_origin_namespaces = require __DIR__ . '/../../vendor/composer/autoload_namespaces.php'; +foreach ( $wp_origin_namespaces as $prefix => $paths ) { + $wp_origin_loader->set( $prefix, $paths ); +} + +$wp_origin_loader->register( true ); + +$wp_origin_files = array( + __DIR__ . '/../../components/DataLiberation/URL/functions.php', + __DIR__ . '/../../components/Encoding/utf8.php', + __DIR__ . '/../../components/Encoding/compat-utf8.php', + __DIR__ . '/../../components/Encoding/utf8-encoder.php', + __DIR__ . '/../../components/Filesystem/functions.php', + __DIR__ . '/../../components/Zip/functions.php', + __DIR__ . '/../../components/Polyfill/mbstring.php', + __DIR__ . '/../../components/Polyfill/php-functions.php', + __DIR__ . '/../../components/Git/functions.php', +); + +foreach ( $wp_origin_files as $wp_origin_file ) { + require_once $wp_origin_file; +} diff --git a/plugins/wp-origin/wp-origin.php b/plugins/wp-origin/wp-origin.php new file mode 100644 index 000000000..8f8cae78f --- /dev/null +++ b/plugins/wp-origin/wp-origin.php @@ -0,0 +1,19 @@ + Date: Wed, 22 Apr 2026 15:35:14 +0200 Subject: [PATCH 3/4] Add Markdown round-trip contract tests and harden WP Origin plugin The round-trip tests lock in the byte-preservation promise: Markdown that enters WordPress and comes back out must be identical, and block markup that is exported to Markdown and pushed back must produce the same blocks. 29 test cases cover paragraphs, headings, lists, tables, blockquotes, images, links, gutenberg fences (opaque unsupported-block preservation), front matter, and mixed content. The plugin gains several stability improvements: the repository now lives in a per-request temp directory that is always cleaned up in a finally block, binary Git responses bypass WordPress REST JSON encoding via a rest_pre_serve_request hook, permission checks cover both editing existing posts and creating new ones, and metadata values are correctly wrapped in arrays as BlocksWithMetadata requires. --- .../Markdown/Tests/MarkdownRoundTripTest.php | 352 ++++++++++++++++++ .../class-wp-origin-buffering-response.php | 3 + plugins/wp-origin/class-wp-origin-plugin.php | 151 ++++++-- 3 files changed, 478 insertions(+), 28 deletions(-) create mode 100644 components/Markdown/Tests/MarkdownRoundTripTest.php diff --git a/components/Markdown/Tests/MarkdownRoundTripTest.php b/components/Markdown/Tests/MarkdownRoundTripTest.php new file mode 100644 index 000000000..524510273 --- /dev/null +++ b/components/Markdown/Tests/MarkdownRoundTripTest.php @@ -0,0 +1,352 @@ + wp -> md (Markdown to block markup and back to Markdown) + * wp -> md -> wp (Block markup to Markdown and back to block markup) + * + * "md -> wp -> md" is the primary contract for the WP Origin workflow: agents + * edit Markdown locally, push to WordPress, then pull back. The pulled + * Markdown must match what was pushed, byte for byte. + * + * "wp -> md -> wp" tests that a WordPress post exported via WP Origin and then + * pushed back produces the same block markup. Leading/trailing whitespace + * inside block comments is normalized away because WordPress itself normalizes + * it on next save. + */ +class MarkdownRoundTripTest extends TestCase { + + // ------------------------------------------------------------------------- + // md -> wp -> md + // ------------------------------------------------------------------------- + + /** + * @dataProvider provider_md_wp_md + */ + public function test_md_to_wp_to_md( $description, $markdown ) { + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + $block_markup = $result->get_block_markup(); + $metadata = array(); + foreach ( $result->get_all_metadata() as $key => $value ) { + $metadata[ $key ] = is_array( $value ) ? array( reset( $value ) ) : array( $value ); + } + + $producer = new MarkdownProducer( new BlocksWithMetadata( $block_markup, $metadata ) ); + $round_tripped = $producer->produce(); + + $this->assertSame( + $markdown, + $round_tripped, + "md -> wp -> md round-trip failed for: $description" + ); + } + + public static function provider_md_wp_md() { + $cases = array(); + + // --- Simple blocks --- + + $cases['paragraph'] = array( + 'description' => 'paragraph', + 'markdown' => "A simple paragraph\n\n", + ); + + $cases['paragraph with trailing space'] = array( + 'description' => 'paragraph with trailing space', + 'markdown' => "A paragraph with a trailing space \n\n", + ); + + $cases['heading h2'] = array( + 'description' => 'heading h2', + 'markdown' => "## Section title\n\n", + ); + + $cases['heading h4'] = array( + 'description' => 'heading h4', + 'markdown' => "#### Sub-section title\n\n", + ); + + $cases['unordered list'] = array( + 'description' => 'unordered list', + 'markdown' => "- Item 1\n- Item 2\n- Item 3\n\n", + ); + + $cases['nested list'] = array( + 'description' => 'nested list', + 'markdown' => "- Item 1\n - Item 1.1\n - Item 1.2\n- Item 2\n\n", + ); + + $cases['link in paragraph'] = array( + 'description' => 'link in paragraph', + 'markdown' => "A paragraph with a [link](https://wordpress.org)\n\n", + ); + + $cases['inline image'] = array( + 'description' => 'inline image', + 'markdown' => "An inline image: ![Alt text](https://example.com/image.png)\n\n", + ); + + $cases['bold and italic'] = array( + 'description' => 'bold and italic', + 'markdown' => "**Bold** and *italic* text\n\n", + ); + + $cases['blockquote'] = array( + 'description' => 'blockquote', + 'markdown' => "> A blockquote\n> \n> \n\n", + ); + + // Tables normalise column-separator padding on export, so the canonical + // input for the round-trip test is already padded identically to what + // MarkdownProducer emits (one space on each side, columns padded to the + // widest cell). A table with three trailing newlines (\n\n\n from the + // producer test fixture) reduces to two (\n\n) when re-parsed; use the + // two-newline form here so the input matches the actual round-trip output. + $cases['table'] = array( + 'description' => 'table', + 'markdown' => "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |\n| Cell 3 | Cell 4 |\n\n", + ); + + // --- Gutenberg fences --- + + // Gutenberg fence: producer emits a single trailing newline after the + // closing fence (not two). Use that form so the input matches the output. + $cases['gutenberg fence is opaque'] = array( + 'description' => 'gutenberg fence is preserved verbatim', + 'markdown' => "\n```gutenberg\n
Roses are red
\n```\n", + ); + + $cases['gutenberg fence with complex block'] = array( + 'description' => 'pullquote preserved inside gutenberg fence', + 'markdown' => "\n```gutenberg\n

Quote

\n```\n", + ); + + // --- Front matter --- + + $cases['front matter survives round-trip'] = array( + 'description' => 'front matter is reproduced exactly', + 'markdown' => "---\nid: \"42\"\ntype: \"post\"\nslug: \"hello-world\"\nstatus: \"publish\"\ntitle: \"Hello World\"\ndate_gmt: \"2024-01-15 10:00:00\"\nmodified_gmt: \"2024-02-20 14:30:00\"\n---\n\nPost body here\n\n", + ); + + // --- Multiple blocks --- + + $cases['heading then paragraph'] = array( + 'description' => 'heading followed by paragraph', + 'markdown' => "## Introduction\n\nThis is the intro paragraph\n\n", + ); + + $cases['two paragraphs'] = array( + 'description' => 'two consecutive paragraphs', + 'markdown' => "First paragraph\n\nSecond paragraph\n\n", + ); + + $cases['mixed content'] = array( + 'description' => 'heading, paragraph, list', + 'markdown' => "## Getting started\n\nFollow these steps:\n\n- Step one\n- Step two\n- Step three\n\n", + ); + + return $cases; + } + + // ------------------------------------------------------------------------- + // wp -> md -> wp + // ------------------------------------------------------------------------- + + /** + * @dataProvider provider_wp_md_wp + */ + public function test_wp_to_md_to_wp( $description, $blocks, $expected_blocks ) { + $producer = new MarkdownProducer( new BlocksWithMetadata( $blocks, array() ) ); + $markdown = $producer->produce(); + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + $round_tripped = $result->get_block_markup(); + + $this->assertSame( + $this->normalize_blocks( $expected_blocks ), + $this->normalize_blocks( $round_tripped ), + "wp -> md -> wp round-trip failed for: $description" + ); + } + + public static function provider_wp_md_wp() { + $cases = array(); + + $cases['paragraph'] = array( + 'description' => 'paragraph', + 'blocks' => '

Hello world

', + 'expected_blocks' => '

Hello world

', + ); + + $cases['paragraph with link'] = array( + 'description' => 'paragraph with link', + 'blocks' => '

Visit WordPress

', + 'expected_blocks' => '

Visit WordPress

', + ); + + $cases['paragraph with bold and italic'] = array( + 'description' => 'paragraph with formatting', + 'blocks' => '

Bold and Italic

', + 'expected_blocks' => '

Bold and Italic

', + ); + + $cases['h2 heading'] = array( + 'description' => 'h2 heading', + 'blocks' => '

Section title

', + // The importer adds class and id attributes, which is expected behaviour. + 'expected_blocks' => '

Section title

', + ); + + $cases['unordered list'] = array( + 'description' => 'unordered list', + 'blocks' => '
  • Item 1
  • Item 2
', + 'expected_blocks' => '
  • Item 1
  • Item 2
', + ); + + $cases['table'] = array( + 'description' => 'table', + 'blocks' => '
Header 1Header 2
Cell 1Cell 2
', + 'expected_blocks' => '
Header 1Header 2
Cell 1Cell 2
', + ); + + // Unsupported blocks must survive as opaque gutenberg fences and come + // back byte-for-byte as the original block markup. + $cases['unsupported block preserved via gutenberg fence'] = array( + 'description' => 'unsupported block survives via gutenberg fence', + 'blocks' => '
Roses are red
', + 'expected_blocks' => '
Roses are red
', + ); + + $cases['mixed: paragraph then unsupported'] = array( + 'description' => 'paragraph followed by unsupported block', + 'blocks' => '

Intro

A poem line
', + 'expected_blocks' => '

Intro

A poem line
', + ); + + return $cases; + } + + // ------------------------------------------------------------------------- + // One-way alias: `block` fence on import normalises to `gutenberg` on export + // ------------------------------------------------------------------------- + + public function test_legacy_block_fence_accepted_on_import() { + $markdown = "```block\n
Roses are red
\n```\n"; + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + + $this->assertSame( + '
Roses are red
', + trim( $result->get_block_markup() ) + ); + } + + public function test_legacy_block_fence_exported_as_gutenberg() { + // When block markup that can't be expressed in Markdown is exported, the + // output uses `gutenberg`, not the old `block` language tag. + $blocks = '
Roses are red
'; + $producer = new MarkdownProducer( new BlocksWithMetadata( $blocks, array() ) ); + $markdown = $producer->produce(); + + $this->assertStringContainsString( '```gutenberg', $markdown ); + $this->assertStringNotContainsString( '```block', $markdown ); + } + + // ------------------------------------------------------------------------- + // Front matter: round-trip via MarkdownProducer + MarkdownConsumer + // ------------------------------------------------------------------------- + + public function test_front_matter_key_order_is_stable() { + $metadata = array( + 'id' => array( '42' ), + 'type' => array( 'post' ), + 'slug' => array( 'hello-world' ), + 'status' => array( 'publish' ), + 'title' => array( 'Hello World' ), + 'date_gmt' => array( '2024-01-15 10:00:00' ), + 'modified_gmt' => array( '2024-02-20 14:30:00' ), + ); + $blocks = '

Body

'; + + $producer = new MarkdownProducer( new BlocksWithMetadata( $blocks, $metadata ) ); + $markdown1 = $producer->produce(); + + // Produce again from the same input — output must be identical. + $producer = new MarkdownProducer( new BlocksWithMetadata( $blocks, $metadata ) ); + $markdown2 = $producer->produce(); + + $this->assertSame( $markdown1, $markdown2, 'Front matter output must be deterministic' ); + } + + public function test_front_matter_values_survive_round_trip() { + $expected = array( + 'id' => '7', + 'type' => 'page', + 'slug' => 'about', + 'status' => 'publish', + 'title' => 'About Us', + 'date_gmt' => '2023-06-01 09:00:00', + 'modified_gmt' => '2024-03-15 12:00:00', + ); + $metadata = array(); + foreach ( $expected as $key => $value ) { + $metadata[ $key ] = array( $value ); + } + $blocks = '

About page content

'; + + $producer = new MarkdownProducer( new BlocksWithMetadata( $blocks, $metadata ) ); + $markdown = $producer->produce(); + + $consumer = new MarkdownConsumer( $markdown ); + $result = $consumer->consume(); + $recovered = array(); + foreach ( $result->get_all_metadata() as $key => $value ) { + $recovered[ $key ] = is_array( $value ) ? reset( $value ) : $value; + } + + foreach ( $expected as $key => $expected_value ) { + $this->assertArrayHasKey( $key, $recovered, "Front matter key '$key' missing after round-trip" ); + $this->assertSame( + (string) $expected_value, + (string) $recovered[ $key ], + "Front matter value for '$key' changed after round-trip" + ); + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Normalise block markup for comparison. + * + * WordPress block markup uses HTML comments as delimiters + * (e.g. ). When the Markdown importer reconstructs + * block markup it may add a newline or space between a comment and the + * next tag. WordPress itself strips that whitespace on next save, so we + * normalise it away here to keep the tests focused on content, not + * incidental whitespace. + */ + private function normalize_blocks( $markup ) { + // Strip whitespace between --> and the next < (opening tag or comment). + $markup = preg_replace( '/-->\s+<', $markup ); + // Strip whitespace between a closing > and the next \s+<', $markup ); + $markup = preg_replace( '/>\s+

Hello world

diff --git a/components/Markdown/Tests/fixtures/roundtrip/wp-to-md-to-wp/unsupported-block.html b/components/Markdown/Tests/fixtures/roundtrip/wp-to-md-to-wp/unsupported-block.html new file mode 100644 index 000000000..1bc53441a --- /dev/null +++ b/components/Markdown/Tests/fixtures/roundtrip/wp-to-md-to-wp/unsupported-block.html @@ -0,0 +1 @@ +
Roses are red
diff --git a/docker-compose.yml b/docker-compose.yml index 104a4e5b9..00200007c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,19 @@ services: security_opt: - no-new-privileges:true network_mode: none + sandbox-wp-origin-e2e: + build: . + volumes: + - .:/app + - vendor_data:/app/vendor + working_dir: /app + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true volumes: vendor_data: diff --git a/plugins/wp-origin/blueprint-e2e.json b/plugins/wp-origin/blueprint-e2e.json index 29c98a3f0..4b714282e 100644 --- a/plugins/wp-origin/blueprint-e2e.json +++ b/plugins/wp-origin/blueprint-e2e.json @@ -7,7 +7,7 @@ }, "preferredVersions": { "php": "8.1", - "wp": "latest" + "wp": "6.9.4" }, "steps": [ { @@ -15,8 +15,11 @@ "pluginPath": "/wordpress/wp-content/plugins/wp-origin" }, { - "step": "wp-cli", - "command": "wp eval '$admins = get_users( array( \"role\" => \"Administrator\", \"number\" => 1 ) ); $admin = $admins[0]; update_option( \"permalink_structure\", \"/%postname%/\" ); flush_rewrite_rules(); $hello = get_page_by_path( \"hello-world\", OBJECT, \"post\" ); if ( $hello ) { wp_update_post( array( \"ID\" => $hello->ID, \"post_title\" => \"Hello World\", \"post_content\" => \"

Hello from WordPress

\", \"post_status\" => \"publish\" ) ); } $page = get_page_by_path( \"sample-page\", OBJECT, \"page\" ); if ( $page ) { wp_update_post( array( \"ID\" => $page->ID, \"post_title\" => \"Sample Page\", \"post_content\" => \"

Page from WordPress

\", \"post_status\" => \"publish\" ) ); } list( $raw_password ) = WP_Application_Passwords::create_new_application_password( $admin->ID, array( \"name\" => \"WP Origin E2E\" ) ); $password = preg_replace( \"/\\\\s+/\", \"\", $raw_password ); file_put_contents( \"/workspace/.context/wp-origin-e2e.json\", wp_json_encode( array( \"username\" => $admin->user_login, \"password\" => $password, \"post_slug\" => \"hello-world\", \"page_slug\" => \"sample-page\" ) ) );'" + "step": "runPHP", + "code": { + "filename": "seed-wp-origin-e2e.php", + "content": " 'Administrator', 'number' => 1 ) );\n$admin = $admins[0];\nupdate_option( 'permalink_structure', '/%postname%/' );\nflush_rewrite_rules();\n$hello = get_page_by_path( 'hello-world', OBJECT, 'post' );\nif ( $hello ) {\n\twp_update_post( array(\n\t\t'ID' => $hello->ID,\n\t\t'post_title' => 'Hello World',\n\t\t'post_content' => '

Hello from WordPress

',\n\t\t'post_status' => 'publish',\n\t) );\n}\n$page = get_page_by_path( 'sample-page', OBJECT, 'page' );\nif ( $page ) {\n\twp_update_post( array(\n\t\t'ID' => $page->ID,\n\t\t'post_title' => 'Sample Page',\n\t\t'post_content' => '

Page from WordPress

',\n\t\t'post_status' => 'publish',\n\t) );\n}\nlist( $raw_password ) = WP_Application_Passwords::create_new_application_password( $admin->ID, array( 'name' => 'WP Origin E2E' ) );\n$password = preg_replace( '/\\s+/', '', $raw_password );\nfile_put_contents(\n\t'__WP_ORIGIN_CREDENTIALS_FILE__',\n\twp_json_encode(\n\t\tarray(\n\t\t\t'username' => $admin->user_login,\n\t\t\t'password' => $password,\n\t\t\t'post_slug' => 'hello-world',\n\t\t\t'page_slug' => 'sample-page',\n\t\t)\n\t)\n);\n" + } } ] } diff --git a/plugins/wp-origin/class-wp-origin-plugin.php b/plugins/wp-origin/class-wp-origin-plugin.php index 277ad2bbd..85a5181e9 100644 --- a/plugins/wp-origin/class-wp-origin-plugin.php +++ b/plugins/wp-origin/class-wp-origin-plugin.php @@ -20,6 +20,7 @@ class WP_Origin_Plugin { public static function bootstrap() { add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) ); + add_filter( 'rest_post_dispatch', array( __CLASS__, 'add_authentication_challenge' ), 10, 3 ); add_filter( 'rest_pre_serve_request', array( __CLASS__, 'serve_git_response' ), 10, 4 ); } @@ -37,15 +38,29 @@ public static function register_routes() { public static function check_permissions( WP_REST_Request $request ) { $git_path = self::build_git_path( $request ); - if ( self::is_push_request( $git_path ) ) { - return current_user_can( 'edit_posts' ); + if ( ! is_user_logged_in() ) { + return new WP_Error( + 'wp_origin_auth_required', + 'Authentication required.', + array( 'status' => 401 ) + ); + } + + if ( self::is_push_request( $git_path ) && ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'wp_origin_forbidden', + 'You do not have permission to push content changes.', + array( 'status' => 403 ) + ); } - return is_user_logged_in(); + return true; } public static function handle_rest_request( WP_REST_Request $request ) { $repository_path = null; + // Fail closed if PHP would otherwise emit warnings into the Git packet stream. + $previous_error_handler = set_error_handler( array( __CLASS__, 'throw_on_php_warning' ) ); // phpcs:ignore try { $repository_data = self::open_repository(); @@ -102,6 +117,7 @@ public static function handle_rest_request( WP_REST_Request $request ) { array( 'status' => 500 ) ); } finally { + restore_error_handler(); self::delete_directory( $repository_path ); } } @@ -132,6 +148,24 @@ public static function serve_git_response( $served, $result, $request, $server ) return true; } + public static function add_authentication_challenge( $response, $server, $request ) { + unset( $server ); + + if ( 0 !== strpos( $request->get_route(), '/' . self::ROUTE_NAMESPACE . '/md.git' ) ) { + return $response; + } + if ( ! $response instanceof WP_HTTP_Response ) { + return $response; + } + if ( 401 !== $response->get_status() ) { + return $response; + } + + $response->header( 'WWW-Authenticate', 'Basic realm="WP Origin"' ); + + return $response; + } + private static function build_protocol_error_response( $service, $message ) { $response = new WP_Origin_Buffering_Response(); $response->send_header( 'Content-Type', 'application/x-' . $service . '-result' ); @@ -167,7 +201,7 @@ private static function is_push_request( $git_path ) { private static function open_repository() { $repository_path = trailingslashit( sys_get_temp_dir() ) . 'wp-origin-' . wp_generate_uuid4(); - $repository = new GitRepository( + $repository = new GitRepository( LocalFilesystem::create( $repository_path ), array( 'default_branch' => self::DEFAULT_BRANCH, @@ -480,7 +514,7 @@ private static function collect_tree_files( GitRepository $repository, $tree_has } private static function parse_push_header( $request_bytes ) { - if ( ! preg_match( '/([0-9a-f]{40}) ([0-9a-f]{40}) refs\\/heads\\/' . self::DEFAULT_BRANCH . "\0/", $request_bytes, $matches ) ) { + if ( ! preg_match( '/([0-9a-f]{40}) ([0-9a-f]{40}) refs\\/heads\\/' . self::DEFAULT_BRANCH . '/', $request_bytes, $matches ) ) { return false; } @@ -535,4 +569,12 @@ private static function delete_directory( $path ) { rmdir( $path ); } + + public static function throw_on_php_warning( $severity, $message, $file, $line ) { + if ( 0 === ( error_reporting() & $severity ) ) { // phpcs:ignore + return false; + } + + throw new ErrorException( $message, 0, $severity, $file, $line ); + } }