Skip to content

Fix activation rollback and other licensing bugs - #26

Merged
mzakash merged 11 commits into
surecart:mainfrom
themihaicircea:fix/license-activation-rollback
Oct 14, 2026
Merged

mzakash merged 11 commits into
surecart:mainfrom
themihaicircea:fix/license-activation-rollback

Conversation

@themihaicircea

Copy link
Copy Markdown
Contributor

As requested in support ticket 1554756, here are the fixes from the patch I sent there, one commit per fix, on top of v1.2.1.

What changes

1. Delete the new activation when activate() fails
validate_release() runs after the activation is created, and fails, for example, with a key for another product. The catch block then called get() and delete() without an id, so both requests went to v1/public/activations/ and the activation stayed on the license, counting against its limit. It's now deleted by its id. If that request fails, only the id is kept after the options are cleared, so Deactivate License can remove it, and the error says so. A 404 counts as already removed.

2. Deactivate the stored activation, not a posted id
license_form_submit() passed $_POST['activation_id'] to deactivate(). It now calls deactivate() without an id, which already falls back to the stored one.

3. Return on a deactivation error
With deactivated_redirect set, the page redirected and the error never showed. Without it, the error showed next to "This site was successfully deactivated." It now returns after adding the error, as activation already does.

4. Keep the license key and ids out of the license form
The form printed the key as the field's value, the activation id in a hidden field, and with ?debug the license id and activation id. The field is now empty, and an empty field reuses the stored key, so "Click Activate License to reconnect it." still works. The hidden and debug fields are gone: deactivation no longer reads a posted id, and nothing reads a posted license_id. If you rely on ?debug to read the ids, I can bring them back as plain text.

5. Clear cached update data when the license changes
The 3-hour version info cache and the update_plugins (or update_themes) transient both hold the package link, and neither was cleared on activation or deactivation: a deactivated site kept the link, and a newly activated one waited for the next update check. The new Updater::clear_cache() deletes both and runs from activate() and deactivate(). Deleting the whole transient is what core's wp_clean_plugins_cache() and wp_clean_themes_cache() do too, and WordPress rebuilds it on the next admin page load.

6. Keep query args in the settings redirect
redirect() printed esc_url() inside a <script>, which turns & into &#038;, so every arg after the first ended up in the fragment: admin.php?page=my-plugin&tab=license landed on admin.php?page=my-plugin&#038;tab=license. It now prints a JSON string from esc_url_raw().

Testing

  • phpcs with the repo's phpcs.xml: no findings in the three changed files, on every commit.
  • With WP-CLI on a local WordPress 7.1.3 site (PHP 8.3) and a pre_http_request stub answering the licensing API, I ran 20 cases on this branch and on v1.2.1. On v1.2.1 they reproduce each bug above; on this branch they all pass, with no PHP notices. They cover:
    • a release error, with the undo answering 200, 404 and 500, and a failed activation create
    • activating and deactivating a plugin and a theme, checking both caches, then running wp_update_plugins()
    • a deactivation with a different posted id, and a deactivation error with and without deactivated_redirect
    • the form's HTML with ?debug, while activated and after the activation was removed
    • reconnecting with an empty key field and with a new key
    • the redirect URL, parsed with Node's URL

I haven't added unit tests, since the suite runs in wp-env. Happy to add some to tests/unit if you'd like.

validate_release() runs after the activation is created. When it threw,
the catch block called activation()->get() and activation()->delete()
without an id, so both requests went to v1/public/activations/ and the
new activation stayed on the license, counting against its activation
limit.

Delete the activation this attempt created, by its id; a 404 means it's
already gone. If the delete fails, keep only its id after clearing the
options, so Deactivate License can remove it later, and say so in the
error.
license_form_submit() passed $_POST['activation_id'] to
License::deactivate(), so the form decided which activation got deleted.
Call deactivate() without an id: it already falls back to the activation
id stored in the settings.
The error was added, but the function went on. With deactivated_redirect
set, the page redirected and the error never showed; without it, the
page showed "This site was successfully deactivated." next to the error.
Return after adding it, like the activation branch does.
In activation mode the form printed the stored license key as the
password field's value, it always printed the activation id in a hidden
field, and ?debug added two fields with the license and activation ids.
Only users with manage_options see the page, but the key still ended up
in the HTML.

Leave the key field empty and reuse the stored key when it's submitted
empty, so "Click Activate License to reconnect it." still works after an
activation is removed. Drop the hidden field and the debug fields:
deactivation no longer reads a posted id, and nothing reads a posted
license_id.
The updater caches the version info for three hours, and WordPress keeps
it in the update_plugins (or update_themes) site transient. Both hold
the package link, and neither was cleared on activation or deactivation,
so a deactivated site kept the link and a newly activated one waited for
the next update check.

Add Updater::clear_cache() and call it from activate() and deactivate().
redirect() printed esc_url( $url ) inside the <script>. esc_url() turns
& into an HTML entity, and a script doesn't decode entities, so every
query arg after the first one ended up in the URL fragment: a redirect
to admin.php?page=my-plugin&tab=license kept only page in the query and
put tab in the fragment. Print the URL as a JSON string from
esc_url_raw() instead.
@mzakash

mzakash commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Thanks a lot @themihaicircea for the quick PR, will look into it.

activate() cleared every option before it knew whether the attempt could
be undone, so a timeout or a release mismatch wiped a key that was
already saved. Snapshot the options first and restore them when the
attempt fails. Only when the new activation can't be deleted, keep just
its id so Deactivate License can remove it.

validate() also sent an empty key to the API, which hit the licenses
collection. Fail early with "Please enter a license key." instead.

Add Settings::set_options() and tests for the rollback, the empty key,
and clearing the update caches.
When the last activation couldn't be undone, only its id is stored, and
the form said the license was activated. Say that the attempt failed and
to click Deactivate License instead. Show "Leave blank to use the saved
key" in the empty key field when a key is stored, and wp_unslash() the
posted key before sanitizing it.

Add form tests: the saved key is never printed, deactivation ignores a
posted activation id, and the redirect keeps its query args.
@mzakash
mzakash merged commit c974322 into surecart:main Oct 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants