From 3cc41c1d375a743c2c67325b3ce138bb9f3964f4 Mon Sep 17 00:00:00 2001 From: Mihai Circea Date: Sat, 10 Oct 2026 07:16:20 +0300 Subject: [PATCH 01/11] Delete the new activation when activate() fails validate_release() runs after the activation is created. When it threw, the catch block called activation()->get() and activation()->delete() without an id, so both requests went to v1/public/activations/ and the new activation stayed on the license, counting against its activation limit. Delete the activation this attempt created, by its id; a 404 means it's already gone. If the delete fails, keep only its id after clearing the options, so Deactivate License can remove it later, and say so in the error. --- src/License.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/License.php b/src/License.php index 6a69214..c290aa7 100644 --- a/src/License.php +++ b/src/License.php @@ -57,6 +57,7 @@ public function retrieve( $license_key ) { * @throws \Exception If something goes wrong. */ public function activate( $key = '' ) { + $activation_id = ''; try { // validate the license and store it. $license = $this->validate( $key, true ); @@ -65,17 +66,20 @@ public function activate( $key = '' ) { if ( is_wp_error( $activation ) ) { throw new \Exception( $activation->get_error_message() ); } - $this->client->settings()->activation_id = $activation->id; + $activation_id = $activation->id; + $this->client->settings()->activation_id = $activation_id; // validate the release. $this->validate_release(); } catch ( \Exception $e ) { - // undo activation. - $activation = $this->client->activation()->get(); - if ( $activation ) { - $this->client->activation()->delete(); - } // on error, clear options. $this->client->settings()->clear_options(); + // undo the activation this attempt created. + $undone = $activation_id ? $this->client->activation()->delete( $activation_id ) : true; + if ( is_wp_error( $undone ) && 'not_found' !== $undone->get_error_code() ) { + // keep its id, so Deactivate License can remove it. + $this->client->settings()->activation_id = $activation_id; + return new \WP_Error( 'error', $e->getMessage() . ' ' . $this->client->__( 'The activation could not be undone. Click Deactivate License to remove it.' ) ); + } // return \WP_Error. return new \WP_Error( 'error', $e->getMessage() ); } From 897cc219cb66b4f1e6e5fd2b1731bcbcf2a53120 Mon Sep 17 00:00:00 2001 From: Mihai Circea Date: Sat, 10 Oct 2026 07:16:46 +0300 Subject: [PATCH 02/11] Deactivate the stored activation, not a posted id license_form_submit() passed $_POST['activation_id'] to License::deactivate(), so the form decided which activation got deleted. Call deactivate() without an id: it already falls back to the activation id stored in the settings. --- src/Settings.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Settings.php b/src/Settings.php index 2d6d04f..a90a3a3 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -361,7 +361,8 @@ public function license_form_submit() { // handle deactivation. if ( 'deactivate' === $_POST['_action'] ) { - $deactivated = $this->client->license()->deactivate( sanitize_text_field( $_POST['activation_id'] ) ); + // deactivate the stored activation, never one the form posts. + $deactivated = $this->client->license()->deactivate(); if ( is_wp_error( $deactivated ) ) { $this->add_error( $deactivated->get_error_code(), $deactivated->get_error_message() ); } From 67e363351acf137de851f5849e6f633880f836ed Mon Sep 17 00:00:00 2001 From: Mihai Circea Date: Sat, 10 Oct 2026 07:17:13 +0300 Subject: [PATCH 03/11] Return on a deactivation error The error was added, but the function went on. With deactivated_redirect set, the page redirected and the error never showed; without it, the page showed "This site was successfully deactivated." next to the error. Return after adding it, like the activation branch does. --- src/Settings.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Settings.php b/src/Settings.php index a90a3a3..763c585 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -365,6 +365,7 @@ public function license_form_submit() { $deactivated = $this->client->license()->deactivate(); if ( is_wp_error( $deactivated ) ) { $this->add_error( $deactivated->get_error_code(), $deactivated->get_error_message() ); + return; } if ( ! empty( $this->menu_args['deactivated_redirect'] ) ) { From 364f317f01b8f0059d6a956a72ab8a4e8e19f656 Mon Sep 17 00:00:00 2001 From: Mihai Circea Date: Sat, 10 Oct 2026 07:17:54 +0300 Subject: [PATCH 04/11] Keep the license key and ids out of the license form In activation mode the form printed the stored license key as the password field's value, it always printed the activation id in a hidden field, and ?debug added two fields with the license and activation ids. Only users with manage_options see the page, but the key still ended up in the HTML. Leave the key field empty and reuse the stored key when it's submitted empty, so "Click Activate License to reconnect it." still works after an activation is removed. Drop the hidden field and the debug fields: deactivation no longer reads a posted id, and nothing reads a posted license_id. --- src/Settings.php | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/Settings.php b/src/Settings.php index 763c585..2b81446 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -222,7 +222,6 @@ public function settings_output() {
-

menu_args['page_title'] ); ?>

- - - - - - - - - + client->__( 'Activate License' ) : $this->client->__( 'Deactivate License' ) ); ?> @@ -345,7 +336,12 @@ public function license_form_submit() { // handle activation. if ( 'activate' === $_POST['_action'] ) { - $activated = $this->client->license()->activate( sanitize_text_field( $_POST['license_key'] ) ); + $license_key = isset( $_POST['license_key'] ) ? sanitize_text_field( $_POST['license_key'] ) : ''; + // the form doesn't print the stored key, so an empty field reuses it. + if ( '' === $license_key ) { + $license_key = (string) $this->license_key; + } + $activated = $this->client->license()->activate( $license_key ); if ( is_wp_error( $activated ) ) { $this->add_error( $activated->get_error_code(), $activated->get_error_message() ); return; From 850a85f78cd0383b16934a2edd41916e73c06586 Mon Sep 17 00:00:00 2001 From: Mihai Circea Date: Sat, 10 Oct 2026 07:18:27 +0300 Subject: [PATCH 05/11] Clear cached update data when the license changes The updater caches the version info for three hours, and WordPress keeps it in the update_plugins (or update_themes) site transient. Both hold the package link, and neither was cleared on activation or deactivation, so a deactivated site kept the link and a newly activated one waited for the next update check. Add Updater::clear_cache() and call it from activate() and deactivate(). --- src/License.php | 5 +++++ src/Updater.php | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/License.php b/src/License.php index c290aa7..2b26ebc 100644 --- a/src/License.php +++ b/src/License.php @@ -73,6 +73,7 @@ public function activate( $key = '' ) { } catch ( \Exception $e ) { // on error, clear options. $this->client->settings()->clear_options(); + $this->client->updater()->clear_cache(); // undo the activation this attempt created. $undone = $activation_id ? $this->client->activation()->delete( $activation_id ) : true; if ( is_wp_error( $undone ) && 'not_found' !== $undone->get_error_code() ) { @@ -84,6 +85,8 @@ public function activate( $key = '' ) { return new \WP_Error( 'error', $e->getMessage() ); } + $this->client->updater()->clear_cache(); + return true; } @@ -105,12 +108,14 @@ public function deactivate( $activation_id = '' ) { // it has been deleted remotely. if ( 'not_found' === $deactivated->get_error_code() ) { $this->client->settings()->clear_options(); + $this->client->updater()->clear_cache(); return true; } return $deactivated; } $this->client->settings()->clear_options(); + $this->client->updater()->clear_cache(); return true; } diff --git a/src/Updater.php b/src/Updater.php index 6cb2a08..2ce7ff3 100644 --- a/src/Updater.php +++ b/src/Updater.php @@ -130,6 +130,17 @@ private function set_cached_version_info( $value ) { set_transient( $this->cache_key, $value, 3 * HOUR_IN_SECONDS ); } + /** + * Clear the cached version info and WordPress' update data, which hold the package link. + * Call it when the license changes. + * + * @return void + */ + public function clear_cache() { + delete_transient( $this->cache_key ); + delete_site_transient( 'theme' === $this->client->type ? 'update_themes' : 'update_plugins' ); + } + /** * Get plugin info from SureCart\Licensing */ From ac1591b5e9f2edb73334fa32d2b2a708fa524c91 Mon Sep 17 00:00:00 2001 From: Mihai Circea Date: Sat, 10 Oct 2026 07:19:13 +0300 Subject: [PATCH 06/11] Keep query args in the settings redirect redirect() printed esc_url( $url ) inside the Date: Wed, 14 Oct 2026 14:44:32 +0600 Subject: [PATCH 07/11] Keep the saved license when an activation attempt fails activate() cleared every option before it knew whether the attempt could be undone, so a timeout or a release mismatch wiped a key that was already saved. Snapshot the options first and restore them when the attempt fails. Only when the new activation can't be deleted, keep just its id so Deactivate License can remove it. validate() also sent an empty key to the API, which hit the licenses collection. Fail early with "Please enter a license key." instead. Add Settings::set_options() and tests for the rollback, the empty key, and clearing the update caches. --- src/License.php | 24 +++- src/Settings.php | 13 +- tests/unit/LicenseActivationTest.php | 191 +++++++++++++++++++++++++++ 3 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 tests/unit/LicenseActivationTest.php diff --git a/src/License.php b/src/License.php index 2b26ebc..b6c6144 100644 --- a/src/License.php +++ b/src/License.php @@ -57,6 +57,8 @@ public function retrieve( $license_key ) { * @throws \Exception If something goes wrong. */ public function activate( $key = '' ) { + $settings = $this->client->settings(); + $previous = $settings->get_options(); // restored if this attempt fails. $activation_id = ''; try { // validate the license and store it. @@ -66,21 +68,24 @@ public function activate( $key = '' ) { if ( is_wp_error( $activation ) ) { throw new \Exception( $activation->get_error_message() ); } - $activation_id = $activation->id; - $this->client->settings()->activation_id = $activation_id; + $activation_id = $activation->id; + $settings->activation_id = $activation_id; // validate the release. $this->validate_release(); } catch ( \Exception $e ) { - // on error, clear options. - $this->client->settings()->clear_options(); - $this->client->updater()->clear_cache(); // undo the activation this attempt created. $undone = $activation_id ? $this->client->activation()->delete( $activation_id ) : true; if ( is_wp_error( $undone ) && 'not_found' !== $undone->get_error_code() ) { - // keep its id, so Deactivate License can remove it. - $this->client->settings()->activation_id = $activation_id; + // keep only its id, so Deactivate License can remove it. + $settings->clear_options(); + $settings->activation_id = $activation_id; + $this->client->updater()->clear_cache(); return new \WP_Error( 'error', $e->getMessage() . ' ' . $this->client->__( 'The activation could not be undone. Click Deactivate License to remove it.' ) ); } + + // restore the previous options, so a failed or interrupted request never loses a saved key. + $settings->set_options( $previous ); + // return \WP_Error. return new \WP_Error( 'error', $e->getMessage() ); } @@ -157,6 +162,11 @@ public function get_current_release( $expires_in = 900 ) { * @throws \Exception If the license is not valid. */ public function validate( $key, $store = false ) { + // without a key, the request would hit the licenses collection. + if ( '' === trim( (string) $key ) ) { + throw new \Exception( $this->client->__( 'Please enter a license key.' ) ); + } + // get license. $license = $this->retrieve( sanitize_text_field( $key ) ); if ( is_wp_error( $license ) ) { diff --git a/src/Settings.php b/src/Settings.php index 267faa6..dcadae9 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -165,13 +165,24 @@ public function get_options() { return (array) get_option( $this->option_key, array() ); } + /** + * Replace all options. + * + * @param array $options The options. + * + * @return bool + */ + public function set_options( $options ) { + return update_option( $this->option_key, (array) $options ); + } + /** * Clear out the options. * * @return bool */ public function clear_options() { - return update_option( $this->option_key, array() ); + return $this->set_options( array() ); } /** diff --git a/tests/unit/LicenseActivationTest.php b/tests/unit/LicenseActivationTest.php new file mode 100644 index 0000000..ce418cd --- /dev/null +++ b/tests/unit/LicenseActivationTest.php @@ -0,0 +1,191 @@ +client = new \SureCart\Licensing\Client( 'SureCart', __FILE__ ); + $this->requests = array(); + $this->responses = array(); + add_filter( 'pre_http_request', array( $this, 'stub' ), 10, 3 ); + } + + public function tear_down() { + remove_filter( 'pre_http_request', array( $this, 'stub' ), 10 ); + parent::tear_down(); + } + + /** + * Answer licensing API requests from $this->responses. + * + * @param false|array|\WP_Error $pre Unused, required by the filter. + * @param array $args Request arguments. + * @param string $url Request URL. + * + * @return array|\WP_Error + */ + public function stub( $pre, $args, $url ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + $path = preg_replace( '#^https?://[^/]+/#', '', strtok( $url, '?' ) ); + $request = $args['method'] . ' ' . $path; + $this->requests[] = $request; + + foreach ( $this->responses as $prefix => $response ) { + if ( 0 !== strpos( $request, $prefix ) ) { + continue; + } + if ( is_wp_error( $response ) ) { + return $response; + } + return array( + 'headers' => array(), + 'cookies' => array(), + 'response' => array( + 'code' => $response[0], + 'message' => '', + ), + 'body' => wp_json_encode( $response[1] ), + ); + } + + return new \WP_Error( 'unexpected_request', $request ); + } + + /** + * A valid license, a created activation, and a release for $slug. + * + * @param string $slug The release slug. + */ + private function stub_activation( $slug ) { + $this->responses = array( + // before 'GET .../KEY', which is its prefix. + 'GET v1/public/licenses/KEY/expose_current_release' => array( + 200, + array( + 'url' => 'https://example.com/package.zip', + 'release_json' => array( + 'slug' => $slug, + 'version' => '9.9.9', + ), + ), + ), + 'GET v1/public/licenses/KEY' => array( + 200, + array( + 'id' => 'lic_1', + 'key' => 'KEY', + 'status' => 'active', + ), + ), + 'POST v1/public/activations/' => array( 201, array( 'id' => 'act_new' ) ), + ); + } + + public function test_empty_key_fails_without_a_request() { + $result = $this->client->license()->activate( '' ); + + $this->assertWPError( $result ); + $this->assertSame( array(), $this->requests ); + } + + public function test_release_error_deletes_the_new_activation_by_id() { + $this->stub_activation( 'another-product' ); + $this->responses['DELETE v1/public/activations/act_new'] = array( 200, array( 'deleted' => true ) ); + + $result = $this->client->license()->activate( 'KEY' ); + + $this->assertWPError( $result ); + $this->assertContains( 'DELETE v1/public/activations/act_new', $this->requests ); + $this->assertSame( array(), $this->client->settings()->get_options() ); + } + + public function test_undo_404_counts_as_removed() { + $this->stub_activation( 'another-product' ); + $this->responses['DELETE v1/public/activations/act_new'] = array( 404, array() ); + + $result = $this->client->license()->activate( 'KEY' ); + + $this->assertWPError( $result ); + $this->assertSame( array(), $this->client->settings()->get_options() ); + } + + public function test_failed_undo_keeps_only_the_activation_id() { + $this->stub_activation( 'another-product' ); + $this->responses['DELETE v1/public/activations/act_new'] = array( 500, array() ); + + $result = $this->client->license()->activate( 'KEY' ); + + $this->assertWPError( $result ); + $this->assertStringContainsString( 'Deactivate License', $result->get_error_message() ); + $this->assertSame( array( 'sc_activation_id' => 'act_new' ), $this->client->settings()->get_options() ); + } + + public function test_failed_request_restores_the_saved_key() { + $previous = array( + 'sc_license_key' => 'KEY', + 'sc_license_id' => 'lic_1', + 'sc_activation_id' => 'act_removed', + ); + $this->client->settings()->set_options( $previous ); + $this->responses['GET v1/public/licenses/KEY'] = new \WP_Error( 'http_request_failed', 'Timed out' ); + + $result = $this->client->license()->activate( 'KEY' ); + + $this->assertWPError( $result ); + $this->assertSame( $previous, $this->client->settings()->get_options() ); + } + + public function test_activation_and_deactivation_clear_update_caches() { + $this->stub_activation( $this->client->slug ); + $this->responses['DELETE v1/public/activations/act_new'] = array( 200, array( 'deleted' => true ) ); + + $this->prime_update_caches(); + $this->assertTrue( $this->client->license()->activate( 'KEY' ) ); + $this->assert_update_caches_cleared(); + + $this->prime_update_caches(); + $this->assertTrue( $this->client->license()->deactivate() ); + $this->assert_update_caches_cleared(); + } + + /** + * The SDK's version info cache key. + * + * @return string + */ + private function cache_key() { + return 'surecart_' . md5( $this->client->slug ) . '_version_info'; + } + + private function prime_update_caches() { + set_transient( $this->cache_key(), (object) array( 'new_version' => '1.0.0' ) ); + set_site_transient( 'update_plugins', (object) array( 'response' => array() ) ); + } + + private function assert_update_caches_cleared() { + $this->assertFalse( get_transient( $this->cache_key() ) ); + $this->assertFalse( get_site_transient( 'update_plugins' ) ); + } +} From 715b0a4b793af3c46b40058ba5a94a1419897778 Mon Sep 17 00:00:00 2001 From: ManiruzzamanAkash Date: Wed, 14 Oct 2026 14:44:35 +0600 Subject: [PATCH 08/11] Explain a failed undo on the license form and unslash the posted key When the last activation couldn't be undone, only its id is stored, and the form said the license was activated. Say that the attempt failed and to click Deactivate License instead. Show "Leave blank to use the saved key" in the empty key field when a key is stored, and wp_unslash() the posted key before sanitizing it. Add form tests: the saved key is never printed, deactivation ignores a posted activation id, and the redirect keeps its query args. --- src/Settings.php | 8 +-- tests/unit/SettingsFormTest.php | 95 +++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 tests/unit/SettingsFormTest.php diff --git a/src/Settings.php b/src/Settings.php index dcadae9..5924986 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -236,15 +236,17 @@ public function settings_output() {

menu_args['page_title'] ); ?>

- + client->__( 'Activate License' ) : $this->client->__( 'Deactivate License' ) ); ?> @@ -347,7 +349,7 @@ public function license_form_submit() { // handle activation. if ( 'activate' === $_POST['_action'] ) { - $license_key = isset( $_POST['license_key'] ) ? sanitize_text_field( $_POST['license_key'] ) : ''; + $license_key = isset( $_POST['license_key'] ) ? sanitize_text_field( wp_unslash( $_POST['license_key'] ) ) : ''; // the form doesn't print the stored key, so an empty field reuses it. if ( '' === $license_key ) { $license_key = (string) $this->license_key; diff --git a/tests/unit/SettingsFormTest.php b/tests/unit/SettingsFormTest.php new file mode 100644 index 0000000..5f1245f --- /dev/null +++ b/tests/unit/SettingsFormTest.php @@ -0,0 +1,95 @@ +client = new \SureCart\Licensing\Client( 'SureCart', __FILE__ ); + $this->requests = array(); + $this->client->settings()->add_page( array() ); + add_filter( 'pre_http_request', array( $this, 'stub' ), 10, 3 ); + } + + public function tear_down() { + remove_filter( 'pre_http_request', array( $this, 'stub' ), 10 ); + $_POST = array(); + parent::tear_down(); + } + + /** + * Record each request and answer it with a 200. + * + * @param false|array|\WP_Error $pre Unused, required by the filter. + * @param array $args Request arguments. + * @param string $url Request URL. + * + * @return array + */ + public function stub( $pre, $args, $url ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + $this->requests[] = $args['method'] . ' ' . preg_replace( '#^https?://[^/]+/#', '', strtok( $url, '?' ) ); + + return array( + 'headers' => array(), + 'cookies' => array(), + 'response' => array( + 'code' => 200, + 'message' => '', + ), + 'body' => wp_json_encode( array( 'id' => 'act_mine' ) ), + ); + } + + public function test_form_never_prints_the_saved_key() { + $this->client->settings()->license_key = 'SECRET_KEY'; + + ob_start(); + $this->client->settings()->settings_output(); + $html = ob_get_clean(); + + $this->assertStringNotContainsString( 'SECRET_KEY', $html ); + $this->assertStringContainsString( 'Leave blank to use the saved key', $html ); + } + + public function test_deactivation_ignores_a_posted_activation_id() { + $this->client->settings()->license_key = 'KEY'; + $this->client->settings()->activation_id = 'act_mine'; + + $_POST = array( + 'submit' => 'Deactivate License', + '_action' => 'deactivate', + '_nonce' => wp_create_nonce( $this->client->name ), + 'activation_id' => 'act_someone_else', + ); + + $this->client->settings()->license_form_submit(); + + $this->assertSame( array( 'DELETE v1/public/activations/act_mine' ), $this->requests ); + } + + public function test_redirect_keeps_query_args() { + ob_start(); + $this->client->settings()->redirect( 'https://example.com/wp-admin/admin.php?page=my-plugin&tab=license' ); + $html = ob_get_clean(); + + $this->assertStringContainsString( 'page=my-plugin&tab=license', $html ); + $this->assertStringNotContainsString( '&', $html ); + } +} From 83e7f7fe1024a34cc81f0ca4658858fbc1e6f078 Mon Sep 17 00:00:00 2001 From: ManiruzzamanAkash Date: Wed, 14 Oct 2026 15:01:24 +0600 Subject: [PATCH 09/11] Bump version to 1.2.2 --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index fd2ad0b..935508a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -12,7 +12,7 @@ class Client { * * @var string */ - public $version = '1.2.1'; + public $version = '1.2.2'; /** * Name of the plugin From 92423745e3979ae24f7c4c13196848e509016d29 Mon Sep 17 00:00:00 2001 From: ManiruzzamanAkash Date: Wed, 14 Oct 2026 15:09:15 +0600 Subject: [PATCH 10/11] fixed some wrong docs --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 8404f73..e6785e2 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,6 @@ add_action('init', function(){ 'menu_slug' => $client->slug . '-manage-license', 'icon_url' => '', 'position' => null, - 'parent_slug' => '', 'activated_redirect' => admin_url( 'admin.php?page=my-plugin-page' ), // should you want to redirect on activation of license. 'deactivated_redirect' => admin_url( 'admin.php?page=my-plugin-deactivation-page' ), // should you want to redirect on detactivation of license. ] @@ -109,8 +108,6 @@ add_action('init', function(){ }); ``` -Make sure you call this function directly, never use any action hook to call this function. - > For plugins example code that needs to be used on your main plugin file. > For themes example code that needs to be used on your themes `functions.php` file. From f138d04e2e5c2ebc13eb21f4c2a197e52f783259 Mon Sep 17 00:00:00 2001 From: ManiruzzamanAkash Date: Wed, 14 Oct 2026 15:19:10 +0600 Subject: [PATCH 11/11] changed example plugin and theme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e6785e2..04579a7 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,9 @@ $client->set_textdomain( 'your-project-textdomain' ); ``` ## Example Plugin -[surecart-plugin-license.zip](https://d.pr/f/6ZyfAS) +[surecart-plugin-license.zip](https://d.pr/f/6MPcya) ## Example Theme -[surecart-theme-license.zip](https://d.pr/f/RK8NFS) +[surecart-theme-license.zip](https://d.pr/f/mYc39R) For full setup and functionality details, see the official documentation: [Licensing Setup and Functionality](https://surecart.com/docs/licensing-setup-and-functionality/).