diff --git a/components/Blueprints/Runner.php b/components/Blueprints/Runner.php index 84be474..3b96e40 100644 --- a/components/Blueprints/Runner.php +++ b/components/Blueprints/Runner.php @@ -111,7 +111,6 @@ class Runner { public function __construct( RunnerConfiguration $configuration ) { $this->configuration = $configuration; - $this->validateConfiguration( $configuration ); $this->client = new SocketClient(); $this->mainTracker = new Tracker(); @@ -193,6 +192,8 @@ private function validateConfiguration( RunnerConfiguration $config ): void { } public function run(): void { + $this->validateConfiguration( $this->configuration ); + $tempRoot = wp_unix_sys_get_temp_dir() . '/wp-blueprints-runtime-' . uniqid(); // TODO: Are there cases where we should not have these permissions? mkdir( $tempRoot, 0777, true ); @@ -639,7 +640,7 @@ private function createExecutionPlan(): array { * @return mixed A Step object instance. * @throws InvalidArgumentException If the step type is unknown or data is invalid. */ - private function createStepObject( string $stepType, array $data ) { + protected function createStepObject( string $stepType, array $data ) { switch ( $stepType ) { case 'activatePlugin': return new ActivatePluginStep( $data['pluginPath'] ); @@ -650,29 +651,6 @@ private function createStepObject( string $stepType, array $data ) { case 'defineConstants': return new DefineConstantsStep( $data['constants'] ); case 'importContent': - /** - * Flatten the content declaration from - * - * "content": [ - * { - * "type": "posts", - * "source": [ "post1.html", "post2.html" ] - * } - * ] - * - * into - * - * "content": [ - * { - * "type": "posts", - * "source": "post1.html" - * }, - * { - * "type": "posts", - * "source": "post2.html" - * } - * ] - */ $content = []; foreach($data['content'] as $contentDefinition) { $source = $contentDefinition['source']; @@ -681,32 +659,30 @@ private function createStepObject( string $stepType, array $data ) { $source = [$source]; } foreach($source as $source_item) { + $ref = $this->createDataReference( $source_item, [ ExecutionContextPath::class ] ); + // Content files must be in ./wp-content/content or subdirs + $this->assert_bundle_path_prefix($ref, './wp-content/content'); $content[] = array_merge( $contentDefinition, - [ 'source' => $this->createDataReference( $source_item, [ ExecutionContextPath::class ] ) ] + [ 'source' => $ref ] ); } } - return new ImportContentStep( $content ); case 'importThemeStarterContent': return new ImportThemeStarterContentStep( $data['themeSlug'] ?? null ); case 'installPlugin': - $source = $this->createDataReference( $data['source'], [ - ExecutionContextPath::class, - WordPressOrgPlugin::class, - ] ); + $source = $this->createDataReference( $data['source'], [ ExecutionContextPath::class, WordPressOrgPlugin::class ] ); + // Plugins must be in ./wp-content/plugins or subdirs + $this->assert_bundle_path_prefix($source, './wp-content/plugins'); $active = $data['active'] ?? true; $options = $data['activationOptions'] ?? null; $onError = isset( $pluginDef['onError'] ) ? $pluginDef['onError'] : 'throw'; - return new InstallPluginStep( $source, $active, $options, $onError ); case 'installTheme': - $source = $this->createDataReference( $data['source'], [ - ExecutionContextPath::class, - WordPressOrgTheme::class, - ] ); - + $source = $this->createDataReference( $data['source'], [ ExecutionContextPath::class, WordPressOrgTheme::class ] ); + // Themes must be in ./wp-content/themes or subdirs + $this->assert_bundle_path_prefix($source, './wp-content/themes'); return new InstallThemeStep( $source, $data['active'] ?? false, @@ -722,23 +698,20 @@ private function createStepObject( string $stepType, array $data ) { case 'rmdir': return new RmDirStep( $data['path'] ); case 'runPHP': - return new RunPHPStep( - $this->createDataReference( $data['code'], [ ExecutionContextPath::class ] ), - $data['env'] ?? [] - ); + $ref = $this->createDataReference( [ 'filename' => 'run-php.php', 'content' => $data['code'] ] ); + // Custom PHP code is not restricted by bundle spec + return new RunPHPStep( $ref, $data['env'] ?? [] ); case 'runSQL': $source = $this->createDataReference( $data['source'], [ ExecutionContextPath::class ] ); + // SQL files must be in ./wp-content/content or subdirs + $this->assert_bundle_path_prefix($source, './wp-content/content'); return new RunSqlStep( $source ); case 'setSiteLanguage': return new SetSiteLanguageStep( $data['language'] ); case 'setSiteOptions': return new SetSiteOptionsStep( $data['options'] ); - case 'createRoles': - if ( empty( $data['roles'] ) || ! is_array( $data['roles'] ) ) { - throw new InvalidArgumentException( 'Invalid roles data: must be a non-empty array.' ); - } - + // No file resource $code = 'createDataReference( [ - 'filename' => 'create-roles.php', - 'content' => $code, - ] ), + $this->createDataReference( [ 'filename' => 'create-roles.php', 'content' => $code ] ), [ 'ROLES' => $data['roles'] ] ); case 'createUsers': - if ( empty( $data['users'] ) || ! is_array( $data['users'] ) ) { - throw new InvalidArgumentException( 'Invalid users data: must be a non-empty array.' ); - } - + // No file resource $code = 'createDataReference( [ - 'filename' => 'create-users.php', - 'content' => $code, - ] ), + $this->createDataReference( [ 'filename' => 'create-users.php', 'content' => $code ] ), [ 'USERS' => $data['users'] ] ); case 'createPostTypes': - if ( empty( $data['postTypes'] ) || ! is_array( $data['postTypes'] ) ) { - throw new InvalidArgumentException( 'Invalid postTypes data: must be a non-empty array.' ); - } - - // @TODO: Do we need a separate step here? To make sure we're not overwriting existing post types? - // Or would WriteFilesStep be enough, perhaps with a "no override" flag? - // @TODO: Install SCF and use it to register post types. - $files = []; foreach ( $data['postTypes'] as $slug => $args ) { if ( ! is_string( $slug ) || $slug === '' ) { continue; } - - // Ensure $args is an array. - if ( ! is_array( $args ) ) { - $args = []; + // @TODO: Validate a full pattern – posts//post-type.json + try { + $args_ref = $this->createDataReference($args, [ ExecutionContextPath::class ]); + } catch (InvalidArgumentException $e) { + $args_ref = InlineFile::from_blueprint_data([ + 'filename' => './wp-content/content/posts/' . $slug . '/post-type.json', + 'content' => $args, + ]); } - - // Build a safe file name for the MU-plugin. + $this->assert_bundle_path_prefix($args_ref, './wp-content/content/posts/'); $fileSlug = preg_replace( '/[^a-z0-9\-]+/i', '-', strtolower( $slug ) ); $pluginPath = "wp-content/mu-plugins/blueprint-post-type-{$fileSlug}.php"; - - // Human-friendly default label. $defaultLabel = addslashes( ucwords( str_replace( [ '-', '_' ], ' ', $slug ) ) ); if ( ! isset( $args['label'] ) ) { $args['label'] = $defaultLabel; } - // Compose the plugin source. + // @TODO: Create a new step class object for this and only resolve the post type definition + // when it runs. + $post_type_definition = $args_ref->get_stream()->json(); $pluginCode = sprintf( <<<'PHP' resolve(), true ) ); - - $files[ $pluginPath ] = $this->createDataReference( [ - 'filename' => $pluginPath, - 'content' => $pluginCode, - ] ); + $ref = $this->createDataReference( [ 'filename' => $pluginPath, 'content' => $pluginCode ] ); + $files[ $pluginPath ] = $ref; } - if ( empty( $files ) ) { throw new InvalidArgumentException( 'No valid post types to register.' ); } - return new WriteFilesStep( $files ); - - case 'runPHP': - return new RunPHPStep( - $this->createDataReference( [ - 'filename' => 'run-php.php', - 'content' => $data['code'], - ] ), - $data['env'] ?? [] - ); case 'unzip': $zipFile = $this->createDataReference( $data['zipFile'], [ ExecutionContextPath::class ] ); - + // Unzipped files must be in ./wp-content/uploads or ./wp-content/content (by context) + $this->assert_bundle_path_prefix($zipFile, './wp-content/uploads'); return new UnzipStep( $zipFile, $data['extractToPath'] ); case 'wp-cli': return new WPCLIStep( $data['command'], $data['wpCliPath'] ?? null ); case 'writeFiles': $files = []; foreach ( $data['files'] as $path => $content ) { - $files[ $path ] = $this->createDataReference( $content, [ ExecutionContextPath::class ] ); + $ref = $this->createDataReference( $content, [ ExecutionContextPath::class ] ); + // General files: restrict by path + if ( strpos( $path, 'wp-content/mu-plugins/' ) === 0 ) { + $this->assert_bundle_path_prefix($ref, './wp-content/mu-plugins'); + } elseif ( strpos( $path, 'wp-content/plugins/' ) === 0 ) { + $this->assert_bundle_path_prefix($ref, './wp-content/plugins'); + } elseif ( strpos( $path, 'wp-content/themes/' ) === 0 ) { + $this->assert_bundle_path_prefix($ref, './wp-content/themes'); + } elseif ( strpos( $path, 'wp-content/languages/' ) === 0 ) { + $this->assert_bundle_path_prefix($ref, './wp-content/languages'); + } elseif ( strpos( $path, 'wp-content/uploads/' ) === 0 ) { + $this->assert_bundle_path_prefix($ref, './wp-content/uploads'); + } elseif ( strpos( $path, 'wp-content/content/' ) === 0 ) { + $this->assert_bundle_path_prefix($ref, './wp-content/content'); + } + $files[ $path ] = $ref; } - return new WriteFilesStep( $files ); case 'importMedia': $media = []; foreach ( $data['media'] as $path => $content ) { if ( is_string( $content ) ) { - $media[ $path ] = MediaFileDefinition::fromArray( [ - 'source' => $this->createDataReference( $content, [ ExecutionContextPath::class ] ), - ] ); + $ref = $this->createDataReference( $content, [ ExecutionContextPath::class ] ); + $this->assert_bundle_path_prefix($ref, './wp-content/uploads'); + $media[ $path ] = MediaFileDefinition::fromArray( [ 'source' => $ref ] ); continue; } - + $ref = $this->createDataReference( $content['source'], [ ExecutionContextPath::class ] ); + $this->assert_bundle_path_prefix($ref, './wp-content/uploads'); $media[ $path ] = MediaFileDefinition::fromArray( [ - 'source' => $this->createDataReference( $content['source'], [ ExecutionContextPath::class ] ), + 'source' => $ref, 'title' => $content['title'] ?? null, 'description' => $content['description'] ?? null, 'alt' => $content['alt'] ?? null, 'caption' => $content['caption'] ?? null, ] ); } - return new ImportMediaStep( $media ); default: throw new InvalidArgumentException( "Unknown step type: {$stepType}" ); @@ -1036,4 +996,23 @@ private function executePlan( Tracker $progress, array $steps, Runtime $runtime return $results; } + + /** + * Assert that an ExecutionContextPath-based DataReference starts with the allowed bundle prefix. + * Throws InvalidArgumentException if not. + * + * @param DataReference $reference + * @param string $allowed_prefix + * @throws \InvalidArgumentException + */ + private function assert_bundle_path_prefix( $reference, string $allowed_prefix ) : void { + if ( $reference instanceof \WordPress\Blueprints\DataReference\ExecutionContextPath ) { + $path = $reference->get_path(); + if ( strpos( $path, $allowed_prefix ) !== 0 ) { + throw new \InvalidArgumentException( + "ExecutionContextPath: Path '$path' must start with allowed prefix '$allowed_prefix' as per bundle spec." + ); + } + } + } } diff --git a/components/Blueprints/Tests/Unit/DataReference/DataReferenceResolverTest.php b/components/Blueprints/Tests/Unit/DataReference/DataReferenceResolverTest.php index 3d6d56a..92c6d4f 100644 --- a/components/Blueprints/Tests/Unit/DataReference/DataReferenceResolverTest.php +++ b/components/Blueprints/Tests/Unit/DataReference/DataReferenceResolverTest.php @@ -20,6 +20,12 @@ use WordPress\Filesystem\Filesystem; use WordPress\HttpClient\Client\SocketClient; +class TestRunner extends \WordPress\Blueprints\Runner { + public function doCreateStepObject(string $stepType, array $data) { + return parent::createStepObject($stepType, $data); + } +}; + class DataReferenceResolverTest extends TestCase { /** @var SocketClient&MockObject */ protected $client; @@ -120,4 +126,91 @@ public function testResolveUnsupportedReferenceTypeThrows() { $this->resolver->resolve( $reference ); } + /** + * @dataProvider runnerInvalidPathPrefixProvider + */ + public function testRunnerEnforcesPathPrefix($step, $invalidData) { + $config = new \WordPress\Blueprints\RunnerConfiguration([]); + $runner = new TestRunner($config); + + // Invalid path should throw + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/must start with allowed prefix/'); + $runner->doCreateStepObject($step, $invalidData); + } + + public function runnerInvalidPathPrefixProvider() { + return [ + // Plugins + [ + 'installPlugin', + [ 'source' => './wp-content/themes/my-theme.zip' ], + ], + // Themes + [ + 'installTheme', + [ 'source' => './wp-content/plugins/my-plugin.zip' ], + ], + [ + 'createPostTypes', + [ 'postTypes' => [ 'foo' => './wp-content/mu-plugins/foo.php' ] ], + ], + // Content + [ + 'importContent', + [ 'content' => [ [ 'source' => './wp-content/plugins/my-plugin.php' ] ] ], + ], + // Uploads + [ + 'importMedia', + [ 'media' => [ 'img.jpg' => './wp-content/plugins/img.jpg' ] ], + ], + ]; + } + /** + * @dataProvider runnerValidPathPrefixProvider + */ + public function testRunnerAcceptsValidPath($step, $validData) { + $config = new \WordPress\Blueprints\RunnerConfiguration([]); + $runner = new TestRunner($config); + + // Valid path should not throw + try { + $runner->doCreateStepObject($step, $validData); + $this->assertTrue(true); + } catch (\InvalidArgumentException $e) { + $this->fail('Should not throw for valid path'); + } + + } + + public function runnerValidPathPrefixProvider() { + return [ + // Plugins + [ + 'installPlugin', + [ 'source' => './wp-content/plugins/my-plugin.zip' ], + ], + // Themes + [ + 'installTheme', + [ 'source' => './wp-content/themes/my-theme.zip' ], + ], + [ + 'createPostTypes', + [ 'postTypes' => [ 'books' => './wp-content/posts/books/post-type.json' ] ], + ], + // Content + [ + 'importContent', + [ 'content' => [ [ 'source' => './wp-content/content/my-post.html' ] ] ], + ], + // Uploads + [ + 'importMedia', + [ 'media' => [ 'img.jpg' => './wp-content/uploads/img.jpg' ] ], + ], + ]; + } + } diff --git a/components/Blueprints/Tests/Unit/RunnerDirectoryValidationTest.php b/components/Blueprints/Tests/Unit/RunnerDirectoryValidationTest.php new file mode 100644 index 0000000..1140731 --- /dev/null +++ b/components/Blueprints/Tests/Unit/RunnerDirectoryValidationTest.php @@ -0,0 +1,402 @@ +execution_context_path = wp_join_unix_paths( $tmp_dir, 'test_' . uniqid() ); + + // Create execution context directory + mkdir( $this->execution_context_path, 0777, true ); + + // Create a minimal blueprint.json + file_put_contents( + wp_join_unix_paths( $this->execution_context_path, 'blueprint.json' ), + json_encode( [ "version" => 2 ] ) + ); + + $config = ( new RunnerConfiguration() ) + ->setExecutionMode( 'create-new-site' ) + ->setTargetSiteRoot( wp_join_unix_paths( $tmp_dir, 'test_site_' . uniqid() ) ) + ->setBlueprint( new AbsoluteLocalPath( wp_join_unix_paths( $this->execution_context_path, 'blueprint.json' ) ) ) + ->setDatabaseEngine( 'sqlite' ) + ->setTargetSiteUrl( 'http://127.0.0.1:2456' ); + + $this->runner = new Runner( $config ); + } + + /** + * @after + */ + public function tearDown(): void { + // Clean up temp directory + if ( is_dir( $this->execution_context_path ) ) { + $this->removeDirectory( $this->execution_context_path ); + } + } + + private function removeDirectory( $dir ) { + if ( ! is_dir( $dir ) ) { + return; + } + $objects = scandir( $dir ); + foreach ( $objects as $object ) { + if ( $object == "." || $object == ".." ) { + continue; + } + + $path = $dir . DIRECTORY_SEPARATOR . $object; + if ( is_dir( $path ) ) { + $this->removeDirectory( $path ); + } else { + unlink( $path ); + } + } + rmdir( $dir ); + } + + public function testPluginMustBeInPluginsDirectory() { + $this->expectException( BlueprintExecutionException::class ); + $this->expectExceptionMessage( 'Plugin resources must be located in the wp-content/plugins directory' ); + + // Create a plugin file in the wrong location + file_put_contents( + wp_join_unix_paths( $this->execution_context_path, 'invalid-plugin.php' ), + 'runner ); + $method = $reflection->getMethod( 'createPluginDataReference' ); + $method->setAccessible( true ); + + // This should throw an exception + $method->invoke( $this->runner, './invalid-plugin.php' ); + } + + public function testPluginInPluginsDirectoryIsValid() { + // Create wp-content/plugins directory + mkdir( wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'plugins' ), 0777, true ); + + // Create a plugin file in the correct location + file_put_contents( + wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'plugins', 'valid-plugin.php' ), + 'runner ); + $method = $reflection->getMethod( 'createPluginDataReference' ); + $method->setAccessible( true ); + + // This should not throw an exception + $reference = $method->invoke( $this->runner, './wp-content/plugins/valid-plugin.php' ); + $this->assertInstanceOf( ExecutionContextPath::class, $reference ); + } + + public function testThemeMustBeInThemesDirectory() { + $this->expectException( BlueprintExecutionException::class ); + $this->expectExceptionMessage( 'Theme resources must be located in the wp-content/themes directory' ); + + // Create a theme file in the wrong location + file_put_contents( + wp_join_unix_paths( $this->execution_context_path, 'invalid-theme.zip' ), + 'fake theme content' + ); + + // Use reflection to access the private createThemeDataReference method + $reflection = new \ReflectionClass( $this->runner ); + $method = $reflection->getMethod( 'createThemeDataReference' ); + $method->setAccessible( true ); + + // This should throw an exception + $method->invoke( $this->runner, './invalid-theme.zip' ); + } + + public function testThemeInThemesDirectoryIsValid() { + // Create wp-content/themes directory + mkdir( wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'themes' ), 0777, true ); + + // Create a theme file in the correct location + file_put_contents( + wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'themes', 'valid-theme.zip' ), + 'fake theme content' + ); + + // Use reflection to access the private createThemeDataReference method + $reflection = new \ReflectionClass( $this->runner ); + $method = $reflection->getMethod( 'createThemeDataReference' ); + $method->setAccessible( true ); + + // This should not throw an exception + $reference = $method->invoke( $this->runner, './wp-content/themes/valid-theme.zip' ); + $this->assertInstanceOf( ExecutionContextPath::class, $reference ); + } + + /** + * @dataProvider invalidTranslationFileProvider + */ + public function testTranslationFilesMustHaveCorrectExtensions($filename) { + $this->expectException( BlueprintExecutionException::class ); + $this->expectExceptionMessage( 'Translation files must have .po, .mo, or .zip extensions' ); + + // Create wp-content/languages directory + mkdir( wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'languages' ), 0777, true ); + + // Create a translation file with wrong extension + file_put_contents( + wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'languages', $filename ), + 'invalid translation file' + ); + + // Use reflection to access the private createGeneralDataReference method + $reflection = new \ReflectionClass( $this->runner ); + $method = $reflection->getMethod( 'createGeneralDataReference' ); + $method->setAccessible( true ); + + // This should throw an exception + $method->invoke( $this->runner, './wp-content/languages/' . $filename ); + } + + public function invalidTranslationFileProvider() { + return [ + ['invalid.txt'], + ['invalid.doc'], + ['invalid'], + ]; + } + + /** + * @dataProvider validTranslationFileProvider + */ + public function testValidTranslationFilesAreAccepted($filename) { + // Create wp-content/languages directory + mkdir( wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'languages' ), 0777, true ); + + // Create a translation file with valid extension + file_put_contents( + wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'languages', $filename ), + 'valid translation file' + ); + + // Use reflection to access the private createGeneralDataReference method + $reflection = new \ReflectionClass( $this->runner ); + $method = $reflection->getMethod( 'createGeneralDataReference' ); + $method->setAccessible( true ); + + // This should not throw an exception + $reference = $method->invoke( $this->runner, './wp-content/languages/' . $filename ); + $this->assertInstanceOf( ExecutionContextPath::class, $reference ); + } + + public function validTranslationFileProvider() { + return [ + ['valid.po'], + ['valid.mo'], + ['valid.zip'], + ]; + } + + /** + * @dataProvider invalidFontFileProvider + */ + public function testFontFilesMustHaveCorrectExtensions($filename) { + $this->expectException( BlueprintExecutionException::class ); + $this->expectExceptionMessage( 'Font files must have .woff2, .woff, .ttf, or .otf extensions' ); + + // Create wp-content/uploads/fonts directory + mkdir( wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'uploads', 'fonts' ), 0777, true ); + + // Create a font file with wrong extension + file_put_contents( + wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'uploads', 'fonts', $filename ), + 'invalid font file' + ); + + // Use reflection to access the private createGeneralDataReference method + $reflection = new \ReflectionClass( $this->runner ); + $method = $reflection->getMethod( 'createGeneralDataReference' ); + $method->setAccessible( true ); + + // This should throw an exception + $method->invoke( $this->runner, './wp-content/uploads/fonts/' . $filename ); + } + + public function invalidFontFileProvider() { + return [ + ['invalid.txt'], + ['invalid.eot'], + ['invalid'], + ]; + } + + /** + * @dataProvider validFontFileProvider + */ + public function testValidFontFilesAreAccepted($filename) { + // Create wp-content/uploads/fonts directory + mkdir( wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'uploads', 'fonts' ), 0777, true ); + + // Create a font file with valid extension + file_put_contents( + wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'uploads', 'fonts', $filename ), + 'valid font file' + ); + + // Use reflection to access the private createDataReference method + $reflection = new \ReflectionClass( $this->runner ); + $method = $reflection->getMethod( 'createDataReference' ); + $method->setAccessible( true ); + + // This should not throw an exception + $reference = $method->invoke( $this->runner, './wp-content/uploads/fonts/' . $filename, [ ExecutionContextPath::class ] ); + $this->assertInstanceOf( ExecutionContextPath::class, $reference ); + } + + public function validFontFileProvider() { + return [ + ['valid.woff2'], + ['valid.woff'], + ['valid.ttf'], + ['valid.otf'], + ]; + } + + public function testContentFilesSqlAndXmlAreAccepted() { + // Create wp-content/content directory + mkdir( wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'content' ), 0777, true ); + + $validFiles = [ 'dump.sql', 'export.xml', 'content.wxr' ]; + + foreach ( $validFiles as $filename ) { + // Create a content file + file_put_contents( + wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'content', $filename ), + 'valid content file' + ); + + // Use reflection to access the private createDataReference method + $reflection = new \ReflectionClass( $this->runner ); + $method = $reflection->getMethod( 'createDataReference' ); + $method->setAccessible( true ); + + // This should not throw an exception + $reference = $method->invoke( $this->runner, './wp-content/content/' . $filename, [ ExecutionContextPath::class ] ); + $this->assertInstanceOf( ExecutionContextPath::class, $reference ); + } + } + + public function testPostContentFilesMustHaveCorrectExtensions() { + $this->expectException( BlueprintExecutionException::class ); + $this->expectExceptionMessage( 'Post content files must have .html, .md, .txt extensions or be named post-type.json' ); + + // Create wp-content/content/posts/articles directory + mkdir( wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'content', 'posts', 'articles' ), 0777, true ); + + // Create a post content file with wrong extension + file_put_contents( + wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'content', 'posts', 'articles', 'invalid.pdf' ), + 'invalid post content file' + ); + + // Use reflection to access the private createDataReference method + $reflection = new \ReflectionClass( $this->runner ); + $method = $reflection->getMethod( 'createDataReference' ); + $method->setAccessible( true ); + + // This should throw an exception + $method->invoke( $this->runner, './wp-content/content/posts/articles/invalid.pdf', [ ExecutionContextPath::class ] ); + } + + public function testValidPostContentFilesAreAccepted() { + // Create wp-content/content/posts/articles directory + mkdir( wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'content', 'posts', 'articles' ), 0777, true ); + + $validFiles = [ 'post.html', 'article.md', 'content.txt', 'post-type.json' ]; + + foreach ( $validFiles as $filename ) { + // Create a post content file + file_put_contents( + wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'content', 'posts', 'articles', $filename ), + 'valid post content file' + ); + + // Use reflection to access the private createDataReference method + $reflection = new \ReflectionClass( $this->runner ); + $method = $reflection->getMethod( 'createDataReference' ); + $method->setAccessible( true ); + + // This should not throw an exception + $reference = $method->invoke( $this->runner, './wp-content/content/posts/articles/' . $filename, [ ExecutionContextPath::class ] ); + $this->assertInstanceOf( ExecutionContextPath::class, $reference ); + } + } + + public function testMediaFilesInUploadsAreAccepted() { + // Create wp-content/uploads directory + mkdir( wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'uploads', '2024', '01' ), 0777, true ); + + // Create various media files + $mediaFiles = [ 'image.jpg', 'video.mp4', 'audio.mp3', 'document.pdf' ]; + + foreach ( $mediaFiles as $filename ) { + file_put_contents( + wp_join_unix_paths( $this->execution_context_path, 'wp-content', 'uploads', '2024', '01', $filename ), + 'media file content' + ); + + // Use reflection to access the private createDataReference method + $reflection = new \ReflectionClass( $this->runner ); + $method = $reflection->getMethod( 'createDataReference' ); + $method->setAccessible( true ); + + // This should not throw an exception + $reference = $method->invoke( $this->runner, './wp-content/uploads/2024/01/' . $filename, [ ExecutionContextPath::class ] ); + $this->assertInstanceOf( ExecutionContextPath::class, $reference ); + } + } + + public function testRootLevelFilesAreAccepted() { + // Create a root level file (like blueprint.json) + file_put_contents( + wp_join_unix_paths( $this->execution_context_path, 'config.json' ), + '{"config": "value"}' + ); + + // Use reflection to access the private createDataReference method + $reflection = new \ReflectionClass( $this->runner ); + $method = $reflection->getMethod( 'createDataReference' ); + $method->setAccessible( true ); + + // This should not throw an exception + $reference = $method->invoke( $this->runner, './config.json', [ ExecutionContextPath::class ] ); + $this->assertInstanceOf( ExecutionContextPath::class, $reference ); + } +} \ No newline at end of file