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 0000000..533e08a Binary files /dev/null and b/.wordpress-org/banner-1544x500.png differ diff --git a/.wordpress-org/banner-772x250.png b/.wordpress-org/banner-772x250.png new file mode 100644 index 0000000..69144f1 Binary files /dev/null and b/.wordpress-org/banner-772x250.png differ diff --git a/.wordpress-org/icon-128x128.png b/.wordpress-org/icon-128x128.png new file mode 100644 index 0000000..e10726b Binary files /dev/null and b/.wordpress-org/icon-128x128.png differ diff --git a/.wordpress-org/icon-256x256.png b/.wordpress-org/icon-256x256.png new file mode 100644 index 0000000..00fdc43 Binary files /dev/null and b/.wordpress-org/icon-256x256.png differ 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 ); }