From 2df2eda4a7005ceae2f77a834cda7366c9fdfb13 Mon Sep 17 00:00:00 2001 From: Tsvetan Stoychev Date: Sun, 20 Sep 2026 13:30:04 +0300 Subject: [PATCH 01/11] Implement Magento 2 Phase 1 parity --- .github/workflows/ci.yml | 52 +++ .gitignore | 4 + Block/Adminhtml/System/Config/ConsentMode.php | 29 +- Block/Adminhtml/System/Config/Logo.php | 4 +- Block/Adminhtml/System/Config/Status.php | 73 ++++ Model/Config.php | 293 ++++++++++++++++ .../System/Config/Backend/BeaconEndpoint.php | 123 +++++++ Model/System/Config/Backend/BrumSiteId.php | 32 ++ .../Config/Backend/WaitMilliseconds.php | 20 ++ README.md | 185 ++++++++-- THIRD-PARTY-NOTICES.txt | 42 +++ ViewModel/Footer.php | 30 +- composer.json | 8 +- docs/PHASE-1-PARITY-COMPLETION.md | 66 ++++ etc/acl.xml | 2 +- etc/adminhtml/system.xml | 113 +++++- etc/config.xml | 28 ++ etc/module.xml | 6 +- package-lock.json | 95 +++++ package.json | 19 + playwright.config.js | 16 + playwright.integration.config.js | 17 + tests/integration/README.md | 31 ++ tests/integration/baseline.env | 6 + tests/integration/configure-disposable.sh | 41 +++ tests/integration/storefront.spec.js | 79 +++++ tests/js/build-loaders.js | 25 ++ tests/js/check-minified.js | 78 +++++ tests/js/loaders.spec.js | 240 +++++++++++++ tests/js/real-boomerang.spec.js | 266 ++++++++++++++ tests/php/bootstrap.php | 272 +++++++++++++++ tests/php/run.php | 324 ++++++++++++++++++ view/frontend/templates/footer.phtml | 124 ++++--- view/frontend/web/js/boomr/LICENSE.txt | 37 ++ .../boomerang-1.815.60.cutting-edge.min.js | 2 +- .../web/js/loaders/boomerang-loader-v15.js | 194 +++++++++++ .../js/loaders/boomerang-loader-v15.min.js | 1 + .../loaders/consent-boomerang-loader-v1-15.js | 261 ++++++++++++++ .../consent-boomerang-loader-v1-15.min.js | 2 + 39 files changed, 3122 insertions(+), 118 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Block/Adminhtml/System/Config/Status.php create mode 100644 Model/Config.php create mode 100644 Model/System/Config/Backend/BeaconEndpoint.php create mode 100644 Model/System/Config/Backend/BrumSiteId.php create mode 100644 Model/System/Config/Backend/WaitMilliseconds.php create mode 100644 THIRD-PARTY-NOTICES.txt create mode 100644 docs/PHASE-1-PARITY-COMPLETION.md create mode 100644 etc/config.xml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.js create mode 100644 playwright.integration.config.js create mode 100644 tests/integration/README.md create mode 100644 tests/integration/baseline.env create mode 100755 tests/integration/configure-disposable.sh create mode 100644 tests/integration/storefront.spec.js create mode 100644 tests/js/build-loaders.js create mode 100644 tests/js/check-minified.js create mode 100644 tests/js/loaders.spec.js create mode 100644 tests/js/real-boomerang.spec.js create mode 100644 tests/php/bootstrap.php create mode 100644 tests/php/run.php create mode 100644 view/frontend/web/js/boomr/LICENSE.txt create mode 100644 view/frontend/web/js/loaders/boomerang-loader-v15.js create mode 100644 view/frontend/web/js/loaders/boomerang-loader-v15.min.js create mode 100644 view/frontend/web/js/loaders/consent-boomerang-loader-v1-15.js create mode 100644 view/frontend/web/js/loaders/consent-boomerang-loader-v1-15.min.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fbcbf74 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + php: + name: PHP 8.3 validation + runs-on: ubuntu-24.04 + container: php:8.3-cli + steps: + - name: Check out source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Report runtime + run: php --version + + - name: Lint PHP + run: find . -type f \( -name '*.php' -o -name '*.phtml' \) -not -path './vendor/*' -print0 | xargs -0 -n1 php -l + + - name: Run focused PHP checks + run: php tests/php/run.php + + - name: Validate Composer metadata + run: php -r '$data=json_decode(file_get_contents("composer.json"), true, 512, JSON_THROW_ON_ERROR); if (($data["require"]["php"] ?? "") !== ">=8.2 <8.5") { exit(1); }' + + browser: + name: Chromium loader and beacon checks + runs-on: ubuntu-24.04 + steps: + - name: Check out source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.19.0 + cache: npm + + - name: Install test dependencies + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Run loader and real-Boomerang checks + run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..49582b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.test-results/ +test-results/ +playwright-report/ diff --git a/Block/Adminhtml/System/Config/ConsentMode.php b/Block/Adminhtml/System/Config/ConsentMode.php index 81ea151..74a0c79 100644 --- a/Block/Adminhtml/System/Config/ConsentMode.php +++ b/Block/Adminhtml/System/Config/ConsentMode.php @@ -3,15 +3,22 @@ namespace BasicRum\Analytics\Block\Adminhtml\System\Config; -class ConsentMode implements \Magento\Framework\Data\OptionSourceInterface +use BasicRum\Analytics\Model\Config; +use Magento\Framework\Data\OptionSourceInterface; + +/** + * Manual consent connection choices, including preserved legacy values. + */ +class ConsentMode implements OptionSourceInterface { - public function toOptionArray() - { - return [ - ['value' => 'explicit', 'label' => __('Explicit Consent')], - ['value' => 'implicit', 'label' => __('Implicit Consent')], - ['value' => 'cookie', 'label' => __('Cookie Banner')], - ['value' => 'gdpr', 'label' => __('GDPR Banner')] - ]; - } -} \ No newline at end of file + public function toOptionArray(): array + { + return [ + ['value' => Config::CONSENT_MODE_MANUAL, 'label' => __('Manual callbacks')], + ['value' => 'explicit', 'label' => __('Legacy: Explicit Consent (manual callbacks; review)')], + ['value' => 'implicit', 'label' => __('Legacy: Implicit Consent (manual callbacks; review)')], + ['value' => 'cookie', 'label' => __('Legacy: Cookie Banner (manual callbacks; review)')], + ['value' => 'gdpr', 'label' => __('Legacy: GDPR Banner (manual callbacks; review)')], + ]; + } +} diff --git a/Block/Adminhtml/System/Config/Logo.php b/Block/Adminhtml/System/Config/Logo.php index 607b187..27ca0d8 100644 --- a/Block/Adminhtml/System/Config/Logo.php +++ b/Block/Adminhtml/System/Config/Logo.php @@ -19,8 +19,8 @@ public function __construct( public function render(AbstractElement $element) { $html = '
'; - $html .= 'BasicRum Logo'; - $html .= 'BasicRUM Analytics'; + $html .= 'Basicrum Logo'; + $html .= 'Basicrum Analytics'; $html .= '
'; return $html; } diff --git a/Block/Adminhtml/System/Config/Status.php b/Block/Adminhtml/System/Config/Status.php new file mode 100644 index 0000000..423b1c6 --- /dev/null +++ b/Block/Adminhtml/System/Config/Status.php @@ -0,0 +1,73 @@ +getSelectedScope(); + $status = $this->config->getStatus($scopeType, $scopeCode); + + $messages = [ + 'disabled' => __('Inactive: enable Basicrum to emit monitoring scripts.'), + 'missing_endpoint' => __('Inactive: enter a Beacon Endpoint for this scope or inherit one.'), + 'invalid_endpoint' => __('Inactive: the effective Beacon Endpoint is invalid.'), + 'missing_site_id' => __('Inactive: enter a UUIDv4 Brum Site ID for this scope or inherit one.'), + 'invalid_site_id' => __('Inactive: the effective Brum Site ID is not a UUIDv4.'), + 'active_consent' => __('Ready, consent-controlled: the storefront emits only the inert consent wrapper until the current page receives an authoritative allow callback.'), + 'active_immediate' => __('Ready, immediate: Boomerang loads without waiting for a consent decision and may set cookies and send performance data.'), + ]; + + $message = $messages[$status['state']] ?? __('Inactive: review the effective Basicrum configuration.'); + $legacyNotice = ''; + if (in_array($status['consent_mode'], Config::LEGACY_CONSENT_MODES, true)) { + $legacyNotice = ' ' . __( + 'The saved legacy consent mode is retained for compatibility, is treated as manual callbacks, and never counts as consent.' + ); + } + + return sprintf( + '
%s
', + str_starts_with($status['state'], 'active_') ? 'success' : 'warning', + $this->escapeHtml((string) $message . (string) $legacyNotice) + ); + } + + /** + * @return array{0: string, 1: string|null} + */ + private function getSelectedScope(): array + { + $storeCode = (string) $this->getRequest()->getParam('store', ''); + if ($storeCode !== '') { + return [ScopeInterface::SCOPE_STORE, $storeCode]; + } + + $websiteCode = (string) $this->getRequest()->getParam('website', ''); + if ($websiteCode !== '') { + return [ScopeInterface::SCOPE_WEBSITE, $websiteCode]; + } + + return [ScopeConfigInterface::SCOPE_TYPE_DEFAULT, null]; + } +} diff --git a/Model/Config.php b/Model/Config.php new file mode 100644 index 0000000..b86b9ff --- /dev/null +++ b/Model/Config.php @@ -0,0 +1,293 @@ + + */ + public static function getDefaults(): array + { + return [ + 'enabled' => false, + 'beacon_endpoint' => '', + 'brum_site_id' => '', + 'consent_enabled' => true, + 'consent_mode' => self::CONSENT_MODE_MANUAL, + 'strip_query_string' => false, + 'wait_after_onload' => false, + 'delay_ms' => 0, + 'development_mode' => false, + ]; + } + + /** + * Return a complete, validated runtime configuration or null when inactive. + * + * @param string|int|null $scopeCode + * @return array|null + */ + public function getRuntimeConfig( + string $scopeType = ScopeInterface::SCOPE_STORE, + $scopeCode = null + ): ?array { + if (!$this->getBoolean(self::XML_PATH_ENABLED, false, $scopeType, $scopeCode)) { + return null; + } + + $endpoint = $this->getBeaconEndpoint($scopeType, $scopeCode); + $siteId = $this->getBrumSiteId($scopeType, $scopeCode); + + if ($endpoint === null || $siteId === null) { + return null; + } + + return [ + 'beacon_endpoint' => $endpoint, + 'brum_site_id' => $siteId, + 'consent_enabled' => $this->getBoolean( + self::XML_PATH_CONSENT_ENABLED, + true, + $scopeType, + $scopeCode + ), + 'consent_mode' => $this->getConsentMode($scopeType, $scopeCode), + 'strip_query_string' => $this->getBoolean( + self::XML_PATH_STRIP_QUERY_STRING, + false, + $scopeType, + $scopeCode + ), + 'wait_after_onload' => $this->getBoolean( + self::XML_PATH_WAIT_ENABLED, + false, + $scopeType, + $scopeCode + ), + 'delay_ms' => self::normalizeWaitMilliseconds( + $this->scopeConfig->getValue(self::XML_PATH_WAIT_MS, $scopeType, $scopeCode) + ), + ]; + } + + /** + * Describe why the effective scope is active or inactive for admin feedback. + * + * @param string|int|null $scopeCode + * @return array{state: string, consent_mode: string} + */ + public function getStatus( + string $scopeType = ScopeInterface::SCOPE_STORE, + $scopeCode = null + ): array { + if (!$this->getBoolean(self::XML_PATH_ENABLED, false, $scopeType, $scopeCode)) { + return ['state' => 'disabled', 'consent_mode' => $this->getConsentMode($scopeType, $scopeCode)]; + } + + $rawEndpoint = trim((string) $this->scopeConfig->getValue( + self::XML_PATH_BEACON_ENDPOINT, + $scopeType, + $scopeCode + )); + if ($rawEndpoint === '') { + return ['state' => 'missing_endpoint', 'consent_mode' => $this->getConsentMode($scopeType, $scopeCode)]; + } + if (!self::isValidBeaconEndpoint($rawEndpoint)) { + return ['state' => 'invalid_endpoint', 'consent_mode' => $this->getConsentMode($scopeType, $scopeCode)]; + } + + $rawSiteId = trim((string) $this->scopeConfig->getValue( + self::XML_PATH_BRUM_SITE_ID, + $scopeType, + $scopeCode + )); + if ($rawSiteId === '') { + return ['state' => 'missing_site_id', 'consent_mode' => $this->getConsentMode($scopeType, $scopeCode)]; + } + if (!self::isValidBrumSiteId($rawSiteId)) { + return ['state' => 'invalid_site_id', 'consent_mode' => $this->getConsentMode($scopeType, $scopeCode)]; + } + + $consentRequired = $this->getBoolean( + self::XML_PATH_CONSENT_ENABLED, + true, + $scopeType, + $scopeCode + ); + + return [ + 'state' => $consentRequired ? 'active_consent' : 'active_immediate', + 'consent_mode' => $this->getConsentMode($scopeType, $scopeCode), + ]; + } + + /** + * Validate a collector URL without accepting executable URL schemes. + */ + public static function isValidBeaconEndpoint($value): bool + { + if (!is_string($value) || $value === '' || trim($value) !== $value) { + return false; + } + + if (filter_var($value, FILTER_VALIDATE_URL) === false) { + return false; + } + + $parts = parse_url($value); + if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { + return false; + } + + return in_array(strtolower((string) $parts['scheme']), ['http', 'https'], true); + } + + /** + * Validate a Brum Site ID as an RFC 4122 UUIDv4. + */ + public static function isValidBrumSiteId($value): bool + { + return is_string($value) && preg_match(self::BRUM_SITE_ID_PATTERN, $value) === 1; + } + + /** + * Normalize only explicit boolean values. Unknown values fail to the caller's default. + */ + public static function normalizeBoolean($value, bool $default): bool + { + if ($value === true || $value === 1 || $value === '1') { + return true; + } + + if ($value === false || $value === 0 || $value === '0') { + return false; + } + + return $default; + } + + /** + * Clamp a configured delay to the supported zero-to-30-second range. + */ + public static function normalizeWaitMilliseconds($value): int + { + if (!is_scalar($value) || !is_numeric($value)) { + return 0; + } + + return min(self::MAX_WAIT_MS, max(0, (int) $value)); + } + + /** + * @param string|int|null $scopeCode + */ + private function getBeaconEndpoint(string $scopeType, $scopeCode): ?string + { + $endpoint = trim((string) $this->scopeConfig->getValue( + self::XML_PATH_BEACON_ENDPOINT, + $scopeType, + $scopeCode + )); + + if (!self::isValidBeaconEndpoint($endpoint)) { + return null; + } + + $developmentMode = $this->getBoolean( + self::XML_PATH_DEVELOPMENT_MODE, + false, + $scopeType, + $scopeCode + ); + if (!$developmentMode && stripos($endpoint, 'http://') === 0) { + $endpoint = 'https://' . substr($endpoint, 7); + } + + return $endpoint; + } + + /** + * @param string|int|null $scopeCode + */ + private function getBrumSiteId(string $scopeType, $scopeCode): ?string + { + $siteId = trim((string) $this->scopeConfig->getValue( + self::XML_PATH_BRUM_SITE_ID, + $scopeType, + $scopeCode + )); + + return self::isValidBrumSiteId($siteId) ? $siteId : null; + } + + /** + * @param string|int|null $scopeCode + */ + private function getConsentMode(string $scopeType, $scopeCode): string + { + $mode = trim((string) $this->scopeConfig->getValue( + self::XML_PATH_CONSENT_MODE, + $scopeType, + $scopeCode + )); + + if ($mode === self::CONSENT_MODE_MANUAL || in_array($mode, self::LEGACY_CONSENT_MODES, true)) { + return $mode; + } + + return self::CONSENT_MODE_MANUAL; + } + + /** + * @param string|int|null $scopeCode + */ + private function getBoolean( + string $path, + bool $default, + string $scopeType, + $scopeCode + ): bool { + return self::normalizeBoolean( + $this->scopeConfig->getValue($path, $scopeType, $scopeCode), + $default + ); + } +} diff --git a/Model/System/Config/Backend/BeaconEndpoint.php b/Model/System/Config/Backend/BeaconEndpoint.php new file mode 100644 index 0000000..7bd5d55 --- /dev/null +++ b/Model/System/Config/Backend/BeaconEndpoint.php @@ -0,0 +1,123 @@ +getValue()); + + if ($value !== '' && !Config::isValidBeaconEndpoint($value)) { + throw new LocalizedException( + __('Beacon Endpoint must be a valid HTTP or HTTPS URL.') + ); + } + + if (!$this->isHttpAllowed() && stripos($value, 'http://') === 0) { + $value = 'https://' . substr($value, 7); + } + + $this->setValue($value); + + return parent::beforeSave(); + } + + /** + * Resolve the policy submitted in the same scoped configuration form. + */ + private function isHttpAllowed(): bool + { + $groups = $this->getData('groups'); + $field = $groups['developer']['fields']['development_mode'] ?? null; + + if (is_array($field) && empty($field['inherit']) && array_key_exists('value', $field)) { + return Config::normalizeBoolean($field['value'], false); + } + + $scope = (string) $this->getScope(); + $scopeCode = (string) $this->getScopeCode(); + + if ($scope === ScopeInterface::SCOPE_STORES && $scopeCode !== '') { + if (is_array($field) && !empty($field['inherit'])) { + $websiteCode = (string) $this->storeManager->getStore($scopeCode)->getWebsite()->getCode(); + return Config::normalizeBoolean( + $this->_config->getValue( + Config::XML_PATH_DEVELOPMENT_MODE, + ScopeInterface::SCOPE_WEBSITE, + $websiteCode + ), + false + ); + } + + return Config::normalizeBoolean( + $this->_config->getValue( + Config::XML_PATH_DEVELOPMENT_MODE, + ScopeInterface::SCOPE_STORE, + $scopeCode + ), + false + ); + } + + if ($scope === ScopeInterface::SCOPE_WEBSITES + && $scopeCode !== '' + && !(is_array($field) && !empty($field['inherit'])) + ) { + return Config::normalizeBoolean( + $this->_config->getValue( + Config::XML_PATH_DEVELOPMENT_MODE, + ScopeInterface::SCOPE_WEBSITE, + $scopeCode + ), + false + ); + } + + return Config::normalizeBoolean( + $this->_config->getValue(Config::XML_PATH_DEVELOPMENT_MODE), + false + ); + } +} diff --git a/Model/System/Config/Backend/BrumSiteId.php b/Model/System/Config/Backend/BrumSiteId.php new file mode 100644 index 0000000..8af1662 --- /dev/null +++ b/Model/System/Config/Backend/BrumSiteId.php @@ -0,0 +1,32 @@ +getValue()); + + if ($value !== '' && !Config::isValidBrumSiteId($value)) { + throw new LocalizedException( + __('Brum Site ID must be a valid UUIDv4 copied from the Basicrum backoffice.') + ); + } + + $this->setValue($value); + + return parent::beforeSave(); + } +} diff --git a/Model/System/Config/Backend/WaitMilliseconds.php b/Model/System/Config/Backend/WaitMilliseconds.php new file mode 100644 index 0000000..39cf0b7 --- /dev/null +++ b/Model/System/Config/Backend/WaitMilliseconds.php @@ -0,0 +1,20 @@ +setValue(Config::normalizeWaitMilliseconds($this->getValue())); + + return parent::beforeSave(); + } +} diff --git a/README.md b/README.md index 7acd972..ac8f2f9 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,185 @@ -# BasicRum Analytics for Magento 2 +# Basicrum Analytics for Magento 2 -BasicRum Analytics is a Magento 2 extension that helps you collect and analyze real user monitoring (RUM) data for your Magento store, providing insights into your website's performance from the user's perspective. +Basicrum adds Boomerang real user monitoring (RUM) to a Magento 2 storefront. +Monitoring is fail-closed: no Basicrum storefront scripts are emitted unless +the module is enabled and the effective store-scope Beacon Endpoint and UUIDv4 +Brum Site ID are valid. -## Requirements +## Supported baseline -- Magento Open Source or Commerce version 2.3.x or higher -- PHP 7.2 or higher +The Phase 1 integration baseline is Magento Open Source **2.4.7-p10** with +**PHP 8.3** and Composer 2.10. The complete disposable-store dependency set is +pinned in `tests/integration/baseline.env`. Composer metadata allows PHP 8.2 +through 8.4 and Magento framework 103.x so the module can be evaluated on the +adjacent Magento 2.4 release lines, but those combinations are not represented +as integration-tested here. ## Installation ```sh -composer require basicrum/basicrum-analytics +composer require basicrum/basicrum-analytics bin/magento module:enable BasicRum_Analytics bin/magento setup:upgrade bin/magento cache:flush ``` +In production mode, deploy static content using the store's normal deployment +process after installing or upgrading the module. + ## Configuration -1. Log in to your Magento Admin Panel -2. Navigate to **Stores > Configuration > BasicRum Analytics** -3. Configure the following options: - - **Enable Module**: Set to "Yes" to enable the extension - - **Beacon Endpoint**: Enter the URL where the data should be sent. This will be the endpoint where a BasicRUM beacon catcher is running. +Open **Stores > Configuration > Basicrum Analytics**. Every setting supports +Magento default, website, and store inheritance. + +Required settings: + +- **Enable Basicrum**: new installations default to No. +- **Beacon Endpoint**: a valid HTTP or HTTPS collector URL. HTTPS is enforced + unless the explicit development exception is enabled. +- **Brum Site ID**: a UUIDv4 copied from the Basicrum backoffice. + +If any effective value is disabled, missing, malformed, or unsafe, the +Monitoring Status row explains the inactive state and the storefront template +emits nothing. Values are validated on save and again at render time so +programmatic or stale configuration cannot bypass the runtime gate. + +Collection controls: + +- **Require Consent Before Monitoring** defaults to Yes. Select No only for a + deliberate immediate-loading policy. +- **Strip Query Strings** defaults to No. When enabled, Boomerang replaces + complete query strings in page, navigation, referrer, and resource URLs with + `?qs-redacted` before beacon transmission. +- **Wait After Onload** defaults to No with a zero delay. Its configured delay + is bounded to 30,000 milliseconds. +- **Allow HTTP Beacon Endpoint** defaults to No and is intended only for local + development. Without it, saved and effective HTTP endpoints are upgraded to + HTTPS. + +The runtime emits the existing Magento 2 `p_type` values, `p_gen=mage2`, and +the configured `brum_site_id`. Boomerang uses `instrument_xhr=false`, +Continuity and ResourceTiming with `splitAtPath`, and Secure/SameSite Strict +cookie settings, matching the reviewed Basicrum configuration. + +## Consent integration + +Phase 1 provides a manual, page-level callback contract. Basicrum does not +display a banner, decide whether consent is legally required, infer consent +from a cookie or legacy mode string, or persist its own consent decision. + +When the site's external consent tool authoritatively allows performance +monitoring on the current page, call: + +```js +if (typeof window.OPT_IN_BASICRUM_LOADER_WRAPPER === "function") { + window.OPT_IN_BASICRUM_LOADER_WRAPPER(); +} +``` + +On denial, expiry, or withdrawal, call: + +```js +if (typeof window.OPT_OUT_BASICRUM_LOADER_WRAPPER === "function") { + window.OPT_OUT_BASICRUM_LOADER_WRAPPER(); +} +``` + +The consent wrapper is inert until allow. Repeated allow calls load Boomerang +at most once. Denial before the first allow cleans measurement and legacy +consent cookies without preventing a later allow on that page. Withdrawal +during download prevents the arriving bundle from initializing. Withdrawal +after initialization disables further collection, cancels a pending Wait After +Onload timer, and removes `RT`, `BA`, `BRUM_CONSENT`, and `BOOMR_CONSENT` +cookies where JavaScript can reach them. Data already transmitted cannot be +retracted. + +After withdrawal once loading has started, re-grant requires a page reload. +This intentionally prevents a same-page restart from a partially initialized +state. The callbacks are registered by the footer loader; calls made before +registration are not queued. Connect both allow and deny/change events in the +site's consent tool on every page. + +Automatic consent-provider adapters are not part of Phase 1. + +## Upgrade behavior from 0.0.2 + +No data migration renames, deletes, or heuristically rewrites stored settings. +Review the following before enabling the upgraded module: + +- Existing `basicrum/general/beacon_endpoint` values remain in place but now + receive save-time and runtime validation. Invalid values make monitoring + inactive. HTTP becomes HTTPS unless the development exception is explicit. +- `basicrum/general/brum_site_id` is new and required. Existing enabled stores + stay inactive until a valid UUIDv4 value is configured at the appropriate + scope. +- Existing `basicrum/consent/enabled=0` means deliberate immediate loading. + Value `1` means consent-controlled loading. Invalid or absent effective + values fail to consent-controlled behavior. +- Legacy `basicrum/consent/mode` values (`explicit`, `implicit`, `cookie`, and + `gdpr`) are retained and shown in Admin, but all are treated as manual + integration metadata. None counts as an allow decision. +- The old five-second wait was hardcoded and had no stored setting. It is + replaced with `basicrum/performance/wait_after_onload` and `delay_ms`, both + defaulting to off/zero. Administrators who need the former timing must + explicitly enable it and enter 5000 ms. + +After changing module configuration, clean Magento configuration, layout, +block HTML, and full-page caches. Production deployments must also publish the +new static assets and invalidate any CDN or optimizer cache that can retain old +HTML or JavaScript. Magento's versioned static asset URLs provide browser cache +invalidation only after the deployment/content version changes. + +The template uses Magento's `SecureHtmlRenderer`, and the loader and Boomerang +assets are same-origin module assets. The disposable-store check exercises the +actual layout and CSP path, but Phase 1 does not claim compatibility with a +broad set of third-party script delay/combine/optimizer extensions. + +## Testing + +Fast checks: + +```sh +docker run --rm -v "$PWD:/module:ro" -w /module php:8.3-cli php tests/php/run.php +npm ci +npm test +``` + +The PHP harness covers defaults, validation, save normalization, runtime gates, +scope inheritance, template serialization/loader selection, and artifact +provenance. Browser tests execute the packaged readable and minified loaders +and the real bundled Boomerang against intercepted local requests. They cover +pre-consent silence, one-time loading, denial and withdrawal races, cookie +cleanup, query redaction, and beacon identity. -4. Click "Save Config" to apply the changes -5. Clear the cache by going to **System > Cache Management** and clicking "Flush Magento Cache" +For the actual layout/template/static-content/CSP/storefront-to-beacon path, +use the guarded disposable-store harness in `tests/integration/README.md`. +The regular CI workflow runs the fast PHP and Chromium checks. The disposable +Magento check needs a licensed/authenticated Magento installation and is not +reported as passing unless it is run separately. -## Verification +## Privacy and lifecycle notes -To verify that the extension is working properly: +Boomerang may collect page/navigation/referrer/resource URLs, performance +timings, browser/network characteristics, and the configured Basicrum identity, +then send them to the configured Beacon Endpoint. With consent-controlled +loading, the Boomerang monitoring bundle, beacon transmission, and measurement +cookies do not start before the current page receives allow. The inert consent +wrapper is present so the external tool can signal that decision. Immediate +mode has no such gate. -1. Open your store in a web browser -2. Open the browser's developer tools (F12) -3. Check the Network tab for requests to the BasicRum collection endpoint -4. Visit your BasicRum dashboard to confirm that data is being collected +Disabling the module and cleaning page caches stops future script emission. +Uninstall behavior and settings deletion are not automated in Phase 1; removing +module files does not delete configuration or data already sent to a collector. +Store operators remain responsible for their consent tool, privacy disclosure, +collector access, retention, and deletion processes. +## Third-party software and license -## License +The reviewed Boomerang 1.815.60 artifact and loader provenance, checksum, and +BSD license are recorded in `THIRD-PARTY-NOTICES.txt` and +`view/frontend/web/js/boomr/LICENSE.txt`. -This extension is released under the [MIT License](LICENSE). +The existing Composer metadata declares this module as MIT. This repository +still does not contain an approved module-level license text; that pre-existing +distribution gap must be resolved by the rights holder before release. Phase 1 +does not silently relicense the module. diff --git a/THIRD-PARTY-NOTICES.txt b/THIRD-PARTY-NOTICES.txt new file mode 100644 index 0000000..79a4b29 --- /dev/null +++ b/THIRD-PARTY-NOTICES.txt @@ -0,0 +1,42 @@ +# Third-Party Notices + +The Basicrum module's existing Composer metadata declares the module license as +MIT. The module also distributes the following third-party software under its +own license. Nothing in this notice changes or relicenses the module. + +## Boomerang 1.815.60 + +- Project: Akamai Boomerang (https://github.com/akamai/boomerang) +- Bundled file: `view/frontend/web/js/boomr/boomerang-1.815.60.cutting-edge.min.js` +- License: BSD License +- License text: `view/frontend/web/js/boomr/LICENSE.txt` +- Source: commit `ead2783a33a2ce91205fe34f8fc992433faba9a2` in the `master` branch of + https://github.com/basicrum/boomerang, a fork of upstream Akamai Boomerang +- Reproducible build: Node 12 (`.nvmrc`), `npm ci` against the source lockfile + (uglify-js 3.19.3), then + `grunt clean build --build-flavor=cutting-edge --build-number=815` +- SHA-256: `90e8a1c85949b10d43e441efc3f0545f95e4384e26ee3042344a8b2b4110589c` +- Banner note: the artifact banner stamps parent commit + `564759ed70de7801bb64de5e2025fb6ac049ff5f` because the final source change + was uncommitted when the shipped file was generated; the code matches + `ead2783a` byte for byte +- Fork changes include removal of Long Tasks monitoring and deprecated FID, + Time to First Interaction changes, removal of unused utilities, and the + Basicrum configuration bootstrap. + +The Boomerang copyright notice and BSD license remain applicable to this file. + +## Boomerang Loader Snippet + +- Project: Akamai Boomerang (https://github.com/akamai/boomerang) +- Bundled and adapted files: `view/frontend/web/js/loaders/boomerang-loader-v15.js`, + its minified build, and the standard loader block inside the consent wrapper +- License: BSD License +- License text: `view/frontend/web/js/boomr/LICENSE.txt` +- Provenance: Boomerang Loader Snippet version 15, taken from the reviewed + Basicrum WordPress implementation at commit + `64f19d9e5a9fbe580c12c19796e86e3ad0dd17ff` + +The consent wrapper adds Basicrum lifecycle, withdrawal, wait cancellation, +and legacy-cookie cleanup around the loader. Those adaptations do not change +the license that applies to the original loader snippet. diff --git a/ViewModel/Footer.php b/ViewModel/Footer.php index 4dd6813..f025e67 100644 --- a/ViewModel/Footer.php +++ b/ViewModel/Footer.php @@ -3,33 +3,25 @@ namespace BasicRum\Analytics\ViewModel; use BasicRum\Analytics\Api\PageTypeDetectorInterface; -use Magento\Framework\App\Config\ScopeConfigInterface; +use BasicRum\Analytics\Model\Config; use Magento\Framework\View\Element\Block\ArgumentInterface; -use Magento\Store\Model\ScopeInterface; class Footer implements ArgumentInterface { public function __construct( private PageTypeDetectorInterface $pageTypeDetector, - private ScopeConfigInterface $scopeConfig + private Config $config ) { } /** - * Get module configuration + * Get validated effective configuration or null when monitoring is inactive. + * + * @return array|null */ - public function getConfig(): array + public function getConfig(): ?array { - // If you already have a getConfig method, keep its implementation - // and add to it if needed - $config = [ - 'beacon_endpoint' => $this->scopeConfig->getValue( - 'basicrum/general/beacon_endpoint', - ScopeInterface::SCOPE_STORE - ) - ]; - - return $config; + return $this->config->getRuntimeConfig(); } /** @@ -39,4 +31,12 @@ public function getPageType(): string { return $this->pageTypeDetector->getPageType(); } + + /** + * Get the reviewed bundled Boomerang version. + */ + public function getBoomerangVersion(): string + { + return Config::BOOMERANG_VERSION; + } } diff --git a/composer.json b/composer.json index 806d241..d7fcdb2 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "basicrum/basicrum-analytics", - "description": "BasicRUM Analytics Magento 2 module", + "description": "Basicrum real user monitoring for Magento 2", "type": "magento2-module", "version": "0.0.2", "authors": [ @@ -10,9 +10,9 @@ } ], "require": { - "php": "^8.1|^8.2|^8.3", - "magento/framework": "*", - "magento/module-store": "*" + "php": ">=8.2 <8.5", + "magento/framework": "^103.0", + "magento/module-store": "^101.1" }, "archive": { "exclude": [ diff --git a/docs/PHASE-1-PARITY-COMPLETION.md b/docs/PHASE-1-PARITY-COMPLETION.md new file mode 100644 index 0000000..27c2c52 --- /dev/null +++ b/docs/PHASE-1-PARITY-COMPLETION.md @@ -0,0 +1,66 @@ +# Magento 2 Phase 1 parity completion notes + +These notes are for incorporation into the central Basicrum parity task. The +shared parity ledger and both reference plugins were intentionally left +unchanged. + +## Review mapping + +- **R-001:** connected immediate and consent-controlled settings to loader + selection; added canonical public callbacks, fail-closed page-level consent, + idempotent allow, denial/withdrawal handling, wait cancellation, cookie + cleanup, and documented reload-to-regrant behavior. +- **R-002:** added required UUIDv4 Brum Site ID, save-time and runtime identity + validation, fail-closed rendering, safe JSON, scoped settings, admin inactive + states, and `brum_site_id` beacons. +- **R-005:** added HTTPS enforcement with explicit development HTTP exception, + optional query stripping, configurable zero-to-30-second Wait After Onload, + `instrument_xhr=false`, and the byte-identical reviewed Boomerang artifact + with provenance and BSD notices. +- **R-006 (manual portion only):** documented and exposed the working manual + callback contract. Automatic Magento consent-provider adapters remain later + work. +- **R-007 (foundation only):** retained `SecureHtmlRenderer`, used Magento + static asset URLs, documented cache/static-deployment behavior, and added a + disposable-store check for the real layout/CSP path. A broad optimizer and + full-page-cache compatibility matrix remains later work. +- **R-008:** added focused PHP/template tests, real-artifact browser tests with + intercepted beacons, and a guarded disposable Magento storefront-to-beacon + harness. +- **R-009 (Phase 1 foundation):** added CI for PHP and browser checks plus + Boomerang provenance/checksum verification. Installable package build/smoke + and publishing remain later work. +- **R-010 (Phase 1 documentation):** aligned new customer copy to Basicrum and + Brum Site ID, reconciled runtime requirements, and documented privacy, + consent, cache, lifecycle, upgrade, and verification behavior. + +## Compatibility decisions + +There is no data migration. Existing paths and values remain stored. Explicit +`consent/enabled=0` selects immediate loading; `1` selects consent-controlled +loading. Legacy mode names remain visible but behave only as manual-integration +metadata and never grant consent. Existing sites require the new Site ID before +monitoring resumes. The unstored hardcoded five-second wait becomes explicit +off/zero settings; administrators can opt back into 5000 ms. + +## Deferred boundaries + +D-001 page vocabulary, D-002 staff exclusion, D-003 placement/readiness queues, +and D-004 cross-plugin wording remain unchanged. Automatic provider adapters, +a broad optimizer matrix, package/release publishing, and module-level license +text approval are not completed by Phase 1. + +## Verification recorded at completion + +- Focused PHP harness on PHP 8.3: 8 groups passed. +- PHP syntax checks: all module and test PHP/PHTML files passed. +- XML well-formedness: module, admin, and layout XML passed. +- Loader minification/provenance checks: passed. +- Chromium browser suite: 28 tests passed against readable/minified loaders + and the real reviewed Boomerang artifact with intercepted local beacons. +- Composer 2.10 validation: valid with the pre-existing recommendation to omit + the explicit package `version` field. + +No disposable Magento 2 installation was available in the workspace, so the +native storefront harness was added but not run. Remote GitHub Actions were +also not run from this local implementation. diff --git a/etc/acl.xml b/etc/acl.xml index ee48a8c..b906f33 100644 --- a/etc/acl.xml +++ b/etc/acl.xml @@ -7,7 +7,7 @@ - + diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 5b3dec9..44dc041 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -3,44 +3,121 @@ xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd"> - + -
- +
+ basicrum BasicRum_Analytics::basicrum_analytics - + - - - - - + + + Basicrum does not show a consent banner or persist its own consent decision. Connect your site's consent tool with both public callbacks when consent is required. + + Magento\Config\Model\Config\Source\Yesno + Yes keeps Boomerang unloaded until an allow callback on each page. No starts monitoring immediately; choose No only when deliberate and permitted. - - + + BasicRum\Analytics\Block\Adminhtml\System\Config\ConsentMode + Phase 1 supports manual callbacks. Legacy mode values remain stored but do not imply an allow decision. 1 + + + window.OPT_IN_BASICRUM_LOADER_WRAPPER() only after your consent tool authoritatively allows performance monitoring on the current page. Call window.OPT_OUT_BASICRUM_LOADER_WRAPPER() for denial, expiry, or withdrawal. A denial before loading may be followed by allow on the same page. After withdrawal during download or after initialization, reload before re-granting. Calls made before the footer loader registers these functions are not queued. + ]]> + + 1 + + + + + + + + Magento\Config\Model\Config\Source\Yesno + When enabled, complete query strings in page, navigation, referrer, and resource URLs are replaced with ?qs-redacted before beacons are sent. URL paths remain. + + + + + + + Magento\Config\Model\Config\Source\Yesno + Delay the page-load beacon after onload. Disabled by default. + + + + validate-number validate-zero-or-greater + BasicRum\Analytics\Model\System\Config\Backend\WaitMilliseconds + Zero disables the delay. Values are bounded to 30000 milliseconds (30 seconds). + + 1 + + + + + + + + Magento\Config\Model\Config\Source\Yesno + Development-only exception for local testing. Keep disabled on production stores so HTTP endpoints are upgraded to HTTPS. +
diff --git a/etc/config.xml b/etc/config.xml new file mode 100644 index 0000000..004130f --- /dev/null +++ b/etc/config.xml @@ -0,0 +1,28 @@ + + + + + + 0 + + + 1.815.60 + + + 1 + manual + + + 0 + + + 0 + 0 + + + 0 + + + + diff --git a/etc/module.xml b/etc/module.xml index e88396f..95cdfac 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -1,5 +1,9 @@ - + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..74cbe0e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,95 @@ +{ + "name": "basicrum-magento-2-tests", + "version": "0.0.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "basicrum-magento-2-tests", + "version": "0.0.2", + "devDependencies": { + "@playwright/test": "1.62.1", + "uglify-js": "3.19.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bd19b84 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "basicrum-magento-2-tests", + "version": "0.0.2", + "private": true, + "description": "Automated verification for the Basicrum Magento 2 module", + "engines": { + "node": ">=20" + }, + "scripts": { + "build:loaders": "node tests/js/build-loaders.js", + "check:minified": "node tests/js/check-minified.js", + "test:browser": "playwright test", + "test": "npm run check:minified && npm run test:browser" + }, + "devDependencies": { + "@playwright/test": "1.62.1", + "uglify-js": "3.19.3" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..3207440 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,16 @@ +const { defineConfig } = require("@playwright/test"); + +module.exports = defineConfig({ + testDir: "./tests/js", + testMatch: "**/*.spec.js", + timeout: 15000, + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: "line", + outputDir: ".test-results/playwright", + use: { + browserName: "chromium", + headless: true + } +}); diff --git a/playwright.integration.config.js b/playwright.integration.config.js new file mode 100644 index 0000000..b445519 --- /dev/null +++ b/playwright.integration.config.js @@ -0,0 +1,17 @@ +const { defineConfig } = require("@playwright/test"); + +module.exports = defineConfig({ + testDir: "./tests/integration", + testMatch: "storefront.spec.js", + timeout: 30000, + fullyParallel: false, + forbidOnly: true, + retries: 0, + reporter: "line", + outputDir: ".test-results/magento-integration", + use: { + browserName: "chromium", + headless: true, + ignoreHTTPSErrors: true + } +}); diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..49ad63d --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,31 @@ +# Disposable Magento integration check + +The declared Phase 1 baseline is pinned in `baseline.env`: Magento Open Source +2.4.7-p10 on PHP 8.3, with the listed Composer, MariaDB, and OpenSearch lines. +Adobe's current system-requirements table lists PHP 8.2 and 8.3 for the 2.4.7 +release line. This is a baseline, not a claim that every patch or platform +combination has been verified. + +Install this checkout as `BasicRum_Analytics`, enable it, run `setup:upgrade`, +and make its storefront reachable before running this harness. Then: + +```sh +BASICRUM_DISPOSABLE_MAGENTO=1 \ +MAGENTO_ROOT=/absolute/path/to/disposable-magento \ +MAGENTO_STOREFRONT_URL=https://magento.test/ \ +tests/integration/configure-disposable.sh +``` + +Set `BASICRUM_DEPLOY_STATIC=1` when the installation uses production static +content rather than developer-mode asset materialization. The script changes +Basicrum settings and cleans Magento caches, so it refuses to run unless the +explicit disposable-installation guard is present. + +The browser opens the rendered storefront, verifies the consent loader is in +the page, confirms no Boomerang request, beacon, `RT`, or `BA` cookie occurs +before allow, calls the public API twice, intercepts the local beacon, and +asserts URL redaction plus `p_type`, `p_gen`, and `brum_site_id`. It then checks +withdrawal cookie cleanup, reloads the page to exercise a warm full-page-cache +response, and proves that the new page still waits for a fresh allow decision. +It therefore covers the real layout, template, CSP path, static asset URL, +cached HTML, and bundled Boomerang rather than a copied fixture. diff --git a/tests/integration/baseline.env b/tests/integration/baseline.env new file mode 100644 index 0000000..0589e74 --- /dev/null +++ b/tests/integration/baseline.env @@ -0,0 +1,6 @@ +# Declared Phase 1 disposable-store integration baseline. +MAGENTO_VERSION=2.4.7-p10 +PHP_VERSION=8.3 +COMPOSER_VERSION=2.10 +MARIADB_VERSION=10.11 +OPENSEARCH_VERSION=2.19 diff --git a/tests/integration/configure-disposable.sh b/tests/integration/configure-disposable.sh new file mode 100755 index 0000000..2525301 --- /dev/null +++ b/tests/integration/configure-disposable.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env sh +set -eu + +if [ "${BASICRUM_DISPOSABLE_MAGENTO:-}" != "1" ]; then + echo "Set BASICRUM_DISPOSABLE_MAGENTO=1 only for a disposable Magento installation." >&2 + exit 1 +fi + +if [ -z "${MAGENTO_ROOT:-}" ] || [ ! -x "${MAGENTO_ROOT}/bin/magento" ]; then + echo "MAGENTO_ROOT must point to a disposable Magento installation." >&2 + exit 1 +fi + +if [ -z "${MAGENTO_STOREFRONT_URL:-}" ]; then + echo "MAGENTO_STOREFRONT_URL is required." >&2 + exit 1 +fi + +module_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +cd "$module_root" + +magento="${MAGENTO_ROOT}/bin/magento" + +"$magento" module:status BasicRum_Analytics +"$magento" config:set basicrum/general/enabled 1 +"$magento" config:set basicrum/general/beacon_endpoint https://collector.basicrum.test/beacon +"$magento" config:set basicrum/general/brum_site_id 550e8400-e29b-41d4-a716-446655440000 +"$magento" config:set basicrum/consent/enabled 1 +"$magento" config:set basicrum/consent/mode manual +"$magento" config:set basicrum/privacy/strip_query_string 1 +"$magento" config:set basicrum/performance/wait_after_onload 0 +"$magento" config:set basicrum/performance/delay_ms 0 +"$magento" config:set basicrum/developer/development_mode 0 +"$magento" cache:clean config layout block_html full_page + +if [ "${BASICRUM_DEPLOY_STATIC:-0}" = "1" ]; then + "$magento" setup:static-content:deploy -f en_US +fi + +MAGENTO_STOREFRONT_URL="$MAGENTO_STOREFRONT_URL" \ + npx playwright test --config=playwright.integration.config.js diff --git a/tests/integration/storefront.spec.js b/tests/integration/storefront.spec.js new file mode 100644 index 0000000..9766e7f --- /dev/null +++ b/tests/integration/storefront.spec.js @@ -0,0 +1,79 @@ +const { test, expect } = require("@playwright/test"); + +const storefrontUrl = process.env.MAGENTO_STOREFRONT_URL; +const beaconUrl = "https://collector.basicrum.test/beacon"; +const siteId = "550e8400-e29b-41d4-a716-446655440000"; + +test.skip(!storefrontUrl, "MAGENTO_STOREFRONT_URL is required"); + +function requestParameters(request) { + const parameters = new URL(request.url()).searchParams; + const postData = request.postData(); + if (postData) { + for (const [key, value] of new URLSearchParams(postData)) { + parameters.set(key, value); + } + } + return parameters; +} + +test("rendered Magento storefront stays silent until allow and sends the expected beacon", async ({ context, page }) => { + const errors = []; + const beacons = []; + let boomerangRequests = 0; + + page.on("pageerror", (error) => errors.push(error.message)); + page.on("request", (request) => { + if (request.url().includes("BasicRum_Analytics/js/boomr/boomerang-")) { + boomerangRequests += 1; + } + }); + await page.route(`${beaconUrl}*`, (route) => { + beacons.push(route.request()); + return route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*" }, + body: "" + }); + }); + + const url = new URL(storefrontUrl); + url.searchParams.set("basicrum_private", "must-not-leak"); + await page.goto(url.toString(), { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.OPT_IN_BASICRUM_LOADER_WRAPPER === "function"); + await page.waitForTimeout(500); + + expect(boomerangRequests).toBe(0); + expect(beacons).toHaveLength(0); + expect((await context.cookies(url.toString())).some((cookie) => ["RT", "BA"].includes(cookie.name))).toBe(false); + expect(await page.locator('script[src*="consent-boomerang-loader-v1-15.min.js"]').count()).toBe(1); + + await page.evaluate(() => { + window.OPT_IN_BASICRUM_LOADER_WRAPPER(); + window.OPT_IN_BASICRUM_LOADER_WRAPPER(); + }); + await expect.poll(() => beacons.length, { timeout: 15000 }).toBeGreaterThan(0); + expect(boomerangRequests).toBe(1); + + const parameters = requestParameters(beacons[0]); + expect(parameters.get("p_type")).toBe("home"); + expect(parameters.get("p_gen")).toBe("mage2"); + expect(parameters.get("brum_site_id")).toBe(siteId); + expect(parameters.get("u")).toContain("?qs-redacted"); + expect(`${beacons[0].url()}${beacons[0].postData() || ""}`).not.toContain("must-not-leak"); + + await page.evaluate(() => window.OPT_OUT_BASICRUM_LOADER_WRAPPER()); + expect((await context.cookies(url.toString())).some((cookie) => ["RT", "BA"].includes(cookie.name))).toBe(false); + + const beaconsBeforeReload = beacons.length; + await page.reload({ waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.OPT_IN_BASICRUM_LOADER_WRAPPER === "function"); + await page.waitForTimeout(500); + expect(beacons).toHaveLength(beaconsBeforeReload); + expect(await page.locator('script[src*="consent-boomerang-loader-v1-15.min.js"]').count()).toBe(1); + + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await expect.poll(() => beacons.length, { timeout: 15000 }).toBeGreaterThan(beaconsBeforeReload); + expect(boomerangRequests).toBe(2); + expect(errors).toEqual([]); +}); diff --git a/tests/js/build-loaders.js b/tests/js/build-loaders.js new file mode 100644 index 0000000..b9d0eb1 --- /dev/null +++ b/tests/js/build-loaders.js @@ -0,0 +1,25 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const UglifyJS = require("uglify-js"); + +const root = path.resolve(__dirname, "../.."); +const loaders = [ + "boomerang-loader-v15", + "consent-boomerang-loader-v1-15" +]; + +for (const loader of loaders) { + const sourcePath = path.join(root, "view/frontend/web/js/loaders", `${loader}.js`); + const outputPath = path.join(root, "view/frontend/web/js/loaders", `${loader}.min.js`); + const result = UglifyJS.minify(fs.readFileSync(sourcePath, "utf8"), { + compress: false, + mangle: true, + output: { comments: /^!/ } + }); + + if (result.error) { + throw result.error; + } + + fs.writeFileSync(outputPath, result.code); +} diff --git a/tests/js/check-minified.js b/tests/js/check-minified.js new file mode 100644 index 0000000..e99d58a --- /dev/null +++ b/tests/js/check-minified.js @@ -0,0 +1,78 @@ +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const UglifyJS = require("uglify-js"); + +const root = path.resolve(__dirname, "../.."); + +function sha256(contents) { + return crypto.createHash("sha256").update(contents).digest("hex"); +} + +for (const loader of [ + "boomerang-loader-v15", + "consent-boomerang-loader-v1-15" +]) { + const source = fs.readFileSync( + path.join(root, "view/frontend/web/js/loaders", `${loader}.js`), + "utf8" + ); + const actual = fs.readFileSync( + path.join(root, "view/frontend/web/js/loaders", `${loader}.min.js`), + "utf8" + ); + const result = UglifyJS.minify(source, { + compress: false, + mangle: true, + output: { comments: /^!/ } + }); + + if (result.error) { + throw result.error; + } + + assert.equal(actual, result.code, `${loader}.min.js must be regenerated from source`); + + if (loader === "boomerang-loader-v15") { + assert.equal( + sha256(source), + "e22055fc1919b89ab8ba6530a415636c6d31df328c10f096a500ae5a37e93d6d", + "readable standard loader must match the reviewed WordPress source" + ); + assert.equal( + sha256(actual), + "a9c283722d1d2eb97a7e1820d51ba3c317a5f2641f7358922931ae305aab0281", + "minified standard loader must match the reviewed WordPress source" + ); + } +} + +const standardSource = fs.readFileSync( + path.join(root, "view/frontend/web/js/loaders/boomerang-loader-v15.js"), + "utf8" +).trim(); +const consentSource = fs.readFileSync( + path.join(root, "view/frontend/web/js/loaders/consent-boomerang-loader-v1-15.js"), + "utf8" +); +const embedded = consentSource.match( + /\/\* BEGIN BASICRUM STANDARD LOADER \*\/\n([\s\S]*?)\n \/\* END BASICRUM STANDARD LOADER \*\// +); +assert.ok(embedded, "consent wrapper must contain the marked standard loader block"); +assert.equal( + embedded[1].trim(), + standardSource, + "consent wrapper standard-loader block must remain byte-identical" +); + +const boomerang = fs.readFileSync( + path.join(root, "view/frontend/web/js/boomr/boomerang-1.815.60.cutting-edge.min.js") +); +assert.equal( + sha256(boomerang), + "90e8a1c85949b10d43e441efc3f0545f95e4384e26ee3042344a8b2b4110589c", + "Boomerang artifact must match the reviewed build" +); + +console.log("Minified loader checks passed."); diff --git a/tests/js/loaders.spec.js b/tests/js/loaders.spec.js new file mode 100644 index 0000000..e0fe57e --- /dev/null +++ b/tests/js/loaders.spec.js @@ -0,0 +1,240 @@ +const path = require("node:path"); +const { test: base, expect } = require("@playwright/test"); + +const test = base.extend({ + page: async ({ page }, use) => { + const unexpectedRequests = []; + await page.route("**/*", async (route) => { + unexpectedRequests.push(route.request().url()); + await route.abort("blockedbyclient"); + }); + await use(page); + expect(unexpectedRequests).toEqual([]); + } +}); + +const root = path.resolve(__dirname, "../.."); +const shopUrl = "https://shop.example.test/"; +const boomerangUrl = "https://assets.example.test/boomerang.js"; +const bundleStub = ` +window.__bundleExecutions = (window.__bundleExecutions || 0) + 1; +window.BOOMR = window.BOOMR || {}; +window.BOOMR.version = "test"; +window.BOOMR.window = window; +window.BOOMR.init = function(config) { + window.__initCalls = (window.__initCalls || 0) + 1; + window.__lastConfig = config; +}; +window.BOOMR.disable = function() { + window.__disableCalls = (window.__disableCalls || 0) + 1; +}; +window.BOOMR.utils = { + removeCookie: function(name) { + window.__utilityCookieRemovals = window.__utilityCookieRemovals || []; + window.__utilityCookieRemovals.push(name); + } +}; +window.basicRumInitConfig = window.basicRumBoomerangConfig; +if (window.basicRumInitConfig) { + window.BOOMR.init(window.basicRumInitConfig); +} +`; + +function loaderPath(file) { + return path.join(root, "view/frontend/web/js/loaders", file); +} + +async function preparePage(page, options = {}) { + let releaseDownload; + let markDownloadStarted; + let boomerangRequests = 0; + const downloadGate = options.holdDownload + ? new Promise((resolve) => { releaseDownload = resolve; }) + : Promise.resolve(); + const downloadStarted = new Promise((resolve) => { markDownloadStarted = resolve; }); + + await page.route(shopUrl, (route) => route.fulfill({ + contentType: "text/html", + body: '' + })); + await page.route(boomerangUrl, async (route) => { + boomerangRequests += 1; + markDownloadStarted(); + await downloadGate; + await route.fulfill({ contentType: "application/javascript", body: bundleStub }); + }); + + if (options.cookies) { + await page.context().addCookies(options.cookies.map((name) => ({ + name, + value: "legacy", + domain: "shop.example.test", + path: "/" + }))); + } + + await page.goto(shopUrl); + await page.evaluate((url) => { + window.BOOMR = { url }; + window.basicRumBoomerangConfig = { beacon_url: "https://collector.example.test/beacon" }; + window.__initCalls = 0; + window.__bundleExecutions = 0; + window.__disableCalls = 0; + window.__utilityCookieRemovals = []; + }, boomerangUrl); + + return { + downloadStarted, + boomerangRequests: () => boomerangRequests, + releaseDownload: () => releaseDownload && releaseDownload() + }; +} + +for (const standardLoader of ["boomerang-loader-v15.js", "boomerang-loader-v15.min.js"]) { + test(`immediate loader executes Boomerang once: ${standardLoader}`, async ({ page }) => { + const harness = await preparePage(page); + await page.addScriptTag({ path: loaderPath(standardLoader) }); + await page.waitForFunction(() => window.__initCalls === 1); + await page.addScriptTag({ path: loaderPath(standardLoader) }); + await page.waitForTimeout(100); + + await expect.poll(() => page.evaluate(() => ({ + initCalls: window.__initCalls, + executions: window.__bundleExecutions + }))).toEqual({ initCalls: 1, executions: 1 }); + expect(harness.boomerangRequests()).toBe(1); + }); +} + +for (const loader of [ + "boomerang-loader-v15.js", + "boomerang-loader-v15.min.js", + "consent-boomerang-loader-v1-15.js", + "consent-boomerang-loader-v1-15.min.js" +]) { + test(`requires an own nonempty Boomerang URL: ${loader}`, async ({ page }) => { + const harness = await preparePage(page); + for (const urlState of ["missing", "empty", "inherited"]) { + await page.evaluate(({ state, url }) => { + window.BOOMR = state === "inherited" ? Object.create({ url }) + : state === "empty" ? { url: "" } : {}; + }, { state: urlState, url: boomerangUrl }); + await page.addScriptTag({ path: loaderPath(loader) }); + if (loader.startsWith("consent-")) { + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + } + expect(await page.evaluate(() => ({ + executions: window.__bundleExecutions, + snippetExecuted: Boolean(window.BOOMR.snippetExecuted), + injectedScripts: document.querySelectorAll("#boomr-scr-as, #boomr-if-as, #boomr-async").length, + preloads: document.querySelectorAll('link[rel="preload"]').length + }))).toEqual({ executions: 0, snippetExecuted: false, injectedScripts: 0, preloads: 0 }); + expect(harness.boomerangRequests()).toBe(0); + } + }); +} + +for (const consentLoader of [ + "consent-boomerang-loader-v1-15.js", + "consent-boomerang-loader-v1-15.min.js" +]) { + test.describe(`consent wrapper: ${consentLoader}`, () => { + test("stays inert despite a legacy allow cookie", async ({ page }) => { + const harness = await preparePage(page, { cookies: ["BRUM_CONSENT"] }); + await page.addScriptTag({ path: loaderPath(consentLoader) }); + await page.waitForTimeout(200); + + expect(await page.evaluate(() => ({ + initCalls: window.__initCalls, + executions: window.__bundleExecutions, + canonicalIn: typeof window.OPT_IN_BASICRUM_LOADER_WRAPPER, + canonicalOut: typeof window.OPT_OUT_BASICRUM_LOADER_WRAPPER, + snippetExecuted: Boolean(window.BOOMR.snippetExecuted) + }))).toEqual({ + initCalls: 0, + executions: 0, + canonicalIn: "function", + canonicalOut: "function", + snippetExecuted: false + }); + expect(harness.boomerangRequests()).toBe(0); + }); + + test("repeated allow loads once and persists no Basicrum consent cookie", async ({ page }) => { + const harness = await preparePage(page); + await page.addScriptTag({ path: loaderPath(consentLoader) }); + await page.evaluate(() => { + window.OPT_IN_BASICRUM_LOADER_WRAPPER(); + window.OPT_IN_BASICRUM_LOADER_WRAPPER(); + }); + await page.waitForFunction(() => window.__initCalls === 1); + await page.waitForTimeout(100); + + expect(await page.evaluate(() => ({ + initCalls: window.__initCalls, + executions: window.__bundleExecutions, + cookies: document.cookie + }))).toEqual({ initCalls: 1, executions: 1, cookies: "" }); + expect(harness.boomerangRequests()).toBe(1); + }); + + test("denial before loading cleans cookies and permits a later allow", async ({ page }) => { + const cookieNames = ["RT", "BA", "BRUM_CONSENT", "BOOMR_CONSENT"]; + await preparePage(page, { cookies: cookieNames }); + await page.addScriptTag({ path: loaderPath(consentLoader) }); + await page.evaluate(() => window.OPT_OUT_BASICRUM_LOADER_WRAPPER()); + + expect(await page.evaluate(() => ({ + cookies: document.cookie, + configPresent: Boolean(window.basicRumBoomerangConfig), + executions: window.__bundleExecutions + }))).toEqual({ cookies: "", configPresent: true, executions: 0 }); + + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await page.waitForFunction(() => window.__initCalls === 1); + expect(await page.evaluate(() => window.__bundleExecutions)).toBe(1); + }); + + test("withdrawal during download prevents initialization and same-page re-grant", async ({ page }) => { + const gate = await preparePage(page, { holdDownload: true }); + await page.addScriptTag({ path: loaderPath(consentLoader) }); + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await gate.downloadStarted; + await page.evaluate(() => window.OPT_OUT_BASICRUM_LOADER_WRAPPER()); + gate.releaseDownload(); + await page.waitForFunction(() => window.__bundleExecutions === 1); + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await page.waitForTimeout(100); + + expect(await page.evaluate(() => ({ + initCalls: window.__initCalls, + executions: window.__bundleExecutions, + config: window.basicRumBoomerangConfig + }))).toEqual({ initCalls: 0, executions: 1, config: null }); + }); + + test("withdrawal after initialization disables collection and clears cookies", async ({ page }) => { + const cookieNames = ["RT", "BA", "BRUM_CONSENT", "BOOMR_CONSENT"]; + await preparePage(page, { cookies: cookieNames }); + await page.addScriptTag({ path: loaderPath(consentLoader) }); + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await page.waitForFunction(() => window.__initCalls === 1); + await page.evaluate(() => window.OPT_OUT_BASICRUM_LOADER_WRAPPER()); + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + + expect(await page.evaluate(() => ({ + initCalls: window.__initCalls, + executions: window.__bundleExecutions, + disableCalls: window.__disableCalls, + cookies: document.cookie, + removals: window.__utilityCookieRemovals.sort() + }))).toEqual({ + initCalls: 1, + executions: 1, + disableCalls: 1, + cookies: "", + removals: ["BA", "BOOMR_CONSENT", "BRUM_CONSENT", "RT"] + }); + }); + }); +} diff --git a/tests/js/real-boomerang.spec.js b/tests/js/real-boomerang.spec.js new file mode 100644 index 0000000..6c2d873 --- /dev/null +++ b/tests/js/real-boomerang.spec.js @@ -0,0 +1,266 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { test, expect } = require("@playwright/test"); + +const root = path.resolve(__dirname, "../.."); +const shopUrl = "https://shop.example.test/"; +const boomerangUrl = "https://assets.example.test/boomerang.js"; +const beaconUrl = "https://collector.example.test/beacon"; +const siteId = "550e8400-e29b-41d4-a716-446655440000"; +const realBoomerang = fs.readFileSync( + path.join(root, "view/frontend/web/js/boomr/boomerang-1.815.60.cutting-edge.min.js"), + "utf8" +); + +function loaderPath(file) { + return path.join(root, "view/frontend/web/js/loaders", file); +} + +function requestParameters(request) { + const parameters = new URL(request.url).searchParams; + if (request.postData) { + for (const [key, value] of new URLSearchParams(request.postData)) { + parameters.set(key, value); + } + } + return parameters; +} + +async function prepareRealPage(page, options = {}) { + let releaseDownload; + let markDownloadStarted; + let boomerangRequests = 0; + const beaconRequestData = []; + const pageUrl = options.pageUrl || shopUrl; + const resourceUrl = options.resourceUrl; + const downloadGate = options.holdDownload + ? new Promise((resolve) => { releaseDownload = resolve; }) + : Promise.resolve(); + const downloadStarted = new Promise((resolve) => { markDownloadStarted = resolve; }); + + await page.route(`${shopUrl}*`, (route) => route.fulfill({ + contentType: "text/html", + body: `${ + resourceUrl ? `` : "" + }` + })); + if (resourceUrl) { + await page.route(resourceUrl, (route) => route.fulfill({ + status: 200, + contentType: "text/css; charset=utf-8", + body: "body { color: #222; }" + })); + } + await page.route(`${beaconUrl}*`, (route) => { + beaconRequestData.push({ + url: route.request().url(), + postData: route.request().postData() + }); + return route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*" }, + body: "" + }); + }); + await page.route(boomerangUrl, async (route) => { + boomerangRequests += 1; + markDownloadStarted(); + await downloadGate; + await route.fulfill({ + status: 200, + contentType: "application/javascript; charset=utf-8", + body: realBoomerang + }); + }); + + await page.goto(pageUrl, options.referrerUrl ? { referer: options.referrerUrl } : undefined); + await page.evaluate(({ bundleUrl, collectorUrl, stripQueryString, id, waitMs }) => { + window.BOOMR = { url: bundleUrl }; + window.BOOMR_mq = [ + ["addVar", "p_type", "product"], + ["addVar", "p_gen", "mage2"], + ["addVar", "brum_site_id", id] + ]; + + if (waitMs > 0) { + const boomerang = window.BOOMR; + boomerang.plugins = boomerang.plugins || {}; + boomerang.plugins.WaitAfterOnload = { + complete: false, + timer: null, + init() { + boomerang.subscribe("page_ready", function() { + this.timer = window.setTimeout(() => { + this.timer = null; + if (window.basicRumConsentWithdrawn) { + return; + } + this.complete = true; + boomerang.sendBeacon(); + }, waitMs); + window.__basicrumWaitScheduled = true; + }, {}, this); + }, + is_complete() { + return this.complete; + } + }; + } + + window.basicRumBoomerangConfig = { + beacon_url: collectorUrl, + instrument_xhr: false, + strip_query_string: stripQueryString, + Continuity: { enabled: true }, + ResourceTiming: { enabled: true, splitAtPath: true }, + secure_cookie: true, + same_site_cookie: "Strict" + }; + }, { + bundleUrl: boomerangUrl, + collectorUrl: beaconUrl, + stripQueryString: Boolean(options.stripQueryString), + id: siteId, + waitMs: options.waitMs || 0 + }); + + return { + downloadStarted, + releaseDownload: () => releaseDownload && releaseDownload(), + boomerangRequests: () => boomerangRequests, + beaconRequests: () => beaconRequestData.length, + beaconRequestData: () => beaconRequestData + }; +} + +async function waitForRealBoomerang(page) { + await expect.poll( + () => page.evaluate(() => window.BOOMR && window.BOOMR.version) + ).toBe("1.815.60"); +} + +for (const standardLoader of ["boomerang-loader-v15.js", "boomerang-loader-v15.min.js"]) { + test(`real Boomerang redacts URLs and emits Magento identity: ${standardLoader}`, async ({ page }) => { + const secret = "BASICRUM_PRIVATE_QUERY_VALUE_7c0f1e"; + const referrerUrl = `${shopUrl}previous?source=${secret}`; + const resourceUrl = `https://resource.example.test/private.css?token=${secret}`; + const gate = await prepareRealPage(page, { + pageUrl: `${shopUrl}?customer=${secret}&campaign=test`, + stripQueryString: true, + referrerUrl, + resourceUrl + }); + + await page.addScriptTag({ path: loaderPath(standardLoader) }); + await gate.downloadStarted; + await waitForRealBoomerang(page); + await expect.poll(() => gate.beaconRequests(), { timeout: 10000 }).toBeGreaterThan(0); + + const request = gate.beaconRequestData()[0]; + const parameters = requestParameters(request); + expect(parameters.get("u")).toBe(`${shopUrl}?qs-redacted`); + expect(parameters.get("p_type")).toBe("product"); + expect(parameters.get("p_gen")).toBe("mage2"); + expect(parameters.get("brum_site_id")).toBe(siteId); + expect(parameters.get("r")).toContain("?qs-redacted"); + expect(parameters.get("restiming")).toContain("qs-redacted"); + expect(JSON.stringify(gate.beaconRequestData())).not.toContain(secret); + }); +} + +for (const consentLoader of [ + "consent-boomerang-loader-v1-15.js", + "consent-boomerang-loader-v1-15.min.js" +]) { + test.describe(`real Boomerang consent lifecycle: ${consentLoader}`, () => { + test("is silent before allow and loads once after repeated allow", async ({ context, page }) => { + const gate = await prepareRealPage(page); + await page.addScriptTag({ path: loaderPath(consentLoader) }); + await page.waitForTimeout(300); + expect(gate.boomerangRequests()).toBe(0); + expect(gate.beaconRequests()).toBe(0); + expect((await context.cookies(shopUrl)).some((cookie) => ["RT", "BA"].includes(cookie.name))).toBe(false); + + await page.evaluate(() => { + window.OPT_IN_BASICRUM_LOADER_WRAPPER(); + window.OPT_IN_BASICRUM_LOADER_WRAPPER(); + }); + await gate.downloadStarted; + await waitForRealBoomerang(page); + await expect.poll(() => gate.beaconRequests(), { timeout: 10000 }).toBeGreaterThan(0); + expect(gate.boomerangRequests()).toBe(1); + + const parameters = requestParameters(gate.beaconRequestData()[0]); + expect(parameters.get("p_gen")).toBe("mage2"); + expect(parameters.get("brum_site_id")).toBe(siteId); + expect((await context.cookies(shopUrl)).some((cookie) => cookie.name === "RT")).toBe(true); + expect((await context.cookies(shopUrl)).some((cookie) => cookie.name === "BRUM_CONSENT")).toBe(false); + }); + + test("denial before loading remains eligible for a later allow", async ({ page }) => { + const gate = await prepareRealPage(page); + await page.addScriptTag({ path: loaderPath(consentLoader) }); + await page.evaluate(() => window.OPT_OUT_BASICRUM_LOADER_WRAPPER()); + expect(gate.boomerangRequests()).toBe(0); + expect(gate.beaconRequests()).toBe(0); + + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await gate.downloadStarted; + await waitForRealBoomerang(page); + await expect.poll(() => gate.beaconRequests(), { timeout: 10000 }).toBeGreaterThan(0); + }); + + test("withdrawal during download leaves the arrived bundle inert", async ({ context, page }) => { + const gate = await prepareRealPage(page, { holdDownload: true }); + await page.addScriptTag({ path: loaderPath(consentLoader) }); + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await gate.downloadStarted; + await page.evaluate(() => window.OPT_OUT_BASICRUM_LOADER_WRAPPER()); + gate.releaseDownload(); + await waitForRealBoomerang(page); + await page.waitForTimeout(1000); + + expect(gate.beaconRequests()).toBe(0); + expect((await context.cookies(shopUrl)).some((cookie) => ["RT", "BA"].includes(cookie.name))).toBe(false); + expect(await page.evaluate(() => window.basicRumInitConfig || null)).toBe(null); + }); + + test("withdrawal after initialization cancels the pending page-load beacon", async ({ context, page }) => { + const gate = await prepareRealPage(page, { waitMs: 750 }); + await page.addScriptTag({ path: loaderPath(consentLoader) }); + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await gate.downloadStarted; + await waitForRealBoomerang(page); + await page.waitForFunction(() => window.__basicrumWaitScheduled === true); + await page.evaluate(() => window.OPT_OUT_BASICRUM_LOADER_WRAPPER()); + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await page.waitForTimeout(1000); + + expect(gate.beaconRequests()).toBe(0); + expect(gate.boomerangRequests()).toBe(1); + expect((await context.cookies(shopUrl)).some((cookie) => ["RT", "BA"].includes(cookie.name))).toBe(false); + expect(await page.evaluate(() => window.basicRumBoomerangConfig)).toBe(null); + }); + + test("withdrawal after a beacon disables further sends and blocks same-page re-grant", async ({ context, page }) => { + const gate = await prepareRealPage(page); + await page.addScriptTag({ path: loaderPath(consentLoader) }); + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await gate.downloadStarted; + await waitForRealBoomerang(page); + await expect.poll(() => gate.beaconRequests(), { timeout: 10000 }).toBeGreaterThan(0); + + await page.evaluate(() => window.OPT_OUT_BASICRUM_LOADER_WRAPPER()); + const countAfterWithdrawal = gate.beaconRequests(); + await page.evaluate(() => { + window.OPT_IN_BASICRUM_LOADER_WRAPPER(); + window.BOOMR.sendBeacon(); + }); + await page.waitForTimeout(500); + + expect(gate.beaconRequests()).toBe(countAfterWithdrawal); + expect(gate.boomerangRequests()).toBe(1); + expect((await context.cookies(shopUrl)).some((cookie) => ["RT", "BA"].includes(cookie.name))).toBe(false); + }); + }); +} diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php new file mode 100644 index 0000000..7eb1f34 --- /dev/null +++ b/tests/php/bootstrap.php @@ -0,0 +1,272 @@ +_config = $config; + $this->data = $data; + } + + public function beforeSave() + { + return $this; + } + + public function getValue() + { + return $this->value; + } + + public function setValue($value): self + { + $this->value = $value; + return $this; + } + + public function getData($key = null) + { + return $key === null ? $this->data : ($this->data[$key] ?? null); + } + + public function setData($key, $value): self + { + $this->data[$key] = $value; + return $this; + } + + public function getStore() + { + return $this->data['store'] ?? ''; + } + + public function getWebsite() + { + return $this->data['website'] ?? ''; + } + + public function getScope() + { + return $this->data['scope'] ?? ''; + } + + public function getScopeCode() + { + return $this->data['scope_code'] ?? ''; + } + } +} + +namespace Magento\Framework\App\Cache { + interface TypeListInterface + { + } +} + +namespace Magento\Framework\Model { + class Context + { + } +} + +namespace Magento\Framework { + class Registry + { + } +} + +namespace Magento\Framework\Model\ResourceModel { + abstract class AbstractResource + { + } +} + +namespace Magento\Framework\Data\Collection { + abstract class AbstractDb + { + } +} + +namespace Magento\Framework\Exception { + class LocalizedException extends \Exception + { + } +} + +namespace Magento\Store\Model { + interface ScopeInterface + { + public const SCOPE_STORE = 'store'; + public const SCOPE_WEBSITE = 'website'; + public const SCOPE_STORES = 'stores'; + public const SCOPE_WEBSITES = 'websites'; + } + + interface StoreManagerInterface + { + public function getStore($storeId = null); + } +} + +namespace Magento\Framework\View\Element\Block { + interface ArgumentInterface + { + } +} + +namespace { + use Magento\Framework\App\Config\ScopeConfigInterface; + use Magento\Store\Model\ScopeInterface; + + if (!function_exists('__')) { + function __($message) + { + return $message; + } + } + + final class BasicrumTestScopeConfig implements ScopeConfigInterface + { + /** @var array */ + private array $values; + + /** @var array */ + private array $storeWebsites; + + /** @param array $values */ + public function __construct(array $values = [], array $storeWebsites = []) + { + $this->values = $values; + $this->storeWebsites = $storeWebsites; + } + + public function getValue($path = null, $scopeType = null, $scopeCode = null) + { + $scopeType = $scopeType ?: ScopeConfigInterface::SCOPE_TYPE_DEFAULT; + $scopeCode = $scopeCode === null ? '0' : (string) $scopeCode; + $key = $scopeType . '|' . $scopeCode . '|' . $path; + if (array_key_exists($key, $this->values)) { + return $this->values[$key]; + } + + if ($scopeType === ScopeInterface::SCOPE_STORE) { + $website = $this->storeWebsites[$scopeCode] ?? null; + if ($website !== null) { + $websiteKey = ScopeInterface::SCOPE_WEBSITE . '|' . $website . '|' . $path; + if (array_key_exists($websiteKey, $this->values)) { + return $this->values[$websiteKey]; + } + } + } + + $defaultKey = ScopeConfigInterface::SCOPE_TYPE_DEFAULT . '|0|' . $path; + return $this->values[$defaultKey] ?? null; + } + + public function isSetFlag($path, $scopeType = null, $scopeCode = null) + { + return (bool) $this->getValue($path, $scopeType, $scopeCode); + } + } + + final class BasicrumTestWebsite + { + public function __construct(private string $code) + { + } + + public function getCode(): string + { + return $this->code; + } + } + + final class BasicrumTestStore + { + public function __construct(private string $websiteCode) + { + } + + public function getWebsite(): BasicrumTestWebsite + { + return new BasicrumTestWebsite($this->websiteCode); + } + } + + final class BasicrumTestStoreManager implements \Magento\Store\Model\StoreManagerInterface + { + /** @param array $storeWebsites */ + public function __construct(private array $storeWebsites = []) + { + } + + public function getStore($code = null): BasicrumTestStore + { + return new BasicrumTestStore($this->storeWebsites[(string) $code] ?? 'base'); + } + } + + function basicrum_test_key(string $scope, $scopeCode, string $path): string + { + return $scope . '|' . (string) $scopeCode . '|' . $path; + } + + function basicrum_assert_true($actual, string $message): void + { + if ($actual !== true) { + throw new RuntimeException($message . '; actual=' . var_export($actual, true)); + } + } + + function basicrum_assert_false($actual, string $message): void + { + if ($actual !== false) { + throw new RuntimeException($message . '; actual=' . var_export($actual, true)); + } + } + + function basicrum_assert_same($expected, $actual, string $message): void + { + if ($expected !== $actual) { + throw new RuntimeException( + $message . '; expected=' . var_export($expected, true) . '; actual=' . var_export($actual, true) + ); + } + } + + function basicrum_assert_contains(string $needle, string $haystack, string $message): void + { + if (!str_contains($haystack, $needle)) { + throw new RuntimeException($message . '; missing=' . $needle); + } + } + + function basicrum_assert_not_contains(string $needle, string $haystack, string $message): void + { + if (str_contains($haystack, $needle)) { + throw new RuntimeException($message . '; unexpected=' . $needle); + } + } +} diff --git a/tests/php/run.php b/tests/php/run.php new file mode 100644 index 0000000..e6ddba4 --- /dev/null +++ b/tests/php/run.php @@ -0,0 +1,324 @@ + $tests */ +$tests = []; + +$tests['fresh-install defaults fail closed and match config.xml'] = function () use ($root): void { + $defaults = Config::getDefaults(); + basicrum_assert_false($defaults['enabled'], 'fresh installs must be disabled'); + basicrum_assert_true($defaults['consent_enabled'], 'fresh installs must require consent'); + basicrum_assert_false($defaults['strip_query_string'], 'query stripping must be opt-in'); + basicrum_assert_false($defaults['wait_after_onload'], 'wait must be disabled initially'); + basicrum_assert_same(0, $defaults['delay_ms'], 'delay must default to zero'); + basicrum_assert_false($defaults['development_mode'], 'HTTP exception must be off'); + + $xml = simplexml_load_file($root . '/etc/config.xml'); + basicrum_assert_same('0', (string) $xml->default->basicrum->general->enabled, 'config enabled default'); + basicrum_assert_same('1', (string) $xml->default->basicrum->consent->enabled, 'config consent default'); + basicrum_assert_same('manual', (string) $xml->default->basicrum->consent->mode, 'manual integration default'); + basicrum_assert_same('0', (string) $xml->default->basicrum->privacy->strip_query_string, 'query default'); + basicrum_assert_same('0', (string) $xml->default->basicrum->performance->wait_after_onload, 'wait default'); + basicrum_assert_same('0', (string) $xml->default->basicrum->performance->delay_ms, 'delay default'); +}; + +$tests['validators accept only supported endpoint and UUIDv4 values'] = function (): void { + basicrum_assert_true( + Config::isValidBeaconEndpoint('https://collector.example.test/beacon?key=value'), + 'HTTPS endpoint should be accepted' + ); + basicrum_assert_true( + Config::isValidBeaconEndpoint('http://127.0.0.1:8080/beacon'), + 'HTTP endpoint should be structurally valid for explicit development use' + ); + basicrum_assert_false(Config::isValidBeaconEndpoint('javascript:alert(1)'), 'executable scheme'); + basicrum_assert_false(Config::isValidBeaconEndpoint('https:///missing-host'), 'missing host'); + basicrum_assert_false(Config::isValidBeaconEndpoint(' https://collector.test'), 'untrimmed URL'); + basicrum_assert_true( + Config::isValidBrumSiteId('550e8400-e29b-41d4-a716-446655440000'), + 'UUIDv4 should be accepted' + ); + basicrum_assert_false( + Config::isValidBrumSiteId('550e8400-e29b-11d4-a716-446655440000'), + 'non-v4 UUID should fail' + ); + basicrum_assert_false(Config::normalizeBoolean('yes', false), 'invalid enable must fail disabled'); + basicrum_assert_true(Config::normalizeBoolean('yes', true), 'invalid consent must fail required'); + basicrum_assert_same(0, Config::normalizeWaitMilliseconds(-1), 'negative wait'); + basicrum_assert_same(30000, Config::normalizeWaitMilliseconds(90000), 'bounded wait'); + basicrum_assert_same(0, Config::normalizeWaitMilliseconds('not-a-number'), 'invalid wait'); +}; + +$tests['save backends validate normalize and honor the same-form HTTP decision'] = function (): void { + $scopeConfig = new BasicrumTestScopeConfig(); + $storeManager = new BasicrumTestStoreManager(); + $context = new Context(); + $registry = new Registry(); + $cacheTypeList = new class implements TypeListInterface {}; + + $endpoint = new BeaconEndpoint($context, $registry, $scopeConfig, $cacheTypeList, $storeManager); + $endpoint->setValue('http://collector.example.test/beacon'); + $endpoint->setData('groups', [ + 'developer' => ['fields' => ['development_mode' => ['value' => '0']]], + ]); + $endpoint->beforeSave(); + basicrum_assert_same('https://collector.example.test/beacon', $endpoint->getValue(), 'HTTP upgrade'); + + $developmentEndpoint = new BeaconEndpoint($context, $registry, $scopeConfig, $cacheTypeList, $storeManager); + $developmentEndpoint->setValue('http://127.0.0.1:8080/beacon'); + $developmentEndpoint->setData('groups', [ + 'developer' => ['fields' => ['development_mode' => ['value' => '1']]], + ]); + $developmentEndpoint->beforeSave(); + basicrum_assert_same('http://127.0.0.1:8080/beacon', $developmentEndpoint->getValue(), 'HTTP exception'); + + $scopedConfig = new BasicrumTestScopeConfig([ + basicrum_test_key(ScopeInterface::SCOPE_WEBSITE, 'base', Config::XML_PATH_DEVELOPMENT_MODE) => '1', + ]); + $scopedEndpoint = new BeaconEndpoint( + $context, + $registry, + $scopedConfig, + $cacheTypeList, + new BasicrumTestStoreManager(['default' => 'base']) + ); + $scopedEndpoint->setValue('http://scoped.test/beacon'); + $scopedEndpoint->setData('scope', ScopeInterface::SCOPE_STORES); + $scopedEndpoint->setData('scope_code', 'default'); + $scopedEndpoint->setData('groups', [ + 'developer' => ['fields' => ['development_mode' => ['inherit' => '1']]], + ]); + $scopedEndpoint->beforeSave(); + basicrum_assert_same('http://scoped.test/beacon', $scopedEndpoint->getValue(), 'inherited website HTTP policy'); + + $site = new BrumSiteId($context, $registry, $scopeConfig, $cacheTypeList); + $site->setValue('not-a-uuid'); + try { + $site->beforeSave(); + throw new RuntimeException('invalid Site ID did not throw'); + } catch (LocalizedException $exception) { + basicrum_assert_contains('UUIDv4', $exception->getMessage(), 'site validation error'); + } + + $wait = new WaitMilliseconds($context, $registry, $scopeConfig, $cacheTypeList); + $wait->setValue('45000'); + $wait->beforeSave(); + basicrum_assert_same(30000, $wait->getValue(), 'save-time wait bound'); +}; + +$tests['runtime gate requires enable endpoint and site identity'] = function (): void { + $default = ScopeConfigInterface::SCOPE_TYPE_DEFAULT; + $base = [ + basicrum_test_key($default, 0, Config::XML_PATH_ENABLED) => '1', + basicrum_test_key($default, 0, Config::XML_PATH_CONSENT_ENABLED) => '1', + basicrum_test_key($default, 0, Config::XML_PATH_CONSENT_MODE) => 'implicit', + ]; + + $missing = new Config(new BasicrumTestScopeConfig($base)); + basicrum_assert_same(null, $missing->getRuntimeConfig($default), 'missing identity must be inactive'); + basicrum_assert_same('missing_endpoint', $missing->getStatus($default)['state'], 'admin missing endpoint'); + + $base[basicrum_test_key($default, 0, Config::XML_PATH_BEACON_ENDPOINT)] = 'https://collector.test/beacon'; + $badSite = new Config(new BasicrumTestScopeConfig($base + [ + basicrum_test_key($default, 0, Config::XML_PATH_BRUM_SITE_ID) => 'invalid', + ])); + basicrum_assert_same(null, $badSite->getRuntimeConfig($default), 'invalid Site ID must be inactive'); + + $base[basicrum_test_key($default, 0, Config::XML_PATH_BRUM_SITE_ID)] = + '550e8400-e29b-41d4-a716-446655440000'; + $valid = new Config(new BasicrumTestScopeConfig($base)); + $runtime = $valid->getRuntimeConfig($default); + basicrum_assert_same('implicit', $runtime['consent_mode'], 'legacy value must be retained'); + basicrum_assert_true($runtime['consent_enabled'], 'legacy mode must not grant consent'); + basicrum_assert_same('active_consent', $valid->getStatus($default)['state'], 'consent state'); + + $base[basicrum_test_key($default, 0, Config::XML_PATH_ENABLED)] = 'malformed'; + basicrum_assert_same( + null, + (new Config(new BasicrumTestScopeConfig($base)))->getRuntimeConfig($default), + 'invalid enable value must fail closed' + ); +}; + +$tests['effective default website and store scope values are preserved'] = function (): void { + $default = ScopeConfigInterface::SCOPE_TYPE_DEFAULT; + $values = [ + basicrum_test_key($default, 0, Config::XML_PATH_ENABLED) => '1', + basicrum_test_key($default, 0, Config::XML_PATH_BEACON_ENDPOINT) => 'http://default.test/beacon', + basicrum_test_key($default, 0, Config::XML_PATH_BRUM_SITE_ID) => '550e8400-e29b-41d4-a716-446655440000', + basicrum_test_key($default, 0, Config::XML_PATH_CONSENT_ENABLED) => '1', + basicrum_test_key($default, 0, Config::XML_PATH_DEVELOPMENT_MODE) => '0', + basicrum_test_key(ScopeInterface::SCOPE_WEBSITE, 'eu', Config::XML_PATH_BEACON_ENDPOINT) => + 'https://eu.test/beacon', + basicrum_test_key(ScopeInterface::SCOPE_WEBSITE, 'eu', Config::XML_PATH_CONSENT_ENABLED) => '0', + basicrum_test_key(ScopeInterface::SCOPE_STORE, 'bg', Config::XML_PATH_BRUM_SITE_ID) => + '123e4567-e89b-42d3-a456-426614174000', + basicrum_test_key(ScopeInterface::SCOPE_STORE, 'dev', Config::XML_PATH_DEVELOPMENT_MODE) => '1', + basicrum_test_key(ScopeInterface::SCOPE_STORE, 'dev', Config::XML_PATH_BEACON_ENDPOINT) => + 'http://127.0.0.1:8080/beacon', + ]; + $config = new Config(new BasicrumTestScopeConfig($values, ['bg' => 'eu', 'dev' => 'eu'])); + + $defaultRuntime = $config->getRuntimeConfig($default); + basicrum_assert_same('https://default.test/beacon', $defaultRuntime['beacon_endpoint'], 'HTTPS runtime policy'); + + $websiteRuntime = $config->getRuntimeConfig(ScopeInterface::SCOPE_WEBSITE, 'eu'); + basicrum_assert_same('https://eu.test/beacon', $websiteRuntime['beacon_endpoint'], 'website endpoint'); + basicrum_assert_false($websiteRuntime['consent_enabled'], 'website immediate override'); + + $storeRuntime = $config->getRuntimeConfig(ScopeInterface::SCOPE_STORE, 'bg'); + basicrum_assert_same('https://eu.test/beacon', $storeRuntime['beacon_endpoint'], 'store inherits website endpoint'); + basicrum_assert_same('123e4567-e89b-42d3-a456-426614174000', $storeRuntime['brum_site_id'], 'store Site ID'); + + $devRuntime = $config->getRuntimeConfig(ScopeInterface::SCOPE_STORE, 'dev'); + basicrum_assert_same( + 'http://127.0.0.1:8080/beacon', + $devRuntime['beacon_endpoint'], + 'explicit development mode permits HTTP' + ); +}; + +$tests['system fields preserve default website and store inheritance'] = function () use ($root): void { + $xml = simplexml_load_file($root . '/etc/adminhtml/system.xml'); + $fields = [ + $xml->system->section->group[0]->field[3], + $xml->system->section->group[0]->field[4], + $xml->system->section->group[1]->field[0], + $xml->system->section->group[2]->field[0], + $xml->system->section->group[3]->field[0], + $xml->system->section->group[3]->field[1], + $xml->system->section->group[4]->field[0], + ]; + + foreach ($fields as $field) { + foreach (['showInDefault', 'showInWebsite', 'showInStore'] as $scopeAttribute) { + basicrum_assert_same('1', (string) $field[$scopeAttribute], $field['id'] . ' ' . $scopeAttribute); + } + } + + basicrum_assert_same( + 'BasicRum\\Analytics\\Model\\System\\Config\\Backend\\BeaconEndpoint', + (string) $xml->system->section->group[0]->field[3]->backend_model, + 'endpoint save validator' + ); + basicrum_assert_same( + 'BasicRum\\Analytics\\Model\\System\\Config\\Backend\\BrumSiteId', + (string) $xml->system->section->group[0]->field[4]->backend_model, + 'Site ID save validator' + ); +}; + +$tests['template renders safely and selects consent or immediate loader'] = function () use ($root): void { + $detector = new class implements PageTypeDetectorInterface { + public function getPageType(): string + { + return 'home'; + } + + public function isHomePage(): bool { return true; } + public function isProductPage(): bool { return false; } + public function isCheckoutPage(): bool { return false; } + }; + + $render = static function (array $values) use ($root, $detector): string { + $config = new Config(new BasicrumTestScopeConfig($values)); + $footer = new Footer($detector, $config); + $block = new class($footer) { + public function __construct(private Footer $footer) {} + public function getViewModel(): Footer { return $this->footer; } + public function getViewFileUrl(string $asset): string + { + return 'https://shop.test/static/version123/' . str_replace('::', '/', $asset); + } + }; + $secureRenderer = new class { + public function renderTag(string $tag, array $attributes, string $content, bool $textContent): string + { + $rendered = ''; + foreach ($attributes as $name => $value) { + $rendered .= ' ' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '="' + . htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8') . '"'; + } + return '<' . $tag . $rendered . '>' . $content . ''; + } + }; + + ob_start(); + include $root . '/view/frontend/templates/footer.phtml'; + return (string) ob_get_clean(); + }; + + $default = ScopeConfigInterface::SCOPE_TYPE_DEFAULT; + basicrum_assert_same('', $render([]), 'disabled render must be empty'); + + $base = [ + basicrum_test_key($default, 0, Config::XML_PATH_ENABLED) => '1', + basicrum_test_key($default, 0, Config::XML_PATH_BEACON_ENDPOINT) => 'https://collector.test/beacon', + basicrum_test_key($default, 0, Config::XML_PATH_BRUM_SITE_ID) => '550e8400-e29b-41d4-a716-446655440000', + basicrum_test_key($default, 0, Config::XML_PATH_CONSENT_ENABLED) => '1', + basicrum_test_key($default, 0, Config::XML_PATH_WAIT_ENABLED) => '1', + basicrum_test_key($default, 0, Config::XML_PATH_WAIT_MS) => '500', + ]; + $consent = $render($base); + basicrum_assert_contains('consent-boomerang-loader-v1-15.min.js', $consent, 'consent loader'); + basicrum_assert_contains('brum_site_id', $consent, 'Site ID variable'); + basicrum_assert_contains('p_gen', $consent, 'generator variable'); + basicrum_assert_contains('mage2', $consent, 'Magento generator value'); + basicrum_assert_contains('strip_query_string', $consent, 'query config'); + basicrum_assert_contains('this.timer', $consent, 'cancellable wait timer'); + basicrum_assert_contains('500', $consent, 'configured wait'); + basicrum_assert_contains('\\u003C/script\\u003E', $consent, 'JSON-safe page type'); + basicrum_assert_not_contains('home