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( '' ) );
+ }
+}