diff --git a/.gitignore b/.gitignore index 70adfcd2..c80ac2ac 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ repo/ vendor/ .idea/ /composer.lock + +local-scripts/ diff --git a/README.md b/README.md index 2bcba4f2..254e45c1 100644 --- a/README.md +++ b/README.md @@ -92,11 +92,12 @@ src/Uplink/Helper.php The file should match the following - keeping the `KEY` constant set to a blank string, or, if you want a default license key, set it to that.: ```php - ⚠️ This will render license key fields for all of your registered plugins/services in the same Uplink/Container instance. ```php -use StellarWP\Uplink\Config; +use StellarWP\Uplink as UplinkNamespace; -$fields = Config::get_container()->get( License_Field::class ); +$form = UplinkNamespace\get_form(); +$plugins = UplinkNamespace\get_plugins(); -// Do one of the following: -$fields->render(); // Render the fields, titles, and submit button. -$fields->render( false ); // Render the fields without the titles. -$fields->render( false, false ); // Render the fields without the titles or submit buttons. +foreach ( $plugins as $plugin ) { + $field = UplinkNamespace\get_field( $plugin->get_slug() ); + // Tha name property of the input field. + $field->set_field_name( 'field-' . $slug ); + $form->add_field( $field ); +} + +$form->render(); +// or echo $form->get_render_html(); ``` To render a single product's license key, use the following: ```php -use StellarWP\Uplink\Config; +use StellarWP\Uplink as UplinkNamespace; + +$field = UplinkNamespace\get_field( 'my-test-plugin' ); + +$field->render(); +// or echo $field->get_render_html(); -$fields = Config::get_container()->get( License_Field::class ); -// Do one of the following: -$fields->render_single( 'my-plugin' ); // Render the fields, titles, and submit button. -$fields->render_single( 'my-plugin', false ); // Render the fields without the titles. -$fields->render_single( 'my-plugin', false, false ); // Render the fields without the titles or submit buttons. ``` ### Example: Register settings page and render license fields @@ -195,13 +205,23 @@ add_action( 'admin_menu', function () { Add lines below to your settings page. This will render license key form with titles and a submit button ```php -use StellarWP\Uplink\Config; +use StellarWP\Uplink as UplinkNamespace; function render_settings_page() { // ... + $form = UplinkNamespace\get_form(); + $plugins = UplinkNamespace\get_plugins(); + + foreach ( $plugins as $plugin ) { + $field = UplinkNamespace\get_field( $plugin->get_slug() ); + // Tha name property of the input field. + $field->set_field_name( 'field-' . $slug ); + $form->add_field( $field ); + } + + $form->show_button( true, __( 'Submit', 'text-domain' ) ); - $fields = Config::get_container()->get( License_Field::class ); - $fields->render(); // or $fields->render_single( 'my-plugin' ); to render a single plugin + $form->render(); //.... } @@ -256,15 +276,15 @@ You can also pass in a custom license domain, which can be fetched on the Uplink This connects to the licensing server to check in real time if the license is authorized. Use sparingly. ```php -$container = \StellarWP\Uplink\Config::get_container(); -$token_manager = $container->get( \StellarWP\Uplink\Auth\Token\Contracts\Token_Manager::class ); -$token = $token_manager->get(); +$token = \StellarWP\Uplink\get_authorization_token( 'my-plugin-slug' ); +$license_key = \StellarWP\Uplink\get_license_key( 'my-plugin-slug' ); +$domain = \StellarWP\Uplink\get_license_domain(); -if ( ! $token ) { - return; +if ( ! $token || ! $license_key || ! $domain ) { + return; // or, log/show errors. } -$is_authorized = \StellarWP\Uplink\is_authorized( 'customer_license_key', $token, 'customer_domain' ); +$is_authorized = \StellarWP\Uplink\is_authorized( $license_key, 'my-plugin-slug', $token, $domain ); echo $is_authorized ? esc_html__( 'authorized' ) : esc_html__( 'not authorized' ); ``` @@ -274,13 +294,7 @@ echo $is_authorized ? esc_html__( 'authorized' ) : esc_html__( 'not authorized' If for some reason you need to fetch your `auth_url` manually, you can do so by: ```php -$container = \StellarWP\Uplink\Config::get_container(); -$auth_url_manager = $container->get( \StellarWP\Uplink\API\V3\Auth\Contracts\Auth_Url::class ); - -// Pass your product or service slug. -$auth_url = $auth_url_manager->get( 'kadence-blocks-pro' ); - -echo $auth_url; +echo esc_url( \StellarWP\Uplink\get_auth_url( 'my-plugin-slug' ) ); ``` > 💡 Auth URL connections are cached for one day using transients. diff --git a/composer.json b/composer.json index 025a16f9..60b020e9 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "codeception/module-rest": "^1.0", "codeception/module-webdriver": "^1.0", "codeception/util-universalframework": "^1.0", + "lucatume/codeception-snapshot-assertions": "^0.4.0", "lucatume/di52": "^3.0", "lucatume/wp-browser": "^3.0.14", "phpspec/prophecy": "^1.0", diff --git a/src/Uplink/API/V3/Auth/Auth_Url_Cache_Decorator.php b/src/Uplink/API/V3/Auth/Auth_Url_Cache_Decorator.php index 74195e81..a01c63f7 100644 --- a/src/Uplink/API/V3/Auth/Auth_Url_Cache_Decorator.php +++ b/src/Uplink/API/V3/Auth/Auth_Url_Cache_Decorator.php @@ -3,6 +3,7 @@ namespace StellarWP\Uplink\API\V3\Auth; use InvalidArgumentException; +use StellarWP\Uplink\Storage\Contracts\Storage; /** * Auth URL cache decorator. @@ -16,6 +17,11 @@ final class Auth_Url_Cache_Decorator implements Contracts\Auth_Url { */ private $auth_url; + /** + * @var Storage + */ + private $storage; + /** * The cache expiration in seconds. * @@ -27,8 +33,9 @@ final class Auth_Url_Cache_Decorator implements Contracts\Auth_Url { * @param Auth_Url $auth_url Remotely fetch the Origin's Auth URL. * @param int $expiration The cache expiration in seconds. */ - public function __construct( Auth_Url $auth_url, int $expiration = DAY_IN_SECONDS ) { + public function __construct( Auth_Url $auth_url, Storage $storage, int $expiration = DAY_IN_SECONDS ) { $this->auth_url = $auth_url; + $this->storage = $storage; $this->expiration = $expiration; } @@ -48,16 +55,16 @@ public function get( string $slug ): string { $transient = $this->build_transient( $slug ); - $url = get_transient( $transient ); + $url = $this->storage->get( $transient ); - if ( $url !== false ) { + if ( $url !== null ) { return $url; } $url = $this->auth_url->get( $slug ); // We'll cache empty auth URLs to prevent further remote requests. - set_transient( $transient, $url, $this->expiration ); + $this->storage->set( $transient, $url, $this->expiration ); return $url; } diff --git a/src/Uplink/API/V3/Auth/Contracts/Token_Authorizer.php b/src/Uplink/API/V3/Auth/Contracts/Token_Authorizer.php index 7de31571..ecd9e0c1 100644 --- a/src/Uplink/API/V3/Auth/Contracts/Token_Authorizer.php +++ b/src/Uplink/API/V3/Auth/Contracts/Token_Authorizer.php @@ -11,11 +11,12 @@ interface Token_Authorizer { * @see \StellarWP\Uplink\API\V3\Auth\Token_Authorizer * * @param string $license The license key. + * @param string $slug The plugin/service slug. * @param string $token The stored token. * @param string $domain The user's domain. * * @return bool */ - public function is_authorized( string $license, string $token, string $domain ): bool; + public function is_authorized( string $license, string $slug, string $token, string $domain ): bool; } diff --git a/src/Uplink/API/V3/Auth/Token_Authorizer.php b/src/Uplink/API/V3/Auth/Token_Authorizer.php index d9b67013..91dbaae4 100644 --- a/src/Uplink/API/V3/Auth/Token_Authorizer.php +++ b/src/Uplink/API/V3/Auth/Token_Authorizer.php @@ -29,16 +29,21 @@ public function __construct( Client_V3 $client ) { * Manually check if a license is authorized. * * @see is_authorized() + * @see Token_Authorizer_Cache_Decorator * * @param string $license The license key. - * @param string $token The stored token. - * @param string $domain The user's domain. + * @param string $slug The plugin/service slug. + * @param string $token The stored token. + * @param string $domain The user's domain. * * @return bool + * + * @see is_authorized() */ - public function is_authorized( string $license, string $token, string $domain ): bool { + public function is_authorized( string $license, string $slug, string $token, string $domain ): bool { $response = $this->client->get( 'tokens/auth', [ 'license' => $license, + 'slug' => $slug, 'token' => $token, 'domain' => $domain, ] ); diff --git a/src/Uplink/API/V3/Auth/Token_Authorizer_Cache_Decorator.php b/src/Uplink/API/V3/Auth/Token_Authorizer_Cache_Decorator.php index 76d18240..4b6cf5d9 100644 --- a/src/Uplink/API/V3/Auth/Token_Authorizer_Cache_Decorator.php +++ b/src/Uplink/API/V3/Auth/Token_Authorizer_Cache_Decorator.php @@ -4,6 +4,7 @@ use StellarWP\Uplink\API\V3\Provider; use StellarWP\Uplink\Config; +use StellarWP\Uplink\Storage\Contracts\Storage; /** * Token Authorizer Cache Decorator. @@ -19,6 +20,11 @@ final class Token_Authorizer_Cache_Decorator implements Contracts\Token_Authoriz */ private $authorizer; + /** + * @var Storage + */ + private $storage; + /** * The cache expiration in seconds. * @@ -35,9 +41,11 @@ final class Token_Authorizer_Cache_Decorator implements Contracts\Token_Authoriz */ public function __construct( Token_Authorizer $authorizer, + Storage $storage, int $expiration = 21600 ) { $this->authorizer = $authorizer; + $this->storage = $storage; $this->expiration = $expiration; } @@ -49,24 +57,25 @@ public function __construct( * @see Token_Authorizer * * @param string $license The license key. + * @param string $slug The plugin/service slug. * @param string $token The stored token. * @param string $domain The user's domain. * * @return bool */ - public function is_authorized( string $license, string $token, string $domain ): bool { + public function is_authorized( string $license, string $slug, string $token, string $domain ): bool { $transient = $this->build_transient( [ $license, $token, $domain ] ); - $is_authorized = get_transient( $transient ); + $is_authorized = $this->storage->get( $transient ); if ( $is_authorized === true ) { return true; } - $is_authorized = $this->authorizer->is_authorized( $license, $token, $domain ); + $is_authorized = $this->authorizer->is_authorized( $license, $slug, $token, $domain ); // Only cache successful responses. if ( $is_authorized ) { - set_transient( $transient, true, $this->expiration ); + $this->storage->set( $transient, true, $this->expiration ); } return $is_authorized; @@ -75,12 +84,23 @@ public function is_authorized( string $license, string $token, string $domain ): /** * Build a transient key. * - * @param array ...$args + * @param array $args + * + * @return string + */ + public function build_transient( array $args ): string { + return self::TRANSIENT_PREFIX . $this->build_transient_no_prefix( $args ); + } + + /** + * Build a transient key without the prefix. + * + * @param array $args * * @return string */ - public function build_transient( array ...$args ): string { - return self::TRANSIENT_PREFIX . hash( 'sha256', json_encode( $args ) ); + public function build_transient_no_prefix( array $args ): string { + return hash( 'sha256', json_encode( $args ) ); } } diff --git a/src/Uplink/API/V3/Provider.php b/src/Uplink/API/V3/Provider.php index 2d4795e0..dba8d0f2 100644 --- a/src/Uplink/API/V3/Provider.php +++ b/src/Uplink/API/V3/Provider.php @@ -9,6 +9,7 @@ use StellarWP\Uplink\API\V3\Contracts\Client_V3; use StellarWP\Uplink\Config; use StellarWP\Uplink\Contracts\Abstract_Provider; +use StellarWP\Uplink\Storage\Contracts\Storage; use WP_Http; final class Provider extends Abstract_Provider { @@ -74,6 +75,7 @@ private function register_token_authorizer(): void { static function ( $c ) use ( $expiration ): Token_Authorizer { return new Token_Authorizer_Cache_Decorator( $c->get( Auth\Token_Authorizer::class ), + $c->get( Storage::class ), $expiration ); } diff --git a/src/Uplink/Admin/Ajax.php b/src/Uplink/Admin/Ajax.php index 398c3e38..f4e263b7 100644 --- a/src/Uplink/Admin/Ajax.php +++ b/src/Uplink/Admin/Ajax.php @@ -14,8 +14,14 @@ class Ajax { */ protected $container; - public function __construct() { + /** + * @var Group + */ + protected $group; + + public function __construct( Group $group ) { $this->container = Config::get_container(); + $this->group = $group; } /** @@ -29,7 +35,7 @@ public function validate_license(): void { 'key' => Utils\Sanitize::key( wp_unslash( $_POST['key'] ?? '' ) ), ]; - if ( empty( $submission['key'] ) || ! wp_verify_nonce( $submission['_wpnonce'], $this->container->get( License_Field::class )->get_group_name() ) ) { + if ( empty( $submission['key'] ) || ! wp_verify_nonce( $submission['_wpnonce'], $this->group->get_name() ) ) { wp_send_json_error( [ 'status' => 0, 'message' => __( 'Invalid request: nonce field is expired. Please try again.', '%TEXTDOMAIN%' ), diff --git a/src/Uplink/Admin/Asset_Manager.php b/src/Uplink/Admin/Asset_Manager.php new file mode 100644 index 00000000..a288220b --- /dev/null +++ b/src/Uplink/Admin/Asset_Manager.php @@ -0,0 +1,62 @@ +assets_path = $assets_path; + $this->handle = sprintf( 'stellarwp-uplink-license-admin-%s', Config::get_hook_prefix() ); + } + + /** + * @return void + */ + public function register_assets(): void { + /** + * Filters the JS source for the admin. + * + * @since TBD + * + * @param string $js_src The JS source. + */ + $js_src = apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix() . '/admin_js_source', $this->assets_path . '/js/key-admin.js' ); + + wp_register_script( $this->handle, $js_src, [ 'jquery' ], '1.0.0', true ); + + $action_postfix = Config::get_hook_prefix_underscored(); + wp_localize_script( $this->handle, sprintf( 'stellarwp_config_%s', $action_postfix ), [ 'action' => sprintf( 'pue-validate-key-uplink-%s', $action_postfix ) ] ); + + /** + * Filters the CSS source for the admin. + * + * @since TBD + * + * @param string $css_src The CSS source. + */ + $css_src = apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix() . '/admin_css_source', $this->assets_path . '/css/main.css' ); + + wp_register_style( $this->handle, $css_src ); + } + + /** + * Enqueue the registered scripts and styles, only when rendering fields. + * + * @return void + */ + public function enqueue_assets(): void { + wp_enqueue_script( $this->handle ); + wp_enqueue_style( $this->handle ); + } +} diff --git a/src/Uplink/Admin/Field.php b/src/Uplink/Admin/Field.php index ca8d2ff8..59046c2d 100644 --- a/src/Uplink/Admin/Field.php +++ b/src/Uplink/Admin/Field.php @@ -6,9 +6,6 @@ use StellarWP\Uplink\Resources\Collection; abstract class Field { - - public const STELLARWP_UPLINK_GROUP = 'stellarwp_uplink_group'; - /** * Path to page template * @@ -18,6 +15,11 @@ abstract class Field { */ protected $path = ''; + /** + * @var Group + */ + protected $group; + /** * @since 1.0.0 * @@ -37,6 +39,10 @@ abstract public function register_settings(): void; */ abstract public function render( bool $show_title = true, bool $show_button = true ): void; + public function __construct( Group $group ) { + $this->group = $group; + } + /** * @param array $args * @@ -66,17 +72,6 @@ public function get_html_content( array $args = [] ) : string { return $args['html']; } - /** - * @param string $group_modifier - * - * @return string - */ - public function get_group_name( string $group_modifier = '' ) : string { - $group_name = sprintf( '%s_%s', self::STELLARWP_UPLINK_GROUP, $group_modifier ); - - return apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix() . '/license_field_group_name', $group_name, self::STELLARWP_UPLINK_GROUP, $group_modifier ); - } - /** * @param array $args * @@ -113,7 +108,7 @@ public function field_html( array $args = [] ): void { * @return string */ public function add_nonce_field() : string { - return ''; + return ''; } /** diff --git a/src/Uplink/Admin/Fields/Field.php b/src/Uplink/Admin/Fields/Field.php new file mode 100644 index 00000000..583c2e07 --- /dev/null +++ b/src/Uplink/Admin/Fields/Field.php @@ -0,0 +1,342 @@ +view = $view; + $this->asset_manager = $asset_manager; + $this->group = $group; + } + + /** + * Sets the resource. + * + * @param Resource $resource The resource. + * + * @return static + */ + public function set_resource( Resource $resource ): self { + $this->resource = $resource; + + return $this; + } + + /** + * Gets the field ID. + * + * @return string + */ + public function get_field_id(): string { + if ( empty( $this->field_id ) ) { + return $this->resource->get_license_object()->get_key_option_name(); + } + + return $this->field_id; + } + + /** + * Gets the field name. + * + * @return string + */ + public function get_field_name(): string { + return $this->field_name; + } + + /** + * Gets the field value. + * + * @return string + */ + public function get_field_value(): string { + return $this->resource->get_license_key(); + } + + /** + * Gets the HTML for the key status information. + * + * @return string + */ + public function get_key_status_html(): string { + $html = $this->view->render( 'admin/fields/key-status' ); + + /** + * Filters the key status HTML. + * + * @param string $html The HTML. + * @param string $slug The plugin slug. + */ + return apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix() . '/license_field_key_status_html', $html, $this->get_slug() ); + } + + /** + * Gets the field label. + * + * @return string + */ + public function get_label(): string { + return $this->label; + } + + /** + * Gets the nonce action. + * + * @return string + */ + public function get_nonce_action() : string { + /** + * Filters the nonce action. + * + * @param string $group The Settings group. + */ + return apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix() . '/license_field_group_name', Config::get_hook_prefix_underscored() ); + } + + /** + * Gets the nonce field. + * + * @return string + */ + public function get_nonce_field(): string { + $nonce_name = "stellarwp-uplink-license-key-nonce__" . $this->get_slug(); + $nonce_action = $this->group->get_name(); + + return ''; + } + + /** + * Gets the field placeholder. + * + * @return string + */ + public function get_placeholder(): string { + return __( 'License key', '%TEXTDOMAIN%' ); + } + + /** + * Gets the product name. + * + * @return string + */ + public function get_product(): string { + return $this->resource->get_path(); + } + + /** + * Gets the product slug. + * + * @return string + */ + public function get_product_slug(): string { + return $this->resource->get_slug(); + } + + /** + * Gets the field slug. + * + * @return string + */ + public function get_slug(): string { + return $this->resource->get_slug(); + } + + /** + * Gets the field classes. + * + * @return string + */ + public function get_classes(): string { + return 'stellarwp-uplink-license-key-field'; + } + + /** + * Renders the field. + * + * @return void + */ + public function render( array $args = [] ): void { + echo $this->get_render_html(); + } + + /** + * Returns the rendered field HTML. + * + * @return string + */ + public function get_render_html(): string { + $this->asset_manager->enqueue_assets(); + + if ( $this->resource->is_using_oauth() ) { + ob_start(); + UplinkNamespace\render_authorize_button( $this->get_slug() ); + return (string) ob_get_clean(); + } + + $args = [ + 'field' => $this, + 'group' => $this->group->get_name( $this->get_slug() ), + ]; + + $html = $this->view->render( self::VIEW, $args ); + + /** + * Filters the field HTML. + * + * @param string $html The HTML. + * @param string $slug The plugin slug. + */ + return apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix() . '/license_field_html', $html, $this->get_slug() ); + } + + /** + * Sets the field ID. + * + * @param string $field_id Field ID. + * + * @return self + */ + public function set_field_id( string $field_id ): self { + $this->field_id = $field_id; + + return $this; + } + + /** + * Sets the field name. + * + * @param string $field_name Field name. + * + * @return self + */ + public function set_field_name( string $field_name ): self { + $this->field_name = $field_name; + + return $this; + } + + /** + * Sets the field label. + * + * @param string $label Field label. + * + * @return self + */ + public function set_label( string $label ): self { + $this->label = $label; + + return $this; + } + + /** + * Whether to show the field heading. + * + * @return bool + */ + public function should_show_heading(): bool { + return $this->show_heading; + } + + /** + * Whether to show the field label. + * + * @return bool + */ + public function should_show_label(): bool { + return $this->show_label; + } + + /** + * Whether to show the field heading. + * + * @param bool $state Whether to show the field heading. + * + * @return $this + */ + public function show_heading( bool $state = true ): self { + $this->show_heading = $state; + + return $this; + } + + /** + * Whether to show the field label. + * + * @param bool $state Whether to show the field label. + * + * @return $this + */ + public function show_label( bool $state = true ): self { + $this->show_label = $state; + + return $this; + } +} diff --git a/src/Uplink/Admin/Fields/Form.php b/src/Uplink/Admin/Fields/Form.php new file mode 100644 index 00000000..4e8670e0 --- /dev/null +++ b/src/Uplink/Admin/Fields/Form.php @@ -0,0 +1,137 @@ + + */ + protected array $fields = []; + + /** + * @var string + */ + protected string $slug = ''; + + /** + * @var bool + */ + protected bool $show_button = true; + + /** + * @var string + */ + protected string $button_text = ''; + + /** + * @var string + */ + protected const VIEW = 'admin/fields/form'; + + /** + * Adds a field to the form. + * + * @param Field $field + * + * @return $this + */ + public function add_field( Field $field ): self { + $this->fields[ $field->get_slug() ] = $field; + + return $this; + } + + /** + * Gets the button text. + * + * @return string + */ + public function get_button_text(): string { + if ( empty( $this->button_text ) ) { + return esc_html__( 'Save Changes', '%TEXTDOMAIN%' ); + } + + return $this->button_text; + } + + /** + * Gets the fields. + * + * @return array + */ + public function get_fields(): array { + return $this->fields; + } + + /** + * Renders the form. + * + * @return void + */ + public function render( array $args = [] ): void { + echo $this->get_render_html(); + } + + /** + * Renders the form. + * + * @return string + */ + public function get_render_html(): string { + $args = [ 'form' => $this ]; + $html = $this->view->render( self::VIEW, $args ); + + /** + * Filters the form HTML. + * + * @since TBD + * + * @param string $html The form HTML. + */ + return apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix() . '/license_form_html', $html ); + } + + /** + * Sets the submit button text. + * + * @param string $button_text The text to display on the button. + * + * @return $this + */ + public function set_button_text( string $button_text ): self { + $this->button_text = $button_text; + + return $this; + } + + /** + * Whether to show the field label. + * + * @param bool $state Whether to show the field label. + * @param string $button_text The button text. + * + * @return $this + */ + public function show_button( bool $state = true, string $button_text = '' ): self { + if ( ! empty( $button_text ) ) { + $this->set_button_text( $button_text ); + } + + $this->show_button = $state; + + return $this; + } + + /** + * Whether to show the button. + * + * @return bool + */ + public function should_show_button(): bool { + return $this->show_button; + } +} diff --git a/src/Uplink/Admin/Group.php b/src/Uplink/Admin/Group.php new file mode 100644 index 00000000..6fbe8607 --- /dev/null +++ b/src/Uplink/Admin/Group.php @@ -0,0 +1,20 @@ +handle = sprintf( 'stellarwp-uplink-license-admin-%s', Config::get_hook_prefix() ); + public function __construct( Group $group, Asset_Manager $asset_manager ) { + parent::__construct( $group ); + $this->asset_manager = $asset_manager; } /** @@ -51,11 +57,11 @@ public function register_settings(): void { self::get_section_name( $resource ), '', [ $this, 'description' ], // @phpstan-ignore-line - $this->get_group_name( sanitize_title( $resource->get_slug() ) ) + $this->group->get_name( sanitize_title( $resource->get_slug() ) ) ); register_setting( - $this->get_group_name( sanitize_title( $resource->get_slug() ) ), + $this->group->get_name( sanitize_title( $resource->get_slug() ) ), $resource->get_license_object()->get_key_option_name() ); @@ -63,7 +69,7 @@ public function register_settings(): void { $resource->get_license_object()->get_key_option_name(), __( 'License Key', '%TEXTDOMAIN%' ), [ $this, 'field_html' ], - $this->get_group_name( sanitize_title( $resource->get_slug() ) ), + $this->group->get_name( sanitize_title( $resource->get_slug() ) ), self::get_section_name( $resource ), [ 'id' => $resource->get_license_object()->get_key_option_name(), @@ -117,7 +123,7 @@ public function render( bool $show_title = true, bool $show_button = true ): voi * @return void */ public function render_single( string $plugin_slug, bool $show_title = true, bool $show_button = true ): void { - $this->enqueue_assets(); + $this->asset_manager->enqueue_assets(); $resource = $this->get_resources()->offsetGet( $plugin_slug ); @@ -131,33 +137,4 @@ public function render_single( string $plugin_slug, bool $show_title = true, boo 'show_button' => $show_button, ] ); } - - - /** - * @since 1.0.0 - * - * @return void - */ - public function register_assets(): void { - $path = Config::get_container()->get( Uplink::UPLINK_ASSETS_URI ); - $js_src = apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix() . '/admin_js_source', $path . '/js/key-admin.js' ); - wp_register_script( $this->handle, $js_src, [ 'jquery' ], '1.0.0', true ); - - $action_postfix = Config::get_hook_prefix_underscored(); - wp_localize_script( $this->handle, sprintf( 'stellarwp_config_%s', $action_postfix ), [ 'action' => sprintf( 'pue-validate-key-uplink-%s', $action_postfix ) ] ); - - $css_src = apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix() . '/admin_css_source', $path . '/css/main.css' ); - wp_register_style( $this->handle, $css_src ); - } - - /** - * Enqueue the registered scripts and styles, only when rendering fields. - * - * @return void - */ - protected function enqueue_assets(): void { - wp_enqueue_script( $this->handle ); - wp_enqueue_style( $this->handle ); - } - } diff --git a/src/Uplink/Admin/Provider.php b/src/Uplink/Admin/Provider.php index 4b9a433f..158fa0e4 100644 --- a/src/Uplink/Admin/Provider.php +++ b/src/Uplink/Admin/Provider.php @@ -4,6 +4,7 @@ use StellarWP\Uplink\Config; use StellarWP\Uplink\Contracts\Abstract_Provider; +use StellarWP\Uplink\Uplink; class Provider extends Abstract_Provider { /** @@ -20,6 +21,10 @@ public function register() { $this->container->singleton( Ajax::class, Ajax::class ); $this->container->singleton( Package_Handler::class, Package_Handler::class ); $this->container->singleton( Update_Prevention::class, Update_Prevention::class ); + $this->container->singleton( Group::class, Group::class ); + $this->container->singleton( Asset_Manager::class, static function ( $c ) { + return new Asset_Manager( $c->get( Uplink::UPLINK_ASSETS_URI ) ); + } ); $this->register_hooks(); } @@ -94,6 +99,10 @@ public function ajax_validate_license(): void { * @return void */ public function admin_init(): void { + if ( wp_doing_ajax() ) { + return; + } + $this->container->get( License_Field::class )->register_settings(); } @@ -105,7 +114,7 @@ public function admin_init(): void { * @return void */ public function register_assets(): void { - $this->container->get( License_Field::class )->register_assets(); + $this->container->get( Asset_Manager::class )->register_assets(); } /** diff --git a/src/Uplink/Auth/Action_Manager.php b/src/Uplink/Auth/Action_Manager.php new file mode 100644 index 00000000..9dc611bd --- /dev/null +++ b/src/Uplink/Auth/Action_Manager.php @@ -0,0 +1,122 @@ +disconnect_controller = $disconnect_controller; + $this->connect_controller = $connect_controller; + $this->resources = $resources; + } + + /** + * Get the resource's unique hook name. + * + * @param string $slug The plugin/service slug. + * + * @example stellarwp/uplink/my_hook_prefix/admin_action_my_plugin_slug + * + * @throws \RuntimeException + * + * @return string + */ + public function get_hook_name( string $slug ): string { + return sprintf( 'stellarwp/uplink/%s/%s_%s', + Config::get_hook_prefix(), + self::ACTION, + $slug + ); + } + + /** + * Register a unique action for each resource in order to fire off connect/disconnect logic + * uniquely so as one plugin would not interfere with another. + * + * @action admin_init + * + * @throws \RuntimeException + * + * @return void + */ + public function add_actions(): void { + foreach ( $this->resources as $resource ) { + $hook_name = $this->get_hook_name( $resource->get_slug() ); + + add_action( + $hook_name, + [ $this->disconnect_controller, 'maybe_disconnect' ] + ); + + add_action( + $hook_name, + [ $this->connect_controller, 'maybe_store_token_data' ] + ); + } + } + + /** + * When an `uplink_slug` query parameter is available, fire off the appropriate + * resource action. + * + * @action current_screen + * + * @throws \RuntimeException + * + * @return void + */ + public function do_action(): void { + if ( empty( $_REQUEST[ Disconnect_Controller::SLUG ] ) ) { + return; + } + + $slug = $_REQUEST[ Disconnect_Controller::SLUG ]; + + /** + * Fires when an 'uplink_slug' request variable is sent. + * + * The dynamic portion of the hook name, `$slug`, refers to + * the action derived from the `GET` or `POST` request. + * + * @example stellarwp/uplink/my_hook_prefix/admin_action_my_plugin_slug + */ + do_action( $this->get_hook_name( $slug ) ); + } + +} diff --git a/src/Uplink/Auth/Admin/Connect_Controller.php b/src/Uplink/Auth/Admin/Connect_Controller.php index f74ebc26..7187e0ee 100644 --- a/src/Uplink/Auth/Admin/Connect_Controller.php +++ b/src/Uplink/Auth/Admin/Connect_Controller.php @@ -2,12 +2,15 @@ namespace StellarWP\Uplink\Auth\Admin; +use StellarWP\Uplink\Auth\Authorizer; use StellarWP\Uplink\Auth\Nonce; use StellarWP\Uplink\Auth\Token\Connector; use StellarWP\Uplink\Auth\Token\Exceptions\InvalidTokenException; +use StellarWP\Uplink\Config; use StellarWP\Uplink\Notice\Notice_Handler; use StellarWP\Uplink\Notice\Notice; use StellarWP\Uplink\Resources\Collection; +use StellarWP\Uplink\Storage\Drivers\Transient_Storage; /** * Handles storing token data after successfully redirecting @@ -35,27 +38,42 @@ final class Connect_Controller { */ private $collection; + /** + * @var Authorizer + */ + private $authorizer; - public function __construct( Connector $connector, Notice_Handler $notice, Collection $collection ) { + /** + * @var Nonce + */ + private $nonce; + + public function __construct( + Connector $connector, + Notice_Handler $notice, + Collection $collection, + Authorizer $authorizer, + Nonce $nonce + ) { $this->connector = $connector; $this->notice = $notice; $this->collection = $collection; + $this->authorizer = $authorizer; + $this->nonce = $nonce; } /** * Store the token data passed back from the Origin site. * - * @action admin_init + * @action stellarwp/uplink/{$prefix}/admin_action_{$slug} + * + * @throws \RuntimeException */ public function maybe_store_token_data(): void { if ( ! is_admin() || wp_doing_ajax() ) { return; } - if ( ! is_user_logged_in() ) { - return; - } - $args = array_intersect_key( $_GET, [ self::TOKEN => true, self::NONCE => true, @@ -67,49 +85,74 @@ public function maybe_store_token_data(): void { return; } - if ( ! Nonce::verify( $args[ self::NONCE ] ?? '' ) ) { + if ( ! $this->authorizer->can_auth() ) { $this->notice->add( new Notice( Notice::ERROR, - __( 'Unable to save token data: nonce verification failed.', '%TEXTDOMAIN%' ), + __( 'Sorry, you do not have permission to connect this site.', '%TEXTDOMAIN%' ), true ) ); return; } - try { - if ( ! $this->connector->connect( $args[ self::TOKEN ] ?? '' ) ) { + + if ( ! $this->nonce->verify( $args[ self::NONCE ] ?? '' ) ) { + if ( ! function_exists( 'is_plugin_active' ) ) { + require_once( ABSPATH . 'wp-admin/includes/plugin.php' ); + } + + // The Litespeed plugin allows completely disabling transients for some reason... + if ( Config::get_storage_driver() === Transient_Storage::class && is_plugin_active( 'litespeed-cache/litespeed-cache.php' ) ) { $this->notice->add( new Notice( Notice::ERROR, - __( 'Error storing token.', '%TEXTDOMAIN%' ), + sprintf( + __( 'The Litespeed plugin was detected, ensure "Store Transients" is set to ON and try again. See the Litespeed documentation for more information.', '%TEXTDOMAIN%' ), + esc_url( 'https://docs.litespeedtech.com/lscache/lscwp/cache/#store-transients' ) + ), true ) ); - - return; } - } catch ( InvalidTokenException $e ) { + $this->notice->add( new Notice( Notice::ERROR, - sprintf( '%s.', $e->getMessage() ), + __( 'Unable to save token data: nonce verification failed.', '%TEXTDOMAIN%' ), true ) ); return; } - $license = $args[ self::LICENSE ] ?? ''; $slug = $args[ self::SLUG ] ?? ''; + $plugin = $this->collection->offsetGet( $slug ); - // Store or override an existing license. - if ( $license && $slug ) { - if ( ! $this->collection->offsetExists( $slug ) ) { + if ( ! $plugin ) { + $this->notice->add( new Notice( Notice::ERROR, + __( 'Plugin or Service slug not found.', '%TEXTDOMAIN%' ), + true + ) ); + + return; + } + + try { + if ( ! $this->connector->connect( $args[ self::TOKEN ] ?? '', $plugin ) ) { $this->notice->add( new Notice( Notice::ERROR, - __( 'Plugin or Service slug not found.', '%TEXTDOMAIN%' ), + __( 'Error storing token.', '%TEXTDOMAIN%' ), true ) ); return; } + } catch ( InvalidTokenException $e ) { + $this->notice->add( new Notice( Notice::ERROR, + sprintf( '%s.', $e->getMessage() ), + true + ) ); - $plugin = $this->collection->offsetGet( $slug ); + return; + } + + // Store or override an existing license. + $license = $args[ self::LICENSE ] ?? ''; + if ( $license ) { if ( ! $plugin->set_license_key( $license, 'network' ) ) { $this->notice->add( new Notice( Notice::ERROR, __( 'Error storing license key.', '%TEXTDOMAIN%' ), diff --git a/src/Uplink/Auth/Admin/Disconnect_Controller.php b/src/Uplink/Auth/Admin/Disconnect_Controller.php index 8d61f096..2bc2d4e3 100644 --- a/src/Uplink/Auth/Admin/Disconnect_Controller.php +++ b/src/Uplink/Auth/Admin/Disconnect_Controller.php @@ -2,13 +2,24 @@ namespace StellarWP\Uplink\Auth\Admin; +use StellarWP\Uplink\API\V3\Auth\Token_Authorizer_Cache_Decorator; +use StellarWP\Uplink\Auth\Authorizer; +use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; use StellarWP\Uplink\Auth\Token\Disconnector; use StellarWP\Uplink\Notice\Notice_Handler; use StellarWP\Uplink\Notice\Notice; +use StellarWP\Uplink\Resources\Resource; final class Disconnect_Controller { - public const ARG = 'uplink_disconnect'; + public const ARG = 'uplink_disconnect'; + public const SLUG = 'uplink_slug'; + public const CACHE_KEY = 'uplink_cache'; + + /** + * @var Authorizer + */ + private $authorizer; /** * @var Disconnector @@ -21,23 +32,70 @@ final class Disconnect_Controller { private $notice; /** - * @param Disconnector $disconnect Disconnects a Token, if the user has the capability. - * @param Notice_Handler $notice Handles storing and displaying notices. + * @var Token_Manager + */ + private $token_manager; + + /** + * @var Token_Authorizer_Cache_Decorator + */ + private $cache; + + /** + * @param Authorizer $authorizer The authorizer. + * @param Disconnector $disconnect Disconnects a Token, if the user has the capability. + * @param Token_Manager $token_manager Manages token storage. + * @param Notice_Handler $notice Handles storing and displaying notices. + * @param Token_Authorizer_Cache_Decorator $cache The token cache. + */ + public function __construct( + Authorizer $authorizer, + Disconnector $disconnect, + Notice_Handler $notice, + Token_Manager $token_manager, + Token_Authorizer_Cache_Decorator $cache + ) { + $this->authorizer = $authorizer; + $this->disconnect = $disconnect; + $this->notice = $notice; + $this->token_manager = $token_manager; + $this->cache = $cache; + } + + /** + * Get the disconnect URL to render. + * + * @param Resource $plugin The plugin/service. + * + * @return string */ - public function __construct( Disconnector $disconnect, Notice_Handler $notice ) { - $this->disconnect = $disconnect; - $this->notice = $notice; + public function get_url( Resource $plugin ): string { + $token = $this->token_manager->get( $plugin ); + + if ( ! $token ) { + return ''; + } + + $cache_key = $this->cache->build_transient_no_prefix( [ $token ] ); + + return wp_nonce_url( add_query_arg( [ + self::ARG => true, + self::SLUG => $plugin->get_slug(), + self::CACHE_KEY => $cache_key, + ], get_admin_url( get_current_blog_id() ) ), self::ARG ); } /** * Disconnect (delete) a token if the user is allowed to. * - * @action admin_init + * @action stellarwp/uplink/{$prefix}/admin_action_{$slug} + * + * @throws \RuntimeException * * @return void */ public function maybe_disconnect(): void { - if ( empty( $_GET[ self::ARG ] ) || empty( $_GET['_wpnonce'] ) ) { + if ( empty( $_GET[ self::ARG ] ) || empty( $_GET['_wpnonce'] ) || empty( $_GET[ self::SLUG ] ) || empty( $_GET[ self::CACHE_KEY ] ) ) { return; } @@ -46,7 +104,7 @@ public function maybe_disconnect(): void { } if ( wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), self::ARG ) ) { - if ( $this->disconnect->disconnect() ) { + if ( $this->authorizer->can_auth() && $this->disconnect->disconnect( $_GET[ self::SLUG ], $_GET[ self::CACHE_KEY ] ) ) { $this->notice->add( new Notice( Notice::SUCCESS, __( 'Token disconnected.', '%TEXTDOMAIN%' ), diff --git a/src/Uplink/Auth/Auth_Pipes/Multisite_Subfolder_Check.php b/src/Uplink/Auth/Auth_Pipes/Multisite_Subfolder_Check.php deleted file mode 100644 index b480571b..00000000 --- a/src/Uplink/Auth/Auth_Pipes/Multisite_Subfolder_Check.php +++ /dev/null @@ -1,44 +0,0 @@ -token_manager = $token_manager; - } - - /** - * Checks if a sub-site already has a network token. - * - * @param bool $can_auth - * @param Closure $next - * - * @return bool - */ - public function __invoke( bool $can_auth, Closure $next ): bool { - if ( ! is_multisite() ) { - return $next( $can_auth ); - } - - if ( is_main_site() ) { - return $next( $can_auth ); - } - - // Token already exists at the network level, don't authorize for this sub-site. - if ( $this->token_manager->get() ) { - return false; - } - - return $next( $can_auth ); - } - -} diff --git a/src/Uplink/Auth/Auth_Pipes/User_Check.php b/src/Uplink/Auth/Auth_Pipes/User_Check.php deleted file mode 100644 index de5b9efa..00000000 --- a/src/Uplink/Auth/Auth_Pipes/User_Check.php +++ /dev/null @@ -1,36 +0,0 @@ -pipeline = $pipeline; - } - - /** - * Runs the pipeline which executes a series of checks to determine if - * the user can use the authorize button on the current site. + * Checks if the current user can perform an action. * - * @see Provider::register_authorizer() + * @throws \RuntimeException * * @return bool */ public function can_auth(): bool { - return $this->pipeline->send( true )->thenReturn(); + /** + * Filters if the current user can perform an action. + * + * @since TBD + * + * @param bool $can_auth Whether the current user can perform an action. + */ + return (bool) apply_filters( + 'stellarwp/uplink/' . Config::get_hook_prefix() . '/auth/can_auth', + is_super_admin() + ); } } diff --git a/src/Uplink/Auth/Nonce.php b/src/Uplink/Auth/Nonce.php index e7bd720e..ad5a3752 100644 --- a/src/Uplink/Auth/Nonce.php +++ b/src/Uplink/Auth/Nonce.php @@ -3,6 +3,7 @@ namespace StellarWP\Uplink\Auth; use StellarWP\Uplink\Config; +use StellarWP\Uplink\Storage\Contracts\Storage; final class Nonce { @@ -11,6 +12,11 @@ final class Nonce { */ public const NONCE_SUFFIX = '_uplink_nonce'; + /** + * @var Storage + */ + private $storage; + /** * How long a nonce is valid for in seconds. * @@ -21,7 +27,8 @@ final class Nonce { /** * @param int $expiration How long the nonce is valid for in seconds. */ - public function __construct( int $expiration = 2100 ) { + public function __construct( Storage $storage, int $expiration = 2100 ) { + $this->storage = $storage; $this->expiration = $expiration; } @@ -32,12 +39,12 @@ public function __construct( int $expiration = 2100 ) { * * @return bool */ - public static function verify( string $nonce ): bool { + public function verify( string $nonce ): bool { if ( ! $nonce ) { return false; } - return $nonce === get_transient( Config::get_hook_prefix_underscored() . self::NONCE_SUFFIX ); + return $nonce === $this->storage->get( Config::get_hook_prefix_underscored() . self::NONCE_SUFFIX ); } /** @@ -46,7 +53,7 @@ public static function verify( string $nonce ): bool { * @return string */ public function create(): string { - $existing = get_transient( $this->key() ); + $existing = $this->storage->get( $this->key() ); if ( $existing ) { return $existing; @@ -54,7 +61,7 @@ public function create(): string { $nonce = wp_generate_password( 16, false ); - set_transient( $this->key(), $nonce, $this->expiration ); + $this->storage->set( $this->key(), $nonce, $this->expiration ); return $nonce; } diff --git a/src/Uplink/Auth/Provider.php b/src/Uplink/Auth/Provider.php index 1d213d4d..93efa798 100644 --- a/src/Uplink/Auth/Provider.php +++ b/src/Uplink/Auth/Provider.php @@ -4,13 +4,10 @@ use StellarWP\Uplink\Auth\Admin\Disconnect_Controller; use StellarWP\Uplink\Auth\Admin\Connect_Controller; -use StellarWP\Uplink\Auth\Auth_Pipes\Multisite_Subfolder_Check; -use StellarWP\Uplink\Auth\Auth_Pipes\Network_Token_Check; -use StellarWP\Uplink\Auth\Auth_Pipes\User_Check; use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; use StellarWP\Uplink\Config; use StellarWP\Uplink\Contracts\Abstract_Provider; -use StellarWP\Uplink\Pipeline\Pipeline; +use StellarWP\Uplink\Storage\Contracts\Storage; final class Provider extends Abstract_Provider { @@ -22,7 +19,7 @@ public function register() { return; } - $this->container->bind( + $this->container->singleton( Token_Manager::class, static function ( $c ) { return new Token\Token_Manager( $c->get( Config::TOKEN_OPTION_NAME ) ); @@ -30,9 +27,7 @@ static function ( $c ) { ); $this->register_nonce(); - $this->register_authorizer(); - $this->register_auth_disconnect(); - $this->register_auth_connect(); + $this->register_auth_connect_disconnect(); } /** @@ -53,55 +48,28 @@ private function register_nonce(): void { $expiration = apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix() . '/auth/nonce_expiration', 2100 ); $expiration = absint( $expiration ); - $this->container->singleton( Nonce::class, new Nonce( $expiration ) ); + $this->container->singleton( Nonce::class, static function( $c ) use ( $expiration ) { + return new Nonce( $c->get( Storage::class ), $expiration ); + } ); } /** - * Registers the Authorizer and the steps in order for the pipeline - * processing. - */ - private function register_authorizer(): void { - $this->container->singleton( - Network_Token_Check::class, - static function ( $c ) { - return new Network_Token_Check( $c->get( Token_Manager::class ) ); - } - ); - - $pipeline = ( new Pipeline( $this->container ) )->through( [ - User_Check::class, - Multisite_Subfolder_Check::class, - Network_Token_Check::class, - ] ); - - $this->container->singleton( - Authorizer::class, - static function () use ( $pipeline ) { - return new Authorizer( $pipeline ); - } - ); - } - - /** - * Register auth disconnection definitions and hooks. + * Register token auth connection/disconnection definitions and hooks. * * @return void */ - private function register_auth_disconnect(): void { + private function register_auth_connect_disconnect(): void { $this->container->singleton( Disconnect_Controller::class, Disconnect_Controller::class ); + $this->container->singleton( Connect_Controller::class, Connect_Controller::class ); + $this->container->singleton( Action_Manager::class, Action_Manager::class ); - add_action( 'admin_init', [ $this->container->get( Disconnect_Controller::class ), 'maybe_disconnect' ], 9, 0 ); - } + $action_manager = $this->container->get( Action_Manager::class ); - /** - * Register auth connection definitions and hooks. - * - * @return void - */ - private function register_auth_connect(): void { - $this->container->singleton( Connect_Controller::class, Connect_Controller::class ); + // Register a unique action for each resource slug. + add_action( 'admin_init', [ $action_manager, 'add_actions' ], 1 ); - add_action( 'admin_init', [ $this->container->get( Connect_Controller::class ), 'maybe_store_token_data'], 9, 0 ); + // Execute the above actions when an uplink_slug query variable. + add_action( 'admin_init', [ $action_manager, 'do_action' ], 9 ); } } diff --git a/src/Uplink/Auth/Token/Connector.php b/src/Uplink/Auth/Token/Connector.php index b4ac265e..598f9f4b 100644 --- a/src/Uplink/Auth/Token/Connector.php +++ b/src/Uplink/Auth/Token/Connector.php @@ -2,31 +2,23 @@ namespace StellarWP\Uplink\Auth\Token; -use StellarWP\Uplink\Auth\Authorizer; use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; use StellarWP\Uplink\Auth\Token\Exceptions\InvalidTokenException; +use StellarWP\Uplink\Resources\Resource; final class Connector { - /** - * @var Authorizer - */ - private $authorizer; - /** * @var Token_Manager */ private $token_manager; /** - * @param Authorizer $authorizer Determines if the current user can perform actions. * @param Token_Manager $token_manager The Token Manager. */ public function __construct( - Authorizer $authorizer, Token_Manager $token_manager ) { - $this->authorizer = $authorizer; $this->token_manager = $token_manager; } @@ -35,16 +27,12 @@ public function __construct( * * @throws InvalidTokenException */ - public function connect( string $token ): bool { - if ( ! $this->authorizer->can_auth() ) { - return false; - } - + public function connect( string $token, Resource $plugin ): bool { if ( ! $this->token_manager->validate( $token ) ) { throw new InvalidTokenException( 'Invalid token format' ); } - return $this->token_manager->store( $token ); + return $this->token_manager->store( $token, $plugin ); } } diff --git a/src/Uplink/Auth/Token/Contracts/Token_Manager.php b/src/Uplink/Auth/Token/Contracts/Token_Manager.php index 695b1c3f..f72f11d7 100644 --- a/src/Uplink/Auth/Token/Contracts/Token_Manager.php +++ b/src/Uplink/Auth/Token/Contracts/Token_Manager.php @@ -2,6 +2,8 @@ namespace StellarWP\Uplink\Auth\Token\Contracts; +use StellarWP\Uplink\Resources\Resource; + interface Token_Manager { /** @@ -31,24 +33,41 @@ public function validate( string $token ): bool; /** * Stores the token in the database. * - * @param string $token + * @param string $token + * @param Resource $plugin * * @return bool */ - public function store( string $token ): bool; + public function store( string $token, Resource $plugin ): bool; /** * Retrieves the stored token. * - * @return string|null + * @note This will return a single legacy token if one exists in the database. + * + * @param Resource $plugin + * + * @return string */ - public function get(): ?string; + public function get( Resource $plugin ): ?string; + + /** + * Retrieve all store tokens, indexed by their slug. + * + * @since TBD + * + * @note If a legacy token previously existed, it will be indexed by the key `legacy`. + * + * @return array + */ + public function get_all(): ?array; /** * Deletes the token from the database. * + * @param string $slug + * * @return bool */ - public function delete(): bool; - + public function delete( string $slug = '' ): bool; } diff --git a/src/Uplink/Auth/Token/Disconnector.php b/src/Uplink/Auth/Token/Disconnector.php index 09e97026..f3552c93 100644 --- a/src/Uplink/Auth/Token/Disconnector.php +++ b/src/Uplink/Auth/Token/Disconnector.php @@ -2,42 +2,64 @@ namespace StellarWP\Uplink\Auth\Token; -use StellarWP\Uplink\Auth\Authorizer; +use StellarWP\Uplink\API\V3\Auth\Token_Authorizer_Cache_Decorator; use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; +use StellarWP\Uplink\Resources\Collection; +use StellarWP\Uplink\Storage\Contracts\Storage; final class Disconnector { /** - * @var Authorizer + * @var Token_Manager */ - private $authorizer; + private $token_manager; /** - * @var Token_Manager + * @var Collection */ - private $token_manager; + private $resources; /** - * @param Authorizer $authorizer Determines if the current user can perform actions. - * @param Token_Manager $token_manager The Token Manager. + * @var Storage + */ + private $storage; + + /** + * @param Token_Manager $token_manager The Token Manager. */ public function __construct( - Authorizer $authorizer, - Token_Manager $token_manager + Token_Manager $token_manager, + Collection $resources, + Storage $storage ) { - $this->authorizer = $authorizer; $this->token_manager = $token_manager; + $this->resources = $resources; + $this->storage = $storage; } /** * Delete a token if the current user is allowed to. + * + * @param string $slug The plugin or service slug. + * @param string $cache_key The token cache key. + * + * @return bool */ - public function disconnect(): bool { - if ( ! $this->authorizer->can_auth() ) { + public function disconnect( string $slug, string $cache_key ): bool { + $plugin = $this->resources->offsetGet( $slug ); + + if ( ! $plugin ) { return false; } - return $this->token_manager->delete(); + $result = $this->token_manager->delete( $slug ); + + if ( $result ) { + // Delete the authorization cache. + $this->storage->delete( Token_Authorizer_Cache_Decorator::TRANSIENT_PREFIX . $cache_key ); + } + + return $result; } } diff --git a/src/Uplink/Auth/Token/Token_Manager.php b/src/Uplink/Auth/Token/Token_Manager.php index a75d861b..37e325bb 100644 --- a/src/Uplink/Auth/Token/Token_Manager.php +++ b/src/Uplink/Auth/Token/Token_Manager.php @@ -3,6 +3,8 @@ namespace StellarWP\Uplink\Auth\Token; use InvalidArgumentException; +use StellarWP\Uplink\Config; +use StellarWP\Uplink\Resources\Resource; /** * Manages storing authorization tokens in a network. @@ -12,6 +14,11 @@ */ final class Token_Manager implements Contracts\Token_Manager { + /** + * The index used in get_all() for any legacy token. + */ + public const LEGACY_INDEX = 'legacy'; + /** * The option name to store the token in wp_options table. * @@ -59,44 +66,94 @@ public function validate( string $token ): bool { /** * Store the token. * - * @param string $token + * @since TBD Added $plugin param. + * + * @param string $token The token to store. + * @param Resource $plugin The Product to store the token for. * * @return bool */ - public function store( string $token ): bool { + public function store( string $token, Resource $plugin ): bool { if ( ! $token ) { return false; } + $current = $tokens = $this->get_all(); + + $tokens[ $plugin->get_slug() ] = $token; + // WordPress would otherwise return false if the items match. - if ( $token === $this->get() ) { + if ( $tokens === $current ) { return true; } - return update_network_option( get_current_network_id(), $this->option_name, $token ); + return update_network_option( get_current_network_id(), $this->option_name, $tokens ); } /** * Get the token. * + * @since TBD Added $plugin param. + * + * @note This will fallback to the legacy token, if it exists. + * + * @param Resource $plugin The Product to retrieve the token for. + * * @return string|null */ - public function get(): ?string { - return get_network_option( get_current_network_id(), $this->option_name, null ); + public function get( Resource $plugin ): ?string { + $tokens = $this->get_all(); + + return $tokens[ $plugin->get_slug() ] ?? $tokens[ self::LEGACY_INDEX ] ?? null; + } + + /** + * Get all the tokens, indexed by their slug. + * + * @note Legacy tokens are stored as a string, and will be returned with the `legacy` slug. + * + * @since TBD + * + * @return array + */ + public function get_all(): array { + $tokens = (array) get_network_option( get_current_network_id(), $this->option_name, [] ); + + // Index the legacy token by `legacy`. + if ( array_key_exists( 0, $tokens ) ) { + $tokens[ self::LEGACY_INDEX ] = $tokens[0]; + unset( $tokens[0] ); + } + + return $tokens; } /** * Revoke the token. * + * @param string $slug The Product to retrieve the token for. + * * @return bool */ - public function delete(): bool { - // Already doesn't exist, WordPress would normally return false. - if ( $this->get() === null ) { + public function delete( string $slug = '' ): bool { + $current = $tokens = $this->get_all(); + + // We'll always delete the legacy token. + if ( isset( $tokens[ self::LEGACY_INDEX ] ) ) { + unset( $tokens[ self::LEGACY_INDEX ] ); + } + + // Delete the specified token if it exists. + if ( isset( $tokens[ $slug ] ) ) { + unset( $tokens[ $slug ] ); + } + + // No change, return true, otherwise WordPress would return false here. + if ( $tokens === $current ) { return true; } - return delete_network_option( get_current_network_id(), $this->option_name ); + return update_network_option( get_current_network_id(), $this->option_name, $tokens ); } } diff --git a/src/Uplink/Components/Admin/Authorize_Button_Controller.php b/src/Uplink/Components/Admin/Authorize_Button_Controller.php index 4af91b2c..bf415c3d 100644 --- a/src/Uplink/Components/Admin/Authorize_Button_Controller.php +++ b/src/Uplink/Components/Admin/Authorize_Button_Controller.php @@ -9,6 +9,7 @@ use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; use StellarWP\Uplink\Components\Controller; use StellarWP\Uplink\Config; +use StellarWP\Uplink\Resources\Collection; use StellarWP\Uplink\View\Contracts\View; final class Authorize_Button_Controller extends Controller { @@ -33,6 +34,16 @@ final class Authorize_Button_Controller extends Controller { */ private $url_builder; + /** + * @var Collection + */ + private $resources; + + /** + * @var Disconnect_Controller + */ + private $disconnect_controller; + /** * @param View $view The View Engine to render views. * @param Authorizer $authorizer Determines if the current user can perform actions. @@ -43,13 +54,17 @@ public function __construct( View $view, Authorizer $authorizer, Token_Manager $token_manager, - Auth_Url_Builder $url_builder + Auth_Url_Builder $url_builder, + Collection $resources, + Disconnect_Controller $disconnect_controller ) { parent::__construct( $view ); - $this->authorizer = $authorizer; - $this->token_manager = $token_manager; - $this->url_builder = $url_builder; + $this->authorizer = $authorizer; + $this->token_manager = $token_manager; + $this->url_builder = $url_builder; + $this->resources = $resources; + $this->disconnect_controller = $disconnect_controller; } /** @@ -77,10 +92,17 @@ public function render( array $args = [] ): void { return; } + $plugin = $this->resources->offsetGet( $slug ); + + if ( ! $plugin ) { + return; + } + $authenticated = false; $target = '_blank'; $link_text = __( 'Connect', '%TEXTDOMAIN%' ); $classes = [ + 'button', 'uplink-authorize', 'not-authorized', ]; @@ -89,12 +111,12 @@ public function render( array $args = [] ): void { $target = '_self'; $link_text = __( 'Contact your network administrator to connect', '%TEXTDOMAIN%' ); $url = get_admin_url( get_current_blog_id(), 'network/' ); - } elseif ( $this->token_manager->get() ) { + } elseif ( $this->token_manager->get( $plugin ) ) { $authenticated = true; $target = '_self'; $link_text = __( 'Disconnect', '%TEXTDOMAIN%' ); - $url = wp_nonce_url( add_query_arg( [ Disconnect_Controller::ARG => true ], get_admin_url( get_current_blog_id() ) ), Disconnect_Controller::ARG ); - $classes[1] = 'authorized'; + $url = $this->disconnect_controller->get_url( $plugin ); + $classes[2] = 'authorized'; } $hook_prefix = Config::get_hook_prefix(); diff --git a/src/Uplink/Config.php b/src/Uplink/Config.php index a06563b6..6d4087cb 100644 --- a/src/Uplink/Config.php +++ b/src/Uplink/Config.php @@ -6,6 +6,8 @@ use RuntimeException; use StellarWP\ContainerContract\ContainerInterface; use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; +use StellarWP\Uplink\Storage\Contracts\Storage; +use StellarWP\Uplink\Storage\Drivers\Option_Storage; use StellarWP\Uplink\Utils\Sanitize; class Config { @@ -43,6 +45,13 @@ class Config { */ protected static $auth_cache_expiration = self::DEFAULT_AUTH_CACHE; + /** + * The storage driver FQCN to use. + * + * @var class-string + */ + protected static $storage_driver = Option_Storage::class; + /** * Get the container. * @@ -208,4 +217,26 @@ public static function get_auth_cache_expiration(): int { return static::$auth_cache_expiration; } + /** + * Set the underlying storage driver. + * + * @param class-string $class_name The FQCN to a storage driver. + * + * @return void + */ + public static function set_storage_driver( string $class_name ): void { + static::$storage_driver = $class_name; + } + + /** + * Get the underlying storage driver. + * + * @return class-string + */ + public static function get_storage_driver(): string { + $driver = static::$storage_driver; + + return $driver ?: Option_Storage::class; + } + } diff --git a/src/Uplink/Notice/Notice_Controller.php b/src/Uplink/Notice/Notice_Controller.php index 4d745217..9bca9ece 100644 --- a/src/Uplink/Notice/Notice_Controller.php +++ b/src/Uplink/Notice/Notice_Controller.php @@ -3,6 +3,7 @@ namespace StellarWP\Uplink\Notice; use StellarWP\Uplink\Components\Controller; +use StellarWP\Uplink\View\Exceptions\FileNotFoundException; /** * Renders a notice. @@ -22,6 +23,8 @@ final class Notice_Controller extends Controller { * * @param array{type?: string, message?: string, dismissible?: bool, alt?: bool, large?: bool} $args The notice. * + * @throws FileNotFoundException If the view is not found. + * * @return void */ public function render( array $args = [] ): void { @@ -34,8 +37,27 @@ public function render( array $args = [] ): void { ]; echo $this->view->render( self::VIEW, [ - 'message' => $args['message'], - 'classes' => $this->classes( $classes ) + 'message' => $args['message'], + 'classes' => $this->classes( $classes ), + 'allowed_tags' => [ + 'a' => [ + 'href' => [], + 'title' => [], + 'target' => [], + 'rel' => [], + ], + 'br' => [], + 'code' => [], + 'em' => [], + 'pre' => [], + 'span' => [], + 'strong' => [], + ], + 'allowed_protocols' => [ + 'http', + 'https', + 'mailto', + ], ] ); } diff --git a/src/Uplink/Notice/Notice_Handler.php b/src/Uplink/Notice/Notice_Handler.php index 3d8b122b..6ab93fad 100644 --- a/src/Uplink/Notice/Notice_Handler.php +++ b/src/Uplink/Notice/Notice_Handler.php @@ -2,6 +2,8 @@ namespace StellarWP\Uplink\Notice; +use StellarWP\Uplink\Storage\Contracts\Storage; + /** * An improved admin notice system for general messages. * @@ -9,7 +11,7 @@ */ final class Notice_Handler { - public const TRANSIENT = 'stellarwp_uplink_notices'; + public const STORAGE_KEY = 'stellarwp_uplink_notices'; /** * Handles rendering notices. @@ -18,14 +20,23 @@ final class Notice_Handler { */ private $controller; + /** + * @var Storage + */ + private $storage; + /** * @var Notice[] */ private $notices; - public function __construct( Notice_Controller $controller ) { - $this->notices = $this->all(); + public function __construct( + Notice_Controller $controller, + Storage $storage + ) { $this->controller = $controller; + $this->storage = $storage; + $this->notices = $this->all(); } /** @@ -65,7 +76,7 @@ public function display(): void { * @return Notice[] */ private function all(): array { - return array_filter( (array) get_transient( self::TRANSIENT ) ); + return array_filter( (array) $this->storage->get( self::STORAGE_KEY ) ); } /** @@ -74,7 +85,7 @@ private function all(): array { * @return bool */ private function save(): bool { - return set_transient( self::TRANSIENT, $this->notices, 300 ); + return $this->storage->set( self::STORAGE_KEY, $this->notices, 300 ); } /** @@ -83,7 +94,7 @@ private function save(): bool { * @return bool */ private function clear(): bool { - return delete_transient( self::TRANSIENT ); + return $this->storage->delete( self::STORAGE_KEY ); } } diff --git a/src/Uplink/Notice/Provider.php b/src/Uplink/Notice/Provider.php index 8c603a58..625413d1 100644 --- a/src/Uplink/Notice/Provider.php +++ b/src/Uplink/Notice/Provider.php @@ -3,6 +3,7 @@ namespace StellarWP\Uplink\Notice; use StellarWP\Uplink\Contracts\Abstract_Provider; +use StellarWP\Uplink\Storage\Contracts\Storage; final class Provider extends Abstract_Provider { @@ -12,7 +13,7 @@ final class Provider extends Abstract_Provider { public function register(): void { $this->container->singleton( Notice_Controller::class, Notice_Controller::class ); $this->container->singleton( Notice_Handler::class, static function ( $c ): Notice_Handler { - return new Notice_Handler( $c->get( Notice_Controller::class ) ); + return new Notice_Handler( $c->get( Notice_Controller::class ), $c->get( Storage::class ) ); } ); add_action( 'admin_notices', [ $this->container->get( Notice_Handler::class ), 'display' ], 12, 0 ); diff --git a/src/Uplink/Register.php b/src/Uplink/Register.php index a8eb8bcd..7ec6ec94 100644 --- a/src/Uplink/Register.php +++ b/src/Uplink/Register.php @@ -10,24 +10,27 @@ class Register { * Register a plugin resource. * * @since 1.0.0 + * @since TBD Added oAuth parameter. * * @param string $slug Resource slug. * @param string $name Resource name. * @param string $version Resource version. * @param string $path Resource path to bootstrap file. * @param string $class Resource class. - * @param string $license_class Resource license class + * @param string $license_class Resource license class. + * @param bool $is_oauth Is the plugin using OAuth? * * @return Resources\Resource */ - public static function plugin( $slug, $name, $version, $path, $class, $license_class = null ) { - return Resources\Plugin::register( $slug, $name, $version, $path, $class, $license_class ); + public static function plugin( $slug, $name, $version, $path, $class, $license_class = null, $is_oauth = false ) { + return Resources\Plugin::register( $slug, $name, $version, $path, $class, $license_class, $is_oauth ); } /** * Register a service resource. * * @since 1.0.0 + * @since TBD Added oAuth parameter. * * @param string $slug Resource slug. * @param string $name Resource name. @@ -35,10 +38,11 @@ public static function plugin( $slug, $name, $version, $path, $class, $license_c * @param string $path Resource path to bootstrap file. * @param string $class Resource class. * @param string $license_class Resource license class. + * @param bool $is_oauth Is the plugin using OAuth? * * @return Resources\Resource */ - public static function service( $slug, $name, $version, $path, $class, $license_class = null ) { - return Resources\Service::register( $slug, $name, $version, $path, $class, $license_class ); + public static function service( $slug, $name, $version, $path, $class, $license_class = null, $is_oauth = false) { + return Resources\Service::register( $slug, $name, $version, $path, $class, $license_class, $is_oauth ); } } diff --git a/src/Uplink/Resources/Collection.php b/src/Uplink/Resources/Collection.php index eeae0431..754cbb9c 100644 --- a/src/Uplink/Resources/Collection.php +++ b/src/Uplink/Resources/Collection.php @@ -60,6 +60,16 @@ public function current(): Resource { return current( $this->resources ); } + /** + * Alias of offsetGet(). + * + * @return Resource|null + */ + #[\ReturnTypeWillChange] + public function get( $offset ): ?Resource { + return $this->offsetGet( $offset ); + } + /** * Gets the resource with the given path. * diff --git a/src/Uplink/Resources/License.php b/src/Uplink/Resources/License.php index ec14f4eb..ea8fc185 100644 --- a/src/Uplink/Resources/License.php +++ b/src/Uplink/Resources/License.php @@ -156,8 +156,21 @@ public function get_key( $type = 'any' ) { * @since 1.0.0 * * @param string|null $key The license key. + * @param Resource $resource The resource instance. */ - $key = apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix(). '/license_get_key', $this->key ); + $key = apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix(). '/license_get_key', $this->key, $this->resource ); + + /** + * Filter the license key. + * + * Accepts the resource's slug dynamically. + * + * @since TBD + * + * @param string|null $key The license key. + * @param Resource $resource The resource instance. + */ + $key = apply_filters( 'stellarwp/uplink/' . Config::get_hook_prefix(). '/' . $this->resource->get_slug() . '/license_get_key', $key, $this->resource ); return $key ?: ''; } diff --git a/src/Uplink/Resources/Plugin.php b/src/Uplink/Resources/Plugin.php index 4c65928f..aab50f15 100644 --- a/src/Uplink/Resources/Plugin.php +++ b/src/Uplink/Resources/Plugin.php @@ -169,8 +169,8 @@ protected function get_update_status_option_name(): string { /** * @inheritDoc */ - public static function register( $slug, $name, $version, $path, $class, string $license_class = null ) { - return parent::register_resource( static::class, $slug, $name, $version, $path, $class, $license_class ); + public static function register( $slug, $name, $version, $path, $class, string $license_class = null, bool $is_oauth = false ) { + return parent::register_resource( static::class, $slug, $name, $version, $path, $class, $license_class, $is_oauth ); } /** diff --git a/src/Uplink/Resources/Resource.php b/src/Uplink/Resources/Resource.php index a68e0dd3..6e290e47 100644 --- a/src/Uplink/Resources/Resource.php +++ b/src/Uplink/Resources/Resource.php @@ -4,6 +4,7 @@ use StellarWP\ContainerContract\ContainerInterface; use StellarWP\Uplink\API; +use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; use StellarWP\Uplink\Config; use StellarWP\Uplink\Exceptions; use StellarWP\Uplink\Site\Data; @@ -112,10 +113,20 @@ abstract class Resource { */ protected $home_url; + /** + * Is the plugin using OAuth? + * + * @since TBD + * + * @var bool + */ + protected $is_oauth = false; + /** * Constructor. * * @since 1.0.0 + * @since TBD Added oAuth parameter. * * @param string $slug Resource slug. * @param string $name Resource name. @@ -123,8 +134,9 @@ abstract class Resource { * @param string $path Resource path to bootstrap file. * @param string $class Resource class. * @param string|null $license_class Class that holds the embedded license key. + * @param bool $is_oauth Is the plugin using OAuth? */ - public function __construct( $slug, $name, $version, $path, $class, string $license_class = null ) { + public function __construct( $slug, $name, $version, $path, $class, string $license_class = null, bool $is_oauth = false) { $this->name = $name; $this->slug = $slug; $this->path = $path; @@ -132,6 +144,7 @@ public function __construct( $slug, $name, $version, $path, $class, string $lice $this->license_class = $license_class; $this->version = $version; $this->container = Config::get_container(); + $this->is_oauth = $is_oauth; } /** @@ -147,6 +160,17 @@ public function delete_license_key( $type = 'local' ): bool { return $this->get_license_object()->delete_key( $type ); } + /** + * Get if the plugin is using oAuth. + * + * @since TBD + * + * @return bool + */ + public function is_using_oauth(): bool { + return $this->is_oauth; + } + /** * Gets the resource class. * @@ -192,6 +216,30 @@ public function get_download_args() { return $args; } + /** + * Get the Resource's oAuth token. + * + * @since TBD + * + * @return ?string + */ + public function get_token(): ?string { + return $this->container->get( Token_Manager::class )->get( $this ); + } + + /** + * Store the Resource's oAuth token. + * + * @since TBD + * + * @param string $token The token to store. + * + * @return bool + */ + public function store_token( string $token ): bool { + return $this->container->get( Token_Manager::class )->store( $token, $this ); + } + /** * Get the currently installed version of the plugin. * @@ -402,6 +450,7 @@ abstract public static function register( $name, $slug, $path, $class, $version, * Register a resource and add it to the collection. * * @since 1.0.0 + * @since TBD Added oAuth parameter. * * @param string $resource_class Resource class. * @param string $slug Resource slug. @@ -410,12 +459,13 @@ abstract public static function register( $name, $slug, $path, $class, $version, * @param string $path Resource path to bootstrap file. * @param string $class Resource class. * @param string|null $license_class Class that holds the embedded license key. + * @param bool $is_oauth Is the plugin using OAuth? * * @return Resource */ - public static function register_resource( $resource_class, $slug, $name, $version, $path, $class, string $license_class = null ) { + public static function register_resource( $resource_class, $slug, $name, $version, $path, $class, string $license_class = null, bool $is_oauth = false ) { /** @var Resource */ - $resource = new $resource_class( $slug, $name, $version, $path, $class, $license_class ); + $resource = new $resource_class( $slug, $name, $version, $path, $class, $license_class, $is_oauth ); /** @var Collection */ $collection = Config::get_container()->get( Collection::class ); diff --git a/src/Uplink/Resources/Service.php b/src/Uplink/Resources/Service.php index c8e76c3f..5e874de3 100644 --- a/src/Uplink/Resources/Service.php +++ b/src/Uplink/Resources/Service.php @@ -11,7 +11,7 @@ class Service extends Resource { /** * @inheritDoc */ - public static function register( $slug, $name, $version, $path, $class, string $license_class = null ) { - return parent::register_resource( static::class, $slug, $name, $version, $path, $class, $license_class ); + public static function register( $slug, $name, $version, $path, $class, string $license_class = null, bool $is_oauth = false) { + return parent::register_resource( static::class, $slug, $name, $version, $path, $class, $license_class, $is_oauth ); } } diff --git a/src/Uplink/Storage/Contracts/Storage.php b/src/Uplink/Storage/Contracts/Storage.php new file mode 100644 index 00000000..f9683145 --- /dev/null +++ b/src/Uplink/Storage/Contracts/Storage.php @@ -0,0 +1,63 @@ +option_name = $option_name; + } + + /** + * Put a value in storage. + * + * @param string|int|float|mixed[]|object $key The storage key. Accepts any variable that can be json encoded. + * @param mixed $value The value to store. + * @param int $expire The storage lifespan in seconds. + * + * @throws Invalid_Key_Exception If passed an invalid storage key. + */ + public function set( $key, $value, int $expire = 0 ): bool { + $data = (array) get_option( $this->option_name, [] ); + + $data[ $this->key( $key ) ] = [ + 'value' => $value, + 'expiration' => $expire > 0 ? time() + $expire : 0, + ]; + + return update_option( $this->option_name, $data ); + } + + /** + * Get a value from storage. + * + * @param string|int|float|mixed[]|object $key The storage key. Accepts any variable that can be json encoded. + * + * @throws Invalid_Key_Exception If passed an invalid storage key. + * + * @return null|mixed Returns null if we can't find the storage value. + */ + public function get( $key ) { + $data = (array) get_option( $this->option_name, [] ); + $transient = $data[ $this->key( $key ) ] ?? []; + + if ( isset( $transient['expiration'] ) && $transient['expiration'] > 0 && $transient['expiration'] < time() ) { + $this->delete( $key ); + + return null; + } + + return $transient['value'] ?? null; + } + + /** + * Delete a value from storage. + * + * @param string|int|float|mixed[]|object $key The storage key. + * + * @throws Invalid_Key_Exception If passed an invalid storage key. + */ + public function delete( $key ): bool { + $data = (array) get_option( $this->option_name, [] ); + + unset( $data[ $this->key( $key ) ] ); + + return update_option( $this->option_name, $data ); + } + + /** + * Get an item from storage, or execute the given Closure and store the result. + * + * @param string|int|float|mixed[]|object $key The storage key. + * @param Closure $callback The callback used to generate and store the value. + * @param int $expire The storage lifespan in seconds. + * + * @throws Invalid_Key_Exception If passed an invalid storage key. + * + * @return mixed The storage value. + */ + public function remember( $key, Closure $callback, int $expire = 0 ) { + $value = $this->get( $key ); + + if ( ! is_null( $value ) ) { + return $value; + } + + $value = $callback(); + + $this->set( $key, $value, $expire ); + + return $value; + } + + /** + * Retrieve an item from storage and delete it. + * + * @param string|int|float|mixed[]|object $key The storage key. + * + * @throws Invalid_Key_Exception If passed an invalid storage key. + */ + public function pull( $key ) { + $value = $this->get( $key ); + + $this->delete( $key ); + + return $value; + } + +} diff --git a/src/Uplink/Storage/Drivers/Transient_Storage.php b/src/Uplink/Storage/Drivers/Transient_Storage.php new file mode 100644 index 00000000..40e636f1 --- /dev/null +++ b/src/Uplink/Storage/Drivers/Transient_Storage.php @@ -0,0 +1,93 @@ +key( $key ), $value, $expire ); + } + + /** + * Get a value from storage. + * + * @param string|int|float|mixed[]|object $key The storage key. Accepts any variable that can be json encoded. + * + * @throws Invalid_Key_Exception If passed an invalid storage key. + * + * @return null|mixed Returns null if we can't find the storage value. + */ + public function get( $key ) { + $value = get_transient( $this->key( $key ) ); + + return $value !== false ? $value : null; + } + + /** + * Delete a value from storage. + * + * @param string|int|float|mixed[]|object $key The storage key. + * + * @throws Invalid_Key_Exception If passed an invalid storage key. + */ + public function delete( $key ): bool { + return (bool) delete_transient( $this->key( $key ) ); + } + + /** + * Get an item from storage, or execute the given Closure and store the result. + * + * @param string|int|float|mixed[]|object $key The storage key. + * @param Closure $callback The callback used to generate and store the value. + * @param int $expire The storage lifespan in seconds. + * + * @throws Invalid_Key_Exception If passed an invalid storage key. + * + * @return mixed The storage value. + */ + public function remember( $key, Closure $callback, int $expire = 0 ) { + $value = $this->get( $key ); + + if ( ! is_null( $value ) ) { + return $value; + } + + $value = $callback(); + + $this->set( $key, $value, $expire ); + + return $value; + } + + /** + * Retrieve an item from storage and delete it. + * + * @param string|int|float|mixed[]|object $key The storage key. + * + * @throws Invalid_Key_Exception If passed an invalid storage key. + */ + public function pull( $key ) { + $value = $this->get( $key ); + + $this->delete( $key ); + + return $value; + } + +} diff --git a/src/Uplink/Storage/Exceptions/Invalid_Key_Exception.php b/src/Uplink/Storage/Exceptions/Invalid_Key_Exception.php new file mode 100644 index 00000000..75159145 --- /dev/null +++ b/src/Uplink/Storage/Exceptions/Invalid_Key_Exception.php @@ -0,0 +1,12 @@ +container->singleton( Option_Storage::class, function () { + $option_name = Config::get_hook_prefix() . '_storage'; + + return new Option_Storage( $option_name ); + } ); + + $this->container->singleton( Storage::class, static function( $c ): Storage { + return $c->get( Config::get_storage_driver() ); + } ); + } + +} diff --git a/src/Uplink/Storage/Traits/With_Key_Formatter.php b/src/Uplink/Storage/Traits/With_Key_Formatter.php new file mode 100644 index 00000000..d0c656ce --- /dev/null +++ b/src/Uplink/Storage/Traits/With_Key_Formatter.php @@ -0,0 +1,28 @@ +singleton( self::UPLINK_ADMIN_VIEWS_PATH, dirname( __DIR__ ) . '/admin-views' ); $container->singleton( self::UPLINK_ASSETS_URI, dirname( plugin_dir_url( __FILE__ ) ) . '/assets' ); $container->bind( ContainerInterface::class, $container ); + $container->singleton( Storage\Provider::class, Storage\Provider::class ); $container->singleton( View\Provider::class, View\Provider::class ); $container->singleton( API\Client::class, API\Client::class ); $container->singleton( API\V3\Provider::class, API\V3\Provider::class ); @@ -37,6 +40,7 @@ public static function init(): void { $container->singleton( Auth\Provider::class, Auth\Provider::class ); if ( static::is_enabled() ) { + $container->get( Storage\Provider::class )->register(); $container->get( View\Provider::class )->register(); $container->get( API\V3\Provider::class )->register(); $container->get( Notice\Provider::class )->register(); diff --git a/src/Uplink/functions.php b/src/Uplink/functions.php index e2544006..51096627 100644 --- a/src/Uplink/functions.php +++ b/src/Uplink/functions.php @@ -2,11 +2,33 @@ namespace StellarWP\Uplink; +use StellarWP\ContainerContract\ContainerInterface; +use StellarWP\Uplink\Admin\Fields\Field; +use StellarWP\Uplink\Admin\Fields\Form; +use StellarWP\Uplink\API\V3\Auth\Contracts\Auth_Url; use StellarWP\Uplink\API\V3\Auth\Contracts\Token_Authorizer; +use StellarWP\Uplink\Auth\Admin\Disconnect_Controller; use StellarWP\Uplink\Auth\Auth_Url_Builder; -use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; +use StellarWP\Uplink\Auth\Authorizer; use StellarWP\Uplink\Components\Admin\Authorize_Button_Controller; +use StellarWP\Uplink\Resources\Collection; +use StellarWP\Uplink\Resources\Plugin; +use StellarWP\Uplink\Resources\Service; +use StellarWP\Uplink\Resources\Resource; +use StellarWP\Uplink\Site\Data; use Throwable; +use RuntimeException; + +/** + * Get the uplink container. + * + * @throws \RuntimeException + * + * @return ContainerInterface + */ +function get_container(): ContainerInterface { + return Config::get_container(); +} /** * Displays the token authorization button, which allows admins to @@ -18,7 +40,7 @@ */ function render_authorize_button( string $slug, string $domain = '' ): void { try { - Config::get_container()->get( Authorize_Button_Controller::class ) + get_container()->get( Authorize_Button_Controller::class ) ->render( [ 'slug' => $slug, 'domain' => $domain, @@ -31,20 +53,18 @@ function render_authorize_button( string $slug, string $domain = '' ): void { } /** - * Get the stored authorization token. + * Get the stored authorization token, automatically detects multisite. + * + * @param string $slug The plugin/service slug to use to determine if we use network/single site token storage. + * + * @throws \RuntimeException * * @return string|null */ -function get_authorization_token(): ?string { - try { - return Config::get_container()->get( Token_Manager::class )->get(); - } catch ( Throwable $e ) { - if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { - error_log( "Error occurred when fetching token: {$e->getMessage()} {$e->getFile()}:{$e->getLine()} {$e->getTraceAsString()}" ); - } +function get_authorization_token( string $slug ): ?string { + $resource = get_resource( $slug ); - return null; - } + return $resource ? $resource->get_token() : null; } /** @@ -53,16 +73,17 @@ function get_authorization_token(): ?string { * @note This response may be cached. * * @param string $license The license key. - * @param string $token The stored token. - * @param string $domain The user's domain. + * @param string $slug The plugin/service slug. + * @param string $token The stored token. + * @param string $domain The user's license domain. * * @return bool */ -function is_authorized( string $license, string $token, string $domain ): bool { +function is_authorized( string $license, string $slug, string $token, string $domain ): bool { try { - return Config::get_container() - ->get( Token_Authorizer::class ) - ->is_authorized( $license, $token, $domain ); + return get_container() + ->get( Token_Authorizer::class ) + ->is_authorized( $license, $slug, $token, $domain ); } catch ( Throwable $e ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( "An Authorization error occurred: {$e->getMessage()} {$e->getFile()}:{$e->getLine()} {$e->getTraceAsString()}" ); @@ -72,6 +93,19 @@ function is_authorized( string $license, string $token, string $domain ): bool { } } +/** + * If the current user is allowed to perform token authorization. + * + * Without being filtered, this just runs a is_super_admin() check. + * + * @throws \RuntimeException + * + * @return bool + */ +function is_user_authorized(): bool { + return get_container()->get( Authorizer::class )->can_auth(); +} + /** * Build a brand's authorization URL, with the uplink_callback base64 query variable. * @@ -92,3 +126,146 @@ function build_auth_url( string $slug, string $domain = '' ): string { return ''; } } + +/** + * Get a resource (plugin/service) from the collection. + * + * @param string $slug The resource slug to find. + * + * @throws \RuntimeException + * + * @return Resource|Plugin|Service|null + */ +function get_resource( string $slug ) { + return get_container()->get( Collection::class )->offsetGet( $slug ); +} + +/** + * Get a resource's license key. + * + * @param string $slug The plugin/service slug. + * @param string $type The type of key to get (any, network, local, default). + * + * @throws \RuntimeException + * + * @return string + */ +function get_license_key( string $slug, string $type = 'any' ): string { + $resource = get_resource( $slug ); + + if ( ! $resource ) { + return ''; + } + + return $resource->get_license_key( $type ); +} + +/** + * Set a resource's license key. + * + * @param string $slug The plugin/service slug. + * @param string $license The license key to store. + * @param string $type The type of key to set (any, network, local, default). + * + * @throws \RuntimeException + * + * @return bool + */ +function set_license_key( string $slug, string $license, string $type = 'local' ): bool { + $resource = get_resource( $slug ); + + if ( ! $resource ) { + return false; + } + + $result = $resource->set_license_key( $license, $type ); + + // Force update the key status. + $resource->validate_license( $license, $type === 'network' ); + + return $result; +} + +/** + * Get the disconnect token URL. + * + * @param string $slug The plugin/service slug. + * + * @throws \RuntimeException + * + * @return string + */ +function get_disconnect_url( string $slug ): string { + $resource = get_resource( $slug ); + + if ( ! $resource ) { + return ''; + } + + return get_container()->get( Disconnect_Controller::class )->get_url( $resource ); +} + +/** + * Retrieve an Origin's auth url, if it exists. + * + * @param string $slug The product/service slug. + * + * @throws \RuntimeException + * + * @return string + */ +function get_auth_url( string $slug ): string { + return get_container()->get( Auth_Url::class )->get( $slug ); +} + +/** + * Get the current site's license domain, multisite friendly. + * + * @throws \RuntimeException + * + * @return string + */ +function get_license_domain(): string { + return get_container()->get( Data::class )->get_domain(); +} + +/** + * Get the field object for a resource's slug. + * + * @param string $slug The resource's slug to get the field for. + * + * @throws RuntimeException + * + * @return Field + */ +function get_field( string $slug ): Field { + $resource = get_container()->get( Collection::class )->offsetGet( $slug ); + + if ( ! $resource ) { + throw new RuntimeException( "Resource not found for slug: {$slug}" ); + } + + return get_container()->get( Field::class )->set_resource( $resource ); +} + +/** + * Get the form object for all plugins. + * + * @throws RuntimeException + * + * @return Form + */ +function get_form(): Form { + return get_container()->get( Form::class ); +} + +/** + * Get all plugins. + * + * @throws RuntimeException + * + * @return Collection + */ +function get_plugins(): Collection { + return get_container()->get( Collection::class )->get_plugins(); +} diff --git a/src/admin-views/fields/settings.php b/src/admin-views/fields/settings.php index 5e379ad4..eacd3d40 100644 --- a/src/admin-views/fields/settings.php +++ b/src/admin-views/fields/settings.php @@ -7,6 +7,7 @@ */ use StellarWP\Uplink\Admin\License_Field; +use StellarWP\Uplink\Admin\Group; use StellarWP\Uplink\Config; if ( empty( $plugin ) ) { @@ -14,7 +15,7 @@ } $field = Config::get_container()->get( License_Field::class ); -$group = $field->get_group_name( sanitize_title( $plugin->get_slug() ) ); +$group = Config::get_container()->get( Group::class )->get_name( sanitize_title( $plugin->get_slug() ) ); $action_postfix = Config::get_hook_prefix_underscored(); ?> diff --git a/src/assets/css/main.css b/src/assets/css/main.css index daf5cae0..9344e611 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -13,3 +13,38 @@ .invalid-key { color: red; } + +.uplink-authorize { + margin-bottom: 16px !important; + transition: border-color 300ms ease-in-out; +} + +.uplink-authorize.not-authorized { + background: #b8e6bf; + border-color: #00a32a; + color:#000; +} + +.uplink-authorize.not-authorized:hover, +.uplink-authorize.not-authorized:focus, +.uplink-authorize.not-authorized:active { + background: #00a32a; + border-color: #00a32a; + color: #000; + box-shadow: none; +} + +.uplink-authorize.authorized { + background: #f5e6ab; + border-color: #f0c33c; + color:#000; +} + +.uplink-authorize.authorized:hover, +.uplink-authorize.authorized:focus, +.uplink-authorize.authorized:active { + background: #f0c33c; + border-color: #f0c33c; + color: #000; + box-shadow: none; +} diff --git a/src/assets/js/key-admin.js b/src/assets/js/key-admin.js index 65cda2f7..cef4a7ba 100644 --- a/src/assets/js/key-admin.js +++ b/src/assets/js/key-admin.js @@ -37,11 +37,13 @@ let licenseKey = field.val().trim(); field.val( licenseKey ); + const nonceField = $($el).find('.wp-nonce-fluent') || $($el).find('.wp-nonce'); + const data = { action: window[`stellarwp_config_${action}`]['action'], slug: slug, key: licenseKey, - _wpnonce: $($el).find('.wp-nonce').val() + _wpnonce: nonceField.val() }; $.post(ajaxurl, data, function (response) { diff --git a/src/views/admin/fields/field.php b/src/views/admin/fields/field.php new file mode 100644 index 00000000..bfba4f05 --- /dev/null +++ b/src/views/admin/fields/field.php @@ -0,0 +1,61 @@ + + +get_slug() ); +?> +should_show_label() ) : ?> + + + + + + + +get_slug() ); diff --git a/src/views/admin/fields/form.php b/src/views/admin/fields/form.php new file mode 100644 index 00000000..fcc28cfa --- /dev/null +++ b/src/views/admin/fields/form.php @@ -0,0 +1,45 @@ +get_fields() ) ) { + return; +} + +$action_postfix = Config::get_hook_prefix_underscored(); + +?> + + +

+ +

+
+ +
+
diff --git a/src/views/admin/notice.php b/src/views/admin/notice.php index e3e9c702..de6f6ed5 100644 --- a/src/views/admin/notice.php +++ b/src/views/admin/notice.php @@ -4,12 +4,14 @@ * * @see \StellarWP\Uplink\Notice\Notice_Controller * - * @var string $message The message to display. - * @var string $classes The CSS classes for the notice. + * @var string $message The message to display. + * @var string $classes The CSS classes for the notice. + * @var array $allowed_tags The allowed HTML tags for wp_kses(). + * @var string[] $allowed_protocols The allowed protocols for wp_kses(). */ defined( 'ABSPATH' ) || exit; ?>
-

+

diff --git a/tests/_support/Helper/TestUtils.php b/tests/_support/Helper/TestUtils.php new file mode 100644 index 00000000..aaf0e468 --- /dev/null +++ b/tests/_support/Helper/TestUtils.php @@ -0,0 +1,62 @@ +get_base(); + + return [ + [ + 'slug' => 'plugin-1', + 'name' => 'Plugin 1', + 'path' => $base . '/plugin.php', + 'class' => Uplink::class, + 'license_class' => Uplink::class, + 'version' => '1.0.0', + 'type' => 'plugin', + ], + [ + 'slug' => 'plugin-2', + 'name' => 'Plugin 2', + 'path' => $base . '/plugin.php', + 'class' => Uplink::class, + 'license_class' => Uplink::class, + 'version' => '2.0.0', + 'type' => 'plugin', + ], + [ + 'slug' => 'service-1', + 'name' => 'Service 1', + 'path' => $base . '/service1.php', + 'class' => Uplink::class, + 'license_class' => Uplink::class, + 'version' => '3.0.0', + 'type' => 'service', + ], + [ + 'slug' => 'service-2', + 'name' => 'Service 2', + 'path' => $base . '/service2.php', + 'class' => Uplink::class, + 'license_class' => Uplink::class, + 'version' => '4.0.0', + 'type' => 'service', + ], + ]; + } + + /** + * @before + */ + public function get_base() { + return dirname( __DIR__, 2 ); + } +} diff --git a/tests/_support/Helper/UplinkTestCase.php b/tests/_support/Helper/UplinkTestCase.php index 61911f0a..6c5e6a5a 100644 --- a/tests/_support/Helper/UplinkTestCase.php +++ b/tests/_support/Helper/UplinkTestCase.php @@ -1,11 +1,13 @@ -assertTrue( $screen->in_admin( 'network' ) ); + } + + $this->assertTrue( $screen->in_admin() ); + + // Fire off admin_init to run any of our events hooked into this action. + do_action( 'admin_init' ); + } + } diff --git a/tests/wpunit/API/V3/AuthUrlTest.php b/tests/wpunit/API/V3/AuthUrlTest.php index 370fe438..99f33905 100644 --- a/tests/wpunit/API/V3/AuthUrlTest.php +++ b/tests/wpunit/API/V3/AuthUrlTest.php @@ -6,10 +6,22 @@ use StellarWP\Uplink\API\V3\Auth\Auth_Url_Cache_Decorator; use StellarWP\Uplink\API\V3\Contracts\Client_V3; use StellarWP\Uplink\Auth\Auth_Url_Builder; +use StellarWP\Uplink\Storage\Contracts\Storage; use StellarWP\Uplink\Tests\UplinkTestCase; final class AuthUrlTest extends UplinkTestCase { + /** + * @var Storage + */ + private $storage; + + protected function setUp(): void { + parent::setUp(); + + $this->storage = $this->container->get( Storage::class ); + } + public function test_the_cache_decorator_is_enabled(): void { $auth_url = $this->container->get( \StellarWP\Uplink\API\V3\Auth\Contracts\Auth_Url::class ); @@ -97,7 +109,7 @@ public function test_it_caches_a_valid_auth_url(): void { $auth_url = $this->container->get( Auth_Url_Cache_Decorator::class )->get( 'kadence-blocks-pro' ); $this->assertSame( 'https://www.kadencewp.com/account-auth/', $auth_url ); - $this->assertSame( 'https://www.kadencewp.com/account-auth/', get_transient( Auth_Url_Cache_Decorator::TRANSIENT_PREFIX . 'kadence_blocks_pro' ) ); + $this->assertSame( 'https://www.kadencewp.com/account-auth/', $this->storage->get( Auth_Url_Cache_Decorator::TRANSIENT_PREFIX . 'kadence_blocks_pro' ) ); } public function test_it_caches_an_empty_auth_url(): void { @@ -117,7 +129,7 @@ public function test_it_caches_an_empty_auth_url(): void { $auth_url = $this->container->get( Auth_Url_Cache_Decorator::class )->get( 'kadence-blocks-pro' ); $this->assertSame( '', $auth_url ); - $this->assertSame( '', get_transient( Auth_Url_Cache_Decorator::TRANSIENT_PREFIX . 'kadence_blocks_pro' ) ); + $this->assertSame( '', $this->storage->get( Auth_Url_Cache_Decorator::TRANSIENT_PREFIX . 'kadence_blocks_pro' ) ); } } diff --git a/tests/wpunit/API/V3/AuthorizerCacheTest.php b/tests/wpunit/API/V3/AuthorizerCacheTest.php index 71a0726a..f8ca0573 100644 --- a/tests/wpunit/API/V3/AuthorizerCacheTest.php +++ b/tests/wpunit/API/V3/AuthorizerCacheTest.php @@ -4,10 +4,22 @@ use StellarWP\Uplink\API\V3\Auth\Contracts\Token_Authorizer; use StellarWP\Uplink\API\V3\Auth\Token_Authorizer_Cache_Decorator; +use StellarWP\Uplink\Storage\Contracts\Storage; use StellarWP\Uplink\Tests\UplinkTestCase; final class AuthorizerCacheTest extends UplinkTestCase { + /** + * @var Storage + */ + private $storage; + + protected function setUp(): void { + parent::setUp(); + + $this->storage = $this->container->get( Storage::class ); + } + public function test_it_binds_the_correct_instance_when_auth_cache_is_enabled(): void { $this->assertInstanceOf( Token_Authorizer_Cache_Decorator::class, @@ -28,15 +40,15 @@ public function test_it_caches_a_valid_token_response(): void { $transient = $decorator->build_transient( [ '1234', 'dc2c98d9-9ff8-4409-bfd2-a3cce5b5c840', 'test.com' ] ); // No cache should exist. - $this->assertFalse( get_transient( $transient ) ); + $this->assertNull( $this->storage->get( $transient ) ); - $authorized = $decorator->is_authorized( '1234', 'dc2c98d9-9ff8-4409-bfd2-a3cce5b5c840', 'test.com' ); + $authorized = $decorator->is_authorized( '1234', 'kadence-blocks-pro','dc2c98d9-9ff8-4409-bfd2-a3cce5b5c840', 'test.com' ); $this->assertTrue( $authorized ); // Cache should now be present. - $this->assertTrue( get_transient( $transient ) ); - $this->assertTrue( $decorator->is_authorized( '1234', 'dc2c98d9-9ff8-4409-bfd2-a3cce5b5c840', 'test.com' ) ); + $this->assertTrue( $this->storage->get( $transient ) ); + $this->assertTrue( $decorator->is_authorized( '1234', 'kadence-blocks-pro','dc2c98d9-9ff8-4409-bfd2-a3cce5b5c840', 'test.com' ) ); } public function test_it_does_not_cache_an_invalid_token_response(): void { @@ -52,14 +64,14 @@ public function test_it_does_not_cache_an_invalid_token_response(): void { $transient = $decorator->build_transient( [ '1234', 'dc2c98d9-9ff8-4409-bfd2-a3cce5b5c840', 'test.com' ] ); // No cache should exist. - $this->assertFalse( get_transient( $transient ) ); + $this->assertNull( $this->storage->get( $transient ) ); - $authorized = $decorator->is_authorized( '1234', 'dc2c98d9-9ff8-4409-bfd2-a3cce5b5c840', 'test.com' ); + $authorized = $decorator->is_authorized( '1234', 'kadence-blocks-pro','dc2c98d9-9ff8-4409-bfd2-a3cce5b5c840', 'test.com' ); $this->assertFalse( $authorized ); // Cache should still be empty, unfortunately the default is "false" for transients, so this isn't the best test. - $this->assertFalse( get_transient( $transient ) ); + $this->assertNull( $this->storage->get( $transient ) ); } } diff --git a/tests/wpunit/API/V3/AuthorizerTest.php b/tests/wpunit/API/V3/AuthorizerTest.php index 49a0490d..a7fc84f3 100644 --- a/tests/wpunit/API/V3/AuthorizerTest.php +++ b/tests/wpunit/API/V3/AuthorizerTest.php @@ -40,7 +40,7 @@ public function test_it_authorizes_a_valid_token(): void { $this->container->bind( Client_V3::class, $clientMock ); - $authorizer = $this->container->get( Token_Authorizer::class )->is_authorized( '1234', 'dc2c98d9-9ff8-4409-bfd2-a3cce5b5c840', 'test.com' ); + $authorizer = $this->container->get( Token_Authorizer::class )->is_authorized( '1234', 'kadence-blocks-pro','dc2c98d9-9ff8-4409-bfd2-a3cce5b5c840', 'test.com' ); $this->assertTrue( $authorizer ); } @@ -59,7 +59,7 @@ public function test_it_does_not_authorize_an_invalid_license_key(): void { $this->container->bind( Client_V3::class, $clientMock ); - $authorizer = $this->container->get( Token_Authorizer::class )->is_authorized( 'invalid-license-key', 'dc2c98d9-9ff8-4409-bfd2-a3cce5b5c840', 'test.com' ); + $authorizer = $this->container->get( Token_Authorizer::class )->is_authorized( 'invalid-license-key', 'kadence-blocks-pro','dc2c98d9-9ff8-4409-bfd2-a3cce5b5c840', 'test.com' ); $this->assertFalse( $authorizer ); } @@ -78,7 +78,7 @@ public function test_it_does_not_authorize_an_invalid_token(): void { $this->container->bind( Client_V3::class, $clientMock ); - $authorizer = $this->container->get( Token_Authorizer::class )->is_authorized( '1234', 'invalid', 'test.com' ); + $authorizer = $this->container->get( Token_Authorizer::class )->is_authorized( '1234', 'kadence-blocks-pro', 'invalid', 'test.com' ); $this->assertFalse( $authorizer ); } diff --git a/tests/wpunit/Admin/AjaxText.php b/tests/wpunit/Admin/AjaxText.php index 14e4ecf2..6c5a6384 100644 --- a/tests/wpunit/Admin/AjaxText.php +++ b/tests/wpunit/Admin/AjaxText.php @@ -3,6 +3,7 @@ namespace wpunit\Admin; use StellarWP\Uplink\Admin\Ajax; +use StellarWP\Uplink\Admin\Group; use StellarWP\Uplink\Admin\License_Field; use StellarWP\Uplink\Register; use StellarWP\Uplink\Tests\UplinkTestCase; @@ -36,7 +37,7 @@ public function test_validate_license() { $handler->validate_license(), 'Should return invalid request message if nonce or key is missing is empty' ); - $_POST['_wpnonce'] = wp_create_nonce( License_Field::get_group_name() ); + $_POST['_wpnonce'] = wp_create_nonce( $this->container->get( Group::class )->get_name() ); $_POST['key'] = 'sample'; $_POST['plugin'] = 'sample/index.php'; diff --git a/tests/wpunit/Admin/Fields/FieldTest.php b/tests/wpunit/Admin/Fields/FieldTest.php new file mode 100644 index 00000000..3e8e0a6b --- /dev/null +++ b/tests/wpunit/Admin/Fields/FieldTest.php @@ -0,0 +1,216 @@ +get_test_resources(); + + foreach ( $resources as $resource ) { + yield $resource['slug'] => [ $resource ]; + } + } + + public function setup_container_get_slug( $resource ) { + $collection = Config::get_container()->get( Collection::class ); + + Register::{$resource['type']}( + $resource['slug'], + $resource['name'], + $resource['version'], + $resource['path'], + $resource['class'], + ); + + return $collection->get( $resource['slug'] ); + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_get_fields_with_slug( $resource ) { + $current_resource = $this->setup_container_get_slug( $resource ); + $slug = $current_resource->get_slug(); + $field = $this->container->get( Field::class )->set_resource( $current_resource ); + $license_key = 'license_key' . $slug; + $option_name = $current_resource->get_license_object()->get_key_option_name(); + + // Update the license key to a known value. + update_option( $option_name, $license_key ); + $option_value = get_option( $option_name ); + $this->assertEquals( $option_value, $license_key ); + + $field_name = 'field-' . $slug; + $field->set_field_name( $field_name ); + + // Add assertions to verify the field properties and methods + $this->assertEquals( $slug, $field->get_slug() ); + $this->assertEquals( $current_resource->get_path(), $field->get_product() ); + $this->assertEquals( $field_name, $field->get_field_name() ); + $this->assertEquals( 'stellarwp_uplink_license_key_' . $slug, $field->get_field_id() ); + $this->assertEquals( $license_key, $field->get_field_value(), 'Field value should be equal to the license key' ); + $this->assertStringContainsString( 'A valid license key is required for support and updates', $field->get_key_status_html() ); + $this->assertEquals( 'License key', $field->get_placeholder() ); + $this->assertEquals( 'stellarwp-uplink-license-key-field', $field->get_classes() ); + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_render_fields_correctly( $resource ) { + $current_resource = $this->setup_container_get_slug( $resource ); + $slug = $current_resource->get_slug(); + $field = $this->container->get( Field::class )->set_resource( $current_resource ); + $license_key = 'license_key' . $slug; + $option_name = $current_resource->get_license_object()->get_key_option_name(); + $this->set_fn_return( 'wp_create_nonce', '123456789', false ); + + // Update the license key to a known value. + update_option( $option_name, $license_key ); + $option_value = get_option( $option_name ); + $this->assertEquals( $option_value, $license_key ); + + $field_name = 'field-' . $slug; + $field->set_field_name( $field_name ); + + $test = $field->get_render_html(); + $this->assertMatchesHtmlSnapshot( $test ); + } + + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_set_and_get_field_id( $resource ) { + $field = $this->container->get( Field::class )->set_resource( $this->setup_container_get_slug( $resource ) ); + + $field_id = 'custom-field-id'; + $field->set_field_id( $field_id ); + + $this->assertEquals( $field_id, $field->get_field_id() ); + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_set_and_get_field_label( $resource ) { + $field = $this->container->get( Field::class )->set_resource( $this->setup_container_get_slug( $resource ) ); + + $label = 'Custom Label'; + $field->set_label( $label ); + + $this->assertEquals( $label, $field->get_label() ); + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_get_placeholder( $resource ) { + $field = $this->container->get( Field::class )->set_resource( $this->setup_container_get_slug( $resource ) ); + + $this->assertEquals( 'License key', $field->get_placeholder() ); + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_get_nonce_action_and_field( $resource ) { + $field = $this->container->get( Field::class )->set_resource( $this->setup_container_get_slug( $resource ) ); + + $nonce_action = $this->container->get( Group::class )->get_name(); + $nonce_field = $field->get_nonce_field(); + + // Extract the nonce value from the nonce field + preg_match( '/value=["\']([^"\']+)["\']/', $nonce_field, $matches ); + $nonce_value = $matches[1]; + + $this->assertNotEmpty( $nonce_action, 'Nonce action should not be empty.' ); + $this->assertStringContainsString( 'stellarwp-uplink-license-key-nonce__' . $field->get_slug(), $nonce_field, 'Nonce field should contain the correct action slug.' ); + + // Validate the nonce + $is_valid_nonce = wp_verify_nonce( $nonce_value, $nonce_action ); + $this->assertContains( $is_valid_nonce, [ 1, 2 ], 'Nonce should be valid.' ); + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_show_and_hide_label_and_heading( $resource ) { + $field = $this->container->get( Field::class )->set_resource( $this->setup_container_get_slug( $resource ) ); + + $field->show_label( true ); + $this->assertTrue( $field->should_show_label() ); + + $field->show_label( false ); + $this->assertFalse( $field->should_show_label() ); + + $field->show_heading( true ); + $this->assertTrue( $field->should_show_heading() ); + + $field->show_heading( false ); + $this->assertFalse( $field->should_show_heading() ); + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_render_correct_html( $resource ) { + $field = $this->container->get( Field::class )->set_resource( $this->setup_container_get_slug( $resource ) ); + + $html = $field->get_render_html(); + $this->assertStringContainsString( 'stellarwp-uplink-license-key-field', $html ); + $this->assertStringContainsString( $field->get_slug(), $html ); + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_handle_empty_field_name( $resource ) { + $field = $this->container->get( Field::class )->set_resource( $this->setup_container_get_slug( $resource ) ); + + $this->assertEmpty( $field->get_field_name(), 'Field name should be empty by default' ); + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_handle_empty_field_id( $resource ) { + $field = $this->container->get( Field::class )->set_resource( $this->setup_container_get_slug( $resource ) ); + + $this->assertNotEmpty( $field->get_field_id(), 'Field ID should not be empty even if not set' ); + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_handle_empty_label( $resource ) { + $field = $this->container->get( Field::class )->set_resource( $this->setup_container_get_slug( $resource ) ); + + $this->assertEmpty( $field->get_label(), 'Label should be empty by default' ); + } +} diff --git a/tests/wpunit/Admin/Fields/FormTest.php b/tests/wpunit/Admin/Fields/FormTest.php new file mode 100644 index 00000000..3136f9cf --- /dev/null +++ b/tests/wpunit/Admin/Fields/FormTest.php @@ -0,0 +1,198 @@ +view = $this->container->get( WordPress_View::class ); + + $this->collection = $this->container->get( Collection::class ); + } + + public function setup_container_get_slug( $resource ) { + $collection = Config::get_container()->get( Collection::class ); + + Register::{$resource['type']}( + $resource['slug'], + $resource['name'], + $resource['version'], + $resource['path'], + $resource['class'] + ); + + return $collection->get( $resource['slug'] ); + } + + public function resourceProvider() { + $resources = $this->get_test_resources(); + + foreach ( $resources as $resource ) { + yield $resource['slug'] => [ $resource ]; + } + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_add_field_to_form( $resource ) { + $current_resource = $this->setup_container_get_slug( $resource ); + $slug = $current_resource->get_slug(); + + $field = UplinkNamespace\get_field( $current_resource->get_slug() ); + $field->set_field_name( 'field-' . $slug ); + $field->show_label( true ); + + $form = new Form( $this->view ); + $form->add_field( $field ); + + $fields = $form->get_fields(); + $this->assertArrayHasKey( $slug, $fields ); + $this->assertSame( $field, $fields[ $slug ] ); + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_set_and_get_button_text( $resource ) { + $button_text = 'Submit'; + $form = new Form( $this->view ); + $form->set_button_text( $button_text ); + + $this->assertEquals( $button_text, $form->get_button_text() ); + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_render_form( $resource ) { + $current_resource = $this->setup_container_get_slug( $resource ); + $slug = $current_resource->get_slug(); + + $field = UplinkNamespace\get_field( $current_resource->get_slug() ); + $field->set_field_name( 'field-' . $slug ); + + $form = new Form( $this->view ); + $form->add_field( $field ); + + $license_key = 'license_key' . $slug; + $option_name = $current_resource->get_license_object()->get_key_option_name(); + $this->set_fn_return( 'wp_create_nonce', '123456789', false ); + + // Update the license key to a known value. + update_option( $option_name, $license_key ); + $option_value = get_option( $option_name ); + $this->assertEquals( $option_value, $license_key ); + + // Render the form and assert the HTML snapshot + $form_html = $form->get_render_html(); + + // Assert the HTML snapshot + $this->assertMatchesHtmlSnapshot( $form_html ); + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_get_button_text( $resource ) { + $form = new Form( $this->view ); + $this->assertEquals( 'Save Changes', $form->get_button_text() ); + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_show_and_hide_button( $resource ) { + $form = new Form( $this->view ); + $this->assertTrue( $form->should_show_button() ); + + $form->show_button( false ); + $this->assertFalse( $form->should_show_button() ); + } + + /** + * @test + */ + public function it_should_add_multiple_fields_to_form() { + $resources = $this->get_test_resources(); + $form = new Form( $this->view ); + $expected_field_count = count( $resources ); + + foreach ( $resources as $resource ) { + $current_resource = $this->setup_container_get_slug( $resource ); + $slug = $current_resource->get_slug(); + $field = UplinkNamespace\get_field( $current_resource->get_slug() ); + $field->set_field_name( 'field-' . $slug ); + $form->add_field( $field ); + + // Assert the field is added to the form + $fields = $form->get_fields(); + $this->assertArrayHasKey( $slug, $fields, "Form should have the field with slug '$slug'" ); + $this->assertEquals( 'field-' . $slug, $fields[ $slug ]->get_field_name(), "Field name should be 'field-$slug'" ); + $this->assertInstanceOf( Field::class, $fields[ $slug ], "Field should be an instance of Field class" ); + } + + $form_fields = $form->get_fields(); + + // Assert the number of fields added to the form + $this->assertCount( $expected_field_count, $form_fields, "Form should contain $expected_field_count fields" ); + } + + /** + * @test + */ + public function it_should_render_form_with_multiple_fields() { + $resources = $this->get_test_resources(); + $form = UplinkNamespace\get_form(); + $expected_field_count = count( $resources ); + + foreach ( $resources as $resource ) { + $current_resource = $this->setup_container_get_slug( $resource ); + $slug = $current_resource->get_slug(); + $field = UplinkNamespace\get_field( $current_resource->get_slug() ); + $field->set_field_name( 'field-' . $slug ); + $form->add_field( $field ); + + // Assert the field is added to the form + $fields = $form->get_fields(); + $this->assertArrayHasKey( $slug, $fields, "Form should have the field with slug '$slug'" ); + $this->assertEquals( 'field-' . $slug, $fields[ $slug ]->get_field_name(), "Field name should be 'field-$slug'" ); + $this->assertInstanceOf( Field::class, $fields[ $slug ], "Field should be an instance of Field class" ); + } + + $this->assertEquals( $expected_field_count, count( $form->get_fields() ) ); + // Mock the wp_create_nonce function + $this->set_fn_return( 'wp_create_nonce', '123456789', false ); + + // Render the form and assert the HTML snapshot + $form_html = $form->get_render_html(); + + // Assert the HTML snapshot + $this->assertMatchesHtmlSnapshot( $form_html ); + } +} diff --git a/tests/wpunit/Admin/Fields/__snapshots__/FieldTest__it_should_render_fields_correctly__plugin-1__0.snapshot.html b/tests/wpunit/Admin/Fields/__snapshots__/FieldTest__it_should_render_fields_correctly__plugin-1__0.snapshot.html new file mode 100644 index 00000000..74f192a9 --- /dev/null +++ b/tests/wpunit/Admin/Fields/__snapshots__/FieldTest__it_should_render_fields_correctly__plugin-1__0.snapshot.html @@ -0,0 +1,27 @@ + + diff --git a/tests/wpunit/Admin/Fields/__snapshots__/FieldTest__it_should_render_fields_correctly__plugin-2__0.snapshot.html b/tests/wpunit/Admin/Fields/__snapshots__/FieldTest__it_should_render_fields_correctly__plugin-2__0.snapshot.html new file mode 100644 index 00000000..82d5f2de --- /dev/null +++ b/tests/wpunit/Admin/Fields/__snapshots__/FieldTest__it_should_render_fields_correctly__plugin-2__0.snapshot.html @@ -0,0 +1,27 @@ + + diff --git a/tests/wpunit/Admin/Fields/__snapshots__/FieldTest__it_should_render_fields_correctly__service-1__0.snapshot.html b/tests/wpunit/Admin/Fields/__snapshots__/FieldTest__it_should_render_fields_correctly__service-1__0.snapshot.html new file mode 100644 index 00000000..45e3accc --- /dev/null +++ b/tests/wpunit/Admin/Fields/__snapshots__/FieldTest__it_should_render_fields_correctly__service-1__0.snapshot.html @@ -0,0 +1,27 @@ + + diff --git a/tests/wpunit/Admin/Fields/__snapshots__/FieldTest__it_should_render_fields_correctly__service-2__0.snapshot.html b/tests/wpunit/Admin/Fields/__snapshots__/FieldTest__it_should_render_fields_correctly__service-2__0.snapshot.html new file mode 100644 index 00000000..4ac10e34 --- /dev/null +++ b/tests/wpunit/Admin/Fields/__snapshots__/FieldTest__it_should_render_fields_correctly__service-2__0.snapshot.html @@ -0,0 +1,27 @@ + + diff --git a/tests/wpunit/Admin/Fields/__snapshots__/FormTest__it_should_render_form__plugin-1__0.snapshot.html b/tests/wpunit/Admin/Fields/__snapshots__/FormTest__it_should_render_form__plugin-1__0.snapshot.html new file mode 100644 index 00000000..55afbc33 --- /dev/null +++ b/tests/wpunit/Admin/Fields/__snapshots__/FormTest__it_should_render_form__plugin-1__0.snapshot.html @@ -0,0 +1,33 @@ + diff --git a/tests/wpunit/Admin/Fields/__snapshots__/FormTest__it_should_render_form__plugin-2__0.snapshot.html b/tests/wpunit/Admin/Fields/__snapshots__/FormTest__it_should_render_form__plugin-2__0.snapshot.html new file mode 100644 index 00000000..b9a37df6 --- /dev/null +++ b/tests/wpunit/Admin/Fields/__snapshots__/FormTest__it_should_render_form__plugin-2__0.snapshot.html @@ -0,0 +1,33 @@ + diff --git a/tests/wpunit/Admin/Fields/__snapshots__/FormTest__it_should_render_form__service-1__0.snapshot.html b/tests/wpunit/Admin/Fields/__snapshots__/FormTest__it_should_render_form__service-1__0.snapshot.html new file mode 100644 index 00000000..a2041c3e --- /dev/null +++ b/tests/wpunit/Admin/Fields/__snapshots__/FormTest__it_should_render_form__service-1__0.snapshot.html @@ -0,0 +1,33 @@ + diff --git a/tests/wpunit/Admin/Fields/__snapshots__/FormTest__it_should_render_form__service-2__0.snapshot.html b/tests/wpunit/Admin/Fields/__snapshots__/FormTest__it_should_render_form__service-2__0.snapshot.html new file mode 100644 index 00000000..0a78c5f5 --- /dev/null +++ b/tests/wpunit/Admin/Fields/__snapshots__/FormTest__it_should_render_form__service-2__0.snapshot.html @@ -0,0 +1,33 @@ + diff --git a/tests/wpunit/Admin/Fields/__snapshots__/FormTest__it_should_render_form_with_multiple_fields__0.snapshot.html b/tests/wpunit/Admin/Fields/__snapshots__/FormTest__it_should_render_form_with_multiple_fields__0.snapshot.html new file mode 100644 index 00000000..85bf3f60 --- /dev/null +++ b/tests/wpunit/Admin/Fields/__snapshots__/FormTest__it_should_render_form_with_multiple_fields__0.snapshot.html @@ -0,0 +1,114 @@ + diff --git a/tests/wpunit/Auth/ActionManagerTest.php b/tests/wpunit/Auth/ActionManagerTest.php new file mode 100644 index 00000000..92e41082 --- /dev/null +++ b/tests/wpunit/Auth/ActionManagerTest.php @@ -0,0 +1,93 @@ +slug_1, + 'Lib Sample 1', + '1.0.10', + 'uplink/index.php', + Sample_Plugin::class + ); + + Register::plugin( + $this->slug_2, + 'Lib Sample 2', + '1.0.10', + 'uplink/index.php', + Sample_Plugin::class + ); + + $this->action_manager = $this->container->get( Action_Manager::class ); + } + + protected function tearDown(): void { + unset( $_REQUEST[ Disconnect_Controller::SLUG ] ); + + parent::tearDown(); + } + + public function test_it_gets_the_correct_hook_name(): void { + $this->assertSame( 'stellarwp/uplink/test/admin_action_sample-1', $this->action_manager->get_hook_name( $this->slug_1 ) ); + $this->assertSame( 'stellarwp/uplink/test/admin_action_sample-2', $this->action_manager->get_hook_name( $this->slug_2 ) ); + } + + public function test_it_registers_and_fires_actions(): void { + $collection = $this->container->get( Collection::class ); + + foreach ( $collection as $resource ) { + $this->assertFalse( has_action( $this->action_manager->get_hook_name( $resource->get_slug() ) ) ); + $this->assertSame( 0, did_action( $this->action_manager->get_hook_name( $resource->get_slug() ) ) ); + } + + // Mock we're an admin inside the dashboard. + $this->admin_init(); + + foreach ( $collection as $resource ) { + $this->assertTrue( has_action( $this->action_manager->get_hook_name( $resource->get_slug() ) ) ); + + $_REQUEST[ Disconnect_Controller::SLUG ] = $resource->get_slug(); + + // Fire off admin_init again, which runs our actions, normally this would run once, but we want to test them all. + do_action( 'admin_init' ); + + $this->assertSame( 1, did_action( $this->action_manager->get_hook_name( $resource->get_slug() ) ) ); + } + } + +} diff --git a/tests/wpunit/Auth/Admin/ConnectControllerTest.php b/tests/wpunit/Auth/Admin/ConnectControllerTest.php index b8259a5b..4e76ce60 100644 --- a/tests/wpunit/Auth/Admin/ConnectControllerTest.php +++ b/tests/wpunit/Auth/Admin/ConnectControllerTest.php @@ -2,12 +2,13 @@ namespace StellarWP\Uplink\Tests\Auth\Admin; +use StellarWP\Uplink\Auth\Action_Manager; use StellarWP\Uplink\Auth\Admin\Connect_Controller; use StellarWP\Uplink\Auth\Nonce; use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; use StellarWP\Uplink\Config; use StellarWP\Uplink\Register; -use StellarWP\Uplink\Resources\Collection; +use StellarWP\Uplink\Resources\Resource; use StellarWP\Uplink\Tests\Sample_Plugin; use StellarWP\Uplink\Tests\UplinkTestCase; use StellarWP\Uplink\Uplink; @@ -28,6 +29,11 @@ final class ConnectControllerTest extends UplinkTestCase { */ private $slug = 'sample'; + /** + * @var Resource + */ + private $plugin; + protected function setUp(): void { parent::setUp(); @@ -45,7 +51,7 @@ protected function setUp(): void { $this->token_manager = $this->container->get( Token_Manager::class ); // Register the sample plugin as a developer would in their plugin. - Register::plugin( + $this->plugin = Register::plugin( $this->slug, 'Lib Sample', '1.0.10', @@ -54,18 +60,12 @@ protected function setUp(): void { ); } - protected function tearDown(): void { - $GLOBALS['current_screen'] = null; - - parent::tearDown(); - } - public function test_it_stores_basic_token_data(): void { global $_GET; wp_set_current_user( 1 ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); $nonce = ( $this->container->get( Nonce::class ) )->create(); $token = '53ca40ab-c6c7-4482-a1eb-14c56da31015'; @@ -73,17 +73,15 @@ public function test_it_stores_basic_token_data(): void { // Mock these were passed via the query string. $_GET[ Connect_Controller::TOKEN ] = $token; $_GET[ Connect_Controller::NONCE ] = $nonce; + $_GET[ Connect_Controller::SLUG ] = $this->slug; // Mock we're an admin inside the dashboard. - $screen = WP_Screen::get( 'dashboard' ); - $GLOBALS['current_screen'] = $screen; - - $this->assertTrue( $screen->in_admin() ); + $this->admin_init(); - // Fire off the action the Connect_Controller is running under. - do_action( 'admin_init' ); + // Fire off the specification action tied to this slug. + do_action( $this->container->get( Action_Manager::class )->get_hook_name( $this->slug ) ); - $this->assertSame( $token, $this->token_manager->get() ); + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); } public function test_it_sets_additional_license_key(): void { @@ -91,10 +89,9 @@ public function test_it_sets_additional_license_key(): void { wp_set_current_user( 1 ); - $plugin = $this->container->get( Collection::class )->offsetGet( $this->slug ); - $this->assertEmpty( $plugin->get_license_key() ); + $this->assertEmpty( $this->plugin->get_license_key() ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); $nonce = ( $this->container->get( Nonce::class ) )->create(); $token = '53ca40ab-c6c7-4482-a1eb-14c56da31015'; @@ -107,16 +104,13 @@ public function test_it_sets_additional_license_key(): void { $_GET[ Connect_Controller::SLUG ] = $this->slug; // Mock we're an admin inside the dashboard. - $screen = WP_Screen::get( 'dashboard' ); - $GLOBALS['current_screen'] = $screen; - - $this->assertTrue( $screen->in_admin() ); + $this->admin_init(); - // Fire off the action the Connect_Controller is running under. - do_action( 'admin_init' ); + // Fire off the specification action tied to this slug. + do_action( $this->container->get( Action_Manager::class )->get_hook_name( $this->slug ) ); - $this->assertSame( $token, $this->token_manager->get() ); - $this->assertSame( $plugin->get_license_key(), $license ); + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); + $this->assertSame( $this->plugin->get_license_key(), $license ); } public function test_it_does_not_store_with_an_invalid_nonce(): void { @@ -124,24 +118,19 @@ public function test_it_does_not_store_with_an_invalid_nonce(): void { wp_set_current_user( 1 ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); $token = '53ca40ab-c6c7-4482-a1eb-14c56da31015'; // Mock these were passed via the query string. $_GET[ Connect_Controller::TOKEN ] = $token; $_GET[ Connect_Controller::NONCE ] = 'wrong_nonce'; + $_GET[ Connect_Controller::SLUG ] = $this->slug; // Mock we're an admin inside the dashboard. - $screen = WP_Screen::get( 'dashboard' ); - $GLOBALS['current_screen'] = $screen; + $this->admin_init(); - $this->assertTrue( $screen->in_admin() ); - - // Fire off the action the Connect_Controller is running under. - do_action( 'admin_init' ); - - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); } public function test_it_does_not_store_an_invalid_token(): void { @@ -149,7 +138,7 @@ public function test_it_does_not_store_an_invalid_token(): void { wp_set_current_user( 1 ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); $nonce = ( $this->container->get( Nonce::class ) )->create(); $token = 'invalid-token-format'; @@ -157,61 +146,25 @@ public function test_it_does_not_store_an_invalid_token(): void { // Mock these were passed via the query string. $_GET[ Connect_Controller::TOKEN ] = $token; $_GET[ Connect_Controller::NONCE ] = $nonce; + $_GET[ Connect_Controller::SLUG ] = $this->slug; // Mock we're an admin inside the dashboard. - $screen = WP_Screen::get( 'dashboard' ); - $GLOBALS['current_screen'] = $screen; - - $this->assertTrue( $screen->in_admin() ); - - // Fire off the action the Connect_Controller is running under. - do_action( 'admin_init' ); - - $this->assertNull( $this->token_manager->get() ); - } - - public function test_it_stores_token_but_not_license_without_a_slug(): void { - global $_GET; - - wp_set_current_user( 1 ); - - $plugin = $this->container->get( Collection::class )->offsetGet( $this->slug ); - $this->assertEmpty( $plugin->get_license_key() ); - - $this->assertNull( $this->token_manager->get() ); - - $nonce = ( $this->container->get( Nonce::class ) )->create(); - $token = '53ca40ab-c6c7-4482-a1eb-14c56da31015'; - $license = '123456'; - - // Mock these were passed via the query string. - $_GET[ Connect_Controller::TOKEN ] = $token; - $_GET[ Connect_Controller::NONCE ] = $nonce; - $_GET[ Connect_Controller::LICENSE ] = $license; - $_GET[ Connect_Controller::SLUG ] = ''; - - // Mock we're an admin inside the dashboard. - $screen = WP_Screen::get( 'dashboard' ); - $GLOBALS['current_screen'] = $screen; - - $this->assertTrue( $screen->in_admin() ); + $this->admin_init(); - // Fire off the action the Connect_Controller is running under. - do_action( 'admin_init' ); + // Fire off the specification action tied to this slug. + do_action( $this->container->get( Action_Manager::class )->get_hook_name( $this->slug ) ); - $this->assertSame( $token, $this->token_manager->get() ); - $this->assertEmpty( $plugin->get_license_key() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); } - public function test_it_stores_token_but_not_license_with_a_slug_that_does_not_exist(): void { + public function test_it_does_not_stores_token_or_license_with_a_slug_that_does_not_exist(): void { global $_GET; wp_set_current_user( 1 ); - $plugin = $this->container->get( Collection::class )->offsetGet( $this->slug ); - $this->assertEmpty( $plugin->get_license_key() ); + $this->assertEmpty( $this->plugin->get_license_key() ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); $nonce = ( $this->container->get( Nonce::class ) )->create(); $token = '53ca40ab-c6c7-4482-a1eb-14c56da31015'; @@ -224,16 +177,13 @@ public function test_it_stores_token_but_not_license_with_a_slug_that_does_not_e $_GET[ Connect_Controller::SLUG ] = 'a-plugin-slug-that-does-not-exist'; // Mock we're an admin inside the dashboard. - $screen = WP_Screen::get( 'dashboard' ); - $GLOBALS['current_screen'] = $screen; - - $this->assertTrue( $screen->in_admin() ); + $this->admin_init(); - // Fire off the action the Connect_Controller is running under. - do_action( 'admin_init' ); + // Fire off the specification action tied to this slug. + do_action( $this->container->get( Action_Manager::class )->get_hook_name( $this->slug ) ); - $this->assertSame( $token, $this->token_manager->get() ); - $this->assertEmpty( $plugin->get_license_key() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + $this->assertEmpty( $this->plugin->get_license_key() ); } public function test_it_stores_token_but_not_license_without_a_license(): void { @@ -241,13 +191,12 @@ public function test_it_stores_token_but_not_license_without_a_license(): void { wp_set_current_user( 1 ); - $plugin = $this->container->get( Collection::class )->offsetGet( $this->slug ); - $this->assertEmpty( $plugin->get_license_key() ); + $this->assertEmpty( $this->plugin->get_license_key() ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); $nonce = ( $this->container->get( Nonce::class ) )->create(); - $token = '53ca40ab-c6c7-4482-a1eb-14c56da31015'; + $token = '53ca40ab-c6c7-4482-a1eb-14c56da31016'; // Mock these were passed via the query string. $_GET[ Connect_Controller::TOKEN ] = $token; @@ -264,8 +213,11 @@ public function test_it_stores_token_but_not_license_without_a_license(): void { // Fire off the action the Connect_Controller is running under. do_action( 'admin_init' ); - $this->assertSame( $token, $this->token_manager->get() ); - $this->assertEmpty( $plugin->get_license_key() ); + // Fire off the specification action tied to this slug. + do_action( $this->container->get( Action_Manager::class )->get_hook_name( $this->slug ) ); + + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); + $this->assertEmpty( $this->plugin->get_license_key() ); } /** @@ -285,11 +237,9 @@ public function test_it_sets_token_and_additional_license_key_on_multisite_netwo $this->assertTrue( update_site_option( 'active_sitewide_plugins', [ 'uplink/index.php' => time(), ] ) ); + $this->assertEmpty( $this->plugin->get_license_key( 'network' ) ); - $plugin = $this->container->get( Collection::class )->offsetGet( $this->slug ); - $this->assertEmpty( $plugin->get_license_key( 'network' ) ); - - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); $nonce = ( $this->container->get( Nonce::class ) )->create(); $token = '53ca40ab-c6c7-4482-a1eb-14c56da31015'; @@ -302,23 +252,19 @@ public function test_it_sets_token_and_additional_license_key_on_multisite_netwo $_GET[ Connect_Controller::SLUG ] = $this->slug; // Mock we're an admin inside the NETWORK dashboard. - $screen = WP_Screen::get( 'dashboard-network' ); - $GLOBALS['current_screen'] = $screen; + $this->admin_init( true ); - $this->assertTrue( $screen->in_admin( 'network' ) ); - $this->assertTrue( $screen->in_admin() ); - - // Fire off the action the Connect_Controller is running under. - do_action( 'admin_init' ); + // Fire off the specification action tied to this slug. + do_action( $this->container->get( Action_Manager::class )->get_hook_name( $this->slug ) ); - $this->assertSame( $token, $this->token_manager->get() ); - $this->assertSame( $plugin->get_license_key( 'network' ), $license ); + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); + $this->assertSame( $this->plugin->get_license_key( 'network' ), $license ); } /** * @env multisite */ - public function test_it_does_not_store_token_data_on_a_subsite(): void { + public function test_it_stores_token_data_on_subfolder_subsite(): void { global $_GET; // Create a subsite. @@ -332,14 +278,11 @@ public function test_it_does_not_store_token_data_on_a_subsite(): void { wp_set_current_user( 1 ); // Mock our sample plugin is network activated, otherwise license key check fails. - $this->assertTrue( update_site_option( 'active_sitewide_plugins', [ - 'uplink/index.php' => time(), - ] ) ); + $this->mock_activate_plugin( 'uplink/index.php', true ); - $plugin = $this->container->get( Collection::class )->offsetGet( $this->slug ); - $this->assertEmpty( $plugin->get_license_key( 'network' ) ); + $this->assertEmpty( $this->plugin->get_license_key( 'network' ) ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); $nonce = ( $this->container->get( Nonce::class ) )->create(); $token = '53ca40ab-c6c7-4482-a1eb-14c56da31015'; @@ -352,17 +295,13 @@ public function test_it_does_not_store_token_data_on_a_subsite(): void { $_GET[ Connect_Controller::SLUG ] = $this->slug; // Mock we're in the subsite admin. - $screen = WP_Screen::get( 'dashboard' ); - $GLOBALS['current_screen'] = $screen; - - $this->assertFalse( $screen->in_admin( 'network' ) ); - $this->assertTrue( $screen->in_admin() ); + $this->admin_init(); - // Fire off the action the Connect_Controller is running under. - do_action( 'admin_init' ); + // Fire off the specification action tied to this slug. + do_action( $this->container->get( Action_Manager::class )->get_hook_name( $this->slug ) ); - $this->assertNull( $this->token_manager->get() ); - $this->assertEmpty( $plugin->get_license_key( 'all' ) ); + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); + $this->assertSame( $this->plugin->get_license_key( 'network' ), $license ); } } diff --git a/tests/wpunit/Auth/AuthorizerTest.php b/tests/wpunit/Auth/AuthorizerTest.php index 2be66851..14cb9fdb 100644 --- a/tests/wpunit/Auth/AuthorizerTest.php +++ b/tests/wpunit/Auth/AuthorizerTest.php @@ -3,7 +3,6 @@ namespace StellarWP\Uplink\Tests\Auth; use StellarWP\Uplink\Auth\Authorizer; -use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; use StellarWP\Uplink\Config; use StellarWP\Uplink\Tests\UplinkTestCase; use StellarWP\Uplink\Uplink; @@ -40,10 +39,21 @@ public function test_it_authorizes_a_single_site(): void { $this->assertTrue( $this->authorizer->can_auth() ); } + public function test_it_does_not_authorize_a_non_super_admin_on_single_site(): void { + $id = wp_create_user( 'tester', 'tester', 'tester@wordpress.test' ); + + wp_set_current_user( $id ); + + $this->assertTrue( is_user_logged_in() ); + $this->assertFalse( is_super_admin() ); + + $this->assertFalse( $this->authorizer->can_auth() ); + } + /** * @env multisite */ - public function test_it_does_not_authorize_a_subsite_with_multisite_subfolders_enabled(): void { + public function test_it_does_not_authorize_a_non_super_admin_on_multisite(): void { $this->assertTrue( is_multisite() ); // Main test domain is wordpress.test, create a subfolder sub-site. @@ -54,9 +64,12 @@ public function test_it_does_not_authorize_a_subsite_with_multisite_subfolders_e switch_to_blog( $sub_site_id ); - wp_set_current_user( 1 ); + $id = wp_create_user( 'tester', 'tester', 'tester@wordpress.test' ); + + wp_set_current_user( $id ); + $this->assertTrue( is_user_logged_in() ); - $this->assertTrue( is_super_admin() ); + $this->assertFalse( is_super_admin() ); $this->assertFalse( $this->authorizer->can_auth() ); } @@ -64,20 +77,11 @@ public function test_it_does_not_authorize_a_subsite_with_multisite_subfolders_e /** * @env multisite */ - public function test_it_does_not_authorize_a_subsite_with_an_existing_network_token(): void { + public function test_it_authorizes_a_subsite_with_multisite_subfolders(): void { $this->assertTrue( is_multisite() ); - $token_manager = $this->container->get( Token_Manager::class ); - $token = '695be4b3-ad6e-4863-9287-3052f597b1f6'; - - // Store a token while on the main site. - $this->assertTrue( $token_manager->store( '695be4b3-ad6e-4863-9287-3052f597b1f6' ) ); - - $this->assertSame( $token, get_network_option( get_current_network_id(), $token_manager->option_name() ) ); - $this->assertEmpty( get_option( $token_manager->option_name() ) ); - - // Main test domain is wordpress.test, create a sub domain. - $sub_site_id = wpmu_create_blog( 'sub1.wordpress.test', '/', 'Test Subsite', 1 ); + // Main test domain is wordpress.test, create a subfolder sub-site. + $sub_site_id = wpmu_create_blog( 'wordpress.test', '/sub1', 'Test Subsite', 1 ); $this->assertNotInstanceOf( WP_Error::class, $sub_site_id ); $this->assertGreaterThan( 1, $sub_site_id ); @@ -88,13 +92,14 @@ public function test_it_does_not_authorize_a_subsite_with_an_existing_network_to $this->assertTrue( is_user_logged_in() ); $this->assertTrue( is_super_admin() ); - $this->assertFalse( $this->authorizer->can_auth() ); + $this->assertTrue( $this->authorizer->can_auth() ); } /** * @env multisite */ public function test_it_authorizes_a_subsite(): void { + $this->mock_activate_plugin( 'uplink/index.php', true ); $this->assertTrue( is_multisite() ); // Main test domain is wordpress.test, create a completely custom domain. diff --git a/tests/wpunit/Auth/NonceTest.php b/tests/wpunit/Auth/NonceTest.php index 1e3f076a..c2d428a1 100644 --- a/tests/wpunit/Auth/NonceTest.php +++ b/tests/wpunit/Auth/NonceTest.php @@ -26,8 +26,8 @@ public function test_it_creates_a_nonce(): void { $this->assertNotEmpty( $nonce ); $this->assertSame( 16, strlen( $nonce ) ); - $this->assertFalse( Nonce::verify( '') ); - $this->assertTrue( Nonce::verify( $nonce ) ); + $this->assertFalse( $this->nonce->verify( '') ); + $this->assertTrue( $this->nonce->verify( $nonce ) ); } public function test_it_creates_a_nonce_url(): void { @@ -47,8 +47,8 @@ public function test_it_creates_a_nonce_url(): void { $this->assertNotEmpty( $nonce ); $this->assertSame( 16, strlen( $nonce ) ); - $this->assertFalse( Nonce::verify( '') ); - $this->assertTrue( Nonce::verify( $nonce ) ); + $this->assertFalse( $this->nonce->verify( '') ); + $this->assertTrue( $this->nonce->verify( $nonce ) ); } public function test_it_creates_a_nonce_url_with_extra_query_arguments(): void { @@ -69,8 +69,8 @@ public function test_it_creates_a_nonce_url_with_extra_query_arguments(): void { $this->assertNotEmpty( $nonce ); $this->assertSame( 16, strlen( $nonce ) ); - $this->assertFalse( Nonce::verify( '' ) ); - $this->assertTrue( Nonce::verify( $nonce ) ); + $this->assertFalse( $this->nonce->verify( '' ) ); + $this->assertTrue( $this->nonce->verify( $nonce ) ); } } diff --git a/tests/wpunit/Auth/Token/CustomDomainMultisiteTokenMangerTest.php b/tests/wpunit/Auth/Token/CustomDomainMultisiteTokenMangerTest.php index 8403827c..ddf2a31b 100644 --- a/tests/wpunit/Auth/Token/CustomDomainMultisiteTokenMangerTest.php +++ b/tests/wpunit/Auth/Token/CustomDomainMultisiteTokenMangerTest.php @@ -4,6 +4,9 @@ use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; use StellarWP\Uplink\Config; +use StellarWP\Uplink\Register; +use StellarWP\Uplink\Resources\Resource; +use StellarWP\Uplink\Tests\Sample_Plugin; use StellarWP\Uplink\Tests\UplinkTestCase; use StellarWP\Uplink\Uplink; use WP_Error; @@ -15,6 +18,18 @@ final class CustomDomainMultisiteTokenMangerTest extends UplinkTestCase { */ private $token_manager; + /** + * The sample plugin slug + * + * @var string + */ + private $slug = 'sample'; + + /** + * @var Resource + */ + private $plugin; + protected function setUp(): void { parent::setUp(); @@ -29,6 +44,15 @@ protected function setUp(): void { $this->assertNotInstanceOf( WP_Error::class, $sub_site_id ); $this->assertGreaterThan( 1, $sub_site_id ); + // Register the sample plugin as a developer would in their plugin. + $this->plugin = Register::plugin( + $this->slug, + 'Lib Sample', + '1.0.10', + 'uplink/index.php', + Sample_Plugin::class + ); + switch_to_blog( $sub_site_id ); $this->token_manager = $this->container->get( Token_Manager::class ); @@ -40,15 +64,15 @@ protected function setUp(): void { public function test_it_stores_and_retrieves_a_token_from_the_network(): void { $this->assertTrue( is_multisite() ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); $token = 'cd4b77be-985f-4737-89b7-eaa13b335fe8'; - $this->assertTrue( $this->token_manager->store( $token ) ); + $this->assertTrue( $this->token_manager->store( $token, $this->plugin ) ); - $this->assertSame( $token, $this->token_manager->get() ); + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); - $this->assertSame( $token, get_network_option( get_current_network_id(), $this->token_manager->option_name() ) ); + $this->assertSame( $token, get_network_option( get_current_network_id(), $this->token_manager->option_name() )[ $this->slug ] ); $this->assertEmpty( get_option( $this->token_manager->option_name() ) ); } @@ -56,17 +80,17 @@ public function test_it_stores_and_retrieves_a_token_from_the_network(): void { * @env multisite */ public function test_it_deletes_a_token(): void { - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); $token = 'b5aad022-71ca-4b29-85c2-70da5c8a5779'; - $this->assertTrue( $this->token_manager->store( $token ) ); + $this->assertTrue( $this->token_manager->store( $token, $this->plugin ) ); - $this->assertSame( $token, $this->token_manager->get() ); + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); - $this->token_manager->delete(); + $this->token_manager->delete( $this->slug ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); $this->assertEmpty( get_network_option( get_current_network_id(), $this->token_manager->option_name() ) ); } @@ -74,11 +98,31 @@ public function test_it_deletes_a_token(): void { * @env multisite */ public function test_it_does_not_store_an_empty_token(): void { - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + + $this->assertFalse( $this->token_manager->store( '', $this->plugin ) ); + + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + } + + /** + * @env multisite + */ + public function test_it_fetches_and_deletes_a_legacy_token(): void { + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + + $token = 'b5aad022-71ca-4b29-85c2-70da5c8a5779'; + + // Manually store a legacy string token. + $this->assertTrue( update_network_option( get_current_network_id(), $this->token_manager->option_name(), $token ) ); + + $this->assertTrue( $this->token_manager->store( $token, $this->plugin ) ); + + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); - $this->assertFalse( $this->token_manager->store( '' ) ); + $this->assertTrue( $this->token_manager->delete( $this->slug ) ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); } /** diff --git a/tests/wpunit/Auth/Token/MultipleTokensTest.php b/tests/wpunit/Auth/Token/MultipleTokensTest.php new file mode 100644 index 00000000..ad921848 --- /dev/null +++ b/tests/wpunit/Auth/Token/MultipleTokensTest.php @@ -0,0 +1,220 @@ +collection = $this->container->get( Collection::class ); + $this->token_manager = $this->container->get( Token_Manager::class ); + } + + /** + * Sets up the container and returns the slug of a resource. + * + * @param array $resource + * + * @return Resource + */ + public function setup_container_get_slug( array $resource ): Resource { + Register::{$resource['type']}( + $resource['slug'], + $resource['name'], + $resource['version'], + $resource['path'], + $resource['class'] + ); + + return $this->collection->get( $resource['slug'] ); + } + + /** + * Get token-slug pairs from test resources. + * + * @return array + */ + private function get_token_slug_pairs(): array { + $resources = $this->get_test_resources(); + $tokens = []; + $dynamicTokenPrefix = 'dynamic-token-value_'; + + foreach ( $resources as $resource ) { + $slug = $this->setup_container_get_slug( $resource )->get_slug(); + $tokens[ $slug ] = $dynamicTokenPrefix . $slug; + } + + return $tokens; + } + + /** + * @test + */ + public function it_should_register_multiple_tokens(): void { + $tokens = $this->get_token_slug_pairs(); + + foreach ( $tokens as $slug => $token ) { + $plugin = $this->collection->get( $slug ); + + $this->assertTrue( $this->token_manager->store( $token, $plugin ) ); + } + + // Retrieve all tokens and perform assertion + $all_tokens = $this->token_manager->get_all(); + $this->assertSame( $tokens, $all_tokens ); + + // Perform individual assertions for each slug + foreach ( $tokens as $slug => $expectedToken ) { + $plugin = $this->collection->get( $slug ); + + $retrieved_token = $this->token_manager->get( $plugin ); + $this->assertSame( $expectedToken, $retrieved_token ); + } + } + + /** + * @test + */ + public function it_deletes_multiple_tokens(): void { + $tokens = $this->get_token_slug_pairs(); + + foreach ( $tokens as $slug => $token ) { + $plugin = $this->collection->get( $slug ); + + $this->assertTrue( $this->token_manager->store( $token, $plugin ) ); + } + + // Delete all tokens and assert they are removed + foreach ( array_keys( $tokens ) as $slug ) { + $this->token_manager->delete( $slug ); + $plugin = $this->collection->get( $slug ); + + $this->assertNull( $this->token_manager->get( $plugin ) ); + } + + // Assert get_all is empty after deletion + $all_tokens = $this->token_manager->get_all(); + $this->assertEmpty( $all_tokens ); + } + + /** + * @test + */ + public function it_does_not_store_empty_tokens(): void { + $resources = $this->get_test_resources(); + + foreach ( $resources as $resource ) { + $plugin = $this->setup_container_get_slug( $resource ); + + $this->assertFalse( $this->token_manager->store( '', $plugin ) ); + $this->assertNull( $this->token_manager->get( $plugin ) ); + } + } + + /** + * @test + */ + public function it_can_delete_the_legacy_token(): void { + $slug = 'single-plugin-1'; + $plugin = Register::{'plugin'}( + $slug, + 'Single Plugin', + '1.0.0', + dirname( __DIR__, 2 ) . '/plugin.php', + Uplink::class + ); + + $this->assertNull( $this->token_manager->get( $plugin ) ); + + $token = '0904a5c8-0458-4982-8fc9-ce32d6dd8c03'; + + // Manually store a legacy string token. + $this->assertTrue( update_network_option( get_current_network_id(), $this->token_manager->option_name(), $token ) ); + $this->assertSame( $token, $this->token_manager->get( $plugin ) ); + $this->assertTrue( $this->token_manager->delete( $slug ) ); + $this->assertNull( $this->token_manager->get( $plugin ) ); + } + + /** + * @test + */ + public function it_should_have_backwards_compatibility_with_fetching_legacy_tokens(): void { + $plugin = Register::{'plugin'}( + 'single-plugin-1', + 'Single Plugin', + '1.0.0', + dirname( __DIR__, 2 ) . '/plugin.php', + Uplink::class + ); + + $this->assertNull( $this->token_manager->get( $plugin ) ); + + $token = '53ca40ab-c6c7-4482-a1eb-14c56da31015'; + + // Step 1: Manually store a legacy string token. + $this->assertTrue( update_network_option( get_current_network_id(), $this->token_manager->option_name(), $token ) ); + $this->assertSame( $token, $this->token_manager->get( $plugin ) ); + + // Retrieve all tokens and include the single token. + $all_tokens = $this->token_manager->get_all(); + $expected_tokens = [ + ConcreteTokenManager::LEGACY_INDEX => $token, // This will be the legacy token format. + ]; + $this->assertSame( $expected_tokens, $all_tokens ); + + // Step 2: Update the plugin's token to the new format. + $new_token = '3349c16e-fc4a-4f07-9156-7c8b305ce938'; + + $this->assertTrue( $this->token_manager->store( $new_token, $plugin ) ); + + // New token will be fetched from now on for this slug. + $this->assertSame( $new_token, $this->token_manager->get( $plugin ) ); + + // Appended the new token. + $expected_tokens[ $plugin->get_slug() ] = $new_token; + + $this->assertSame( $expected_tokens, $this->token_manager->get_all() ); + + // Step 3: Store multiple tokens and verify. + $tokens = $this->get_token_slug_pairs(); + + foreach ( $tokens as $slug => $token ) { + $plugin = $this->collection->get( $slug ); + $this->assertTrue( $this->token_manager->store( $token, $plugin ) ); + $expected_tokens[ $slug ] = $token; // Update expected tokens + } + + // Retrieve all tokens and perform assertion. + $all_tokens = $this->token_manager->get_all(); + $this->assertSame( $expected_tokens, $all_tokens ); + } + +} diff --git a/tests/wpunit/Auth/Token/SingleSiteTokenManagerTest.php b/tests/wpunit/Auth/Token/SingleSiteTokenManagerTest.php new file mode 100644 index 00000000..234c128f --- /dev/null +++ b/tests/wpunit/Auth/Token/SingleSiteTokenManagerTest.php @@ -0,0 +1,119 @@ +plugin = Register::plugin( + $this->slug, + 'Lib Sample', + '1.0.10', + 'uplink/index.php', + Sample_Plugin::class + ); + + + $this->token_manager = $this->container->get( Token_Manager::class ); + } + + /** + * @env singlesite + */ + public function test_it_stores_and_retrieves_a_token(): void { + $this->assertFalse( is_multisite() ); + + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + + $token = 'b0679a2e-b36d-41ca-8121-f43267751938'; + + $this->assertTrue( $this->token_manager->store( $token, $this->plugin ) ); + + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); + + $this->assertSame( $token, get_option( $this->token_manager->option_name() )[ $this->slug ] ); + + } + + /** + * @env singlesite + */ + public function test_it_deletes_a_token(): void { + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + + $token = 'b5aad022-71ca-4b29-85c2-70da5c8a5779'; + + $this->assertTrue( $this->token_manager->store( $token, $this->plugin ) ); + + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); + + $this->token_manager->delete( $this->slug ); + + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + } + + /** + * @env singlesite + */ + public function test_it_does_not_store_an_empty_token(): void { + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + + $this->assertFalse( $this->token_manager->store( '', $this->plugin ) ); + + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + } + + /** + * @env singlesite + */ + public function test_it_fetches_and_deletes_a_legacy_token(): void { + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + + $token = 'b5aad022-71ca-4b29-85c2-70da5c8a5779'; + + // Manually store a legacy string token. + $this->assertTrue( update_option( $this->token_manager->option_name(), $token ) ); + + $this->assertTrue( $this->token_manager->store( $token, $this->plugin ) ); + + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); + + $this->assertTrue( $this->token_manager->delete( $this->slug ) ); + + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + } + +} diff --git a/tests/wpunit/Auth/Token/SingleSiteTokenMangerTest.php b/tests/wpunit/Auth/Token/SingleSiteTokenMangerTest.php deleted file mode 100644 index 23eece6b..00000000 --- a/tests/wpunit/Auth/Token/SingleSiteTokenMangerTest.php +++ /dev/null @@ -1,74 +0,0 @@ -token_manager = $this->container->get( Token_Manager::class ); - } - - /** - * @env singlesite - */ - public function test_it_stores_and_retrieves_a_token(): void { - $this->assertFalse( is_multisite() ); - - $this->assertNull( $this->token_manager->get() ); - - $token = 'b0679a2e-b36d-41ca-8121-f43267751938'; - - $this->assertTrue( $this->token_manager->store( $token ) ); - - $this->assertSame( $token, $this->token_manager->get() ); - - $this->assertSame( $token, get_option( $this->token_manager->option_name() ) ); - - } - - /** - * @env singlesite - */ - public function test_it_deletes_a_token(): void { - $this->assertNull( $this->token_manager->get() ); - - $token = 'b5aad022-71ca-4b29-85c2-70da5c8a5779'; - - $this->assertTrue( $this->token_manager->store( $token ) ); - - $this->assertSame( $token, $this->token_manager->get() ); - - $this->token_manager->delete(); - - $this->assertNull( $this->token_manager->get() ); - } - - /** - * @env singlesite - */ - public function test_it_does_not_store_an_empty_token(): void { - $this->assertNull( $this->token_manager->get() ); - - $this->assertFalse( $this->token_manager->store( '' ) ); - - $this->assertNull( $this->token_manager->get() ); - } - -} diff --git a/tests/wpunit/Auth/Token/SubDomainMultisiteTokenMangerTest.php b/tests/wpunit/Auth/Token/SubDomainMultisiteTokenMangerTest.php index c61d01bb..e4bea238 100644 --- a/tests/wpunit/Auth/Token/SubDomainMultisiteTokenMangerTest.php +++ b/tests/wpunit/Auth/Token/SubDomainMultisiteTokenMangerTest.php @@ -4,6 +4,9 @@ use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; use StellarWP\Uplink\Config; +use StellarWP\Uplink\Register; +use StellarWP\Uplink\Resources\Resource; +use StellarWP\Uplink\Tests\Sample_Plugin; use StellarWP\Uplink\Tests\UplinkTestCase; use StellarWP\Uplink\Uplink; use WP_Error; @@ -15,6 +18,18 @@ final class SubDomainMultisiteTokenMangerTest extends UplinkTestCase { */ private $token_manager; + /** + * The sample plugin slug + * + * @var string + */ + private $slug = 'sample'; + + /** + * @var Resource + */ + private $plugin; + protected function setUp(): void { parent::setUp(); @@ -29,6 +44,15 @@ protected function setUp(): void { $this->assertNotInstanceOf( WP_Error::class, $sub_site_id ); $this->assertGreaterThan( 1, $sub_site_id ); + // Register the sample plugin as a developer would in their plugin. + $this->plugin = Register::plugin( + $this->slug, + 'Lib Sample', + '1.0.10', + 'uplink/index.php', + Sample_Plugin::class + ); + switch_to_blog( $sub_site_id ); $this->token_manager = $this->container->get( Token_Manager::class ); @@ -40,15 +64,15 @@ protected function setUp(): void { public function test_it_stores_and_retrieves_a_token_from_the_network(): void { $this->assertTrue( is_multisite() ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); $token = 'cd4b77be-985f-4737-89b7-eaa13b335fe8'; - $this->assertTrue( $this->token_manager->store( $token ) ); + $this->assertTrue( $this->token_manager->store( $token, $this->plugin ) ); - $this->assertSame( $token, $this->token_manager->get() ); + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); - $this->assertSame( $token, get_network_option( get_current_network_id(), $this->token_manager->option_name() ) ); + $this->assertSame( $token, get_network_option( get_current_network_id(), $this->token_manager->option_name() )[ $this->slug ] ); $this->assertEmpty( get_option( $this->token_manager->option_name() ) ); } @@ -56,28 +80,48 @@ public function test_it_stores_and_retrieves_a_token_from_the_network(): void { * @env multisite */ public function test_it_deletes_a_token(): void { - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); $token = 'b5aad022-71ca-4b29-85c2-70da5c8a5779'; - $this->assertTrue( $this->token_manager->store( $token ) ); + $this->assertTrue( $this->token_manager->store( $token, $this->plugin ) ); - $this->assertSame( $token, $this->token_manager->get() ); + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); - $this->token_manager->delete(); + $this->assertTrue( $this->token_manager->delete( $this->slug ) ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); } /** * @env multisite */ public function test_it_does_not_store_an_empty_token(): void { - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + + $this->assertFalse( $this->token_manager->store( '', $this->plugin ) ); + + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + } + + /** + * @env multisite + */ + public function test_it_fetches_and_deletes_a_legacy_token(): void { + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + + $token = 'b5aad022-71ca-4b29-85c2-70da5c8a5779'; + + // Manually store a legacy string token. + $this->assertTrue( update_network_option( get_current_network_id(), $this->token_manager->option_name(), $token ) ); + + $this->assertTrue( $this->token_manager->store( $token, $this->plugin ) ); + + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); - $this->assertFalse( $this->token_manager->store( '' ) ); + $this->assertTrue( $this->token_manager->delete( $this->slug ) ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); } } diff --git a/tests/wpunit/Auth/Token/SubfolderMultisiteTokenMangerTest.php b/tests/wpunit/Auth/Token/SubfolderMultisiteTokenMangerTest.php index 8b5613db..ca7447f5 100644 --- a/tests/wpunit/Auth/Token/SubfolderMultisiteTokenMangerTest.php +++ b/tests/wpunit/Auth/Token/SubfolderMultisiteTokenMangerTest.php @@ -4,6 +4,9 @@ use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; use StellarWP\Uplink\Config; +use StellarWP\Uplink\Register; +use StellarWP\Uplink\Resources\Resource; +use StellarWP\Uplink\Tests\Sample_Plugin; use StellarWP\Uplink\Tests\UplinkTestCase; use StellarWP\Uplink\Uplink; use WP_Error; @@ -15,6 +18,18 @@ final class SubfolderMultisiteTokenMangerTest extends UplinkTestCase { */ private $token_manager; + /** + * The sample plugin slug + * + * @var string + */ + private $slug = 'sample'; + + /** + * @var Resource + */ + private $plugin; + protected function setUp(): void { parent::setUp(); @@ -29,6 +44,15 @@ protected function setUp(): void { $this->assertNotInstanceOf( WP_Error::class, $sub_site_id ); $this->assertGreaterThan( 1, $sub_site_id ); + // Register the sample plugin as a developer would in their plugin. + $this->plugin = Register::plugin( + $this->slug, + 'Lib Sample', + '1.0.10', + 'uplink/index.php', + Sample_Plugin::class + ); + switch_to_blog( $sub_site_id ); $this->token_manager = $this->container->get( Token_Manager::class ); @@ -40,15 +64,15 @@ protected function setUp(): void { public function test_it_stores_and_retrieves_a_token_from_the_network(): void { $this->assertTrue( is_multisite() ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); - $token = 'ddbbec78-4439-4180-a6e8-1a63a1df4e2c'; + $token = 'cd4b77be-985f-4737-89b7-eaa13b335fe8'; - $this->assertTrue( $this->token_manager->store( $token ) ); + $this->assertTrue( $this->token_manager->store( $token, $this->plugin ) ); - $this->assertSame( $token, $this->token_manager->get() ); + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); - $this->assertSame( $token, get_network_option( get_current_network_id(), $this->token_manager->option_name() ) ); + $this->assertSame( $token, get_network_option( get_current_network_id(), $this->token_manager->option_name() )[ $this->slug ] ); $this->assertEmpty( get_option( $this->token_manager->option_name() ) ); } @@ -56,28 +80,48 @@ public function test_it_stores_and_retrieves_a_token_from_the_network(): void { * @env multisite */ public function test_it_deletes_a_token(): void { - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); $token = 'b5aad022-71ca-4b29-85c2-70da5c8a5779'; - $this->assertTrue( $this->token_manager->store( $token ) ); + $this->assertTrue( $this->token_manager->store( $token, $this->plugin ) ); - $this->assertSame( $token, $this->token_manager->get() ); + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); - $this->token_manager->delete(); + $this->assertTrue( $this->token_manager->delete( $this->slug ) ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); } /** * @env multisite */ public function test_it_does_not_store_an_empty_token(): void { - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + + $this->assertFalse( $this->token_manager->store( '', $this->plugin ) ); + + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + } + + /** + * @env multisite + */ + public function test_it_fetches_and_deletes_a_legacy_token(): void { + $this->assertNull( $this->token_manager->get( $this->plugin ) ); + + $token = 'b5aad022-71ca-4b29-85c2-70da5c8a5779'; + + // Manually store a legacy string token. + $this->assertTrue( update_network_option( get_current_network_id(), $this->token_manager->option_name(), $token ) ); + + $this->assertTrue( $this->token_manager->store( $token, $this->plugin ) ); + + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); - $this->assertFalse( $this->token_manager->store( '' ) ); + $this->assertTrue( $this->token_manager->delete( $this->slug ) ); - $this->assertNull( $this->token_manager->get() ); + $this->assertNull( $this->token_manager->get( $this->plugin ) ); } } diff --git a/tests/wpunit/Auth/Token/TokenResourceTest.php b/tests/wpunit/Auth/Token/TokenResourceTest.php new file mode 100644 index 00000000..684354c1 --- /dev/null +++ b/tests/wpunit/Auth/Token/TokenResourceTest.php @@ -0,0 +1,62 @@ +plugin = Register::plugin( + $this->slug, + 'Lib Sample', + '1.0.10', + 'uplink/index.php', + Sample_Plugin::class + ); + + $this->token_manager = $this->container->get( Token_Manager::class ); + } + + public function test_it_gets_and_sets_a_token_from_a_resource(): void { + $this->assertNull( $this->plugin->get_token() ); + + $token = 'b0679a2e-b36d-41ca-8121-f43267751938'; + + $this->assertTrue( $this->plugin->store_token( $token ) ); + $this->assertSame( $token, $this->plugin->get_token() ); + $this->assertSame( $token, $this->token_manager->get( $this->plugin ) ); + } + +} diff --git a/tests/wpunit/ConfigTest.php b/tests/wpunit/ConfigTest.php index f0ea865b..d0afe944 100644 --- a/tests/wpunit/ConfigTest.php +++ b/tests/wpunit/ConfigTest.php @@ -5,6 +5,8 @@ use InvalidArgumentException; use StellarWP\Uplink\Auth\Token\Contracts\Token_Manager; use StellarWP\Uplink\Config; +use StellarWP\Uplink\Storage\Drivers\Option_Storage; +use StellarWP\Uplink\Storage\Drivers\Transient_Storage; final class ConfigTest extends UplinkTestCase { @@ -62,4 +64,14 @@ public function test_it_gets_and_sets_auth_token_cache_expiration(): void { $this->assertSame( DAY_IN_SECONDS, Config::get_auth_cache_expiration() ); } + public function test_it_gets_default_storage_driver(): void { + $this->assertSame( Option_Storage::class, Config::get_storage_driver() ); + } + + public function test_it_gets_and_sets_storage_driver(): void { + Config::set_storage_driver( Transient_Storage::class ); + + $this->assertSame( Transient_Storage::class, Config::get_storage_driver() ); + } + } diff --git a/tests/wpunit/RegisterTest.php b/tests/wpunit/RegisterTest.php new file mode 100644 index 00000000..ce8fd15c --- /dev/null +++ b/tests/wpunit/RegisterTest.php @@ -0,0 +1,48 @@ +get_test_resources(); + + foreach ( $resources as $resource ) { + yield [ $resource ]; + } + } + + /** + * @test + * @dataProvider resourceProvider + */ + public function it_should_register_resource( $resource ) { + $collection = Config::get_container()->get( Collection::class ); + + $this->assertFalse( $collection->offsetExists( $resource['slug'] ) ); + + $is_oauth = 'service' === $resource['type'] ? true : false; + + Register::{$resource['type']}( + $resource['slug'], + $resource['name'], + $resource['version'], + $resource['path'], + $resource['class'], + null, + $is_oauth, + ); + + $this->assertTrue( $collection->offsetExists( $resource['slug'] ) ); + + $this->assertEquals( $is_oauth, $collection->get( $resource['slug'] )->is_using_oauth() ); + } +} diff --git a/tests/wpunit/Storage/Drivers/StorageDriverTest.php b/tests/wpunit/Storage/Drivers/StorageDriverTest.php new file mode 100644 index 00000000..7e93e689 --- /dev/null +++ b/tests/wpunit/Storage/Drivers/StorageDriverTest.php @@ -0,0 +1,165 @@ +> + */ + public function storageProvider(): array { + $drivers = [ + [ new Transient_Storage() ], + [ new Option_Storage( 'uplink_test_storage' ) ], + ]; + + $test_cases = []; + + foreach ( $drivers as $d ) { + foreach ( $d as $driver ) { + $description = sprintf( 'with %s', get_class( $driver ) ); + $test_cases[ $description ][] = $driver; + } + } + + return $test_cases; + } + + /** + * @dataProvider storageProvider + */ + public function test_it_sets_and_gets_values( Storage $storage ): void { + $key = 'uplink_test_key'; + $value = 'test'; + + $this->assertTrue( $storage->set( $key, $value, $this->expire ) ); + $this->assertSame( $value, $storage->get( $key ) ); + } + + /** + * @dataProvider storageProvider + */ + public function test_it_deletes_values( Storage $storage ): void { + $key = 'uplink_test_key'; + $value = 'test'; + + $this->assertTrue( $storage->set( $key, $value, $this->expire ) ); + $this->assertSame( $value, $storage->get( $key ) ); + $this->assertTrue( $storage->delete( $key ) ); + $this->assertNull( $storage->get( $key ) ); + } + + /** + * @dataProvider storageProvider + */ + public function test_it_remembers_values( Storage $storage ): void { + $key = 'uplink_test_key'; + $value = 'test'; + + $stored_value = $storage->remember( $key, function () use ( $value ) { + return $value; + }, $this->expire ); + + $this->assertSame( $value, $stored_value ); + $this->assertSame( $value, $storage->get( $key ) ); + } + + /** + * @dataProvider storageProvider + */ + public function test_it_pulls_value( Storage $storage ): void { + $key = 'uplink_test_key'; + $value = 'test'; + + $this->assertTrue( $storage->set( $key, $value, $this->expire ) ); + $pulled_value = $storage->pull( $key ); + + $this->assertSame( $value, $pulled_value ); + $this->assertNull( $storage->get( $key ) ); + } + + /** + * @dataProvider storageProvider + */ + public function test_it_throws_exception_with_invalid_string_storage_key( Storage $storage ): void { + $this->expectException( Invalid_Key_Exception::class ); + + $storage->get( '' ); + } + + /** + * @dataProvider storageProvider + */ + public function test_it_throws_exception_with_invalid_array_storage_key( Storage $storage ): void { + $this->expectException( Invalid_Key_Exception::class ); + + $storage->get( [] ); + } + + /** + * @dataProvider storageProvider + * + * Some bug here where WP_INSTALLING is defined when using --env multisite + * causing it to use local object cache. + * + * @env singlesite + */ + public function test_it_expires_values( Storage $storage ): void { + $key = 'uplink_test_key'; + $value = 'expired'; + $expire = 1; + + $this->assertTrue( $storage->set( $key, $value, $expire ) ); + + codecept_debug( 'Sleeping for 2 seconds...' ); + + sleep( 2 ); + + $this->assertNull( $storage->get( $key ) ); + } + + /** + * @dataProvider storageProvider + * + * Some bug here where WP_INSTALLING is defined when using --env multisite + * causing it to use local object cache. + * + * @env singlesite + */ + public function test_zero_expiration_does_not_expire( Storage $storage ): void { + $key = 'uplink_test_key'; + $value = 'not_expired'; + $expire = 0; + + $this->assertTrue( $storage->set( $key, $value, $expire ) ); + + codecept_debug( 'Sleeping for 2 seconds...' ); + + sleep( 2 ); + + $this->assertSame( $value, $storage->get( $key ) ); + } + +} diff --git a/tests/wpunit/Storage/Drivers/StorageKeyTest.php b/tests/wpunit/Storage/Drivers/StorageKeyTest.php new file mode 100644 index 00000000..58c1bad6 --- /dev/null +++ b/tests/wpunit/Storage/Drivers/StorageKeyTest.php @@ -0,0 +1,119 @@ +assertTrue( $driver->set( $key, $data, $expire ) ); + $this->assertEquals( $data, $driver->get( $key ) ); + $this->assertTrue( $driver->delete( $key ) ); + $this->assertNull( $driver->get( $key ) ); + } + + /** + * Data provider for storage classes. + * + * @return array> + */ + public function storageProvider(): array { + return [ + 'option_storage' => [ new Option_Storage( 'uplink_test_storage' ) ], + 'Transient Storage' => [ new Transient_Storage() ], + ]; + } + + /** + * Date provider for multiple key/data type combinations. + * + * @return array + */ + public function keyProvider(): array { + $keys = [ + 'string key' => 'test_string', + 'integer key' => 12345, + 'float key' => 12345.6789, + 'array key' => [ + 'some' => 'key', + 'more' => true, + 'one' => 1, + 'two' => 2.0, + ], + 'object key' => (object) [ + 'propertyA' => 'valueA', + 'propertyB' => 'valueB', + 'propertyC' => (object) [ + 'propertyA' => 'valueA', + 'propertyB' => 'valueB', + ], + 'propertyD' => [ + 'one' => 'one', + 'two' => 'two', + ], + ], + ]; + + $data = [ + 'string data' => 'Hello World', + 'integer data' => 56789, + 'float data' => 56789.12345, + 'boolean data true' => true, + 'boolean data false' => false, + 'array data' => [ + 'test' => true, + 'one' => 1, + 'two' => 'two', + ], + 'object data' => (object) [ + 'propertyA' => 'valueA', + 'propertyB' => 'valueB', + 'propertyC' => (object) [ + 'propertyA' => 'valueA', + 'propertyB' => 'valueB', + ], + ], + ]; + + $test_cases = []; + + foreach ( $keys as $key_type => $key ) { + foreach ( $data as $data_type => $data_value ) { + $test_cases[ "$key_type and $data_type" ] = [ $key, $data_value ]; + } + } + + return $test_cases; + } + + /** + * @return array + */ + public function compositeProvider(): array { + $test_cases = []; + + foreach ( $this->keyProvider() as $key_and_data_description => $key_and_data ) { + foreach ( $this->storageProvider() as $driver_type => $driver ) { + $description = "$key_and_data_description with $driver_type"; + $test_cases[ $description ] = array_merge( $key_and_data, $driver ); + } + } + + return $test_cases; + } + +}