Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 1.4.0 - 2026-08-05

- Ping now resolves the plugin and site details it reports instead of relying on the `init` hook having fired. Activating a plugin includes its file and fires `activate_{file}` after `init` has already passed, so the callback registered in the constructor never ran and the activation ping reported `admin_email` and `admin_name` empty — which the server then wrote over the stored values. `product_version` escaped only because `clear_updates_transient()` happens to backfill it first
- Add `Integration::prepare_product_data()`; `Updater::init()` now delegates to it, and values already set are left alone so the normal ping path does no extra work

## 1.3.0 - 2026-08-05

- Send the stored license code with each ping, so the server can bind the install to its license. Previously ping carried the site environment but no license, while `updates`/`check_license` carried the license but no site identity — the two halves never met, and install rows were recorded unbound. Requires `shazzad-plugin-repo` 1.7.1 or later, which stops treating a license-less ping as an instruction to clear the binding
Expand Down
4 changes: 4 additions & 0 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ public function __construct( Integration $integration ) {
public function ping() {
global $wpdb;

// Fills anything `init` did not get to set — an activation ping
// runs in a request where that hook has already passed.
$this->integration->prepare_product_data();

$request_url = "{$this->integration->api_url}/products/{$this->integration->product_id}/ping";

$body = [
Expand Down
45 changes: 45 additions & 0 deletions src/Integration.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,51 @@ public function setMetaCallback( callable $callback ) {
return $this;
}

/**
* Resolves the plugin and site details reported to the API.
*
* Normally filled on `init`, but a ping can fire in a request where
* that hook has already passed: activating a plugin includes its file
* and fires `activate_{file}` after `init`, so the callback registered
* in this constructor never runs and the activation ping would report
* empty details. Calling this from ping() keeps the payload complete
* whatever the hook timing.
*
* @since 1.4
*
* @param bool $force Overwrite values that are already set. Default false.
* @return void
*/
public function prepare_product_data( $force = false ) {
if ( $force || empty( $this->product_version ) || empty( $this->product_name ) ) {
if ( ! function_exists( 'get_plugin_data' ) ) {
include_once ABSPATH . 'wp-admin/includes/plugin.php';
}

$plugin = get_plugin_data( WP_PLUGIN_DIR . '/' . $this->product_file );

if ( $force || empty( $this->product_version ) ) {
$this->product_version = $plugin['Version'];
}

if ( $force || empty( $this->product_name ) ) {
$this->product_name = $plugin['Name'];
}
}

if ( $force || empty( $this->admin_email ) ) {
$this->admin_email = get_option( 'admin_email' );
}

if ( $force || empty( $this->admin_name ) ) {
$admins = get_users( [ 'role' => 'administrator', 'number' => 1, 'orderby' => 'ID', 'order' => 'ASC' ] );

if ( ! empty( $admins ) ) {
$this->admin_name = $admins[0]->display_name;
}
}
}

/**
* Retrieves the option key for storing the license code.
*
Expand Down
12 changes: 1 addition & 11 deletions src/Updater.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,7 @@ public function __construct( Integration $integration ) {
* @return void
*/
public function init() {
include_once ABSPATH . 'wp-admin/includes/plugin.php';
$plugin = get_plugin_data( WP_PLUGIN_DIR . '/' . $this->integration->product_file );

$this->integration->product_version = $plugin['Version'];
$this->integration->product_name = $plugin['Name'];
$this->integration->admin_email = get_option( 'admin_email' );

$admins = get_users( [ 'role' => 'administrator', 'number' => 1, 'orderby' => 'ID', 'order' => 'ASC' ] );
if ( ! empty( $admins ) ) {
$this->integration->admin_name = $admins[0]->display_name;
}
$this->integration->prepare_product_data( true );

// Schedule a cron job to refresh license data hourly.
$hook_name = "wprepo_sync_license_data_{$this->integration->license_name}";
Expand Down
74 changes: 74 additions & 0 deletions tests/ClientApiRequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,80 @@ public function error_400_with_no_code_or_message_uses_both_fallbacks() {
$this->assertSame( 'API request failed', $result->get_error_message() );
}

/** @test */
public function ping_resolves_details_when_init_has_not_run() {
$integration = $this->create_integration();
$this->stub_api_dependencies();
Functions\when( 'sanitize_text_field' )->returnArg();
Functions\when( 'wp_unslash' )->returnArg();

// The state an activation request leaves behind: the plugin file is
// included after `init` has already fired, so the hook never runs.
$integration->product_version = '';
$integration->product_name = '';
$integration->admin_email = '';
$integration->admin_name = '';

Functions\when( 'get_plugin_data' )->justReturn( [
'Version' => '2.5.0.5-feed',
'Name' => 'HomeRunner',
] );
Functions\when( 'get_option' )->justReturn( 'eli@hudsoncreativestudio.com' );
Functions\when( 'get_users' )->justReturn( [ (object) [ 'display_name' => 'Hudson Dev' ] ] );

$captured_args = null;
$fixture = $this->load_fixture_raw( 'ping-success.json' );

Functions\expect( 'wp_remote_post' )
->once()
->with( \Mockery::any(), \Mockery::on( function ( $args ) use ( &$captured_args ) {
$captured_args = $args;
return true;
} ) )
->andReturn( [ 'body' => $fixture ] );

Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 200 );
Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture );

$integration->client->ping();

$this->assertSame( '2.5.0.5-feed', $captured_args['body']['product_version'] );
$this->assertSame( 'eli@hudsoncreativestudio.com', $captured_args['body']['admin_email'] );
$this->assertSame( 'Hudson Dev', $captured_args['body']['admin_name'] );
}

/** @test */
public function ping_does_not_re_resolve_details_already_set() {
$integration = $this->create_integration();
$this->stub_api_dependencies();
Functions\when( 'sanitize_text_field' )->returnArg();
Functions\when( 'wp_unslash' )->returnArg();

// Everything is already populated, so the normal ping path must not
// re-read the plugin header or query users on every request.
Functions\expect( 'get_plugin_data' )->never();
Functions\expect( 'get_users' )->never();

$captured_args = null;
$fixture = $this->load_fixture_raw( 'ping-success.json' );

Functions\expect( 'wp_remote_post' )
->once()
->with( \Mockery::any(), \Mockery::on( function ( $args ) use ( &$captured_args ) {
$captured_args = $args;
return true;
} ) )
->andReturn( [ 'body' => $fixture ] );

Functions\expect( 'wp_remote_retrieve_response_code' )->once()->andReturn( 200 );
Functions\expect( 'wp_remote_retrieve_body' )->once()->andReturn( $fixture );

$integration->client->ping();

$this->assertSame( 'admin@example.com', $captured_args['body']['admin_email'] );
$this->assertSame( 'Site Admin', $captured_args['body']['admin_name'] );
}

/** @test */
public function license_included_in_updates_when_enabled() {
$integration = $this->create_integration( [ 'license_enabled' => true ] );
Expand Down
6 changes: 5 additions & 1 deletion tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,12 @@ protected function create_integration( array $overrides = [] ): Integration {
$args['display_menu']
);

// Set a default version for tests.
// Stand in for what prepare_product_data() resolves on `init`, so tests
// exercise the path they name rather than re-resolving it every time.
$integration->product_version = '1.0.0';
$integration->product_name = 'My Plugin';
$integration->admin_email = 'admin@example.com';
$integration->admin_name = 'Site Admin';

return $integration;
}
Expand Down
Loading