fix: prevent fatal errors when LinkedIn authorization fails - #1100
fix: prevent fatal errors when LinkedIn authorization fails#1100lucadobrescu wants to merge 8 commits into
Conversation
LinkedIn authorization failures could crash to the WordPress critical-error screen instead of surfacing the LinkedIn error: - authorize() called the non-existent Exception::getDescription() in its catch block, turning any caught error into a second fatal - add_account_with_app() ran array_pop() on the result of unserialize() without validating the payload, fataling on PHP 8 when the popup posts back an error payload instead of account data - add_account_li() ignored the add_account_with_app() return value and registered the service regardless - sign-in-btn.vue parsed every popup message as account data Validation failures now log the LinkedIn error to the Revive Social log and answer the REST call with a code 400 response. Adds e2e coverage for the malformed-payload paths (red on the old code) plus a happy-path guard, and drops the stale PHPStan baseline entry for the fixed getDescription() call. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds regression tests for the remaining validation branches: empty payload, pages without a notify entry, notify-only pages with no accounts (asserting no service gets registered), the LinkedIn error landing in the plugin log, and the happy path now verifies the account is actually registered and exposed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The add_account_with_app() validation guarantees the accounts loop always runs, so PHPStan no longer reports 'Variable $account might not be defined' and the baseline ignore became unmatched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Improves LinkedIn authorization failure handling to avoid fatal errors and partial account registration.
Changes:
- Validates LinkedIn authorization payloads and propagates failures through the REST API.
- Handles malformed popup messages without reloading the dashboard.
- Adds E2E coverage and updates the PHPStan baseline.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
vue/src/vue-elements/sign-in-btn.vue |
Rejects malformed authorization messages. |
tests/e2e/specs/dashboard/linkedin-error-handling.spec.js |
Tests LinkedIn failure and success paths. |
phpstan-baseline.neon |
Removes resolved LinkedIn suppressions. |
includes/admin/services/class-rop-linkedin-service.php |
Improves exception and payload handling. |
includes/admin/class-rop-rest-api.php |
Prevents registration after failed account parsing. |
Comments suppressed due to low confidence (1)
includes/admin/services/class-rop-linkedin-service.php:859
- The notify metadata is considered valid solely because the key exists. A payload such as
['notify_user_at' => []]is accepted and persisted, but the later timestamp comparison cannot behave as intended, so token-refresh notifications are silently disabled. Validate thatnotify_user_atis an integer timestamp before registering the account.
if ( empty( $accounts_array ) || ! is_array( $notify_user_at ) || ! isset( $notify_user_at['notify_user_at'] ) ) {
$this->logger->alert_error( 'Linkedin Error: received malformed account data, the account was not added.' );
return false;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if ( empty( $accounts_array ) || ! is_array( $notify_user_at ) || ! isset( $notify_user_at['notify_user_at'] ) ) { | ||
| $this->logger->alert_error( 'Linkedin Error: received malformed account data, the account was not added.' ); | ||
| return false; | ||
| } |
There was a problem hiding this comment.
Fixed in 50f9443 — every remaining entry in the pages array must now be an array carrying all the fields the loop reads (with a non-empty string id), and the array is re-indexed with array_values() before the loop so the index-based access can't hit missing offsets. Also validated the notify timestamp is numeric while there. Covered by a new e2e test with the exact string-entry payload from this comment.
| if ( empty( $the_id ) || ! is_array( $accounts_array ) ) { | ||
| $this->logger->alert_error( 'Linkedin Error: received malformed account data, the account was not added.' ); | ||
| return false; | ||
| } |
There was a problem hiding this comment.
Fixed in 50f9443 — $the_id must now be a non-empty string, so serialized arrays/objects are rejected before reaching str_replace()/the service id. Covered by a new e2e test sending an id that decodes to an array.
| test( 'rejected payload leaves the LinkedIn error in the plugin log', async ( { page } ) => { | ||
| await callRopApi( page, 'add_account_li', { | ||
| id: VALID_ID, | ||
| pages: btoa( 'linkedin-error-string-not-account-data' ), | ||
| } ); |
There was a problem hiding this comment.
Fixed in 50f9443 — the test now clears the log via the get_log endpoint's force flag right before making the request, so the assertion can only be satisfied by this request's log entry.
| $referrer = isset( $_SERVER['HTTP_REFERER'] ) ? $_SERVER['HTTP_REFERER'] : ''; | ||
| // If the user is trying to authenticate. | ||
| if ( ! empty( substr_count( $referrer, 'linkedin.com' ) ) ) { | ||
| exit( wp_redirect( $this->get_legacy_url() ) ); |
There was a problem hiding this comment.
Not changing this one here. The referrer-based browser-vs-cron detection predates this PR — this change only removes the undefined-index notice when the header is absent, keeping the existing behavior in both branches. Reworking the detection to key off the callback request/state would change behavior in the legacy own-app OAuth flow, which we can't exercise end-to-end (it needs a real LinkedIn OAuth round-trip), so it's out of scope for this fix. Worth a separate issue if the missing-referrer path proves to be a problem in practice.
Addresses the Copilot review on #1100: - require the decoded account id to be a non-empty string so it cannot reach str_replace()/array-key usage as an array or object - validate that the notify entry timestamp is numeric before persisting the refresh-token notice - validate every remaining pages entry is a complete account array before reading its fields, and reindex with array_values() so the consuming loop cannot hit missing offsets - e2e: clear the plugin log before asserting the rejection entry so the test cannot pass on entries left by earlier tests; add regression tests for the string-account-entry and non-string-id payloads Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@lucadobrescu let's update this branch based on the new changes on |
…din-auth-error-handling
The e2e utils that came in with #1094 enable pretty permalinks in the test site, so `rest_url()` — and therefore `ropApiSettings.root` — no longer carries a query string. Appending `&req=...` to it produced `/wp-json/tweet-old-post/v8/api/&req=add_account_li`, which the REST server resolved to nothing: all 10 LinkedIn tests failed with `rest_no_route` / 404 after merging development. Let URL/searchParams place `?` or `&`, the way the plugin's own `fetchAJAX` passes `req` through vue-resource's `params` option. Works under either permalink mode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@Soare-Robert-Daniel updated — One follow-up was needed. The e2e utils that came with #1094 enable pretty permalinks in the test site, so and the REST server resolved that to nothing, so all 10 LinkedIn tests failed with Verified locally against a fresh wp-env: 14/14 Playwright specs pass (the 9 LinkedIn ones plus accounts, general-settings, post-format ×2, publish-now) and PHPUnit is clean apart from Heads up: the |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
tests/e2e/specs/dashboard/linkedin-error-handling.spec.js:59
- These tests assume the authenticated-service store is initially empty, but setup never resets it; the only reset is at the end of the happy-path test and is skipped if that test fails after registering LinkedIn. A retry, a focused local run, or a following spec can therefore observe the leftover account and fail assertions such as
not.toContain('linkedin'). Reset accounts inbeforeEachso every scenario starts from a known state.
test.beforeEach( async ( { page, admin } ) => {
await admin.visitAdminPage( '/admin.php?page=TweetOldPost' );
await page.waitForSelector( '.tab-view[type="accounts"]' );
} );
| if ( ! $this->is_set_not_empty( $accounts_data, array( 'id', 'pages' ) ) ) { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
Confirmed and fixed in edc94b6 — this was a real hole, not a theoretical one. Reproduced it first: { id: [VALID_ID], pages: [VALID_PAGES] } came back as
HTTP 500 {"code":"internal_server_error","message":"<p>There has been a critical error on this website.</p>..."}
which is exactly the response this PR exists to remove. The cause is as you describe — is_set_not_empty() routes array values through is_valid_serialize_data(), which explicitly handles arrays (if ( is_array( $data ) ), abstract :901) and reports them valid, so both fields reached base64_decode().
Both encoded fields must now be strings before anything decodes them, and there is a new e2e test sending your exact payload; it fails with the critical-error response when the guard is reverted and passes with it.
Worth noting for a separate issue: the same shape exists in the sibling services that pass array keys to is_set_not_empty() and then decode — class-rop-facebook-service.php:1055 (array( 'id', 'pages' )), plus the array( 'id' ) callers in the twitter, tumblr, mastodon, gmb and vk services. I have kept this PR to the LinkedIn path from #1098 rather than widening it.
Copilot review feedback on #1100. `is_set_not_empty()` deliberately accepts array values — `is_valid_serialize_data()` maps over them — so a payload of `{ id: [<valid>], pages: [<valid>] }` cleared the guard and reached `base64_decode()`, which raises a PHP 8 TypeError. That is the same critical-error response this PR set out to remove; verified locally, the request returned HTTP 500 "There has been a critical error on this website" without the new check. Guard both encoded fields as strings before decoding, and cover the payload with an e2e test that fails without it. Also reset the services store in `beforeEach`. The happy-path test registers a LinkedIn account and only cleans up on success, so with CI retries enabled a failed run left that account behind and assertions like `not.toContain('linkedin')` could pass or fail depending on order. Uses the `ropUtils` fixture that came in with #1094 rather than a second bespoke helper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Addressed the fresh Copilot review in 1. Array-valued 2. Tests did not reset the services store. Only the happy-path test cleaned up, and only when it passed, so with The earlier Local verification against a fresh wp-env: 15/15 Playwright specs pass (12 LinkedIn including the new one, plus accounts, general-settings, post-format ×2), PHPUnit 46/47, and One unrelated flake to flag, since it is in |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
vue/src/vue-elements/sign-in-btn.vue:902
- A malformed authorization message returns without unregistering the global
messagehandler, unlike the adjacent parsed-error path. Because the popup response is terminal, this leaves the stale component listening for later messages from the auth origin and can route them using the oldmodal.serviceName. Remove the listener in the parse-error branch as well.
} catch (e) {
this.is_loading = false;
Vue.$log.error('Received a malformed message from the authorization window', e);
return;
includes/admin/services/class-rop-linkedin-service.php:139
- The new regression spec exercises only
add_account_li, so this separate token-exchange catch path remains untested;tests/test-accounts.phpcovers otherRop_Linkedin_Servicebehavior but never makesauthorize()catch a plainException. Add a public-seam PHPUnit case with a fake LinkedIn client whose token exchange throws, then assert the real exception message is logged without a fatal error.
$description = method_exists( $e, 'getDescription' ) ? $e->getDescription() : $e->getMessage();
$message = 'Linkedin Error: Code[ ' . $e->getCode() . ' ] ' . $description;
Copilot review feedback on #1100. Both failure branches this PR added to getChildWindowMessage() are terminal — the popup has already answered — but only the parsed-error one detached the global `message` handler. After a malformed payload the component stayed subscribed to the auth origin and would route any later message using the stale `modal.serviceName`. Detach in the parse branch too, matching the adjacent branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Latest Copilot pass generated no new comments on the two fixes above. It did surface two suppressed findings — took one, declining the other. Taken: leaked Declined: PHPUnit coverage for the token-exchange
So a test driving The line's actual behaviour is already pinned down: |
When LinkedIn authorization fails during account connection, the plugin crashed to the WordPress critical-error screen instead of showing the LinkedIn error (issue #1098). The error-handling paths themselves fataled; this PR makes them report the failure and keep the dashboard alive.
What changed
Rop_Linkedin_Service::authorize()— the catch block calledException::getDescription(), which does not exist on plain exceptions, so any caught error (e.g. a failed token exchange) became a second fatal. It now falls back togetMessage()and no longer assumesHTTP_REFERERis set. The stale PHPStan baseline entry for this exact error is removed.Rop_Linkedin_Service::add_account_with_app()— an error payload without validpagesdata reachedarray_pop( false ), a fatalTypeErroron PHP 8. The payload is now validated (pagespresent, unserializes to a non-empty array, notify entry well-formed) and rejected with a logged error.Rop_Rest_Api::add_account_li()— previously ignored theadd_account_with_app()return value and registered the service even after a failed add. It now answers with a code400response pointing to the Revive Social log.sign-in-btn.vue— the popup message handler parsed every message as account data. Malformed JSON and error payloads are now dropped with a logged error instead of being sent to the server and reloading the page.E2E tests — new
linkedin-error-handling.spec.jscovers seven scenarios: missing, garbled, and empty payloads, pages without a notify entry, notify-only pages (asserting no service gets registered), the LinkedIn error landing in the plugin log, and a happy-path add that verifies the account is registered. The two malformed-payload tests fail with a fatal on the previous code.LinkedIn account connection flow
flowchart LR A[LinkedIn popup<br/>posts message] --> B{Changed:<br/>valid account<br/>payload?}:::changed B -- No --> C[Log error,<br/>stop loading] B -- Yes --> D[REST<br/>add_account_li] D --> E{New:<br/>payload passes<br/>validation?}:::added E -- No --> F[Code 400 +<br/>Revive Social log] E -- Yes --> G[Account added] H[authorize<br/>token exchange fails] --> I[Changed:<br/>log real LinkedIn error]:::changed classDef added fill:#1a7f37,color:#fff,stroke:#116329,stroke-width:3px classDef changed fill:#9a6700,color:#fff,stroke:#5c3d00,stroke-width:3px,stroke-dasharray:6 3Note
With
ROP_DEBUGon (any non-production environment) a missing-key payload is answered by the pre-existingRop_Exception_Handlerdebug output instead of the JSON400response. The e2e test accepts both shapes; production behavior is the400response.QA
Log in as an administrator and open the plugin dashboard at
/wp-admin/admin.php?page=TweetOldPost(Revive Social entry in the admin menu). In the browser DevTools console, simulate the error payload LinkedIn hands back on a failed authorization:Expect: a JSON response with
"code":"400"(no critical-error page, no HTTP 500 fatal).Reload the dashboard page.
Expect: the Accounts screen renders normally and LinkedIn still offers its sign-in button; no LinkedIn account was half-registered.
With a real LinkedIn app configured, start Sign in to LinkedIn from the Accounts tab and cancel/deny the authorization on the LinkedIn side.
Expect: the dashboard stays functional and the LinkedIn error is recorded in the plugin log (Revive Social dashboard → Logs) instead of a WordPress critical-error screen.
🤖 Generated with Claude Code