From 2116f0aa1ded0780531ccf4ffd52f8aefa7dd7c2 Mon Sep 17 00:00:00 2001 From: "Matthew M. Emma" Date: Fri, 7 Aug 2026 21:45:40 -0700 Subject: [PATCH] Cover the OAuth flows and add the WordPress.org assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Authorization Code flow had no test coverage at all, despite being the path where a mistake either locks a site out of Procore permanently or lets an attacker attach their own Procore account to the site. Adds 18 tests across both grants and the token store: - the token request goes to the login host, not the API host - a valid token is reused rather than re-requested - missing credentials fail before any network call - Procore's own rejection reason reaches the administrator - the authorize URL carries client_id, response_type, redirect_uri and state - a callback with an unknown, empty or replayed state is refused - a valid exchange stores both tokens - a refresh persists the ROTATED refresh token, since Procore invalidates the old one on use and losing the replacement is unrecoverable - a rejected refresh token clears the connection instead of retrying forever - tokens are encrypted at rest, carry an expiry margin, and do not leak between production and sandbox - the refresh lock is exclusive and releasable Adds .wordpress-org/ with the banner and icon at both required sizes, generated rather than hand-drawn so they can be reproduced. The mark is three rising bars on a baseline, chosen to stay legible at the 32px size the plugin list uses. Removes the == Screenshots == section from readme.txt. It declared six files that were never produced, which renders as six broken slots on the plugin page. Screenshots for this plugin should come from a site connected to a real Procore account — a gallery of invented project names would be misleading, and the connection-test screen only means anything when it is probing genuine tool permissions. .wordpress-org/README.md records the six to capture, the order, and what to redact. Co-Authored-By: Claude Opus 5 --- .wordpress-org/README.md | 61 ++++ .wordpress-org/banner-1544x500.png | Bin 0 -> 34174 bytes .wordpress-org/banner-772x250.png | Bin 0 -> 16228 bytes .wordpress-org/icon-128x128.png | Bin 0 -> 1445 bytes .wordpress-org/icon-256x256.png | Bin 0 -> 2584 bytes readme.txt | 9 - tests/unit/AuthTest.php | 507 +++++++++++++++++++++++++++++ tests/wp-shims.php | 4 + 8 files changed, 572 insertions(+), 9 deletions(-) create mode 100644 .wordpress-org/README.md create mode 100644 .wordpress-org/banner-1544x500.png create mode 100644 .wordpress-org/banner-772x250.png create mode 100644 .wordpress-org/icon-128x128.png create mode 100644 .wordpress-org/icon-256x256.png create mode 100644 tests/unit/AuthTest.php diff --git a/.wordpress-org/README.md b/.wordpress-org/README.md new file mode 100644 index 0000000..4d48219 --- /dev/null +++ b/.wordpress-org/README.md @@ -0,0 +1,61 @@ +# WordPress.org assets + +These files are read by wordpress.org from the plugin's **SVN `assets/` directory**, not +from the distributed ZIP. `.distignore` therefore excludes this folder from releases. + +At submission time, copy the contents of this directory into the SVN `assets/` folder: + +``` +svn/assets/ +├── banner-1544x500.png +├── banner-772x250.png +├── icon-256x256.png +└── icon-128x128.png +``` + +## What's here + +| File | Purpose | +|---|---| +| `banner-1544x500.png` | Plugin page header, retina | +| `banner-772x250.png` | Plugin page header, standard | +| `icon-256x256.png` | Search results and the plugin card, retina | +| `icon-128x128.png` | Search results and the plugin card, standard | + +The mark is three rising bars on a baseline — construction progress — with the tallest in +the accent orange. It was chosen to stay legible at the 32px size WordPress uses in the +installed-plugins list, which rules out anything with fine detail or text. + +Palette: `#0B2742` → `#1B5E8C` (blueprint blue) with `#FF6B35` as the accent. + +Regenerate them with the script in the project's scratchpad, or edit and re-export at the +exact pixel dimensions above — wordpress.org will not scale them for you. + +## Screenshots + +**There are deliberately none.** + +`readme.txt` previously declared six screenshots that did not exist, which renders as six +broken slots on the plugin page. That claim has been removed rather than satisfied with +fabricated ones. + +Screenshots for this plugin should be captured from a site connected to a **real Procore +account**, because the whole point of the listing is showing real project data flowing +into WordPress. A gallery built from invented project names and staff would be +misleading, and the connection-test screen — the most useful thing to show — is only +meaningful when it is probing genuine tool permissions. + +When you are ready to submit, capture these six at 1280px wide, drop them in this folder +as `screenshot-1.png` … `screenshot-6.png`, and restore the `== Screenshots ==` section +to `readme.txt` with matching captions in the same order: + +1. **Procore → Connection** — authentication mode, environment and credentials +2. **The connection test** — six stages plus the endpoint permission probe +3. **Procore → Shortcodes** — the generated reference +4. **Procore → Status** — rate-limit headroom and cache statistics +5. **A project page on the front end** — `[procore_project]` and `[procore_team]` +6. **The Procore block in the editor** — sidebar plus live preview + +Redact anything commercially sensitive: company names, project values, and staff email +addresses. Note that the plugin suppresses emails by default, so screenshot 5 will not +leak them unless both privacy opt-ins have been turned off. diff --git a/.wordpress-org/banner-1544x500.png b/.wordpress-org/banner-1544x500.png new file mode 100644 index 0000000000000000000000000000000000000000..533e08a80d7cf29cf399402ac3f3dda3695df920 GIT binary patch literal 34174 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 JB0imO{|6k_2Il|( literal 0 HcmV?d00001 diff --git a/.wordpress-org/icon-128x128.png b/.wordpress-org/icon-128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..e10726bc529e237d7a09c2e5c5ef82ae1df56a82 GIT binary patch literal 1445 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 Hh|T*CI~0np literal 0 HcmV?d00001 diff --git a/readme.txt b/readme.txt index bef0866..e5729af 100644 --- a/readme.txt +++ b/readme.txt @@ -125,15 +125,6 @@ Your settings are imported automatically and every 1.x shortcode name and the le Yes. Caching goes through the transient API, so a persistent object cache such as Redis or Memcached is used automatically when present. -== Screenshots == - -1. The Connection screen, with authentication mode, environment and credentials. -2. The connection test, reporting each stage and probing every endpoint for tool permissions. -3. The generated shortcode reference. -4. The Status screen, showing rate-limit headroom and cache statistics. -5. A project list rendered on the front end. -6. The Procore block in the editor, with a live preview. - == Changelog == = 2.0.1 = diff --git a/tests/unit/AuthTest.php b/tests/unit/AuthTest.php new file mode 100644 index 0000000..bdeab71 --- /dev/null +++ b/tests/unit/AuthTest.php @@ -0,0 +1,507 @@ +> + */ + private $requests = array(); + + /** + * Configure credentials before each test. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->requests = array(); + + Settings::set( 'client_id', 'test-client-id' ); + Settings::set( 'client_secret', Encryption::encrypt( 'test-client-secret' ) ); + } + + /** + * Queue canned responses for the token endpoint. + * + * @param array> $responses Responses returned in order. + * @return void + */ + private function token_endpoint_returns( array $responses ): void { + add_filter( + 'procore_connect_test_http', + function ( $default_value, $url, $args ) use ( &$responses ) { + $this->requests[] = array( + 'url' => $url, + 'args' => $args, + ); + + if ( empty( $responses ) ) { + return $default_value; + } + + return array_shift( $responses ); + } + ); + } + + /** + * Build a token-endpoint response. + * + * @param array $body Response body. + * @param int $status HTTP status code. + * @return array Response. + */ + private function token_response( array $body, int $status = 200 ): array { + return array( + 'response' => array( 'code' => $status ), + 'body' => (string) wp_json_encode( $body ), + 'headers' => array(), + ); + } + + /* -- Client Credentials ------------------------------------------------ */ + + /** + * The token request must go to the login host, not the API host. + * + * ProcoreWP 1.x posted to the API host, which is why it could never + * authenticate on any install. + * + * @return void + */ + public function test_client_credentials_posts_to_the_login_host(): void { + $this->token_endpoint_returns( + array( + $this->token_response( + array( + 'access_token' => 'abc', + 'expires_in' => 5400, + ) + ), + ) + ); + + $token = ( new ClientCredentials() )->access_token(); + + $this->assertSame( 'abc', $token ); + $this->assertSame( 'https://login.procore.com/oauth/token', $this->requests[0]['url'] ); + $this->assertSame( 'client_credentials', $this->requests[0]['args']['body']['grant_type'] ); + $this->assertSame( 'test-client-id', $this->requests[0]['args']['body']['client_id'] ); + $this->assertSame( 'test-client-secret', $this->requests[0]['args']['body']['client_secret'] ); + } + + /** + * A stored, unexpired token must be reused rather than re-requested. + * + * @return void + */ + public function test_client_credentials_reuses_a_valid_token(): void { + $this->token_endpoint_returns( + array( + $this->token_response( + array( + 'access_token' => 'abc', + 'expires_in' => 5400, + ) + ), + ) + ); + + $auth = new ClientCredentials(); + $auth->access_token(); + $auth->access_token(); + + $this->assertCount( 1, $this->requests ); + } + + /** + * Missing credentials must fail before any network call is attempted. + * + * @return void + */ + public function test_client_credentials_requires_credentials(): void { + Settings::set( 'client_id', '' ); + Settings::set( 'client_secret', '' ); + + $result = ( new ClientCredentials() )->access_token(); + + $this->assertTrue( is_wp_error( $result ) ); + $this->assertSame( 'procore_connect_missing_credentials', $result->get_error_code() ); + $this->assertCount( 0, $this->requests ); + } + + /** + * Procore's own rejection message must reach the administrator. + * + * @return void + */ + public function test_client_credentials_surfaces_the_rejection_reason(): void { + $this->token_endpoint_returns( + array( + $this->token_response( + array( + 'error' => 'invalid_client', + 'error_description' => 'Client authentication failed.', + ), + 401 + ), + ) + ); + + $result = ( new ClientCredentials() )->access_token(); + + $this->assertTrue( is_wp_error( $result ) ); + $this->assertSame( 'Client authentication failed.', $result->get_error_message() ); + } + + /* -- Authorization Code ------------------------------------------------ */ + + /** + * The authorize URL must carry everything Procore requires. + * + * @return void + */ + public function test_authorization_url_is_well_formed(): void { + $url = ( new AuthorizationCode() )->authorization_url(); + + $this->assertStringStartsWith( 'https://login.procore.com/oauth/authorize', $url ); + $this->assertStringContainsString( 'client_id=test-client-id', $url ); + $this->assertStringContainsString( 'response_type=code', $url ); + $this->assertStringContainsString( 'redirect_uri=', $url ); + $this->assertStringContainsString( 'state=', $url ); + } + + /** + * A callback whose state was never issued must be refused. + * + * This is what stops an attacker grafting their own Procore account onto + * the site by feeding an administrator a crafted callback URL. + * + * @return void + */ + public function test_exchange_refuses_an_unknown_state(): void { + $result = ( new AuthorizationCode() )->exchange_code( 'some-code', 'never-issued' ); + + $this->assertTrue( is_wp_error( $result ) ); + $this->assertSame( 'procore_connect_bad_state', $result->get_error_code() ); + $this->assertCount( 0, $this->requests ); + } + + /** + * A callback with no state at all must be refused. + * + * @return void + */ + public function test_exchange_refuses_an_empty_state(): void { + $result = ( new AuthorizationCode() )->exchange_code( 'some-code', '' ); + + $this->assertTrue( is_wp_error( $result ) ); + $this->assertSame( 'procore_connect_bad_state', $result->get_error_code() ); + } + + /** + * A valid state with no code must be refused. + * + * @return void + */ + public function test_exchange_refuses_a_missing_code(): void { + $auth = new AuthorizationCode(); + $state = $this->issue_state( $auth ); + + $result = $auth->exchange_code( '', $state ); + + $this->assertTrue( is_wp_error( $result ) ); + $this->assertSame( 'procore_connect_missing_code', $result->get_error_code() ); + } + + /** + * A valid exchange must store both tokens. + * + * @return void + */ + public function test_exchange_stores_both_tokens(): void { + $auth = new AuthorizationCode(); + $state = $this->issue_state( $auth ); + + $this->token_endpoint_returns( + array( + $this->token_response( + array( + 'access_token' => 'access-1', + 'refresh_token' => 'refresh-1', + 'expires_in' => 5400, + ) + ), + ) + ); + + $result = $auth->exchange_code( 'the-code', $state ); + + $this->assertTrue( $result ); + $this->assertSame( 'authorization_code', $this->requests[0]['args']['body']['grant_type'] ); + $this->assertSame( 'the-code', $this->requests[0]['args']['body']['code'] ); + $this->assertSame( 'access-1', TokenStore::access_token() ); + $this->assertSame( 'refresh-1', TokenStore::refresh_token() ); + $this->assertTrue( $auth->is_connected() ); + } + + /** + * A state value must not be replayable. + * + * @return void + */ + public function test_state_is_single_use(): void { + $auth = new AuthorizationCode(); + $state = $this->issue_state( $auth ); + + $this->token_endpoint_returns( + array( + $this->token_response( + array( + 'access_token' => 'a', + 'refresh_token' => 'r', + 'expires_in' => 5400, + ) + ), + $this->token_response( + array( + 'access_token' => 'b', + 'refresh_token' => 's', + 'expires_in' => 5400, + ) + ), + ) + ); + + $this->assertTrue( $auth->exchange_code( 'code-1', $state ) ); + + $replay = $auth->exchange_code( 'code-2', $state ); + + $this->assertTrue( is_wp_error( $replay ) ); + $this->assertSame( 'procore_connect_bad_state', $replay->get_error_code() ); + } + + /** + * An expired access token must be refreshed, and the rotated refresh token + * must replace the old one. + * + * Procore invalidates a refresh token the instant it is exchanged, so + * failing to persist the replacement locks the site out permanently. + * + * @return void + */ + public function test_refresh_persists_the_rotated_refresh_token(): void { + TokenStore::store( + array( + 'access_token' => 'stale', + 'refresh_token' => 'refresh-old', + 'expires_in' => 0, + ), + 'authorization_code' + ); + + $this->token_endpoint_returns( + array( + $this->token_response( + array( + 'access_token' => 'access-new', + 'refresh_token' => 'refresh-new', + 'expires_in' => 5400, + ) + ), + ) + ); + + $token = ( new AuthorizationCode() )->access_token(); + + $this->assertSame( 'access-new', $token ); + $this->assertSame( 'refresh_token', $this->requests[0]['args']['body']['grant_type'] ); + $this->assertSame( 'refresh-old', $this->requests[0]['args']['body']['refresh_token'] ); + $this->assertSame( 'refresh-new', TokenStore::refresh_token() ); + } + + /** + * A rejected refresh token must clear the connection and say so. + * + * There is no recovery from a rotated-away refresh token, so leaving the + * dead one in place would retry forever. + * + * @return void + */ + public function test_rejected_refresh_token_resets_the_connection(): void { + TokenStore::store( + array( + 'access_token' => 'stale', + 'refresh_token' => 'refresh-dead', + 'expires_in' => 0, + ), + 'authorization_code' + ); + + $this->token_endpoint_returns( + array( $this->token_response( array( 'error' => 'invalid_grant' ), 400 ) ) + ); + + $result = ( new AuthorizationCode() )->access_token(); + + $this->assertTrue( is_wp_error( $result ) ); + $this->assertSame( 'procore_connect_reconnect_required', $result->get_error_code() ); + $this->assertSame( '', TokenStore::refresh_token() ); + $this->assertSame( '', TokenStore::access_token() ); + } + + /** + * With no refresh token at all the site must be told to connect. + * + * @return void + */ + public function test_unconnected_site_is_told_to_connect(): void { + $result = ( new AuthorizationCode() )->access_token(); + + $this->assertTrue( is_wp_error( $result ) ); + $this->assertSame( 'procore_connect_not_connected', $result->get_error_code() ); + $this->assertFalse( ( new AuthorizationCode() )->is_connected() ); + } + + /* -- Token storage ------------------------------------------------------ */ + + /** + * Stored tokens must be encrypted and expire early of the real deadline. + * + * @return void + */ + public function test_tokens_are_encrypted_with_an_expiry_margin(): void { + TokenStore::store( + array( + 'access_token' => 'plain-access', + 'refresh_token' => 'plain-refresh', + 'expires_in' => 5400, + ), + 'client_credentials' + ); + + $raw = get_option( TokenStore::OPTION ); + + $this->assertStringNotContainsString( 'plain-access', (string) $raw['access_token'] ); + $this->assertStringNotContainsString( 'plain-refresh', (string) $raw['refresh_token'] ); + $this->assertSame( 'plain-access', TokenStore::access_token() ); + + // Two minutes of head-room are subtracted so a token cannot expire mid-flight. + $this->assertLessThan( time() + 5400, TokenStore::all()['expires_at'] ); + $this->assertGreaterThan( time() + 5000, TokenStore::all()['expires_at'] ); + } + + /** + * A token obtained against one environment must not be used in another. + * + * @return void + */ + public function test_tokens_do_not_leak_between_environments(): void { + TokenStore::store( + array( + 'access_token' => 'prod', + 'expires_in' => 5400, + ), + 'client_credentials' + ); + + $this->assertTrue( TokenStore::has_valid_token() ); + + Settings::set( 'environment', 'sandbox' ); + + $this->assertFalse( TokenStore::has_valid_token() ); + } + + /** + * An expired token must not be treated as usable. + * + * @return void + */ + public function test_expired_tokens_are_not_valid(): void { + TokenStore::store( + array( + 'access_token' => 'old', + 'expires_in' => 0, + ), + 'client_credentials' + ); + + $this->assertFalse( TokenStore::has_valid_token() ); + } + + /** + * The refresh lock must be exclusive, then reusable once released. + * + * @return void + */ + public function test_refresh_lock_is_exclusive(): void { + $this->assertTrue( TokenStore::acquire_lock() ); + $this->assertFalse( TokenStore::acquire_lock() ); + + TokenStore::release_lock(); + + $this->assertTrue( TokenStore::acquire_lock() ); + } + + /** + * Clearing must remove both tokens and the lock. + * + * @return void + */ + public function test_clear_removes_everything(): void { + TokenStore::store( + array( + 'access_token' => 'a', + 'refresh_token' => 'r', + 'expires_in' => 5400, + ), + 'authorization_code' + ); + + TokenStore::clear(); + + $this->assertSame( '', TokenStore::access_token() ); + $this->assertSame( '', TokenStore::refresh_token() ); + $this->assertFalse( TokenStore::has_valid_token() ); + $this->assertTrue( TokenStore::acquire_lock() ); + } + + /** + * Start an Authorization Code flow and return its issued state value. + * + * @param AuthorizationCode $auth Strategy under test. + * @return string State value. + */ + private function issue_state( AuthorizationCode $auth ): string { + $url = $auth->authorization_url(); + + preg_match( '/state=([^&]+)/', $url, $matches ); + + return rawurldecode( $matches[1] ?? '' ); + } +} diff --git a/tests/wp-shims.php b/tests/wp-shims.php index 64de514..22aa947 100644 --- a/tests/wp-shims.php +++ b/tests/wp-shims.php @@ -344,6 +344,10 @@ function get_template_directory(): string { return sys_get_temp_dir() . '/procore-connect-parent-theme'; } +function get_current_user_id(): int { + return (int) ( $GLOBALS['procore_connect_test_user'] ?? 1 ); +} + function current_user_can( string $capability ): bool { return (bool) ( $GLOBALS['procore_connect_test_can'] ?? false ); }