diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..e01d8db --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,83 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + phpunit: + name: PHP ${{ matrix.php }} / WP ${{ matrix.wp }} + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: wordpress_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2', '8.3'] + wp: ['6.5', '6.6', '6.7', '6.8', '6.9'] + + steps: + - uses: actions/checkout@v4 + + - name: Install SVN + run: sudo apt-get update -q && sudo apt-get install -y subversion + + - name: Set up PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer + coverage: none + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('composer.json') }} + restore-keys: ${{ runner.os }}-php-${{ matrix.php }}- + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Install WordPress test suite + run: bash bin/install-wp-tests.sh wordpress_test root root 127.0.0.1:3306 ${{ matrix.wp }} true + + - name: Run PHPUnit + run: vendor/bin/phpunit + + - name: Run PHPUnit (multisite) + run: WP_MULTISITE=1 vendor/bin/phpunit + + phpcs: + name: PHPCS + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer + coverage: none + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run PHPCS + run: vendor/bin/phpcs diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 634e6b2..a018a4e 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -6,6 +6,7 @@ . /vendor/ /node_modules/ + /tests/ @@ -19,12 +20,13 @@ - + + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1f65be6..0000000 --- a/.travis.yml +++ /dev/null @@ -1,58 +0,0 @@ -sudo: false -dist: trusty - -language: php - -notifications: - email: - on_success: never - on_failure: change - -branches: - only: - - master - -cache: - directories: - - $HOME/.composer/cache - -matrix: - include: - - php: 7.3 - env: WP_VERSION=latest - - php: 7.2 - env: WP_VERSION=latest - - php: 7.1 - env: WP_VERSION=latest - - php: 7.0 - env: WP_VERSION=latest - - php: 7.0 - env: WP_VERSION=trunk - - php: 7.0 - env: WP_TRAVISCI=phpcs - dist: precise - -before_script: - - export PATH="$HOME/.composer/vendor/bin:$PATH" - - composer install --ignore-platform-reqs --optimize-autoloader --no-interaction --prefer-dist - - | - if [ -f ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini ]; then - phpenv config-rm xdebug.ini - else - echo "xdebug.ini does not exist" - fi - - | - if [[ ! -z "$WP_VERSION" ]] ; then - bash bin/install-wp-tests.sh wordpress_test root '' localhost $WP_VERSION - fi - -script: - - | - if [[ ! -z "$WP_VERSION" ]] ; then - vendor/bin/phpunit - WP_MULTISITE=1 vendor/bin/phpunit - fi - - | - if [[ "$WP_TRAVISCI" == "phpcs" ]] ; then - vendor/bin/phpcs - fi diff --git a/composer.json b/composer.json index dbfddbe..afec5a4 100644 --- a/composer.json +++ b/composer.json @@ -11,14 +11,21 @@ ], "require": { "composer/installers": "~1.0", - "php": "^5.6.0||^7.0||^8.0" + "php": ">=7.4" }, "require-dev": { "squizlabs/php_codesniffer": "^3.3.1", "wp-coding-standards/wpcs": "^2.1.1", - "dealerdirect/phpcodesniffer-composer-installer": "^0.5.0", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", "phpcompatibility/phpcompatibility-wp": "^2.0", - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0" + "phpunit/phpunit": "^9.6", + "yoast/phpunit-polyfills": "^1.1 || ^2.0" + }, + "config": { + "allow-plugins": { + "composer/installers": true, + "dealerdirect/phpcodesniffer-composer-installer": true + } }, "scripts": { "post-install-cmd": "\"vendor/bin/phpcs\" --config-set installed_paths vendor/wp-coding-standards/wpcs", diff --git a/inc/endpoints/class-token.php b/inc/endpoints/class-token.php index 2bdfc09..46368f4 100644 --- a/inc/endpoints/class-token.php +++ b/inc/endpoints/class-token.php @@ -20,20 +20,21 @@ public function register_routes() { 'oauth2', '/access_token', [ - 'methods' => 'POST', - 'callback' => [ $this, 'exchange_token' ], - 'args' => [ - 'grant_type' => [ + 'methods' => 'POST', + 'callback' => [ $this, 'exchange_token' ], + 'permission_callback' => '__return_true', + 'args' => [ + 'grant_type' => [ 'required' => true, 'type' => 'string', 'validate_callback' => [ $this, 'validate_grant_type' ], ], - 'client_id' => [ + 'client_id' => [ 'required' => false, 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', ], - 'code' => [ + 'code' => [ 'required' => false, 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', @@ -67,7 +68,7 @@ public function validate_grant_type( $type ) { * @return array|WP_Error Token data on success, or error on failure. */ public function exchange_token( WP_REST_Request $request ) { - if ( $request['grant_type'] === 'client_credentials' ) { + if ( 'client_credentials' === $request['grant_type'] ) { return $this->handle_client_credentials( $request ); } @@ -206,7 +207,7 @@ private function extract_client_credentials( WP_REST_Request $request ) { $encoded = substr( $auth_header, 6 ); $decoded = base64_decode( $encoded, true ); - if ( $decoded === false ) { + if ( false === $decoded ) { return new WP_Error( 'oauth2.endpoints.token.invalid_request', __( 'Invalid Authorization header.', 'oauth2' ), diff --git a/inc/endpoints/namespace.php b/inc/endpoints/namespace.php index e5e5049..08d9445 100644 --- a/inc/endpoints/namespace.php +++ b/inc/endpoints/namespace.php @@ -23,8 +23,9 @@ function register() { 'oauth2', '/authorize', [ - 'methods' => 'GET', - 'callback' => __NAMESPACE__ . '\\redirect_to_authorize', + 'methods' => 'GET', + 'callback' => __NAMESPACE__ . '\\redirect_to_authorize', + 'permission_callback' => '__return_true', ] ); } diff --git a/inc/tokens/class-access-token.php b/inc/tokens/class-access-token.php index f8c3022..188bc53 100644 --- a/inc/tokens/class-access-token.php +++ b/inc/tokens/class-access-token.php @@ -303,7 +303,7 @@ public static function create_for_client( ClientInterface $client, $meta = [] ) * @return bool True if this is a client token, false otherwise. */ public function is_client_token() { - return $this->user === null; + return null === $this->user; } /** diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 16a3902..ff60523 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,14 +1,13 @@ +> - + ./tests/ ./tests/test-sample.php diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 027784f..7ec0678 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -16,6 +16,9 @@ exit( 1 ); } +// PHPUnit Polyfills required by the WP test bootstrap since WP 5.9. +define( 'WP_TESTS_PHPUNIT_POLYFILLS_PATH', dirname( __DIR__ ) . '/vendor/yoast/phpunit-polyfills' ); + // Give access to tests_add_filter() function. require_once $_tests_dir . '/includes/functions.php'; diff --git a/tests/class-test-case.php b/tests/class-test-case.php new file mode 100644 index 0000000..9035d85 --- /dev/null +++ b/tests/class-test-case.php @@ -0,0 +1,46 @@ + $name, + 'description' => 'Test client description.', + 'meta' => array_merge( + [ + 'callback' => 'https://example.com/callback', + 'type' => 'web', + 'client_credentials_enabled' => false, + ], + $meta_overrides + ), + ]; + + $client = Client::create( $data ); + $this->assertInstanceOf( Client::class, $client, 'create_client helper: Client::create() must return a Client instance.' ); + $client->approve(); + + return $client; + } +} diff --git a/tests/test-access-token.php b/tests/test-access-token.php new file mode 100644 index 0000000..0b479ca --- /dev/null +++ b/tests/test-access-token.php @@ -0,0 +1,177 @@ +client = $this->create_client(); + $this->user = $this->factory->user->create_and_get(); + } + + public function test_create_returns_token_instance() { + $token = Access_Token::create( $this->client, $this->user ); + $this->assertInstanceOf( Access_Token::class, $token ); + } + + public function test_create_stores_user_meta() { + $token = Access_Token::create( $this->client, $this->user ); + $meta_key = Access_Token::META_PREFIX . $token->get_key(); + $value = get_user_meta( $this->user->ID, $meta_key, false ); + $this->assertNotEmpty( $value ); + } + + public function test_create_returns_error_for_invalid_user() { + $invalid_user = new WP_User(); + $result = Access_Token::create( $this->client, $invalid_user ); + $this->assertWPError( $result ); + $this->assertEquals( 'oauth2.tokens.access_token.create.no_user', $result->get_error_code() ); + } + + public function test_create_for_client_returns_token() { + $client = $this->create_client( [ 'client_credentials_enabled' => true ] ); + $token = Access_Token::create_for_client( $client ); + $this->assertInstanceOf( Access_Token::class, $token ); + } + + public function test_create_for_client_stores_post_meta() { + $client = $this->create_client( [ 'client_credentials_enabled' => true ] ); + $token = Access_Token::create_for_client( $client ); + $meta_key = Access_Token::CLIENT_META_PREFIX . $token->get_key(); + $value = get_post_meta( $client->get_post_id(), $meta_key, true ); + $this->assertNotEmpty( $value ); + } + + public function test_create_for_client_errors_for_personal_client() { + $personal = PersonalClient::get_instance(); + $result = Access_Token::create_for_client( $personal ); + $this->assertWPError( $result ); + $this->assertEquals( 'oauth2.tokens.access_token.create_for_client.invalid_client', $result->get_error_code() ); + } + + public function test_is_client_token_false_for_user_token() { + $token = Access_Token::create( $this->client, $this->user ); + $this->assertFalse( $token->is_client_token() ); + } + + public function test_is_client_token_true_for_client_token() { + $client = $this->create_client( [ 'client_credentials_enabled' => true ] ); + $token = Access_Token::create_for_client( $client ); + $this->assertTrue( $token->is_client_token() ); + } + + public function test_get_by_id_finds_user_token() { + $token = Access_Token::create( $this->client, $this->user ); + $found = Access_Token::get_by_id( $token->get_key() ); + $this->assertInstanceOf( Access_Token::class, $found ); + $this->assertEquals( $token->get_key(), $found->get_key() ); + } + + public function test_get_by_id_finds_client_token() { + $client = $this->create_client( [ 'client_credentials_enabled' => true ] ); + $token = Access_Token::create_for_client( $client ); + $found = Access_Token::get_by_id( $token->get_key() ); + $this->assertInstanceOf( Access_Token::class, $found ); + $this->assertEquals( $token->get_key(), $found->get_key() ); + } + + public function test_get_by_id_returns_null_for_missing() { + $result = Access_Token::get_by_id( 'nonexistenttoken' ); + $this->assertNull( $result ); + } + + public function test_get_for_user_returns_all_user_tokens() { + Access_Token::create( $this->client, $this->user ); + Access_Token::create( $this->client, $this->user ); + + $tokens = Access_Token::get_for_user( $this->user ); + $this->assertCount( 2, $tokens ); + } + + public function test_revoke_deletes_user_meta() { + $token = Access_Token::create( $this->client, $this->user ); + $key = $token->get_key(); + $token->revoke(); + + $found = Access_Token::get_by_id( $key ); + $this->assertNull( $found ); + } + + public function test_revoke_deletes_client_token_post_meta() { + $client = $this->create_client( [ 'client_credentials_enabled' => true ] ); + $token = Access_Token::create_for_client( $client ); + $key = $token->get_key(); + $token->revoke(); + + $found = Access_Token::get_by_id( $key ); + $this->assertNull( $found ); + } + + public function test_get_client_returns_client_instance() { + $token = Access_Token::create( $this->client, $this->user ); + $client = $token->get_client(); + $this->assertInstanceOf( ClientInterface::class, $client ); + } + + public function test_get_creation_time_is_recent() { + $before = time(); + $token = Access_Token::create( $this->client, $this->user ); + $after = time(); + + $created = $token->get_creation_time(); + $this->assertGreaterThanOrEqual( $before, $created ); + $this->assertLessThanOrEqual( $after, $created ); + } + + public function test_is_valid_always_true() { + $token = Access_Token::create( $this->client, $this->user ); + $this->assertTrue( $token->is_valid() ); + } + + public function test_get_meta_returns_null_for_missing_key() { + $token = Access_Token::create( $this->client, $this->user ); + $this->assertNull( $token->get_meta( 'nonexistent_key' ) ); + } + + public function test_set_and_get_meta_roundtrip() { + $token = Access_Token::create( $this->client, $this->user ); + $token->set_meta( 'description', 'My token' ); + $this->assertEquals( 'My token', $token->get_meta( 'description' ) ); + } + + public function test_set_meta_persists_to_database() { + $token = Access_Token::create( $this->client, $this->user ); + $token->set_meta( 'description', 'Persisted' ); + + $reloaded = Access_Token::get_by_id( $token->get_key() ); + $this->assertInstanceOf( Access_Token::class, $reloaded ); + $this->assertEquals( 'Persisted', $reloaded->get_meta( 'description' ) ); + } +} diff --git a/tests/test-authentication.php b/tests/test-authentication.php new file mode 100644 index 0000000..8d185e3 --- /dev/null +++ b/tests/test-authentication.php @@ -0,0 +1,149 @@ +client = $this->create_client(); + $this->user = $this->factory->user->create_and_get(); + unset( $_SERVER['HTTP_AUTHORIZATION'] ); + } + + public function tear_down() { + unset( $_SERVER['HTTP_AUTHORIZATION'] ); + global $oauth2_error; + $oauth2_error = null; + parent::tear_down(); + } + + // ------------------------------------------------------------------------- + // get_token_from_bearer_header + // ------------------------------------------------------------------------- + + public function test_get_token_from_bearer_header_valid() { + $token = get_token_from_bearer_header( 'Bearer abc123' ); + $this->assertEquals( 'abc123', $token ); + } + + public function test_get_token_from_bearer_header_case_insensitive_bearer() { + // The regex matches 'Bearer' literally, so 'bearer' won't match — verify + // documented behaviour rather than assuming case-insensitivity. + $token = get_token_from_bearer_header( 'Bearer testtoken' ); + $this->assertEquals( 'testtoken', $token ); + } + + public function test_get_token_from_bearer_header_returns_null_for_empty() { + $token = get_token_from_bearer_header( '' ); + $this->assertNull( $token ); + } + + public function test_get_token_from_bearer_header_returns_null_for_basic() { + $token = get_token_from_bearer_header( 'Basic dXNlcjpwYXNz' ); + $this->assertNull( $token ); + } + + // ------------------------------------------------------------------------- + // attempt_authentication + // ------------------------------------------------------------------------- + + public function test_attempt_authentication_passes_through_existing_user() { + $result = attempt_authentication( $this->user ); + $this->assertEquals( $this->user, $result ); + } + + public function test_attempt_authentication_returns_user_id_for_valid_token() { + $token = Access_Token::create( $this->client, $this->user ); + + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $token->get_key(); + $result = attempt_authentication(); + + $this->assertEquals( $this->user->ID, $result ); + } + + public function test_attempt_authentication_returns_zero_for_client_token() { + $client = $this->create_client( [ 'client_credentials_enabled' => true ] ); + $token = Access_Token::create_for_client( $client ); + + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $token->get_key(); + $result = attempt_authentication(); + + $this->assertEquals( 0, $result ); + } + + public function test_attempt_authentication_sets_error_for_invalid_token() { + global $oauth2_error; + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer invalidtokenxyz'; + + attempt_authentication(); + + $this->assertWPError( $oauth2_error ); + $this->assertEquals( + 'oauth2.authentication.attempt_authentication.invalid_token', + $oauth2_error->get_error_code() + ); + } + + public function test_attempt_authentication_no_op_when_no_token() { + $result = attempt_authentication(); + $this->assertNull( $result ); + } + + // ------------------------------------------------------------------------- + // maybe_report_errors + // ------------------------------------------------------------------------- + + public function test_maybe_report_errors_passes_through_existing_error() { + $existing = new \WP_Error( 'existing_error', 'Pre-existing error' ); + $result = maybe_report_errors( $existing ); + $this->assertEquals( $existing, $result ); + } + + public function test_maybe_report_errors_returns_global_error() { + global $oauth2_error; + $oauth2_error = new \WP_Error( 'oauth2_test_error', 'Test OAuth2 error' ); + + $result = maybe_report_errors( null ); + + $this->assertWPError( $result ); + $this->assertEquals( 'oauth2_test_error', $result->get_error_code() ); + } + + public function test_maybe_report_errors_returns_null_when_no_error() { + global $oauth2_error; + $oauth2_error = null; + + $result = maybe_report_errors( null ); + + $this->assertNull( $result ); + } +} diff --git a/tests/test-authorization-code.php b/tests/test-authorization-code.php new file mode 100644 index 0000000..f7a2a40 --- /dev/null +++ b/tests/test-authorization-code.php @@ -0,0 +1,108 @@ +client = $this->create_client(); + $this->user = $this->factory->user->create_and_get(); + } + + public function test_create_returns_instance() { + $code = Authorization_Code::create( $this->client, $this->user ); + $this->assertInstanceOf( Authorization_Code::class, $code ); + } + + public function test_create_stores_meta_on_client_post() { + $code = Authorization_Code::create( $this->client, $this->user ); + $meta_key = Authorization_Code::KEY_PREFIX . $code->get_code(); + $value = get_post_meta( $this->client->get_post_id(), $meta_key, false ); + $this->assertNotEmpty( $value ); + } + + public function test_get_by_code_returns_instance_for_valid_code() { + $code = Authorization_Code::create( $this->client, $this->user ); + $found = Authorization_Code::get_by_code( $this->client, $code->get_code() ); + $this->assertInstanceOf( Authorization_Code::class, $found ); + } + + public function test_get_by_code_returns_error_for_invalid_code() { + $result = Authorization_Code::get_by_code( $this->client, 'invalid-code-xyz' ); + $this->assertWPError( $result ); + } + + public function test_get_user_returns_correct_user() { + $code = Authorization_Code::create( $this->client, $this->user ); + $user = $code->get_user(); + $this->assertInstanceOf( WP_User::class, $user ); + $this->assertEquals( $this->user->ID, $user->ID ); + } + + public function test_get_expiration_is_in_future() { + $code = Authorization_Code::create( $this->client, $this->user ); + $expiration = $code->get_expiration(); + $this->assertGreaterThan( time(), $expiration ); + } + + public function test_validate_returns_true_for_fresh_code() { + $code = Authorization_Code::create( $this->client, $this->user ); + $result = $code->validate(); + $this->assertTrue( $result ); + } + + public function test_validate_returns_error_for_expired_code() { + $code = Authorization_Code::create( $this->client, $this->user ); + $meta_key = Authorization_Code::KEY_PREFIX . $code->get_code(); + + // Backdate the expiration to force expiry. + $value = get_post_meta( $this->client->get_post_id(), $meta_key, true ); + $value['expiration'] = time() - 1; + update_post_meta( $this->client->get_post_id(), $meta_key, $value ); + + $result = $code->validate(); + $this->assertWPError( $result ); + $this->assertEquals( 'oauth2.tokens.authorization_code.validate.expired', $result->get_error_code() ); + } + + public function test_delete_removes_the_meta() { + $code = Authorization_Code::create( $this->client, $this->user ); + $code->delete(); + + $result = Authorization_Code::get_by_code( $this->client, $code->get_code() ); + $this->assertWPError( $result ); + } + + public function test_code_is_valid_only_for_its_client() { + $other_client = $this->create_client( [], 'Other Client' ); + $code = Authorization_Code::create( $this->client, $this->user ); + + $result = Authorization_Code::get_by_code( $other_client, $code->get_code() ); + $this->assertWPError( $result ); + } +} diff --git a/tests/test-client.php b/tests/test-client.php new file mode 100644 index 0000000..f63fb51 --- /dev/null +++ b/tests/test-client.php @@ -0,0 +1,220 @@ +client = $this->create_client(); + } + + public function test_create_returns_client_instance() { + $client = Client::create( [ + 'name' => 'New Client', + 'description' => 'A description.', + 'meta' => [ + 'callback' => 'https://example.com/callback', + 'type' => 'web', + 'client_credentials_enabled' => false, + ], + ] ); + $this->assertInstanceOf( Client::class, $client ); + } + + public function test_create_stores_correct_post_type() { + $post = get_post( $this->client->get_post_id() ); + $this->assertEquals( Client::POST_TYPE, $post->post_type ); + } + + public function test_create_stores_redirect_uri() { + $uris = $this->client->get_redirect_uris(); + $this->assertContains( 'https://example.com/callback', $uris ); + } + + public function test_create_stores_type() { + $this->assertEquals( 'web', $this->client->get_type() ); + } + + public function test_create_stores_secret() { + $secret = $this->client->get_secret(); + $this->assertNotEmpty( $secret ); + $this->assertIsString( $secret ); + } + + public function test_create_client_credentials_flag_disabled() { + $this->assertFalse( $this->client->is_client_credentials_enabled() ); + } + + public function test_create_client_credentials_flag_enabled() { + $client = $this->create_client( [ 'client_credentials_enabled' => true ] ); + $this->assertTrue( $client->is_client_credentials_enabled() ); + } + + public function test_get_by_id_finds_approved_client() { + $found = Client::get_by_id( $this->client->get_id() ); + $this->assertInstanceOf( Client::class, $found ); + $this->assertEquals( $this->client->get_id(), $found->get_id() ); + } + + public function test_get_by_id_returns_null_for_unknown() { + $result = Client::get_by_id( 'nonexistent-client-id' ); + $this->assertNull( $result ); + } + + public function test_get_by_id_returns_null_for_unapproved() { + $data = [ + 'name' => 'Unapproved', + 'description' => 'Draft client.', + 'meta' => [ + 'callback' => 'https://example.com/callback', + 'type' => 'web', + 'client_credentials_enabled' => false, + ], + ]; + $draft_client = Client::create( $data ); + $this->assertInstanceOf( Client::class, $draft_client ); + + $result = Client::get_by_id( $draft_client->get_id() ); + $this->assertNull( $result ); + } + + public function test_get_by_post_id_finds_client() { + $found = Client::get_by_post_id( $this->client->get_post_id() ); + $this->assertInstanceOf( Client::class, $found ); + $this->assertEquals( $this->client->get_post_id(), $found->get_post_id() ); + } + + public function test_get_by_post_id_returns_null_for_missing() { + $result = Client::get_by_post_id( 99999999 ); + $this->assertNull( $result ); + } + + public function test_approve_publishes_post() { + $data = [ + 'name' => 'To Approve', + 'description' => 'Draft.', + 'meta' => [ + 'callback' => 'https://example.com/callback', + 'type' => 'web', + 'client_credentials_enabled' => false, + ], + ]; + $client = Client::create( $data ); + $this->assertInstanceOf( Client::class, $client ); + + $post = get_post( $client->get_post_id() ); + $this->assertEquals( 'draft', $post->post_status ); + + $client->approve(); + + $post = get_post( $client->get_post_id() ); + $this->assertEquals( 'publish', $post->post_status ); + } + + public function test_delete_removes_post() { + $post_id = $this->client->get_post_id(); + $this->client->delete(); + $this->assertNull( get_post( $post_id ) ); + } + + public function test_regenerate_secret_changes_secret() { + $original = $this->client->get_secret(); + $this->client->regenerate_secret(); + $new = $this->client->get_secret(); + $this->assertNotEquals( $original, $new ); + $this->assertNotEmpty( $new ); + } + + public function test_check_secret_true_for_correct_secret() { + $secret = $this->client->get_secret(); + $this->assertTrue( $this->client->check_secret( $secret ) ); + } + + public function test_check_secret_false_for_wrong_secret() { + $this->assertFalse( $this->client->check_secret( 'wrongsecret' ) ); + } + + public function test_update_changes_name() { + $updated = $this->client->update( [ + 'name' => 'Updated Name', + 'description' => 'Updated description.', + 'meta' => [ + 'callback' => 'https://example.com/callback', + 'type' => 'web', + 'client_credentials_enabled' => false, + ], + ] ); + $this->assertInstanceOf( Client::class, $updated ); + $this->assertEquals( 'Updated Name', $updated->get_name() ); + } + + public function test_issue_token_returns_access_token() { + $user = $this->factory->user->create_and_get(); + $token = $this->client->issue_token( $user ); + $this->assertInstanceOf( Access_Token::class, $token ); + } + + public function test_validate_callback_accepts_https() { + $this->assertTrue( Client::validate_callback( 'https://example.com/callback' ) ); + } + + public function test_validate_callback_accepts_http() { + $this->assertTrue( Client::validate_callback( 'http://example.com/callback' ) ); + } + + public function test_validate_callback_accepts_custom_scheme() { + $this->assertTrue( Client::validate_callback( 'myapp://callback' ) ); + } + + public function test_validate_callback_rejects_no_colon() { + $this->assertFalse( Client::validate_callback( '//example.com/callback' ) ); + } + + public function test_validate_callback_rejects_credentials_in_url() { + $this->assertFalse( Client::validate_callback( 'https://user@example.com/callback' ) ); + } + + public function test_validate_callback_rejects_ipv6_host() { + // IPv6 literal addresses have colons in the host, which are rejected. + $this->assertFalse( Client::validate_callback( 'http://[::1]/callback' ) ); + } + + public function test_check_redirect_uri_matches_exact() { + $this->assertTrue( $this->client->check_redirect_uri( 'https://example.com/callback' ) ); + } + + public function test_check_redirect_uri_ignores_query_string() { + $this->assertTrue( $this->client->check_redirect_uri( 'https://example.com/callback?foo=bar' ) ); + } + + public function test_check_redirect_uri_rejects_different_host() { + $this->assertFalse( $this->client->check_redirect_uri( 'https://other.com/callback' ) ); + } + + public function test_check_redirect_uri_rejects_different_path() { + $this->assertFalse( $this->client->check_redirect_uri( 'https://example.com/other' ) ); + } + + public function test_check_redirect_uri_rejects_unregistered() { + $this->assertFalse( $this->client->check_redirect_uri( 'https://evil.com/steal' ) ); + } +} diff --git a/tests/test-namespace.php b/tests/test-namespace.php new file mode 100644 index 0000000..340156c --- /dev/null +++ b/tests/test-namespace.php @@ -0,0 +1,121 @@ +server = $wp_rest_server = new WP_REST_Server(); + do_action( 'rest_api_init', $this->server ); + } + + public function tear_down() { + global $wp_rest_server; + $wp_rest_server = null; + parent::tear_down(); + } + + public function test_get_grant_types_returns_authorization_code() { + $types = get_grant_types(); + $this->assertArrayHasKey( 'authorization_code', $types ); + $this->assertInstanceOf( Type::class, $types['authorization_code'] ); + } + + public function test_get_grant_types_returns_implicit() { + $types = get_grant_types(); + $this->assertArrayHasKey( 'implicit', $types ); + $this->assertInstanceOf( Type::class, $types['implicit'] ); + } + + public function test_get_grant_types_filters_invalid_handlers() { + $filter = function ( $types ) { + $types['invalid_type'] = new \stdClass(); + return $types; + }; + add_filter( 'oauth2.grant_types', $filter ); + + $this->setExpectedIncorrectUsage( 'WP\OAuth2\get_grant_types' ); + $types = get_grant_types(); + + remove_filter( 'oauth2.grant_types', $filter ); + + $this->assertArrayNotHasKey( 'invalid_type', $types ); + } + + public function test_get_authorization_url_contains_action() { + $url = get_authorization_url(); + $this->assertStringContainsString( 'action=oauth2_authorize', $url ); + } + + public function test_get_token_url_contains_endpoint() { + $url = get_token_url(); + $this->assertStringContainsString( 'oauth2/access_token', $url ); + } + + public function test_get_client_returns_personal_client() { + $client = get_client( PersonalClient::ID ); + $this->assertInstanceOf( PersonalClient::class, $client ); + } + + public function test_get_client_returns_regular_client() { + $created = $this->create_client(); + $found = get_client( $created->get_id() ); + $this->assertInstanceOf( Client::class, $found ); + $this->assertEquals( $created->get_id(), $found->get_id() ); + } + + public function test_get_client_returns_null_for_unknown() { + $result = get_client( 'nonexistent-client-id' ); + $this->assertNull( $result ); + } + + public function test_register_in_index_adds_authentication_endpoints() { + $response = new WP_REST_Response( [] ); + $response = register_in_index( $response ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'authentication', $data ); + $this->assertArrayHasKey( 'oauth2', $data['authentication'] ); + $this->assertArrayHasKey( 'authorization', $data['authentication']['oauth2']['endpoints'] ); + $this->assertArrayHasKey( 'token', $data['authentication']['oauth2']['endpoints'] ); + } + + public function test_register_in_index_lists_grant_types() { + $response = new WP_REST_Response( [] ); + $response = register_in_index( $response ); + + $data = $response->get_data(); + $this->assertArrayHasKey( 'grant_types', $data['authentication']['oauth2'] ); + $this->assertContains( 'authorization_code', $data['authentication']['oauth2']['grant_types'] ); + } +} diff --git a/tests/test-token-endpoint.php b/tests/test-token-endpoint.php new file mode 100644 index 0000000..e9fe11e --- /dev/null +++ b/tests/test-token-endpoint.php @@ -0,0 +1,277 @@ +server = $wp_rest_server = new WP_REST_Server(); + do_action( 'rest_api_init', $this->server ); + $this->client = $this->create_client(); + } + + public function tear_down() { + global $wp_rest_server; + $wp_rest_server = null; + parent::tear_down(); + } + + // ------------------------------------------------------------------------- + // Authorization code grant + // ------------------------------------------------------------------------- + + public function test_exchange_token_missing_client_id() { + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'authorization_code' ); + $request->set_param( 'code', 'somecode' ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_missing_callback_param', $data['code'] ); + } + + public function test_exchange_token_missing_code() { + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'authorization_code' ); + $request->set_param( 'client_id', $this->client->get_id() ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rest_missing_callback_param', $data['code'] ); + } + + public function test_exchange_token_invalid_client() { + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'authorization_code' ); + $request->set_param( 'client_id', 'nonexistent-client' ); + $request->set_param( 'code', 'anycode' ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'oauth2.endpoints.token.exchange_token.invalid_client', $data['code'] ); + } + + public function test_exchange_token_invalid_code() { + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'authorization_code' ); + $request->set_param( 'client_id', $this->client->get_id() ); + $request->set_param( 'code', 'invalid-code-xyz' ); + + $response = $this->server->dispatch( $request ); + + $this->assertNotEquals( 200, $response->get_status() ); + } + + public function test_exchange_token_expired_code() { + $user = $this->factory->user->create_and_get(); + $code = Authorization_Code::create( $this->client, $user ); + + // Backdate the expiration. + $meta_key = Authorization_Code::KEY_PREFIX . $code->get_code(); + $value = get_post_meta( $this->client->get_post_id(), $meta_key, true ); + $value['expiration'] = time() - 1; + update_post_meta( $this->client->get_post_id(), $meta_key, $value ); + + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'authorization_code' ); + $request->set_param( 'client_id', $this->client->get_id() ); + $request->set_param( 'code', $code->get_code() ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + } + + public function test_exchange_token_valid() { + $user = $this->factory->user->create_and_get(); + $code = Authorization_Code::create( $this->client, $user ); + + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'authorization_code' ); + $request->set_param( 'client_id', $this->client->get_id() ); + $request->set_param( 'code', $code->get_code() ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertArrayHasKey( 'access_token', $data ); + $this->assertEquals( 'bearer', $data['token_type'] ); + } + + public function test_exchange_token_deletes_code_after_use() { + $user = $this->factory->user->create_and_get(); + $code = Authorization_Code::create( $this->client, $user ); + + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'authorization_code' ); + $request->set_param( 'client_id', $this->client->get_id() ); + $request->set_param( 'code', $code->get_code() ); + + $this->server->dispatch( $request ); + + $reuse = Authorization_Code::get_by_code( $this->client, $code->get_code() ); + $this->assertWPError( $reuse ); + } + + // ------------------------------------------------------------------------- + // Client credentials grant + // ------------------------------------------------------------------------- + + public function test_client_credentials_via_body_params() { + $client = $this->create_client( [ 'client_credentials_enabled' => true ] ); + $secret = $client->get_secret(); + + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'client_credentials' ); + $request->set_param( 'client_id', $client->get_id() ); + $request->set_param( 'client_secret', $secret ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertArrayHasKey( 'access_token', $data ); + $this->assertEquals( 'bearer', $data['token_type'] ); + } + + public function test_client_credentials_via_basic_auth_header() { + $client = $this->create_client( [ 'client_credentials_enabled' => true ] ); + $secret = $client->get_secret(); + $encoded = base64_encode( $client->get_id() . ':' . $secret ); + + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'client_credentials' ); + $request->add_header( 'Authorization', 'Basic ' . $encoded ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertArrayHasKey( 'access_token', $data ); + } + + public function test_client_credentials_wrong_secret() { + $client = $this->create_client( [ 'client_credentials_enabled' => true ] ); + + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'client_credentials' ); + $request->set_param( 'client_id', $client->get_id() ); + $request->set_param( 'client_secret', 'wrong-secret' ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); + } + + public function test_client_credentials_grant_disabled() { + $secret = $this->client->get_secret(); + + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'client_credentials' ); + $request->set_param( 'client_id', $this->client->get_id() ); + $request->set_param( 'client_secret', $secret ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); + } + + public function test_client_credentials_unknown_client() { + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'client_credentials' ); + $request->set_param( 'client_id', 'nonexistent-client' ); + $request->set_param( 'client_secret', 'any-secret' ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 401, $response->get_status() ); + } + + public function test_client_credentials_invalid_basic_header() { + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'client_credentials' ); + $request->add_header( 'Authorization', 'Basic not-valid-base64!!!' ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + } + + public function test_client_credentials_header_no_colon() { + // Valid base64 but no colon separator between id and secret. + $encoded = base64_encode( 'nocoolonseparator' ); + + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'client_credentials' ); + $request->add_header( 'Authorization', 'Basic ' . $encoded ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + } + + public function test_client_credentials_no_credentials_provided() { + $request = new WP_REST_Request( 'POST', '/oauth2/access_token' ); + $request->set_param( 'grant_type', 'client_credentials' ); + + $response = $this->server->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + } + + // ------------------------------------------------------------------------- + // Grant type validation + // ------------------------------------------------------------------------- + + public function test_validate_grant_type_accepts_authorization_code() { + $handler = new Token(); + $this->assertTrue( $handler->validate_grant_type( 'authorization_code' ) ); + } + + public function test_validate_grant_type_accepts_client_credentials() { + $handler = new Token(); + $this->assertTrue( $handler->validate_grant_type( 'client_credentials' ) ); + } + + public function test_validate_grant_type_rejects_unknown() { + $handler = new Token(); + $this->assertFalse( $handler->validate_grant_type( 'password' ) ); + $this->assertFalse( $handler->validate_grant_type( 'implicit' ) ); + $this->assertFalse( $handler->validate_grant_type( '' ) ); + } +}