diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d84f4ad --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +/.actrc export-ignore +/.distignore export-ignore +/.editorconfig export-ignore +/.github export-ignore +/.gitignore export-ignore +/.typos.toml export-ignore +/AGENTS.md export-ignore +/behat.yml export-ignore +/features export-ignore +/phpcs.xml.dist export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +/wp-cli.yml export-ignore diff --git a/inc/Runner.php b/inc/Runner.php index 8597156..3469e94 100644 --- a/inc/Runner.php +++ b/inc/Runner.php @@ -27,14 +27,8 @@ public static function load_remote_commands() { if ( ! $api_index ) { WP_CLI::error( "Couldn't find index data from {$api_url}." ); } - // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url - $bits = parse_url( $http ); - $auth = array(); - if ( ! empty( $bits['user'] ) ) { - $auth['type'] = 'basic'; - $auth['username'] = $bits['user']; - $auth['password'] = ! empty( $bits['pass'] ) ? $bits['pass'] : ''; - } + $auth = self::resolve_auth( $http, WP_CLI::get_runner()->config ); + foreach ( $api_index['routes'] as $route => $route_data ) { if ( empty( $route_data['schema']['title'] ) ) { WP_CLI::debug( "No schema title found for {$route}, skipping REST command registration.", 'rest' ); @@ -129,6 +123,61 @@ private static function get_api_index( $api_url ) { return json_decode( $response->body, true ); } + /** + * Resolve HTTP Basic Auth credentials from the available sources. + * + * Priority (highest wins): + * 1. Credentials embedded in the URL (user:pass@host). + * 2. WP_REST_CLI_AUTH_USER / WP_REST_CLI_AUTH_PASSWORD environment variables. + * 3. http_user / http_password keys in the WP-CLI config. + * + * @param string $http The URL passed to --http. + * @param array $config WP-CLI config array (e.g. WP_CLI::get_runner()->config). + * @return array Auth array with 'type', 'username', 'password' keys, or empty array. + */ + public static function resolve_auth( $http, array $config = array() ) { + $username = null; + $password = ''; + + // Lowest priority: wp-cli config (http_user / http_password). + if ( ! empty( $config['http_user'] ) ) { + $username = $config['http_user']; + $password = ! empty( $config['http_password'] ) ? $config['http_password'] : ''; + } + + // Medium priority: environment variables. + // An empty username is not valid for authentication, so we skip if it is empty. + // An empty password is allowed (e.g. passwordless setups), consistent with URL embedding. + $env_user = getenv( 'WP_REST_CLI_AUTH_USER' ); + if ( false !== $env_user && '' !== $env_user ) { + $username = $env_user; + $env_password = getenv( 'WP_REST_CLI_AUTH_PASSWORD' ); + $password = ( false !== $env_password ) ? $env_password : ''; + } + + // Highest priority: credentials embedded in the URL. + // Ensure the URL has a scheme so parse_url() can extract user:pass correctly. + if ( false === stripos( $http, 'http://' ) && false === stripos( $http, 'https://' ) ) { + $http = 'http://' . $http; + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url + $bits = parse_url( $http ); + if ( ! empty( $bits['user'] ) ) { + $username = $bits['user']; + $password = ! empty( $bits['pass'] ) ? $bits['pass'] : ''; + } + + if ( null === $username ) { + return array(); + } + + return array( + 'type' => 'basic', + 'username' => $username, + 'password' => $password, + ); + } + /** * Register WP-CLI commands for all endpoints on a route * diff --git a/tests/Runner_Resolve_Auth_Test.php b/tests/Runner_Resolve_Auth_Test.php new file mode 100644 index 0000000..90cc9ee --- /dev/null +++ b/tests/Runner_Resolve_Auth_Test.php @@ -0,0 +1,162 @@ +saved_env[ $var ] = false === $val ? false : $val; + putenv( $var ); + } + } + + public function tear_down() { + foreach ( $this->saved_env as $var => $val ) { + if ( false === $val ) { + putenv( $var ); + } else { + putenv( "{$var}={$val}" ); + } + } + } + + public function test_no_auth_when_nothing_set() { + $auth = \WP_REST_CLI\Runner::resolve_auth( 'example.com' ); + $this->assertSame( array(), $auth ); + } + + public function test_auth_from_config() { + $auth = \WP_REST_CLI\Runner::resolve_auth( + 'example.com', + array( + 'http_user' => 'admin', + 'http_password' => 'secret', + ) + ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => 'admin', + 'password' => 'secret', + ), + $auth + ); + } + + public function test_config_allows_empty_password() { + $auth = \WP_REST_CLI\Runner::resolve_auth( + 'example.com', + array( 'http_user' => 'admin' ) + ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => 'admin', + 'password' => '', + ), + $auth + ); + } + + public function test_env_vars_override_config() { + putenv( 'WP_REST_CLI_AUTH_USER=envuser' ); + putenv( 'WP_REST_CLI_AUTH_PASSWORD=envpass' ); + $auth = \WP_REST_CLI\Runner::resolve_auth( + 'example.com', + array( + 'http_user' => 'cfguser', + 'http_password' => 'cfgpass', + ) + ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => 'envuser', + 'password' => 'envpass', + ), + $auth + ); + } + + public function test_env_user_without_env_password_uses_empty_password() { + putenv( 'WP_REST_CLI_AUTH_USER=envuser' ); + $auth = \WP_REST_CLI\Runner::resolve_auth( 'example.com' ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => 'envuser', + 'password' => '', + ), + $auth + ); + } + + public function test_empty_env_user_skips_env_auth() { + putenv( 'WP_REST_CLI_AUTH_USER=' ); + putenv( 'WP_REST_CLI_AUTH_PASSWORD=envpass' ); + $auth = \WP_REST_CLI\Runner::resolve_auth( 'example.com' ); + $this->assertSame( array(), $auth ); + } + + public function test_url_credentials_override_env_vars() { + putenv( 'WP_REST_CLI_AUTH_USER=envuser' ); + putenv( 'WP_REST_CLI_AUTH_PASSWORD=envpass' ); + $auth = \WP_REST_CLI\Runner::resolve_auth( 'http://urluser:urlpass@example.com' ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => 'urluser', + 'password' => 'urlpass', + ), + $auth + ); + } + + /** + * @dataProvider provide_url_credentials + */ + #[DataProvider( 'provide_url_credentials' )] // phpcs:ignore PHPCompatibility.Attributes.NewAttributes.PHPUnitAttributeFound + public function test_url_credentials( $url, $expected_user, $expected_pass ) { + $auth = \WP_REST_CLI\Runner::resolve_auth( $url ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => $expected_user, + 'password' => $expected_pass, + ), + $auth + ); + } + + public static function provide_url_credentials() { + return array( + 'no scheme' => array( 'urluser:urlpass@example.com', 'urluser', 'urlpass' ), + 'https scheme' => array( 'https://urluser:urlpass@example.com', 'urluser', 'urlpass' ), + 'user only' => array( 'urluser@example.com', 'urluser', '' ), + 'user only, https' => array( 'https://urluser@example.com', 'urluser', '' ), + ); + } + + public function test_url_credentials_override_config() { + $auth = \WP_REST_CLI\Runner::resolve_auth( + 'http://urluser:urlpass@example.com', + array( + 'http_user' => 'cfguser', + 'http_password' => 'cfgpass', + ) + ); + $this->assertSame( + array( + 'type' => 'basic', + 'username' => 'urluser', + 'password' => 'urlpass', + ), + $auth + ); + } +}