diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..05fdabb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +/.github export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore +/.test-results export-ignore +/docs export-ignore +/tests export-ignore +/node_modules export-ignore +/vendor export-ignore +/test-results export-ignore +/playwright-report export-ignore +/package.json export-ignore +/package-lock.json export-ignore +/playwright.config.js export-ignore +/playwright.integration.config.js export-ignore +/phpstan.neon export-ignore +/phpcs.xml export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e4695df --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,94 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + composer: + name: Composer 2.10 strict validation + runs-on: ubuntu-24.04 + steps: + - name: Check out source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Validate Composer metadata strictly + run: docker run --rm -v "$GITHUB_WORKSPACE:/app" -w /app composer:2.10 validate --strict --no-check-publish + + - name: Check optimized production classmap + run: | + docker run --rm -v "$GITHUB_WORKSPACE:/app" -w /app composer:2.10 dump-autoload --optimize --strict-psr --no-dev --no-scripts --no-plugins --no-interaction + docker run --rm -v "$GITHUB_WORKSPACE:/module:ro" -w /module php:8.3-cli php tests/php/check-classmap.php + + php: + name: PHP ${{ matrix.php }} validation + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4'] + container: php:${{ matrix.php }}-cli + steps: + - name: Install Git for committed-package regression checks + run: apt-get update && apt-get install -y --no-install-recommends git + + - 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 declared PHP constraint + 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 + env: + BASICRUM_TEST_PHP: php + steps: + - name: Check out source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up PHP for rendered browser fixtures + uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # 2.35.5 + with: + php-version: '8.3' + tools: none + coverage: none + + - 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: Check native integration test discovery (no Magento execution) + run: npx --no-install playwright test --config=playwright.integration.config.js --list + + - name: Install Chromium + run: npx --no-install playwright install --with-deps chromium + + - name: Run loader and real-Boomerang checks + run: npm test + + - name: Upload browser report and failure traces + if: ${{ !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: chromium-report + path: playwright-report/ + retention-days: 7 diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml new file mode 100644 index 0000000..0758e05 --- /dev/null +++ b/.github/workflows/native.yml @@ -0,0 +1,22 @@ +name: Pinned native Magento +on: + workflow_dispatch: + pull_request: +permissions: + contents: read +jobs: + native: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Build and provision isolated Mage-OS mirror baseline + run: sh tests/integration/docker/start.sh + - name: Install distribution and run pinned native gate + run: | + docker compose -f tests/integration/docker/compose.yaml exec -T -w /module php sh tests/integration/build-artifact.sh + docker compose -f tests/integration/docker/compose.yaml exec -T php sh /module/tests/integration/docker/install-artifact.sh + docker compose -f tests/integration/docker/compose.yaml exec -T -w /module -e BASICRUM_RELEASE_TAG=0.1.0 php sh tests/integration/release-gate.sh + - name: Stop only this disposable stack + if: always() + run: docker compose -f tests/integration/docker/compose.yaml down diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..a5ac8cd --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,47 @@ +name: Magento code quality +on: + push: + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + quality: + name: PHP ${{ matrix.php }} / ${{ matrix.dependencies }} components + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4'] + dependencies: [lowest, stable] + include: + - php: '8.3' + dependencies: locked + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # 2.35.5 + with: + php-version: ${{ matrix.php }} + tools: composer:2.10 + extensions: bcmath, gd, intl, pdo_mysql, soap, sodium, xsl, zip + coverage: none + - name: Resolve real Magento components, never test doubles + working-directory: tests/quality + env: + DEPENDENCIES: ${{ matrix.dependencies }} + run: | + case "$DEPENDENCIES" in + locked) composer install --prefer-dist --no-scripts --no-interaction ;; + lowest) composer update --prefer-lowest --prefer-stable --prefer-dist --no-scripts --no-interaction ;; + stable) composer update --prefer-stable --prefer-dist --no-scripts --no-interaction ;; + esac + - name: Record resolved Magento component versions + working-directory: tests/quality + run: composer show 'magento/*' --no-interaction + - name: PHPStan level 8 and Magento coding standards + run: sh tests/quality/check.sh + - name: Build and verify production distribution + if: matrix.dependencies == 'locked' + run: | + sh tests/integration/build-artifact.sh + php tests/integration/test-artifact.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b558003 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.test-results/ +test-results/ +playwright-report/ +vendor/ diff --git a/Api/PageTypeDetectorInterface.php b/Api/PageTypeDetectorInterface.php index 57037d7..e9122ee 100644 --- a/Api/PageTypeDetectorInterface.php +++ b/Api/PageTypeDetectorInterface.php @@ -1,7 +1,7 @@ escapeHtml(sprintf( + 'Boomerang JS v. %s - cutting-edge - 30 KB (gzipped)', + Config::BOOMERANG_VERSION + )); } -} \ No newline at end of file +} diff --git a/Block/Adminhtml/System/Config/ConsentMode.php b/Block/Adminhtml/System/Config/ConsentMode.php deleted file mode 100644 index 81ea151..0000000 --- a/Block/Adminhtml/System/Config/ConsentMode.php +++ /dev/null @@ -1,17 +0,0 @@ - '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 diff --git a/Block/Adminhtml/System/Config/Logo.php b/Block/Adminhtml/System/Config/Logo.php index 607b187..7489414 100644 --- a/Block/Adminhtml/System/Config/Logo.php +++ b/Block/Adminhtml/System/Config/Logo.php @@ -1,32 +1,37 @@ '; - $html .= 'BasicRum Logo'; - $html .= 'BasicRUM Analytics'; - $html .= ''; - return $html; + return $this->_toHtml(); } - protected function _getElementHtml(AbstractElement $element) + /** + * @inheritDoc + */ + protected function _getElementHtml(AbstractElement $element): string { return $this->render($element); } + + /** + * Resolve the logo through Magento's deployed static-asset URLs. + */ + public function getLogoUrl(): string + { + return $this->getViewFileUrl('Basicrum_Analytics::images/basicrum-logo.png'); + } } diff --git a/Block/Adminhtml/System/Config/ReadOnlyField.php b/Block/Adminhtml/System/Config/ReadOnlyField.php new file mode 100644 index 0000000..ce84ea3 --- /dev/null +++ b/Block/Adminhtml/System/Config/ReadOnlyField.php @@ -0,0 +1,24 @@ +unsScope() + ->unsCanUseWebsiteValue() + ->unsCanUseDefaultValue() + ->unsCanRestoreToDefault(); + + return parent::render($element); + } +} diff --git a/Block/Adminhtml/System/Config/Status.php b/Block/Adminhtml/System/Config/Status.php new file mode 100644 index 0000000..2e9daa4 --- /dev/null +++ b/Block/Adminhtml/System/Config/Status.php @@ -0,0 +1,81 @@ + $data + */ + public function __construct( + Context $context, + private Config $config, + array $data = [] + ) { + parent::__construct($context, $data); + } + + /** + * @inheritDoc + */ + protected function _getElementHtml(AbstractElement $element): string + { + [$scopeType, $scopeCode] = $this->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.'), + // Translation keys remain whole strings, not concatenated fragments. + // phpcs:ignore Generic.Files.LineLength.TooLong + 'active_consent' => __('Ready, consent-controlled: the storefront emits only the inert consent wrapper until the current page receives an authoritative allow callback.'), + // phpcs:ignore Generic.Files.LineLength.TooLong + 'active_immediate' => __('Ready, immediate: Boomerang loads without waiting for a consent decision and may set cookies and send performance data.'), + ]; + + $message = $messages[$status] ?? __('Inactive: review the effective Basicrum configuration.'); + + return sprintf( + '
%s
', + str_starts_with($status, 'active_') ? 'success' : 'warning', + $this->escapeHtml((string) $message) + ); + } + + /** + * Resolve the scope selected in Magento's configuration UI. + * + * @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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..758871e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,114 @@ +# Changelog + +Notable changes to the Basicrum Analytics module are recorded here. + +## [Unreleased] + +### Added + +- Magento-aware PHPStan level 8, Magento coding standards, precise runtime + configuration types, and lowest/stable component-dependency CI checks. +- Magento-version/image-pinned Mage-OS-mirror native test stack, production ZIP verification, + storefront/FPM installation binding, served-asset hashing, and two-visitor FPC + consent isolation tests. Development tooling is excluded from distribution. +- Fail-closed required Beacon Endpoint and UUIDv4 Brum Site ID configuration, + scoped validation, and Admin monitoring status. +- Manual consent-controlled loading with the public opt-in and opt-out + callbacks, plus deliberate immediate loading. +- Optional query-string redaction and a bounded, opt-in Wait After Onload. +- Focused PHP, real-loader browser, CI, and guarded disposable Magento test + foundations. +- A guarded native-Magento `0.1.0` release gate covering upgrade, DI + compilation, static deployment, storefront beacons, and Admin rendering. +- Native configuration-save tests covering validation, same-form HTTP policy, + scoped inheritance, and invalid imported values, with transaction rollback. +- A frontend-only dynamic CSP collector that adds the validated effective + Beacon Endpoint origin to `connect-src` and `img-src` only while monitoring + is active. + +### Changed + +- **Breaking:** Magento 2 `p_type` now uses Magento 1's exact 27 named labels + for equivalent native pages, including `Checkout Success`, account/address, + wishlist, guest-order, and PayPal billing-agreement pages. HTTP 404 takes + precedence; unmapped native actions keep an explicit diagnostic fallback. + No historical beacon/reporting data is migrated. `p_gen=mage2` is unchanged. +- Updated the Admin logo to the supplied 200 by 200 pixel Basicrum branding + asset, displayed at 48 by 48 pixels. +- Visitor Consent and Privacy expand on every visit to the Admin configuration + page, keeping their settings and guidance immediately visible. +- **Breaking:** normalized the technical Magento module identifier and PHP + namespace to the “Basicrum” spelling: `Basicrum_Analytics` and + `Basicrum\\Analytics`. The lowercase configuration paths remain unchanged. + This pre-release break was accepted because the extension has no + installations to migrate. +- Removed the explicit Composer package version. Release versions now come + from immutable VCS tags. The breaking rename remains unreleased and requires + a new `0.1.0` tag rather than reuse of `0.0.2`. +- Production HTTP Beacon Endpoints normalize to HTTPS; the explicit + development exception preserves HTTP. +- The reviewed Boomerang 1.815.60 artifact and configuration now emit + `brum_site_id` while preserving `p_gen=mage2`. +- Magento Config, Backend, CSP, and Store dependencies are declared explicitly + in Composer metadata and module sequencing. +- Page-type detection injects Magento's concrete HTTP response and uses a + strict 404 status comparison. Public helper methods remain available with + Magento 1-aligned label comparisons. +- Admin logo markup and styles live in a template and namespaced stylesheet; + the displayed Boomerang version uses the module's central version constant. +- Removed the obsolete consent-mode selector and compatibility handling. The + consent-required switch and public manual callbacks remain unchanged; any old + database rows are ignored, not deleted. +- Admin status and runtime eligibility share one decision. XML owns install + defaults; unused duplicate defaults and the stored Boomerang-version default + are removed. Tests focus on behavior instead of broad branding/source scans. + +### Fixed + +- Fresh disposable Magento provisioning no longer writes the Admin Usage setting + after disabling its owning module. Configuration failures still stop the job. +- Release ZIPs and expected hashes now come from committed files; ignored local + files cannot enter the archive. Installed distributions reject extra development + and hidden files. The cache-isolation test populates a fresh entry after consent. +- Persist the disposable stack's Git trust configuration across container recreation + and install npm dependencies as the host user. Document unlocked application + dependencies and the actual static-analysis component/PHP requirements. +- Resolve store-to-website inheritance through public `StoreInterface` and + store-manager APIs, without relying on concrete store-model methods. +- Browser integration checks now intercept at context scope and restrict + transport to the disposable store, including redirects. Unexpected traffic + fails the test; collectors are never added to the proxy allowlist. +- The release gate checks installed Magento, PHP, Composer, MariaDB, and + OpenSearch against `baseline.env` before running upgrade or configuration writes. +- Exclude test doubles from Composer's production classmap; CI now checks strict + optimized autoload generation and verifies that no test classes leak into it. +- Remove inheritance controls and scope labels from display-only Admin status, + Boomerang version, and manual callback instructions, preserving inheritance + for saved settings. +- Execute the PHP-rendered Wait After Onload script in real-Boomerang browser + tests, including delayed completion and withdrawal cancellation. Flaky browser + retries now fail CI rather than masking the initial failure. +- Register the CSP collector alongside Magento's global collectors with an + explicit frontend-only runtime guard. The former frontend DI array replaced + core collectors, dropping existing policy sources and blocking checkout's + own requests under enforced CSP. Native browser checks now assert that core + policy sources survive, require an enforcing empty-cart checkout response, + exclude the collector from Admin CSP, and include an opt-in offline checkout + journey. Documentation reflects global registration, native header timing, + address-book redirects, and compiled-DI upgrade requirements. + +### Compatibility and deferred work + +- Active configuration paths are preserved. Stored legacy consent-mode values + are left untouched but no longer read; legacy values do not grant consent. +- There is no configuration data migration. Existing enabled stores remain + inactive until the new required Brum Site ID is valid. +- Automatic consent-provider adapters, full-page-cache invalidation analysis, + a broad optimizer matrix, release publishing, and the + central parity ledger's D-002 through D-004 items remain deferred. D-001's + Magento 2-to-Magento 1 label alignment is now implemented; a shared + cross-platform taxonomy and historical reporting migration remain deferred. + +## [0.0.2] + +- Previous module baseline. diff --git a/Model/Config.php b/Model/Config.php new file mode 100644 index 0000000..c00c997 --- /dev/null +++ b/Model/Config.php @@ -0,0 +1,246 @@ +getStatus($scopeType, $scopeCode); + if (!in_array($state, ['active_consent', 'active_immediate'], true)) { + return null; + } + + return [ + 'beacon_endpoint' => self::normalizeBeaconEndpoint( + $this->getString(self::XML_PATH_BEACON_ENDPOINT, $scopeType, $scopeCode), + $this->getBoolean(self::XML_PATH_DEVELOPMENT_MODE, false, $scopeType, $scopeCode) + ), + 'brum_site_id' => $this->getString(self::XML_PATH_BRUM_SITE_ID, $scopeType, $scopeCode), + 'consent_enabled' => $state === 'active_consent', + '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 $scopeType + * @param string|int|null $scopeCode + */ + public function getStatus( + string $scopeType = ScopeInterface::SCOPE_STORE, + $scopeCode = null + ): string { + if (!$this->getBoolean(self::XML_PATH_ENABLED, false, $scopeType, $scopeCode)) { + return 'disabled'; + } + + $rawEndpoint = $this->getString(self::XML_PATH_BEACON_ENDPOINT, $scopeType, $scopeCode); + if ($rawEndpoint === '') { + return 'missing_endpoint'; + } + if (!self::isValidBeaconEndpoint($rawEndpoint)) { + return 'invalid_endpoint'; + } + + $rawSiteId = $this->getString(self::XML_PATH_BRUM_SITE_ID, $scopeType, $scopeCode); + if ($rawSiteId === '') { + return 'missing_site_id'; + } + if (!self::isValidBrumSiteId($rawSiteId)) { + return 'invalid_site_id'; + } + + $consentRequired = $this->getBoolean( + self::XML_PATH_CONSENT_ENABLED, + true, + $scopeType, + $scopeCode + ); + + return $consentRequired ? 'active_consent' : 'active_immediate'; + } + + /** + * Validate a collector URL without accepting executable URL schemes. + * + * @param mixed $value + */ + public static function isValidBeaconEndpoint(mixed $value): bool + { + if (!is_string($value) || $value === '' || trim($value) !== $value) { + return false; + } + + if (filter_var($value, FILTER_VALIDATE_URL) === false) { + return false; + } + + // Native parsing deliberately matches validation and the CSP origin parser. + // phpcs:ignore Magento2.Functions.DiscouragedFunction.Discouraged + $parts = parse_url($value); + if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { + return false; + } + + // Credentials would be exposed in storefront configuration, while a + // fragment is never part of an HTTP request. Query strings remain + // supported for compatibility with collectors that require them. + if (array_key_exists('user', $parts) + || array_key_exists('pass', $parts) + || array_key_exists('fragment', $parts) + ) { + return false; + } + + return in_array(strtolower((string) $parts['scheme']), ['http', 'https'], true); + } + + /** + * Validate a Brum Site ID as an RFC 4122 UUIDv4. + * + * @param mixed $value + */ + public static function isValidBrumSiteId(mixed $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. + * + * @param mixed $value + * @param bool $default + */ + public static function normalizeBoolean(mixed $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. + * + * @param mixed $value + */ + public static function normalizeWaitMilliseconds(mixed $value): int + { + if (!is_scalar($value) || !is_numeric($value)) { + return 0; + } + + return min(self::MAX_WAIT_MS, max(0, (int) $value)); + } + + /** + * Read a trimmed scoped string. + * + * @param string $path + * @param string $scopeType + * @param string|int|null $scopeCode + */ + private function getString(string $path, string $scopeType, $scopeCode): string + { + return trim((string) $this->scopeConfig->getValue($path, $scopeType, $scopeCode)); + } + + /** + * Apply the same HTTPS policy at save time and runtime after validation. + * + * @param string $endpoint + * @param bool $httpAllowed + */ + public static function normalizeBeaconEndpoint(string $endpoint, bool $httpAllowed): string + { + return !$httpAllowed && stripos($endpoint, 'http://') === 0 + ? 'https://' . substr($endpoint, 7) + : $endpoint; + } + + /** + * Read an explicitly supported boolean, or the caller's safe default. + * + * @param string $path + * @param bool $default + * @param string $scopeType + * @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/Csp/BeaconPolicyCollector.php b/Model/Csp/BeaconPolicyCollector.php new file mode 100644 index 0000000..0999258 --- /dev/null +++ b/Model/Csp/BeaconPolicyCollector.php @@ -0,0 +1,91 @@ +appState->getAreaCode() !== Area::AREA_FRONTEND) { + return $defaultPolicies; + } + } catch (LocalizedException $exception) { + // CLI/bootstrap contexts may not have selected an area yet. + return $defaultPolicies; + } + + $runtimeConfig = $this->config->getRuntimeConfig(); + if ($runtimeConfig === null) { + return $defaultPolicies; + } + + $origin = $this->getOrigin((string) $runtimeConfig['beacon_endpoint']); + if ($origin === null) { + return $defaultPolicies; + } + + foreach (self::DIRECTIVES as $directive) { + $defaultPolicies[] = new FetchPolicy($directive, false, [$origin]); + } + + return $defaultPolicies; + } + + /** + * Extract a fetch-policy origin from the validated effective endpoint. + * + * @param string $endpoint + */ + private function getOrigin(string $endpoint): ?string + { + // Use the same native URL parser as Config's endpoint validation. + // phpcs:ignore Magento2.Functions.DiscouragedFunction.Discouraged + $parts = parse_url($endpoint); + if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { + return null; + } + + $scheme = strtolower((string) $parts['scheme']); + if (!in_array($scheme, ['http', 'https'], true)) { + return null; + } + + $origin = $scheme . '://' . $parts['host']; + if (isset($parts['port'])) { + $origin .= ':' . $parts['port']; + } + + return $origin; + } +} diff --git a/Model/PageTypeDetector.php b/Model/PageTypeDetector.php index de706d3..5287f5c 100644 --- a/Model/PageTypeDetector.php +++ b/Model/PageTypeDetector.php @@ -1,17 +1,58 @@ '404 Not Found', + 'cms_index_defaultnoroute' => '404 Not Found', + 'cms_index_index' => 'Home', + 'cms_page_view' => 'CMS Page', + 'catalog_category_view' => 'Category', + 'catalog_product_view' => 'Product', + 'catalogsearch_result_index' => 'Search', + 'catalogsearch_advanced_index' => 'Advanced Search', + 'checkout_cart_index' => 'Cart', + 'checkout_index_index' => 'Checkout', + 'checkout_onepage_success' => 'Checkout Success', + 'customer_account_login' => 'Login', + 'customer_account_create' => 'Register', + 'customer_account_index' => 'Account', + 'customer_account_logoutsuccess' => 'Logout Success', + 'contact_index_index' => 'Contact', + 'sales_guest_form' => 'Orders and Returns', + 'customer_account_edit' => 'Customer Account Edit', + 'sales_order_view' => 'Order View', + 'paypal_billing_agreement_index' => 'Billing Agreements', + 'paypal_billing_agreement_view' => 'Billing Agreement View', + 'sales_guest_view' => 'Guest Order View', + 'customer_address_form' => 'Customer Address Edit', + 'customer_address_index' => 'Customer Address List', + 'wishlist_index_configure' => 'Wishlist Item Configure', + 'wishlist_index_index' => 'Wishlist Items List', + 'sales_order_history' => 'Order History', + 'customer_account_forgotpassword' => 'Forgot Password', + ]; + + /** + * Initialize detection from Magento's final request and response. + * + * @param HttpRequest $request + * @param HttpResponse $response + */ public function __construct( private HttpRequest $request, - private ResponseInterface $response + private HttpResponse $response ) { } @@ -22,36 +63,20 @@ public function __construct( */ public function getPageType(): string { - // Check for error pages first - if ($this->response->getStatusCode() == 404) { - return '404_not_found'; + // A missing entity can return 404 even when its action is otherwise known. + if ($this->response->getStatusCode() === 404) { + return '404 Not Found'; } - // Get full action name - $fullActionName = $this->request->getFullActionName(); - - // Common page types based on full action name - $pageTypeMap = [ - 'cms_index_index' => 'home', - 'cms_page_view' => 'cms_page', - 'catalog_product_view' => 'product', - 'catalog_category_view' => 'category', - 'checkout_index_index' => 'checkout', - 'checkout_cart_index' => 'cart', - 'customer_account_login' => 'customer_login', - 'customer_account_create' => 'customer_register', - 'customer_account_index' => 'customer_account', - 'sales_order_history' => 'order_history', - 'contact_index_index' => 'contact', - 'catalogsearch_result_index' => 'search_results', - ]; + $fullActionName = strtolower($this->request->getFullActionName()); - if (isset($pageTypeMap[$fullActionName])) { - return $pageTypeMap[$fullActionName]; + // Native Http returns "__" when route/controller/action are all unset. + if ($fullActionName === '' || trim($fullActionName, '_') === '') { + return 'unknown'; } - // Default fallback - return 'unmapped_' . $fullActionName; + // Do not infer a type from generic layout handles or URL/entity parameters. + return self::PAGE_TYPES[$fullActionName] ?? 'unmapped_' . $fullActionName; } /** @@ -61,7 +86,7 @@ public function getPageType(): string */ public function isHomePage(): bool { - return $this->getPageType() === 'home'; + return $this->getPageType() === 'Home'; } /** @@ -71,7 +96,7 @@ public function isHomePage(): bool */ public function isProductPage(): bool { - return $this->getPageType() === 'product'; + return $this->getPageType() === 'Product'; } /** @@ -81,6 +106,6 @@ public function isProductPage(): bool */ public function isCheckoutPage(): bool { - return $this->getPageType() === 'checkout'; + return $this->getPageType() === 'Checkout'; } } diff --git a/Model/System/Config/Backend/BeaconEndpoint.php b/Model/System/Config/Backend/BeaconEndpoint.php new file mode 100644 index 0000000..83a117e --- /dev/null +++ b/Model/System/Config/Backend/BeaconEndpoint.php @@ -0,0 +1,134 @@ + $data + */ + public function __construct( + Context $context, + Registry $registry, + ScopeConfigInterface $config, + TypeListInterface $cacheTypeList, + private StoreManagerInterface $storeManager, + ?AbstractResource $resource = null, + ?AbstractDb $resourceCollection = null, + array $data = [] + ) { + parent::__construct( + $context, + $registry, + $config, + $cacheTypeList, + $resource, + $resourceCollection, + $data + ); + } + + /** + * Validate the endpoint and apply the effective HTTP policy before saving. + * + * @throws LocalizedException + */ + public function beforeSave() + { + $value = trim((string) $this->getValue()); + + if ($value !== '' && !Config::isValidBeaconEndpoint($value)) { + throw new LocalizedException( + __('Beacon Endpoint must be a valid HTTP or HTTPS URL without embedded credentials or a fragment.') + ); + } + + $this->setValue(Config::normalizeBeaconEndpoint($value, $this->isHttpAllowed())); + + 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'])) { + $websiteId = $this->storeManager->getStore($scopeCode)->getWebsiteId(); + $websiteCode = (string) $this->storeManager->getWebsite($websiteId)->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..0358a34 --- /dev/null +++ b/Model/System/Config/Backend/BrumSiteId.php @@ -0,0 +1,34 @@ +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..e5b1da5 --- /dev/null +++ b/Model/System/Config/Backend/WaitMilliseconds.php @@ -0,0 +1,23 @@ +setValue(Config::normalizeWaitMilliseconds($this->getValue())); + + return parent::beforeSave(); + } +} diff --git a/README.md b/README.md index 7acd972..f68cfde 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,309 @@ -# 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. Platform version requirements are declared in +`tests/integration/baseline.env`; container images are pinned by digest. The full +application dependency set is not locked: first-time provisioning resolves +transitive Composer dependencies, so upstream changes can affect a new install. +Focused PHP checks run on PHP 8.2, +8.3, and 8.4. Composer metadata allows Magento framework 103.x so the module +can be evaluated on adjacent Magento 2.4 release lines, but only the pinned +PHP 8.3 combination is declared for disposable Magento integration testing. +This combination has now been exercised with Luma and built-in full-page cache, +including the installed distribution ZIP. Adobe Commerce, Hyvä, headless/PWA, +Varnish, and other Magento/PHP combinations are **not** certified by that run. +The source repository's `docs/QUALITY-AND-RELEASE-READINESS.md` records the exact +verification scope, skips, and remaining release requirements. ## Installation +Install a published package with Composer: + ```sh -composer require basicrum/basicrum-analytics -bin/magento module:enable BasicRum_Analytics +composer require basicrum/basicrum-analytics +``` + +For a manual source installation, place this module at the exact path below. +The casing is required on case-sensitive filesystems: + +```text +app/code/Basicrum/Analytics +``` + +Then enable and initialize the module: + +```sh +bin/magento module:enable Basicrum_Analytics bin/magento setup:upgrade +bin/magento setup:di:compile bin/magento cache:flush ``` +Regenerate compiled DI after installation or upgrade, including developer +installations where DI was previously compiled. This applies changed constructor +metadata and the global CSP collector registration. In production mode, deploy +static content using the store's normal deployment process as well. + ## 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. Display-only status, version, +and callback instructions have no inheritance controls or stored values. +Visitor Consent and Privacy open expanded each time you visit the page. You +can collapse them while working; they reopen on your next visit. + +Required settings: + +- **Enable Basicrum**: new installations default to No. +- **Beacon Endpoint**: a valid HTTP or HTTPS collector URL without embedded + credentials or a fragment. Endpoint query strings remain supported for + compatibility. 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. Magento classifies this exception as environment-specific: configuration + dumps put it in `app/etc/env.php`, not shared `app/etc/config.php`. Do not promote + development `env.php` values to production. This follows Magento's native + [configuration deployment rules](https://experienceleague.adobe.com/en/docs/commerce-operations/configuration-guide/deployment/technical-details). + Existing database values and previously exported files are not rewritten; + review any old shared export of `basicrum/developer/development_mode` before + re-exporting configuration. The default, configuration path, and scope + inheritance are unchanged. + +The runtime emits Magento 1's exact named `p_type` values for equivalent +Magento 2 pages (for example, `Home`, `Product`, `Search`, and +`Checkout Success`), while retaining `p_gen=mage2` and the configured +`brum_site_id`. Unknown actions use `unmapped_`; +an unavailable action uses `unknown`. The [page-type alignment notes](docs/PAGE-TYPE-ALIGNMENT.md) +list all mappings, native-route adaptations, and reporting impact. +Boomerang uses `instrument_xhr=false`, +Continuity and ResourceTiming with `splitAtPath`, and Secure/SameSite Strict +cookie settings, matching the reviewed Basicrum configuration. + +When the effective runtime configuration is active, the module adds only the +normalized Beacon Endpoint origin (scheme, host, and optional port) to the +storefront `connect-src` and `img-src` CSP policies. Paths and query strings +are not copied into CSP. Inactive or invalid configuration adds no collector +origin. This covers Boomerang's send-beacon/XHR and image-fallback transports; +it does not weaken other directives or add a wildcard. + +## 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: + +- Magento 2 `p_type` labels now match Magento 1, including capitalization and + spaces. This intentionally changes existing report groupings; historical + beacons are not migrated and no legacy-label mode is provided. Update any + report filters and purge cached HTML after upgrading. WordPress and Magento + 1 labels are unchanged. + +- Before the first public release, the technical module identifier and PHP + namespace were normalized to the “Basicrum” spelling. This is an intentional + breaking rename; the supported identifiers are `Basicrum_Analytics` and + `Basicrum\\Analytics`. Lowercase `basicrum/*` configuration paths are + unchanged. The exact manual installation path is documented above. + +- 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. +- The obsolete `basicrum/consent/mode` selector and runtime handling are + removed. Existing database rows are left untouched but ignored, including + `manual`, `explicit`, `implicit`, `cookie`, and `gdpr`. Only the consent-required + switch controls loading; none of these old strings counts as consent. Manual + callbacks remain the supported integration. +- 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`, the loader and Boomerang +assets are same-origin module assets, and the validated collector origin is +added dynamically to storefront CSP. 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 fast PHP harness uses test doubles to cover defaults, validation, save +normalization, runtime gates, scope inheritance, CSP origin policy, all Magento 1-aligned page-type mappings +and fallbacks, 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. Global setup renders the actual PHP footer +template using PHP 8.3 in Docker (Docker must be running), then the browser +executes its inline configuration and Wait After Onload plugin. Set +`BASICRUM_TEST_PHP=php` (or an absolute executable path) to use an installed +PHP CLI instead. The Chromium CI job explicitly provisions PHP 8.3 and selects +it through this setting; fixture rendering does not pull or start a Docker image +in that job. A missing or failing selected PHP executable fails setup without +falling back to Docker. +Magento block/renderer doubles are used here; native rendering is covered by +the separate integration suite. The checks cover pre-consent silence, one-time +loading, denial and withdrawal races, cookie cleanup, query redaction, and +beacon identity, delayed sending, and cancellation of the rendered wait timer. + +For the actual layout/template/static-content/CSP/storefront-to-beacon path, +use the guarded disposable-store harness in `tests/integration/README.md`. +It also exercises Magento's real Admin configuration-save model, backend +validation, and default/website/store inheritance inside rolled-back database +transactions. Browser traffic is limited to the disposable storefront/Admin; +the expected beacon is fulfilled locally and unexpected destinations fail the +test. External payment scripts must be disabled in that test installation. +The native suite also renders a test-only, non-cacheable Magento page under +enforcing CSP with inline scripts disabled. It checks matching bootstrap +nonces, real first-party script execution and consent-gated beacons, and a +blocked unnonced inline negative control. The fixture is never packaged with +the extension. This is separate from the report-only homepage FPC checks; it +does not certify nonce handling on cacheable pages or custom strict-dynamic +policies. See the integration README for fixture installation and scope. +The regular CI workflow runs strict Composer 2.10 validation and optimized +production classmap checks plus the fast PHP and Chromium checks. The Chromium +job uploads an HTML report with traces from failed test attempts, retained for +seven days. A flaky pass still fails the job; retries do not hide failures. +Additional CI checks run Magento-aware PHPStan level 8 and Magento coding +standards against real Magento components, with lowest/stable dependency +resolution on PHP 8.2–8.4 and a locked PHP 8.3 job. These component checks are +not full-platform compatibility certification. The committed tooling lock requires +PHP 8.3 or 8.4 and resolves framework 103.0.9 (the Magento 2.4.9 component line). +The locally tested PHP 8.3 lowest resolution uses framework 103.0.7 (2.4.7 GA), +not 2.4.7-p10. CI's lowest/stable jobs resolve afresh for their PHP version; their +logs report the exact component versions. No PHPStan run against the native +2.4.7-p10 dependency set is claimed. Run the locked tools locally on PHP 8.3/8.4: + +```sh +composer --working-dir=tests/quality install --no-interaction --no-scripts +sh tests/quality/check.sh +``` -4. Click "Save Config" to apply the changes -5. Clear the cache by going to **System > Cache Management** and clicking "Flush Magento Cache" +The native CI workflow provisions a separate Magento-version/image-pinned stack from the +anonymous Mage-OS mirror; it does not use production credentials. It disables +external Braintree scripts only in that test stack. See the integration README +for setup, synthetic credentials, localhost-only ports, and cleanup. +Before tagging Phase 1 as `0.1.0`, the documented native +release gate is also required: it requires a clean candidate checkout, verifies +the distribution ZIP and registered installed module match it, and enforces all versions in `baseline.env`, +then runs Magento upgrade, DI compilation, static deployment, native save tests, +storefront/beacon assertions with a proven full-page-cache HIT, and an authenticated +Admin rendering check. A temporary challenge binds the browser URL to that +installation and its web PHP version; served loader/Boomerang bytes must match +the candidate. A visitor with measurement cookies populates a fresh cache entry; +another visitor's first request to that URL must be a HIT and remain silent until +its own allow callback. It rechecks candidate identity afterward and records the +tested commit SHA in its success output. Run `npm ci` first: the native runner +uses only the installed Playwright binary, never an automatic download. +The production archive is built from the clean Git commit, never loose working-tree +files. It excludes tests, CI, developer tooling/configuration and generated output, +and retains production code/assets and license notices. Installed-package checks +reject all extra files, including leftover development directories and hidden files. +No remote CI job is reported as passing merely because its definition was added. -## 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..fa1b49f 100644 --- a/ViewModel/Footer.php +++ b/ViewModel/Footer.php @@ -1,35 +1,34 @@ $this->scopeConfig->getValue( - 'basicrum/general/beacon_endpoint', - ScopeInterface::SCOPE_STORE - ) - ]; - - return $config; + return $this->config->getRuntimeConfig(); } /** @@ -39,4 +38,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..2b2ba1c 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,7 @@ { "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": [ { "name": "Tsvetan Stoychev", @@ -10,13 +9,19 @@ } ], "require": { - "php": "^8.1|^8.2|^8.3", - "magento/framework": "*", - "magento/module-store": "*" + "php": ">=8.2 <8.5", + "magento/framework": "^103.0", + "magento/module-backend": "^102.0", + "magento/module-config": "^101.2", + "magento/module-csp": "^100.4", + "magento/module-store": "^101.1" }, "archive": { "exclude": [ - ".github" + "/.github", "/.gitignore", "/.gitattributes", "/.test-results", + "/docs", "/tests", "/node_modules", "/vendor", "/test-results", "/playwright-report", + "/package.json", "/package-lock.json", "/playwright.config.js", "/playwright.integration.config.js", + "/phpstan.neon", "/phpcs.xml" ] }, "repositories": [ @@ -26,11 +31,18 @@ } ], "autoload": { + "exclude-from-classmap": [ + "/tests/", + "/.test-results/", + "/node_modules/", + "/test-results/", + "/playwright-report/" + ], "files": [ "registration.php" ], "psr-4": { - "BasicRum\\Analytics\\": "" + "Basicrum\\Analytics\\": "" } }, "license": "MIT" diff --git a/docs/CSP-CACHING-ADOPTION-NOTES.md b/docs/CSP-CACHING-ADOPTION-NOTES.md new file mode 100644 index 0000000..7159a40 --- /dev/null +++ b/docs/CSP-CACHING-ADOPTION-NOTES.md @@ -0,0 +1,180 @@ +# CSP, caching and testing: adoption notes + +Recorded on 2026-09-23 after a read-only consultation with Opus 5.5 Max and +Grok 4.7 through their CLIs, followed by independent Codex verification. +Claude reported `claude-opus-5-5`; Grok reported `grok-4.7-build` in usage metadata. +The reviewed module commit was `c2d6241efc21c16b7ef5723eae4052807c137c41`. + +**Status: future work only.** These are selected priorities, not implemented +features, passing tests, or authorization for a release. The user requested +that the decisions be retained for later. Production behavior is unchanged; +[CR-D-002](DEFERRED-CODE-REVIEW-FINDINGS.md#cr-d-002-full-page-cache-invalidation-after-basicrum-configuration-changes) +remains deferred. + +## Selected priorities + +| Priority | Proposed work | Reason | Estimated effort | +| --- | --- | --- | --- | +| 1 | Real Admin saves against warm full-page cache | Establish what visitors actually receive after privacy/configuration changes | Medium | +| 2 | CSP assertions on existing real cache-HIT responses | Verify the effective collector origin and Magento's core policy sources survive cache hits | Small | +| 3 | Clearer cache-operation and CSP guidance, including Admin copy | Explain cache refresh requirements and distinguish tested modes from unverified combinations | Small | + +These should be test/documentation improvements first. Do not replace the +loader, consent model, native CSP renderer or collector as part of this work. + +### 1. Real Admin saves against warm full-page cache + +Use the guarded disposable Magento installation, with caching enabled and a +real Admin form submission. The current +[configuration-save harness](../tests/integration/config-save.php) tests +validation and inheritance inside rolled-back transactions, disables +configuration caching for that process, and invokes the save model rather +than the complete Admin controller path. It cannot prove storefront cache +refresh behavior. + +Start with a bounded set of representative transitions: + +- Immediate loading to required consent, including a store override returning + to an inherited consent requirement. +- Enabled to disabled monitoring. +- A Beacon Endpoint change, checking both rendered configuration and CSP. + +Acceptance criteria: + +- Establish a genuine warm FPC HIT before saving; do not manufacture headers. +- Record the cache invalidation status, next response's HIT/MISS status, + rendered configuration/loader, CSP and actual tracking behavior separately. +- Inspect behavior before and after Magento's normal Page Cache refresh. + Do not clean the cache between the save and the pre-refresh observation. +- Use fresh browser contexts for post-save visits so a previous page's + initialized Boomerang cannot obscure the result. +- Restore settings and clean test cache entries in a `finally` path. Keep the + test serial and isolated from other native configuration tests. +- Do not describe one store-view test as proof of cross-store eviction or + Varnish/CDN purge. Expand scope only when necessary for an intended fix. + +A stale response confirms the practical impact of CR-D-002. It does not, by +itself, select an automatic cache-clearing implementation. + +### 2. Check CSP on the HIT itself + +The existing [storefront tests](../tests/integration/storefront.spec.js) +check CSP on an initial response, and separately require actual cache HITs +for reload and independent-visitor scenarios. Add policy assertions to those +HIT responses using the existing [CSP helpers](../tests/integration/csp.js). + +Verify core sources remain present and the effective collector origin appears +in `connect-src` and `img-src`. Retain consent silence, single loading, beacon +identity and visitor-isolation checks. Report-only policy checks must remain +labelled report-only: they are not proof that a browser enforced the policy. + +This is a small test-only change; no production CSP changes are indicated. + +### 3. Explain operational requirements precisely + +After the native observations above, improve README/integration guidance and +relevant Admin copy to explain: + +- Saving configuration and marking a cache invalid are not the same as + removing cached pages. +- Disabling monitoring or tightening consent can require a Page Cache refresh + and any operator-managed CDN/optimizer purge before cached pages reflect + the change. +- The current enforcing-CSP fixture is deliberately non-cacheable; the + homepage FPC checks are separate and report-only. +- Existing pages already open in browsers are not refreshed by a cache purge. + +Keep [verification documentation](QUALITY-AND-RELEASE-READINESS.md) explicit +about the baseline, executed checks and unsupported/unverified combinations. + +## Source findings and an important review correction + +Codex inspected relevant source copied read-only from the stopped disposable +Magento Open Source 2.4.7-p10 installation. The reviewers also inspected the +available 2.4.7 GA and/or 2.4.9-line quality components. Those component sets +must not be presented as native 2.4.7-p10 execution evidence. + +On the inspected baseline: + +- The Admin configuration-save postdispatch observer calls + `Magento\PageCache\Observer\InvalidateCache`, which invokes + `TypeList::invalidate('full_page')`. That method records an invalidation + flag; `cleanType()` is a separate operation that removes cached data. +- `Magento\Config\Model\Config::save()` dispatches the section-change event + with changed paths and scope information, but model-only invocation does + not run the Admin controller's postdispatch observers. +- Built-in FPC stores the response in the result-rendering path. Magento adds + CSP later at `controller_front_send_response_before`. A cache HIT bypasses + normal rendering, so render-time nonce registration cannot be assumed to + survive. Route-specific CSP selection also depends on the full action name, + which requires attention when routing is bypassed on a HIT. +- Varnish caches the completed HTTP response, so its header/nonce lifecycle + differs from built-in FPC. Cleaning local FPC does not prove a Varnish purge. + +Grok initially inferred that storing response headers meant a built-in HIT +necessarily retained a matching CSP header and remained executable. Codex +challenged the event ordering; Grok withdrew that conclusion and its proposed +assertion requiring nonce reuse. Opus independently identified the ordering. +**Do not encode nonce reuse as an intended secure contract.** + +These are source observations. No warm-cache Admin-save or cacheable +enforcing-CSP scenario was executed during this consultation. + +## Later investigations and compatibility coverage + +1. **Bounded cacheable enforcing-CSP diagnostic.** Observe real MISS/HIT and + independent visitors, recording the effective policy mode, nonce/header + relationship and script execution. Check enforcement on every response; + falling back to report-only must not count as an enforcing pass. Use native + configuration in an isolated environment, not rewritten headers/assets. + Keep the existing non-cacheable fixture. A discovered platform limitation + calls for an explicit support boundary, not an improvised nonce bypass. +2. **One additional native Magento/PHP baseline.** Select a currently supported + combination when this work is scheduled. Start with a manually triggered + lane if appropriate; component/static matrices do not replace native + rendering, DI compilation, asset deployment and browser execution. +3. **Pinned Varnish lane.** Add before claiming Varnish verification. Require + genuine MISS/HIT evidence, policy consistency, visitor isolation and actual + purge behavior. This is a separate infrastructure investment, not equivalent + to Redis-backed built-in caching. External CDN management remains distinct. + +## Automatic cache clearing: disagreement and decision + +Opus favored Magento's normal invalidation/manual-refresh convention and +warned about site-wide eviction, unaffected stores and external caches. Grok +favored a narrow native clean if the warm-cache test confirmed stale output. + +**Decision: do not adopt automatic clearing yet.** First establish the real +Admin-save behavior and the intended guarantee. If automatic refresh is later +chosen, evaluate the smallest native solution for changed storefront settings, +including no-op saves, inheritance, inactive-to-active transitions, configured +Varnish, and CLI/import behavior. Do not assume per-store eviction exists, or +that a local `full_page` clean purges Varnish/CDNs. Do not add an unconditional +whole-cache flush or a new cache framework. + +## Peer practices not selected + +The useful lessons are native CSP integration, separation of shared HTML from +visitor state, and tests of actual rendered behavior. Basicrum already uses +`SecureHtmlRenderer`, a validated storefront-only endpoint collector and +current-page consent callbacks without its own persisted consent decision. + +Do not borrow merely because a competitor has it: + +- Customer-data/AJAX storage for Basicrum consent. +- HTML post-processing CSP helpers or hand-written nonce machinery. +- Broad wildcard allowlists or disabling CSP/FPC to make scripts run. +- `cacheable="false"` on normal storefront layouts. +- GTM plumbing, server-side tracking or unrelated visitor identity features. + +An external bootstrap reading non-executable configuration could be examined +if strict CSP plus cached pages becomes a product requirement. It is not +selected here: it changes the loader integration contract and needs its own +cross-plugin behavior review. + +## Consultation boundaries + +No implementation or competitor test suite was run during the review. No +Magento service was started, configuration changed, or beacon transmitted. +The decisions are recommendations, not a new compatibility certification. +WordPress, Magento 1 and the shared parity ledger were left unchanged. diff --git a/docs/DEFERRED-CODE-REVIEW-FINDINGS.md b/docs/DEFERRED-CODE-REVIEW-FINDINGS.md new file mode 100644 index 0000000..ca688d8 --- /dev/null +++ b/docs/DEFERRED-CODE-REVIEW-FINDINGS.md @@ -0,0 +1,46 @@ +# Deferred code-review findings + +These findings were raised independently by the Grok and Fable 5.1 reviews of +the Phase 1 implementation. CR-D-001 was subsequently implemented by adapting +the compatible CSP work from upstream PR #13. CR-D-002 remains intentionally +deferred and visible for follow-up planning. + +## CR-D-001: Dynamic Magento CSP policy for the Beacon Endpoint + +**Status:** Implemented. + +The storefront CSP collector now consumes the same validated effective runtime +configuration as rendering. While monitoring is active, it adds only the +normalized endpoint origin to `connect-src` and `img-src`, covering +send-beacon/XHR and image fallbacks without wildcards. Disabled, incomplete, +or invalid configuration adds nothing. The collector is registered in global +dependency injection alongside Magento's core collectors; area-level array +registration would replace them. An explicit frontend runtime guard keeps +Basicrum's contribution out of Admin, API, cron, and unset-area contexts. + +Focused tests cover the inactive gate, production HTTPS normalization, +path/query exclusion, port retention, both directives, and the explicit +development HTTP exception. Native tests check core whitelist preservation, +an enforcing checkout response, and the absence of Basicrum's origin in Admin. +Execution results and baseline limitations are recorded in +`OPUS-REVIEW-FOLLOWUPS.md`; the pinned pre-release gate remains required. + +## CR-D-002: Full-page-cache invalidation after Basicrum configuration changes + +**Status:** Deferred. + +The 2026-09-23 [CSP/caching adoption notes](CSP-CACHING-ADOPTION-NOTES.md) +record the Opus/Grok consultation, native-core source findings and selected +test-first priorities. They do not implement or resolve this finding. + +The cacheable footer embeds the effective endpoint, Brum Site ID, consent +choice, query-redaction flag, and wait configuration. The module's backend +models validate saved values but do not explicitly invalidate Magento +full-page cache, so cached pages may retain earlier monitoring behavior after +an administrator saves configuration. + +Follow-up must first verify Magento core and Varnish behavior on the disposable +baseline, then implement correctly scoped invalidation for every storefront +setting if core config-save behavior is insufficient. Until then, the README's +upgrade guidance to clean configuration, layout, block HTML, full-page, CDN, +and optimizer caches remains the operational mitigation. diff --git a/docs/OPUS-REVIEW-FOLLOWUPS.md b/docs/OPUS-REVIEW-FOLLOWUPS.md new file mode 100644 index 0000000..6e5a07f --- /dev/null +++ b/docs/OPUS-REVIEW-FOLLOWUPS.md @@ -0,0 +1,83 @@ +# Opus review follow-ups — 2026-09-22 + +All six approved follow-ups are implemented locally. These notes supplement, +not replace, the earlier Phase 1 and page-type alignment execution records. + +## Implemented + +1. **Documentation (R-007/R-010):** replaced stale frontend-DI wording with + global collector registration plus a frontend runtime guard; corrected the + empty-address-book redirect/forward description and native layout header + timing; added compiled-DI regeneration to installation/upgrade guidance. +2. **CSP regressions (R-007/R-008):** reject collector-array replacement in + every area DI file. Native checks require an enforcing checkout response + before following its empty-cart redirect, preserve core `'self'` and a + known Magento PayPal whitelist source, and exclude Basicrum's collector + origin from Admin CSP at default, website, and store scope. +3. **Rendered wait behavior (R-005/R-008):** global browser setup invokes PHP + to render the production `footer.phtml` using the real Config, Footer, and + page detector. Tests execute that inline output with both packaged consent + loaders and the real Boomerang artifact. Controlled browser time verifies + silence before the delay, completion afterward, timer cancellation on + withdrawal, no same-page re-grant, and omission for default/off/zero settings. + The copied JavaScript wait-plugin fixture is removed. +4. **Production autoload (R-009):** Composer excludes `/tests/` from classmap + generation. CI runs strict optimized production autoload generation and + checks that module classes remain present while test doubles are absent. +5. **Display-only Admin rows (R-002/R-008):** a shared renderer removes scope + labels and inheritance/restore flags from Monitoring Status, Boomerang + Version, and Manual Callback API. Native website/store assertions also + prove actual configuration fields retain their inheritance checkboxes. +6. **Flaky privacy checks (R-009):** CI keeps one browser retry for diagnostics, + but `failOnFlakyTests` makes a pass-on-retry fail the job. Native integration + tests already run without retries. + +No runtime consent contract, collection defaults, saved configuration paths, +page labels, bundled assets, or license declarations were changed by these +follow-ups. No configuration migration was introduced. + +## Checks actually run + +- Focused PHP harness: **16 groups passed on each of PHP 8.2, 8.3, and 8.4**. + Module and test PHP/PHTML syntax checks passed on all three versions. +- `npm test`: minified-artifact verification and **36 Chromium tests passed**. + `CI=1 npm test` also passed all 36 without a retry. +- Composer 2.10 in an isolated checkout copy: strict metadata validation and + `dump-autoload --optimize --strict-psr --no-dev --no-scripts --no-plugins + --no-interaction` succeeded. The generated map contained production classes + and no test doubles. This check does not install Magento dependencies. +- Local Magento Open Source **2.4.9 from the Mage-OS mirror / PHP 8.5.6**: + dependency-injection compilation and config/layout/block/full-page cache + cleaning succeeded after updating the disposable module copy. +- Native Chromium integration: **21 passed, 1 deliberately skipped**. This + included sample-data page/beacon checks, the new enforcing checkout response, + storefront consent/cache behavior, and authenticated Admin CSP/rendering at + default, website, and store scope. All measurement requests were intercepted. + The skipped test is the separately opted-in order-creating checkout journey; + the database order count stayed at 3 and saved Basicrum settings were unchanged. +- JavaScript and integration-shell syntax checks and `git diff --check` passed. + +## Limits and remaining work + +- GitHub Actions itself was not run. The commands were exercised locally; + adding CI steps is not evidence of a remote workflow pass. +- The declared **2.4.7-p10 / PHP 8.3** native release gate was not run: the + available installation is 2.4.9 / PHP 8.5.6. Supplemental local success does + not widen Composer's PHP constraint or certify proprietary Adobe Commerce. +- The rendered-wait browser fixtures replace Magento's block/HTML renderer + with test doubles, but never replace the production PHP template or its + JavaScript. They are not a native Magento wait-enabled storefront run; + the local store's wait settings remained off/zero. Native layout/CSP is + independently exercised by the integration suite. +- Checkout CSP is checked on its enforcing empty-cart redirect. The complete + order-creating journey was not rerun; its earlier successful run remains + documented in `PAGE-TYPE-ALIGNMENT.md`. +- No fresh static deployment was needed because these follow-ups changed no + static assets. Package installation/release publishing, authenticated customer + journeys, automatic consent adapters, the optimizer matrix, and deferred + full-page-cache invalidation analysis remain outside this follow-up. +- WordPress, Magento 1, and the central parity ledger were left unchanged. + The review-ID mappings above are completion notes for the central task. + +The existing branch work is preserved. Nothing was committed, pushed, deployed +outside the disposable local installation, or published by this follow-up. diff --git a/docs/PAGE-TYPE-ALIGNMENT.md b/docs/PAGE-TYPE-ALIGNMENT.md new file mode 100644 index 0000000..6dc7eed --- /dev/null +++ b/docs/PAGE-TYPE-ALIGNMENT.md @@ -0,0 +1,183 @@ +# Magento 1 page-type alignment + +## Decision and scope + +Magento 1 is the naming baseline specifically for `p_type`. Magento 2 emits +the same 27 named values, including capitalization and spaces, for equivalent +native pages. WordPress remains the behavioral reference for the other parity +work; neither reference plugin is changed. `p_gen=mage2` remains unchanged. + +The reviewed source is Magento 1's `Helper/PageTypeDetector.php` in its +Analytics community module, at repository +commit `f90de1e78b024b98c533f38fae467beac5f5b2c0` (the detector's last change was +`f8c4e2d5aa71ea87b5d34a2bb1dd9ed47634b99c`). Only its labels are adopted; the +Magento 2 implementation uses the final native dispatched action and HTTP +response, not Magento 1 layout handles or copied platform code. + +This is the explicitly approved Magento 2-to-Magento 1 portion of D-001, +not a shared WordPress/Magento taxonomy or a historical reporting migration. +The shared parity ledger remains unchanged for the central task to reconcile. + +## Exact mapping + +Action names below are normalized to lowercase before lookup. Unless noted, +the corresponding Magento 1 action name is identical. + +| Emitted `p_type` | Magento 2 full action | Platform adaptation | +| --- | --- | --- | +| `404 Not Found` | `cms_noroute_index`, `cms_index_defaultnoroute` | Magento 1's primary route is `cms_index_noroute`; any HTTP 404 also wins over other mappings. | +| `Home` | `cms_index_index` | | +| `CMS Page` | `cms_page_view` | | +| `Category` | `catalog_category_view` | | +| `Product` | `catalog_product_view` | | +| `Search` | `catalogsearch_result_index` | | +| `Advanced Search` | `catalogsearch_advanced_index` | | +| `Cart` | `checkout_cart_index` | | +| `Checkout` | `checkout_index_index` | Magento 1 uses `checkout_onepage_index`. | +| `Checkout Success` | `checkout_onepage_success` | Requires a valid completed-checkout session to render. | +| `Login` | `customer_account_login` | | +| `Register` | `customer_account_create` | | +| `Account` | `customer_account_index` | | +| `Logout Success` | `customer_account_logoutsuccess` | | +| `Contact` | `contact_index_index` | Magento 1 uses `contacts_index_index`. | +| `Orders and Returns` | `sales_guest_form` | | +| `Customer Account Edit` | `customer_account_edit` | | +| `Order View` | `sales_order_view` | | +| `Billing Agreements` | `paypal_billing_agreement_index` | Magento 1 uses `sales_billing_agreement_index`. | +| `Billing Agreement View` | `paypal_billing_agreement_view` | Magento 1 uses `sales_billing_agreement_view`. | +| `Guest Order View` | `sales_guest_view` | | +| `Customer Address Edit` | `customer_address_form` | Native address `edit` and `new` actions forward to `form`, including adding the first address. | +| `Customer Address List` | `customer_address_index` | An empty address book redirects to `customer_address_new`, which forwards to the address form. | +| `Wishlist Item Configure` | `wishlist_index_configure` | | +| `Wishlist Items List` | `wishlist_index_index` | | +| `Order History` | `sales_order_history` | | +| `Forgot Password` | `customer_account_forgotpassword` | | + +The non-identical routes and address forwards were checked against installed +Magento Open Source 2.4.9 controller implementations. PayPal's billing agreement +controllers live in `Magento_Paypal`, not `Magento_Sales`. Native layout results +apply their HTTP headers before rendering the footer. The CMS no-route actions +are also mapped explicitly for route parity and defensive recognition if a +custom response does not retain the usual 404 status; this is not a workaround +for native header timing. + +Detection describes the rendered page, not the URL the visitor first requested. +For example, a logged-out account request renders `Login`; an empty-cart +checkout or a success URL without an order session redirects to `Cart`. + +## Deliberate fallback + +- An HTTP 404 response has priority and emits `404 Not Found`. +- An unrecognized action emits `unmapped_`. + For example, `search_term_popular` emits `unmapped_search_term_popular`. +- An unavailable action (including Magento's uninitialized `__`) emits + `unknown`. +- Generic layout handles, URL slugs, product/category identifiers, and request + parameters are not used to guess a label. Magento 1-only route aliases are + not registered as Magento 2 equivalents. +- Magento 1 has no named mapping for `catalogsearch_advanced_result`; that + action deliberately stays `unmapped_catalogsearch_advanced_result`. +- The public helper methods remain available: `isHomePage`, `isProductPage`, + and `isCheckoutPage`. The last means the checkout page itself, not success. + +## Reporting and upgrade impact + +This intentionally changes Magento 2's beacon labels, with no legacy-label +mode or stored-data migration: + +| Previous Magento 2 label | New label | +| --- | --- | +| `home` | `Home` | +| `cms_page` | `CMS Page` | +| `product` | `Product` | +| `category` | `Category` | +| `checkout` | `Checkout` | +| `cart` | `Cart` | +| `customer_login` | `Login` | +| `customer_register` | `Register` | +| `customer_account` | `Account` | +| `order_history` | `Order History` | +| `contact` | `Contact` | +| `search_results` | `Search` | +| `404_not_found` | `404 Not Found` | + +Newly recognized actions also stop emitting their previous `unmapped_*` +values. Historical beacon records are not rewritten. If reports already exist, +update filters/groupings or explicitly combine old/new Magento 2 labels on the +reporting side. Magento 1 reporting vocabulary is unchanged. Unknown action +diagnostics now normalize casing and distinguish unavailable actions from +unrecognized ones. + +After updating the module, regenerate compiled dependency injection with +`bin/magento setup:di:compile` if the installation uses compiled DI, including +developer installations where it was previously generated. This also applies +the CSP registration correction below. Manual source updates must not retain +the obsolete collector declaration from `etc/frontend/di.xml`; that file is +no longer distributed. Clean Magento's configuration, layout, block HTML, +and full-page caches, plus any external cache retaining HTML. The label is embedded in the +rendered page; cached HTML can otherwise retain old labels. No loader or +Boomerang asset change is required for this mapping. + +## Verification + +`tests/php/run.php` checks every mapped action and exact label, mixed-case +actions, view-model passthrough, 404 precedence, fallback, and all three helper +methods. Existing CI runs these checks on PHP 8.2, 8.3, and 8.4. The real +Boomerang fixture and native-storefront expectations use the new labels. + +The native browser matrix in `tests/integration/page-types.spec.js` checks +actual rendered public pages and intercepted Boomerang beacons, including +redirect destinations, the no-route page, and an unmapped native route. +Sample-data cases cover CMS, category, and product pages. The separately +opted-in offline checkout test can create a disposable order to verify both +`Checkout` and `Checkout Success`. See `tests/integration/README.md` for setup, +side effects, and execution flags. These tests never substitute `p_type` in +the browser. + +### Checkout CSP correction discovered during verification + +The native checkout check uncovered an existing R-007 issue: registering the +collector array in `etc/frontend/di.xml` replaced Magento's global collectors +at the area DI stage. Checkout's enforced policy consequently omitted core +sources such as `'self'`, blocking its own requests. Registration now lives +in `etc/di.xml`, where Magento merges it with its original collectors. An +explicit frontend area check keeps Basicrum's contribution out of Admin, +API, cron, and unset-area contexts. Only the validated collector origin is +added; core policies are not copied or loosened. The browser suite now checks +the actual merged CSP header, and PHP tests cover the area guard. + +### Local execution record — 2026-09-22 + +This records the original alignment run. Subsequent review fixes and newer +test results are in `OPUS-REVIEW-FOLLOWUPS.md`; the order-creating journey was +not repeated for those follow-ups. + +- PHP 8.2, 8.3, and 8.4: all 16 focused groups and all module/test PHP/PHTML + syntax checks passed. The focused harness also passed on the local store's + PHP 8.5.6. +- `npm test`: minified-artifact verification and all 28 loader/real-Boomerang + browser tests passed. +- Native Magento Open Source 2.4.9 from the Mage-OS mirror, on PHP 8.5.6 with + Luma sample data: all 21 Chromium integration tests passed. This includes + 18 page-route cases, the consent/cache/CSP storefront check, Admin rendering, + and the offline checkout journey. All collector requests were intercepted + locally; saved endpoint and Site ID settings were left unchanged. +- The offline checkout produced synthetic pending order `000000003` in the + disposable local store. No live payment was used. Failed setup attempts also + left disposable quotes; no user data was deleted. +- All 28 mapped actions resolved to real native controllers through Magento's + route configuration and action list. This verifies controller existence, not + every authenticated journey or its fixtures. +- Native `setup:di:compile` and configuration/layout/block/full-page cache + cleaning passed. The final browser run used the regenerated DI; an interim + run failed on stale one-argument constructor metadata before recompilation. +- `git diff --check` and integration shell syntax checks passed. The initial + offline checkout failure reproduced the CSP issue documented above; the + completed journey passed after the registration fix and recompilation. + +Not run: authenticated customer/address/order/wishlist and PayPal agreement +browser journeys (no corresponding fixture suite was added); the declared +2.4.7-p10/PHP 8.3 native release gate (the available store is 2.4.9/PHP 8.5.6); +remote CI; release/package/publishing checks. Local 8.5 success does not widen +Composer's declared `>=8.2 <8.5` constraint or certify proprietary Adobe +Commerce. No reference plugin or shared ledger was edited. diff --git a/docs/PHASE-1-PARITY-COMPLETION.md b/docs/PHASE-1-PARITY-COMPLETION.md new file mode 100644 index 0000000..4f52aa5 --- /dev/null +++ b/docs/PHASE-1-PARITY-COMPLETION.md @@ -0,0 +1,122 @@ +# 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, added the validated effective Beacon Endpoint origin to + storefront `connect-src` and `img-src`, 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. The native pre-release gate additionally requires Magento upgrade, + DI compilation, static deployment, and authenticated Admin rendering. +- **R-009 (Phase 1 foundation):** added CI for strict Composer 2.10, PHP, and + browser checks plus Boomerang provenance/checksum verification. The guarded + native Magento check is required separately before the new `0.1.0` tag. + 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. The obsolete consent-mode selector and runtime metadata have been +removed; any old mode rows remain untouched in the database but are ignored and +never grant consent. Existing sites require the new Site ID before +monitoring resumes. Endpoint queries remain compatible, while embedded URL +credentials and fragments now fail validation. The unstored hardcoded +five-second wait becomes explicit off/zero settings; administrators can opt +back into 5000 ms. + +Before the first public release, the technical module identifier and PHP +namespace were intentionally normalized to the “Basicrum” spelling. This is a +breaking identifier change, accepted because there are no extension +installations to migrate. Lowercase `basicrum/*` configuration paths remain +unchanged. The existing `0.0.2` tag is not reused: the guarded Phase 1 release +candidate is `0.1.0`, and Composer derives the package version from that future +immutable VCS tag. + +## Selective follow-up from upstream PR #13 + +Five compatible ideas were adapted without replacing the WordPress-derived +Phase 1 behavior: a dynamic storefront CSP collector; explicit Config, +Backend, CSP, and Store module dependencies; the concrete Magento HTTP +response type for page detection; template/CSS-based Admin logo rendering and +centralized Boomerang version display; and an unreleased-first changelog. +Consent removal, a token setting, asset renaming, page-vocabulary/interface +changes, narrower runtime constraints, and license assertions from that pull +request were not borrowed. + +## Deferred boundaries + +The later, explicitly approved Magento 2-to-Magento 1 `p_type` alignment is +recorded in `PAGE-TYPE-ALIGNMENT.md` for central-task incorporation under +D-001, with additional real-beacon coverage under R-008. It implements all 27 +Magento 1 labels using Magento 2 actions, +preserves `p_gen=mage2`, and defines unmapped/unknown handling. This does not +implement a WordPress-led shared taxonomy or migrate historical report data. +Magento 1, WordPress, and the shared ledger are unchanged. + +Native checkout verification also found and corrected an R-007 CSP +registration defect: the frontend DI array replaced Magento's core collectors. +Global DI registration plus a frontend-only runtime guard now preserves them; +the browser harness checks the real merged policy header. See the alignment +notes for current execution results and limitations, separate from the original +Phase 1 verification snapshot below. + +The approved Opus review follow-ups are recorded in `OPUS-REVIEW-FOLLOWUPS.md`. +They strengthen R-007/R-008 CSP and rendered-wait coverage, R-009 production +autoload/CI checks, R-002 scoped Admin presentation, and R-010 documentation. +That report contains the newer local execution results and explicitly separates +the supplemental 2.4.9 store from the still-required pinned release baseline. + +The subsequent reliability and simplification work is recorded in +`RELIABILITY-AND-MAINTAINABILITY.md`: native configuration-save checks, +fail-closed browser traffic, enforced release-baseline versions, and removal +of obsolete consent-mode and duplicate configuration/test machinery. + +D-002 staff exclusion, D-003 placement/readiness queues, and D-004 cross-plugin +wording remain deferred. Automatic provider adapters, +a broad optimizer matrix, package/release publishing, and module-level license +text approval are not completed by Phase 1. The full-page-cache finding +CR-D-002 recorded in `DEFERRED-CODE-REVIEW-FINDINGS.md` remains explicitly +deferred; CR-D-001's dynamic storefront CSP policy is implemented. + +## Verification recorded at completion + +- Focused PHP harness on PHP 8.2, 8.3, and 8.4: 15 groups passed on each version. +- PHP syntax checks: all module and test PHP/PHTML files passed on PHP 8.2, 8.3, and 8.4. +- 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 strict validation (`--strict --no-check-publish`): valid; + release versions are derived from VCS tags rather than an explicit package + `version` field. + +No disposable Magento 2 installation was available in the workspace, so the +native storefront/Admin release gate was added but not run. Its setup upgrade, +DI compilation, static deployment, cached storefront, intercepted beacon, and +authenticated Admin assertions therefore remain unverified here. Remote GitHub +Actions were also not run from this local implementation. diff --git a/docs/QUALITY-AND-RELEASE-READINESS.md b/docs/QUALITY-AND-RELEASE-READINESS.md new file mode 100644 index 0000000..ad9b6ed --- /dev/null +++ b/docs/QUALITY-AND-RELEASE-READINESS.md @@ -0,0 +1,246 @@ +# Quality and release-readiness follow-up — 2026-09-23 + +Implements approved items **1, 4 and 5**. No WordPress/Magento 1 files or shared +parity ledger entries were changed. No monitoring feature, consent policy, +configuration path, page label or Boomerang artifact was changed. + +## Ideas adapted, not copied + +- [JustBetter Sentry](https://github.com/justbetter/magento2-sentry/tree/31614cb1fc685279e16b03d0491408caa88025f6): + Magento-aware level-8 PHPStan and lowest/stable dependency resolution. Basicrum + uses real Magento component types, never its lightweight test doubles. There + is no PHPStan baseline or blanket error suppression. Runtime configuration + has an explicit shared PHPDoc array shape rather than a new DTO/service layer. +- [GENE New Relic RUM](https://github.com/genecommerce/module-newrelic-rum-page-type/tree/8c4903066a30b7fbb375fc8015044c9962dd3135): + repeatable native Magento integration alongside standards/static analysis. + Basicrum retains its stronger native scoped-save, consent, actual beacon, + enforcing/Admin CSP, cache-HIT and rendered-Admin assertions. +- [Chessio Matomo](https://github.com/fnogatz/magento2-matomo/tree/3aaef162f129473c988d43154cf45567dd23609e): + keep visitor-specific state out of cacheable HTML. A native two-context test + checks a fresh cache entry populated by a request carrying measurement cookies, + then another visitor's first HIT, which must remain silent until its own grant. + +No competitor source code/assets were imported or relicensed. Boomerang's +existing artifact and BSD notices remain unchanged. + +## Implementation and parity-review mapping + +- **R-007/R-008/R-009 — evidence tied to deployed code:** an ephemeral nonce + challenge binds the browser URL to the verified installation and checks FPM + PHP. Actual script response bodies must hash to the candidate loader and + Boomerang assets. Negative tests reject another root/PHP line, redirects, + stale bytes, unexpected scripts and failed asset responses. The challenge is + test-only and is removed even on failure. +- **R-009 — maintainability:** Magento-aware PHPStan level 8 and Magento coding + standard 41, isolated locked tooling, PHP 8.2/8.3/8.4 lowest/stable CI plus a + locked PHP 8.3 job. Static analysis exposed a concrete-model `getWebsite()` + dependency; inheritance now uses the public `getWebsiteId()`/store-manager + contract. Production shape/type annotations are precise. Standards exceptions + are narrow and explained: pure Config validators, native URL parsing and two + indivisible translation keys. No blanket PHPStan suppression was added. +- **R-009 — package/native gate:** matching Composer/Git export boundaries keep + development files out. Every ZIP entry and installed production file is + compared with committed-blob SHA-256 hashes before and after the gate. The ZIP is + installed through Magento's documented manual `app/code` path, followed by + upgrade, DI compilation, static deployment, native saves and browser checks. + The disposable stack disables Braintree rather than allowing external scripts + through the browser guard. CI uses the same harness. + +## Initial iteration verification record + +- Focused PHP: 18 groups pass on each of PHP 8.2, 8.3 and 8.4. +- Fast Chromium/real-Boomerang and helper tests: 45 pass; minified artifacts match. +- PHPStan level 8: passes on PHP 8.3 with both locked stable and lowest resolved + real Magento components. Magento coding-standard scan: zero code violations. + PHPCS itself reports upstream CSS/GraphQL sniff deprecations; these do not + represent ignored module findings. +- Pinned native installation: version checks, upgrade, DI compilation, English + static deployment, 10 native save groups, and 19 native browser tests pass. + Four tests are intentionally skipped: three sample-data routes and the opt-in + order-creating checkout. No order was created and no collector was contacted. +- Production ZIP built, manifest verified and installed in that native stack. + Both Composer and Git archives pass the same production-file manifest check. +- The complete strict native gate passed against temporary clean test-snapshot + commit `00908c24fe43e81cf2bf7cf961c1b0b1fa7e1ff3`, including final installed/ZIP + rechecks. This commit exists only in an ignored local fixture repository, not + on the working branch and not as a release tag. Tested ZIP SHA-256: + `336ef485b29eaf3594f9caca50442eaa462cd91b1befe8b80f18da20dc46678d`. + The log is retained locally at `.test-results/native-gate-final.log`. +- Strict Composer validation and optimized production autoload generation pass; + the classmap contains 13 production classes and no test doubles. Generated + verification directories are also excluded from production autoload discovery. +- Remote workflows were added but have **not** been run for this uncommitted work. + The seven-cell remote component matrix is not being reported as locally run. + +## Opus review follow-ups — 2026-09-23 + +All seven accepted suggestions are addressed without changing production runtime +behavior or the reference plugins/shared ledger: + +1. **R-009, committed packaging:** build the ZIP with `git archive` from the clean + candidate commit. Compute expected SHA-256 hashes from committed blobs, not + local disk contents. Regression fixtures include both tracked-ignore and local + Git excludes, dirty files, and four deliberately damaged ZIPs. +2. **R-007/R-008, cache isolation:** after an authoritative grant and a real + measurement cookie, a second tab of the first visitor populates a unique URL + and must receive MISS. Another visitor's first request to that URL must be HIT + and remain silent until its own grant. No server consent cookie or persisted + Basicrum decision is introduced. +3. **R-009, accurate dependency claims:** deliberately take the narrower wording + option. Images and Magento versions are pinned, but no application dependency + lock or bit-for-bit reproducibility is claimed. +4. **R-009, installed artifact boundary:** reject all extra installed files, + including development directories and hidden files. Checkout-based development + testing stays separate from strict distribution certification. +5. **R-009, restart:** bake only `/module` Git trust into the image's system config; + document retained-volume `up -d` and rebuilding the image when needed. +6. **R-009, ownership:** run bind-mounted `npm ci` with the host caller's UID/GID, + using a per-UID disposable cache rather than creating root-owned dependencies. +7. **R-009, tool compatibility:** identify locked/locally tested component versions + below, document the locked PHP requirement, and print resolved versions in CI. + +Follow-up checks actually run: + +- PHP 8.2/8.3/8.4: 18 groups pass on each, including real Git commit/archive fixtures. + CI now installs Git explicitly for these tests. +- Fast Chromium/helper suite: 45 pass, without retries; loader minification matches. +- Native storefront subset: 9 pass (three consecutive runs of three tests). + The initial same-tab post-consent navigation produced a proxy-blocked beacon + attempt during document unload. The final same-cookie-jar/new-tab scenario + avoids that interception race without relaxing the guard or mocking Boomerang. +- Four ZIP corruption regressions pass and are included in the locked quality CI job. +- PHPStan level 8 and Magento coding standards pass with the locked PHP 8.3 tools; + PHPCS emits its existing upstream sniff-deprecation notices. +- Full native gate passes: baseline checks, upgrade, DI compilation, static + deployment, 10 scoped-save groups, 19 browser checks and 4 explicit skips + (three sample-data routes and the order-creating checkout). No collector was + contacted and no order was created. +- Gate candidate: isolated fixture commit + `48ed0392bb77ff544a53f84b65274c26019921e1`, not a working-branch commit/tag. + ZIP SHA-256: `e90cbc0d991ad9673c711813f6166bc84e9b18a0794916deacc4cf90590de749`. + Evidence: `.test-results/opus-followups-native-gate.log`. +- Image rebuild and host-UID npm installation pass. Dependency files remain + host-owned (501:20 on this Mac); a Linux-host bind-mount run is not claimed. +- Actual `down`/`up -d` recreation passes with retained application/database volumes: + Git trust comes only from `/etc/gitconfig`, baseline verification passes, and + all three storefront checks pass again. Current production files hash-match + the tested snapshot. The dedicated stack is stopped after verification. +- PHP/PHTML lint, shell syntax, Compose validation, integration discovery and + `git diff --check` pass. + +The complete remote CI matrix and a new empty-volume provisioning run were not +executed in this follow-up. The native run reused the dedicated disposable store. +No source commit, push, release, deployment or license approval was made. + +## Fresh-provisioning CI correction — 2026-09-23 + +The first [native CI run](https://github.com/basicrum/basicrum-magento-2/actions/runs/35846942665/job/107135254576) +installed Magento successfully, then failed before installing Basicrum: the +provisioning script wrote `admin/usage/enabled` after disabling the module that +declares that field. Remove the redundant write, keeping Admin Analytics disabled +and preserving fail-fast handling for real configuration errors. + +The new fast regression executes the actual provisioning script with CLI doubles; +it checks both successful completion and failure propagation. All 46 fast checks +and 18 focused PHP 8.3 groups pass. The actual `start.sh` bootstrap also completes +on brand-new application/database volumes in the separate Compose project +`basicrum-native-ci-fix-20260923`, including Nginx startup. Existing installation +volumes are untouched. The local bootstrap log is retained at +`.test-results/fresh-provision-ci-fix.log`; this is fresh-provisioning evidence, +not a claim that a remote rerun has passed. + +## Enforcing CSP script execution — 2026-09-23 + +Adds the missing native execution coverage alongside the earlier checkout-302 +header assertion. A separate `Basicrum_CspTest` fixture module supplies a native, +non-cacheable page with route-specific enforcing CSP and inline scripts disabled. +The release gate installs this fixture before upgrade/DI compilation. It is +development-only, excluded from production archives and optimized classmaps. +Production PHP, templates, loaders, configuration and CSP code are unchanged. + +The browser requires a real enforcing header, a matching production-bootstrap +nonce, fresh nonces on two server renders, same-origin loader/Boomerang requests +with verified bytes, consent silence, single loading, intercepted beacon identity +and withdrawal cookie cleanup. No CSP violations or page errors are accepted +in the positive case. A separate unnonced inline marker must be blocked with an +enforcing violation; this detects accidentally bypassed browser CSP. No response +headers, production assets or nonces are rewritten by the harness. + +Opus 5.5 was consulted through the local Claude CLI, with the returned model +identity confirmed as `claude-opus-5-5`. Its patch review found no blocking +issues. Follow-ups add fail-closed detection of stale fixture files without +deleting them, canonicalize the release gate's installation root before changing +directories, and accept both script CSP directive names in the negative control. +A fast installer regression covers guarded installation, relative roots, refresh +and stale-file rejection. A separate design review suggested enforcing CSP on +the native cart instead; the isolated fixture keeps normal storefront/checkout +policies unchanged and makes the test-only scope explicit. + +Checks actually run locally: + +- Fast Chromium/helper suite: 48 pass; packaged minification matches. +- Focused PHP 8.3: 18 groups pass. Native configuration saves: 10 groups pass. +- Native Magento 2.4.7-p10/PHP 8.3: upgrade, DI compilation and static deployment + pass; full browser suite: 21 pass, 4 existing explicit skips (sample-data routes + and order-creating checkout). Both new CSP tests run, without retries. +- Strict optimized production classmap: 13 classes, no test fixture classes. +- Fixture installer rejects a missing disposable guard, Magento root or stale + extra files, without deleting extras or enabling a rejected fixture. +- Full archive/install/native release gate passes against isolated local test + snapshot `1699b5b44ee0ded40d036a829f1643d4bae1a790`, not a working-branch commit + or release tag. ZIP: 34 production files, SHA-256 + `f1d157dc62c5f45ee60c1226581ae0ae35c93f8820bf8cb6d736a04d5dbc9eb8`. + Log: `.test-results/csp-native-gate.log`. Four archive-corruption checks pass. + This rerun includes the Opus follow-ups and exercises a relative `MAGENTO_ROOT`. +- PHP/JavaScript and shell syntax checks and `git diff --check` pass. + +The enforcing fixture deliberately requires `no-store`, like checkout. Existing +report-only homepage FPC/HIT and two-visitor checks remain unchanged. This run +does not certify nonce handling on cacheable enforcing pages, Varnish/CDN caches, +custom strict-dynamic policies, or a complete checkout/payment journey. Remote +CI has not run for these uncommitted changes. This run reused the dedicated +disposable installation; it did not reprovision fresh volumes. No release or +push was performed. + +## Tested compatibility, not inferred compatibility + +| Combination | Evidence in this follow-up | +| --- | --- | +| Magento Open Source 2.4.7-p10, PHP CLI/FPM 8.3.31, Composer 2.10, MariaDB 10.11, OpenSearch 2.19.4 | Actual isolated installation, native configuration and browser checks | +| Luma, en_US, built-in FPC, deployed static files | Actual Admin/storefront, enforcing checkout CSP, consent/beacon/cookie/redaction and two-visitor HIT checks | +| PHP 8.2 / 8.4 | Focused PHP checks only; not native Magento runs | +| Locked tools on PHP 8.3: framework 103.0.9 / backend 102.0.9 / config 101.2.9 / CSP 100.4.8 / store 101.1.9 | Static analysis against 2.4.9-line components; lock requires PHP 8.3/8.4 within the tooling project's constraint | +| Locally resolved lowest tools on PHP 8.3: framework 103.0.7 / backend 102.0.7 / config 101.2.7 / CSP 100.4.6 / store 101.1.7 | Static analysis against 2.4.7 GA components, not the native 2.4.7-p10 dependency set | +| CI lowest/stable resolutions on PHP 8.2/8.3/8.4 | Versions resolve afresh and are printed in CI; the full remote matrix has not been run locally | +| Adobe Commerce, other Magento patch lines, Hyvä, headless/PWA, Varnish, CDN/optimizer combinations | Not verified | +| Sample products/categories/CMS, authenticated customer journeys, successful checkout | Not exercised in this baseline fixture | + +## Remaining release requirements and deliberate boundaries + +The native Magento project version and Docker image digests are pinned, not the +full application dependency closure. Provisioning still resolves transitive +Composer dependencies; upstream updates/advisories can change or block a fresh +install. No bit-for-bit reproducibility or committed application lock is claimed. + +The existing Composer MIT declaration is unchanged. A rights-holder-approved +root LICENSE still needs the owner's copyright holder/year confirmation; no +approval or legal ownership has been invented. This remains a release blocker, +not a technical-test failure. + +The execution evidence above was collected before committing or pushing the +pipeline implementation. Final release certification must be rerun against the exact clean commit to be tagged; +a working-tree or isolated test-snapshot pass cannot authorize a later commit. +Pushing CI definitions does not itself certify, publish, tag or deploy a release. + +The browser guard is request isolation, not a server-side egress firewall. +The test stack has synthetic credentials, local-only HTTPS, SMTP disabled and +no cron worker; do not expose it publicly or reuse its credentials. Existing +local Magento installations are unchanged. +The test stack was stopped after verification; its application/database volumes +and local evidence were retained. No temporary challenge PHP files remain. + +Quantitative LCP/CLS/INP correctness and browser-lifecycle/Firefox/WebKit expansion +remain outside approved items 1/4/5. CR-D-002 automatic cache invalidation remains +deferred; operational cache cleaning is still required after configuration +changes. A successful native technical gate is not a claim of universal theme, +platform, performance-metric, legal or release readiness. diff --git a/docs/RELIABILITY-AND-MAINTAINABILITY.md b/docs/RELIABILITY-AND-MAINTAINABILITY.md new file mode 100644 index 0000000..2265de6 --- /dev/null +++ b/docs/RELIABILITY-AND-MAINTAINABILITY.md @@ -0,0 +1,87 @@ +# Reliability and maintainability follow-ups + +Implemented locally on 2026-09-22. These notes supplement the original Phase 1 +snapshot; they do not claim release certification. + +## Reliability findings + +1. **Fail-closed browser interception (R-008).** Every native browser suite uses + a shared context-level guard, including Admin and popups. Expected beacons + are fulfilled locally; unexpected destinations and misplaced measurement + payloads fail the test. Service workers and WebSockets are blocked. A small + Node built-in proxy restricts transport to the disposable storefront/Admin, + including redirect chains that bypass Playwright's normal routing. Five + loopback regression tests cover interception, popups, redirects, WebSockets, + and denied HTTPS tunnels. No production dependency was added. +2. **Native configuration-save coverage (R-001/R-002/R-005/R-008).** Ten checks + use Magento's actual Admin save model and scoped configuration, not doubles: + defaults, backend rejection/normalization, same-form HTTP decisions, bounded + wait, website/store overrides and re-inheritance, consent, missing identity, + and invalid endpoint/identity imports. Each case rolls back its database + writes; the original Basicrum rows are verified afterward. Config caching is + disabled only in the test process. Third-party observer/cache side effects + are not transactionally reversible, so a disposable installation is required. +3. **Enforced release baseline (R-009).** The release script checks the installed + Magento edition/patch, PHP, Composer, MariaDB, and OpenSearch before upgrade + or configuration writes. `baseline.env` remains the declared baseline, with + no override flag. Fast tests check each version mismatch and execute the + real shell gate with a failing baseline command to verify mutation ordering. + +## Worth simplifying + +1. **Remove obsolete consent modes (R-001/R-006/R-010).** Removed the selector, + source model, legacy options/notices, and unused runtime metadata. The + consent-required switch and public manual callbacks are unchanged. Existing + `basicrum/consent/mode` rows remain untouched but are no longer read. No old + string can grant consent, and no compatibility shim or data migration was added. +2. **One configuration decision (R-002/R-005).** Admin status and storefront + eligibility now share `Config::getStatus()`. Endpoint HTTPS normalization is + shared between save and runtime. Removed unused parallel defaults; Magento + XML owns install defaults, and the existing constant owns Boomerang version. + Normalization still supplies fail-closed behavior for invalid runtime input. +3. **Test behavior, not incidental source spelling (R-008/R-009).** Removed + repository-wide branding scans and assertions about exact shell snippets, + plus unused test doubles. Kept targeted package metadata, provenance, + behavioral shell-guard, native-rendering, and public-contract assertions. + +## Verification actually run + +- PHP 8.2, 8.3, and 8.4: 17 fast test groups passed on each; PHP/PHTML lint passed. +- `CI=1 npm test`: 41 Chromium tests passed without retries, including the real + packaged readable/minified loaders, bundled Boomerang, rendered wait plugin, + and network-guard regressions. Minification checks passed. +- Composer 2.10 strict validation and strict optimized production autoload: + passed in an isolated container copy; 13 production classes, no test doubles. +- Local Magento Open Source 2.4.9 / PHP 8.5.6: DI compilation passed; all 10 + native save checks passed and the original Basicrum rows were restored. + The existing legacy mode row was retained and the order count remained 3. + This newer installation is supplemental, outside the declared PHP baseline. +- Final native browser run on that installation: **17 passed, 4 failed, + 1 skipped**. All four failures were the network guard blocking external + Braintree `client.min.js` / `paypal-checkout.min.js` requests. Admin rendering + (including the removed selector) and enforcing-checkout CSP passed. The full + native browser suite is therefore **not passing**. Earlier runs also exposed + those assets on other pages; timing changes how many tests observe them. +- The actual baseline checker rejected the local 2.4.9 installation before any + release mutation, as required. Shell syntax and `git diff --check` passed. + +## Remaining verification and boundaries + +The local disposable store still loads external Braintree scripts. Disable +those integrations in the test installation and rerun the native browser +suite; no external-host exceptions were added and unrelated payment settings +were not changed by this work. No live collector request was forwarded. + +The full release gate on Magento 2.4.7-p10 / PHP 8.3 has not run: that installation +is not available locally. Positive native baseline/service-version checks, +static deployment for this revision, and remote GitHub Actions are not reported +as passing. Existing CI automatically picks up the new fast tests; native checks +remain a separately required release gate. The opt-in order-creating checkout +journey was not rerun, and no order was created. + +Automatic full-page-cache invalidation remains explicitly deferred as +CR-D-002. No loader lifecycle, page-type vocabulary, callback, active setting +path, namespace, license, or reference-plugin behavior was changed. WordPress, +Magento 1, and the shared parity ledger were left unchanged. These notes are +for the central parity task to incorporate. No release or deployment to a remote +environment was performed for these follow-ups. diff --git a/docs/REVIEW-VERIFICATION-FOLLOWUPS.md b/docs/REVIEW-VERIFICATION-FOLLOWUPS.md new file mode 100644 index 0000000..fbe02a2 --- /dev/null +++ b/docs/REVIEW-VERIFICATION-FOLLOWUPS.md @@ -0,0 +1,69 @@ +# Joint-review verification follow-ups + +Later execution evidence and the resolved package/native-stack gaps are recorded +in [QUALITY-AND-RELEASE-READINESS.md](QUALITY-AND-RELEASE-READINESS.md). The results +below remain the historical 2026-09-22 record, not the current verification state. + +Implemented locally on 2026-09-22 after the Codex, Opus 5.5 Max, and focused +Grok review. These notes are for the central parity task; the shared ledger +and reference plugins were not changed. + +## Changes + +- **R-009: candidate identity.** The release gate requires a clean committed + module checkout, resolves Magento's registered module path, and hashes both + trees' package files. Stale, missing, extra, or internally symlinked source + fails before Magento writes. The gate repeats the identity checks afterward + and records the successful candidate SHA/tag in its output. Development/output + exclusions and copied-install requirements are documented in the integration + README. This is source verification, not an installable-artifact certification. +- **R-007/R-008: actual cached-page evidence.** The storefront test warms the + anonymous cookie/vary context, requires a native FPC `HIT`, verifies fresh + consent and cookie/beacon silence on that response, then checks the cached + page's beacon identity and redaction. Missing/MISS/UNCACHEABLE responses fail. +- **R-008/R-009: reproducible native runner and CI discovery.** The setup harness + requires the installed Playwright executable before configuration writes. + CI loads/discovers the integration tests without claiming native execution. +- **R-005/R-009: environment-specific HTTP exception.** Magento's `TypePool` + marks only `basicrum/developer/development_mode` as environment-specific for + configuration export. Defaults, scopes, stored settings, and runtime policy + are unchanged. Previously exported files are not silently rewritten. + +## Verification actually run + +- PHP 8.2, 8.3, and 8.4: **18 groups passed on each**, including mismatched + package files, missing local runner, and release-guard mutation ordering. +- `CI=1 npm test`: **43 checks passed without retries**, including the actual + readable/minified loaders, real Boomerang, negative cache-evidence assertions, + and the clean-candidate guard exercised against a temporary real Git repository. + Minification checks passed. +- Integration `--list`: **22 tests discovered**. This checks loading only. +- PHP/PHTML lint on PHP 8.3, shell syntax, and `git diff --check`: passed. +- Magento 2.4.9 native DI XSD validation passed. Its actual `TypePool` accepted + the environment classification read from the candidate XML. This was an + isolated native-class check, not an `app:config:dump` or installed merged-DI run. +- The actual candidate checker resolved the local installation's registered + module and **correctly rejected its different package copy**. Differences + included top-level metadata and `etc/di.xml`; PHP and browser assets matched. + The clean-checkout guard also correctly rejected this uncommitted worktree. +- Targeted native storefront run: **1 passed, 1 failed**. Enforcing empty-cart + checkout CSP passed. The homepage test reached and passed the real FPC HIT, + fresh-consent silence, cookie cleanup, and redacted identity assertions, but + failed at teardown because the network guard blocked existing Braintree + scripts. The test remains red; no external-host exception was added. + +## Not run and remaining boundaries + +The full native gate on Magento 2.4.7-p10 / PHP 8.3 was not run: the pinned +installation is unavailable, and the candidate was uncommitted during verification. The +current local Magento 2.4.9 / PHP 8.5.6 store is supplemental only and was not +resynced. The new native save-suite assertion for the installed `TypePool`, +configuration export, DI compilation/static deployment for this revision, +full native browser suite, Composer checks, and remote GitHub Actions were not +rerun. The local native browser guard still requires removal of external +payment scripts in the disposable installation before it can pass. + +Automatic cache invalidation (CR-D-002), cross-store/Varnish compatibility, and +installable release-artifact verification remain outside this follow-up. No +store settings were changed, no order was created, and no live beacon was +forwarded. No release or remote deployment was performed. diff --git a/etc/acl.xml b/etc/acl.xml index ee48a8c..a520e94 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..ccdf185 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -3,45 +3,116 @@ xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd"> - + -
- +
+ basicrum - BasicRum_Analytics::basicrum_analytics - + Basicrum_Analytics::basicrum_analytics + - - - - - + + + 1 + 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 + + + Basicrum\Analytics\Block\Adminhtml\System\Config\ReadOnlyField + 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 + + + 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..4872f13 --- /dev/null +++ b/etc/config.xml @@ -0,0 +1,26 @@ + + + + + + 0 + + + + + 1 + + + 0 + + + 0 + 0 + + + 0 + + + + diff --git a/etc/di.xml b/etc/di.xml index 354eb16..f8e238a 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -1,5 +1,19 @@ - - + + + + + 1 + + + + + + + + Basicrum\Analytics\Model\Csp\BeaconPolicyCollector + + + diff --git a/etc/module.xml b/etc/module.xml index e88396f..adeed68 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -1,5 +1,12 @@ - + + + + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3ecfaf3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,95 @@ +{ + "name": "basicrum-magento-2-tests", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "basicrum-magento-2-tests", + "version": "0.1.0", + "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..1855baf --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "basicrum-magento-2-tests", + "version": "0.1.0", + "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/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..fec551f --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,19 @@ + + + Magento coding standards for first-party production PHP and templates. + + + + */Model/Config.php + + Api + Block + Model + ViewModel + view/frontend/templates + view/adminhtml/templates + registration.php + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..b2e4fda --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,11 @@ +includes: + - tests/quality/vendor/bitexpert/phpstan-magento/extension.neon + +parameters: + level: 8 + paths: + - Api + - Block + - Model + - ViewModel + tmpDir: .test-results/phpstan diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..2ffd4fe --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,19 @@ +const { defineConfig } = require("@playwright/test"); + +module.exports = defineConfig({ + testDir: "./tests/js", + testMatch: "**/*.spec.js", + globalSetup: require.resolve("./tests/js/render-footer-fixtures.js"), + timeout: 15000, + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + failOnFlakyTests: Boolean(process.env.CI), + reporter: process.env.CI ? [["line"], ["html", { open: "never" }]] : "line", + outputDir: ".test-results/playwright", + use: { + browserName: "chromium", + headless: true, + trace: process.env.CI ? "retain-on-failure" : "off" + } +}); diff --git a/playwright.integration.config.js b/playwright.integration.config.js new file mode 100644 index 0000000..246982b --- /dev/null +++ b/playwright.integration.config.js @@ -0,0 +1,19 @@ +const { defineConfig } = require("@playwright/test"); + +module.exports = defineConfig({ + testDir: "./tests/integration", + testMatch: "*.spec.js", + timeout: 30000, + fullyParallel: false, + workers: 1, + forbidOnly: true, + retries: 0, + reporter: "line", + outputDir: ".test-results/magento-integration", + use: { + browserName: "chromium", + headless: true, + serviceWorkers: "block", + ignoreHTTPSErrors: true + } +}); diff --git a/registration.php b/registration.php index aed60d5..386e28d 100644 --- a/registration.php +++ b/registration.php @@ -4,6 +4,6 @@ ComponentRegistrar::register( ComponentRegistrar::MODULE, - 'BasicRum_Analytics', + 'Basicrum_Analytics', __DIR__ ); diff --git a/tests/integration/README.md b/tests/integration/README.md new file mode 100644 index 0000000..6fc9d47 --- /dev/null +++ b/tests/integration/README.md @@ -0,0 +1,349 @@ +# 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. + +## Magento-version/image-pinned local/CI stack + +`sh tests/integration/docker/start.sh` creates the dedicated Compose project +`basicrum-release-baseline` using pinned image digests and the anonymous Mage-OS +mirror. It provisions exactly 2.4.7-p10, installs the pinned Playwright runner, +and leaves existing Magento stacks alone. Docker, Compose, OpenSSL and several +GB of disk/RAM are required. Provisioning refuses to overwrite an installed +application; reuse it with the commands below, not `start.sh` again. +The full Magento Composer dependency closure is **not** locked in this repository. +Fresh provisioning resolves transitive dependencies from the mirror; upstream +releases/advisories can change that resolution or make installation fail. Image +digests and the Magento version pin do not promise bit-for-bit reproducibility. + +The test browser runs inside the PHP container at `https://web:8443/`; the host +binding is loopback-only port **9443**, never 443 or the existing local shop's +8443. This is a test appliance, not a replacement developer shop. Database and +search ports are not published. Its checked-in credentials are synthetic and +must never be reused elsewhere. Admin 2FA, Admin analytics, Braintree extensions +and outbound SMTP are disabled **only in this disposable installation**. +`Magento_Paypal` stays enabled for the core CSP regression assertion. No browser +allowlist exception is made for Braintree or any other external service. + +For subsequent runs after `down`, first recreate the containers with +`docker compose -f tests/integration/docker/compose.yaml up -d`. +Application/database volumes are retained; do not reprovision them. Git's +`safe.directory` is baked into the PHP image and survives container recreation. +After harness Dockerfile changes, run `docker compose -f tests/integration/docker/compose.yaml build php` +before `up -d`. First-time `start.sh` installs npm dependencies using the caller's +UID/GID so the bind-mounted checkout does not acquire root-owned `node_modules`. +To refresh dependencies later, run `npm ci` as the host user, or use the same +UID/GID-aware `exec` command from `start.sh`. Existing root-owned dependencies +from older harness runs need their ownership repaired before this unprivileged install. + +From a clean committed checkout with the containers running: + +```sh +docker compose -f tests/integration/docker/compose.yaml exec -T -w /module php sh tests/integration/build-artifact.sh +docker compose -f tests/integration/docker/compose.yaml exec -T -w /module php php tests/integration/test-artifact.php +docker compose -f tests/integration/docker/compose.yaml exec -T php sh /module/tests/integration/docker/install-artifact.sh +docker compose -f tests/integration/docker/compose.yaml exec -T -w /module -e BASICRUM_RELEASE_TAG=0.1.0 php sh tests/integration/release-gate.sh +docker compose -f tests/integration/docker/compose.yaml down +``` + +`down` stops/removes this stack's containers/network but retains its database +and application volumes. It does not stop other projects or remove their data. +The installer expands the verified Git-commit ZIP at `app/code/Basicrum/Analytics`; +it tests the documented manual package installation, not a published Packagist +release. Every stale extra file, including in development directories, causes +identity verification to fail, not silent deletion. + +The release gate also installs the separate, test-only `Basicrum_CspTest` +module from `tests/integration/fixtures/Basicrum/CspTest` before upgrade and DI +compilation. Its `/basicrumcsptest/` page uses Magento's normal layout, production +Basicrum footer and assets, and route-specific enforcing CSP. It is excluded +from the production archive with the rest of `tests/`; never install it on a +live store. It does not modify vendor code, headers, or production assets. +The installer refreshes known fixture files but fails on stale extras; inspect +those files and remove them deliberately before retrying. It never deletes them +automatically or enables a fixture that failed the content comparison. + +The `Pinned native Magento` CI workflow runs the same provision/archive/install/ +gate sequence on pull requests and manual dispatch. A workflow definition alone +is not evidence that a remote run passed. The default fixture does not import +sample data or create orders: three sample routes and the opt-in checkout +journey are explicitly skipped. See `docs/QUALITY-AND-RELEASE-READINESS.md` for +the actual execution record and tested/untested matrix. + +Install this checkout as `app/code/Basicrum/Analytics` (module +`Basicrum_Analytics`), enable it, and make its storefront reachable. For a +standalone disposable installation, install the CSP fixture once (or refresh it +after fixture changes), then run upgrade and DI compilation before the browser +suite. The release gate above already performs these steps: + +```sh +BASICRUM_DISPOSABLE_MAGENTO=1 \ +MAGENTO_ROOT=/absolute/path/to/disposable-magento \ +sh tests/integration/install-csp-fixture.sh +/absolute/path/to/disposable-magento/bin/magento setup:upgrade +/absolute/path/to/disposable-magento/bin/magento setup:di:compile +``` + +Then: + +```sh +npm ci +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. +It also requires the locally installed Playwright runner before changing settings; +missing dependencies fail with an `npm ci` instruction, without downloading a +different runner. + +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 and requires an actual `X-Magento-Cache-Debug: HIT` +on the subsequent navigation. It proves that the cached page waits for a fresh +allow decision, stays cookie/beacon-silent beforehand, and then sends the same +expected identity and redaction fields. +It therefore covers the real layout, template, CSP path, static asset URL, +cached HTML, and bundled Boomerang rather than a copied fixture. + +Every browser test first binds its URL to `MAGENTO_ROOT` using a random, exclusive, +short-lived PHP challenge in that installation's `pub` directory. It checks the +nonce and **web/FPM** PHP line, not only CLI PHP. Redirects, another document root, +or a mismatched PHP line fail. The challenge is removed in `finally`, including +after failures. It emits no configuration or secrets, and is never packaged +with the module. The disposable Nginx configuration permits only that tightly +named test path; a separately provisioned installation must configure the same +test-only location. Never add that location to a live shop. Direct browser runs +now also require `BASICRUM_DISPOSABLE_MAGENTO=1` and `MAGENTO_ROOT`. + +Basicrum script responses are hashed from the bytes actually returned to the +browser and compared with the candidate's packaged loader/Boomerang files. +Missing, unexpected or stale scripts fail; the primary storefront test requires +both the consent wrapper and the real Boomerang response. Server-side source +identity alone can no longer pass while stale deployed static content is served. +Script merging or rewriting is not supported by this exact-byte baseline. + +An additional test establishes two independent anonymous browser contexts. The +first visitor grants consent and receives a measurement cookie, then requests a +unique URL that must be a cache MISS. The second visitor's first request to that +URL must be a HIT with the same inline configuration, no Boomerang request, and +no measurement cookie or beacon until its own allow callback. This exercises +cache population by a request carrying measurement state, not only a page +cached before consent. The callback itself still does not persist consent. +The first visitor uses a second tab in its existing cookie jar for the MISS; +this avoids conflating the cache test with navigation-time beacon interception. +Both granted pages withdraw after the assertions. The network guard remains strict. + +Enable full-page caching and use a cacheable homepage. For built-in FPC, the +disposable installation must be in developer mode so Magento emits its debug +header. Magento's Varnish VCL also emits that header; any proxy in front must +preserve it. Do not manufacture a `HIT` header in the web server. Missing headers, +`MISS`, and `UNCACHEABLE` fail rather than skipping coverage. An extra pre-consent +navigation warms the anonymous visitor's cookie/vary context. Browser routing +disables the browser HTTP cache, so it cannot substitute for server FPC here. +This check covers the selected store/homepage, not cross-store cache isolation, +automatic invalidation after settings changes (CR-D-002), or a Varnish matrix. + +The CSP assertions require the baseline's enabled `Magento_Paypal` module: +`www.paypal.com` from its `csp_whitelist.xml` must survive alongside core +`'self'` and the Basicrum collector in storefront fetch directives. A fresh +empty-cart `checkout/` request is inspected without following its redirect, +so the check requires an actual enforcing `Content-Security-Policy` header, +not the cart page's report-only header. This check creates no order. + +The mandatory `enforcing-csp.spec.js` tests render the isolated fixture page +with a real enforcing header and `unsafe-inline` absent from `script-src`. +They require the production inline bootstrap's DOM nonce to match that header, +same-origin loader/Boomerang requests with verified bytes, pre-consent silence, +single loading after repeated allow, intercepted beacon identity, withdrawal +cookie cleanup, and no CSP violations or page errors. Two server-rendered visits +must receive different matching nonces. A separate negative control appends an +unnonced inline marker script: the browser must block it and report an enforcing +violation. Test instrumentation only observes events and invokes the public +consent callbacks outside this explicit negative control; it never rewrites +headers, nonces, or production scripts. + +The fixture page is deliberately non-cacheable, like checkout; its response +must be `no-store`, not an FPC HIT. This verifies enforcing-CSP execution and +per-render nonce handling, not nonce behavior on cacheable pages. The existing +report-only homepage cache-HIT/visitor-isolation checks remain unchanged. A +cacheable enforcing-CSP deployment, custom nonce-only/strict-dynamic policies, +and CDN/Varnish nonce handling require separate verification. Missing fixture +installation fails the native tests rather than silently skipping them. + +Admin checks require at least one website/store view with single-store mode +off and permission to view all three scopes. They verify that the collector +origin is absent from Admin CSP, that the three display-only rows have no +inheritance controls/scope labels, and that real settings retain those controls. +Use a dedicated collector origin that no other module whitelists in Admin; +otherwise origin absence cannot isolate Basicrum's contribution. Scope +switching is read-only: the test never saves configuration. + +## Network isolation + +All browser contexts, including popups, intercept the configured beacon origin +and path and return a local response. Other direct requests are allowed only +to `MAGENTO_STOREFRONT_URL` and `MAGENTO_ADMIN_URL` origins; unexpected beacon +identity payloads are blocked even at another path on those origins. Unexpected +requests fail the test, with URLs logged without query strings or POST bodies. +Service workers and WebSockets are blocked. + +A loopback test proxy permits only the storefront/Admin host and port pairs, +never the collector. This also stops external destinations reached through +redirect chains, which Playwright's route handler alone does not re-intercept. +Use dedicated disposable origins and same-origin assets. Disable external +payment/analytics scripts in that installation; do not whitelist their hosts +to make a failed test pass. This is browser-request isolation, not an OS-level +firewall or a sandbox for Magento's server-side integrations. Keep outbound +email and other server-side services isolated separately. + +## Native configuration-save checks + +With this checkout installed and native DI current, run: + +```sh +BASICRUM_DISPOSABLE_MAGENTO=1 \ +MAGENTO_ROOT=/absolute/path/to/disposable-magento \ +php tests/integration/config-save.php +``` + +This boots Magento's Admin area and uses the actual `Magento\Config\Model\Config` +save model, not the lightweight PHP doubles. It checks defaults, endpoint/UUID +validation, HTTP normalization and same-form changes, bounded waits, website/store +overrides and re-inheritance, deliberate immediate consent, and fail-closed +runtime behavior after invalid imports. It does not automate submission of the +Admin HTML form; the browser suite separately covers its rendering. + +Each case runs within an outer database transaction and rolls back all Basicrum +fixture rows, including after validation exceptions. Configuration caching is +disabled only in that PHP process so fixture values cannot enter shared config +cache. Original rows are compared after rollback. Use disposable data only: +native save events can invalidate caches or trigger third-party observers, and +those non-database side effects are not rolled back. No order is created. + +## Page-type alignment checks + +The suite also checks Magento 1-aligned labels in real beacons from public +Magento 2 pages. It covers native route differences, HTTP 404, explicit +unmapped fallback, and redirect destinations: a logged-out account is `Login`, +and checkout/success without a valid cart/order session is `Cart`. + +Set `MAGENTO_SAMPLE_DATA=1` to include the Luma sample CMS page `about-us`, +product `fusion-backpack.html`, and category `gear/bags.html`. These are skipped +without that flag; all require installed, indexed, active sample data. + +To check an already configured disposable store without rewriting settings, +run Playwright directly. `MAGENTO_BEACON_URL` and `MAGENTO_SITE_ID` can override +the default test endpoint and identity; they must match the effective store +configuration. Consent-controlled loading and query redaction must be enabled. +The browser intercepts the configured endpoint and returns a local response; +it does not forward beacons to that collector. + +```sh +MAGENTO_STOREFRONT_URL=https://magento.test/ \ +MAGENTO_ROOT=/absolute/path/to/disposable-magento \ +BASICRUM_DISPOSABLE_MAGENTO=1 \ +MAGENTO_BEACON_URL=https://collector.basicrum.test/beacon \ +MAGENTO_SITE_ID=550e8400-e29b-41d4-a716-446655440000 \ +MAGENTO_SAMPLE_DATA=1 \ +npx --no-install playwright test --config=playwright.integration.config.js +``` + +`checkout-page-types.spec.js` is separately guarded. In addition to the URL +and identity settings, it requires **both** `BASICRUM_DISPOSABLE_MAGENTO=1` +and `MAGENTO_TEST_CHECKOUT=1`. It assumes Luma, the in-stock Fusion Backpack +sample product, guest checkout, US/California shipping, Flat Rate shipping, +and the offline Check / Money order payment method. It creates one test order +per successful run (and a quote on unsuccessful runs), using synthetic +`example.test` contact details. Use only on disposable data with outbound mail +captured locally; the fixture does not delete orders or alter store settings. +No live payment provider is selected. It verifies actual `Checkout` and +`Checkout Success` beacons, not a manually substituted browser variable. + +Authenticated account/address/order/wishlist and PayPal agreement journeys +are not included in this public browser matrix. Their exact labels, alongside +all other mappings, are covered by the focused PHP tests. A missing journey +fixture is not evidence of end-to-end verification for that page. + +## Required pre-release native gate + +The `0.0.2` identity must not be reused for this breaking, previously +unreleased technical-namespace change. The Phase 1 release candidate is +`0.1.0`, and the guarded release script accepts only `0.1.0` (with an optional +`v` tag prefix). Change that explicit policy in review if the intended release +number changes; do not bypass the gate or add a Composer `version` field. + +Before creating the tag, first require the fast CI jobs to pass, then run the +following against the pinned disposable Magento baseline: + +```sh +BASICRUM_DISPOSABLE_MAGENTO=1 \ +BASICRUM_RELEASE_TAG=0.1.0 \ +MAGENTO_ROOT=/absolute/path/to/disposable-magento \ +MAGENTO_STOREFRONT_URL=https://magento.test/ \ +MAGENTO_ADMIN_URL=https://magento.test/admin/ \ +MAGENTO_ADMIN_USERNAME=basicrum-release-check \ +MAGENTO_ADMIN_PASSWORD='disposable-secret' \ +tests/integration/release-gate.sh +``` + +Use the installation's real secret Admin login path in `MAGENTO_ADMIN_URL`. +The browser follows Magento's own Stores > Configuration navigation so it does +not bypass Admin secret-key URLs. The disposable Admin account must be able to +open that page, and login challenges such as two-factor authentication or +CAPTCHA must be disabled for this isolated test account. + +The gate requires Git and a clean, committed module checkout, including no +untracked files. First build the Git-commit ZIP using `build-artifact.sh` and +install its contents. `BASICRUM_ARTIFACT` can select a ZIP path; the default is +`.test-results/package/basicrum-analytics.zip`. The gate hashes every ZIP entry +against the candidate's production manifest. Before any upgrade/configuration +write it also resolves `Basicrum_Analytics` through +Magento's actual `ComponentRegistrar` and compares SHA-256 hashes of package +files (including PHP, XML, templates, assets, notices, and top-level metadata). +Missing, modified, and any extra installed files fail, including ignored/development +files. This strict distribution gate is not for a full checkout or checkout symlink; +the separate `configure-disposable.sh` development runner still supports those. +The expected manifest hashes committed Git blobs, not working-tree files. +Exclusions come from the committed `composer.json` archive boundary, never installed +metadata; `.gitattributes` applies the same boundary to the Git ZIP. Ignored or +untracked local files cannot become expected archive entries. Tests, development +tools/configuration, CI and generated output are not shipped. PHP, XML, templates, +assets, README, changelog and third-party notices remain verified. Internal source +symlinks are rejected; the module directory itself may be a symlink. + +The gate then compares the installed Magento +Open Source patch exactly and the PHP, Composer, MariaDB, and OpenSearch +major/minor lines with `baseline.env`. Missing versions, a different edition, +or a mismatch fail the gate; there is no bypass flag. Supplemental tests on +another platform do not certify this release baseline. + +The gate then runs `setup:upgrade`, dependency-injection compilation, an English +static-content deployment, and the native configuration-save tests before +applying the browser test configuration. Playwright +then exercises the rendered storefront and intercepted beacon, reloads a warm +full-page-cache response, logs into Magento Admin, and verifies that the +Basicrum logo and required configuration/status fields render. Any command, +login, rendering assertion, or browser check failure blocks the tag. +Afterward it rechecks the clean checkout/commit, installed files, and ZIP identity. +The final success line records the candidate SHA and intended tag; retain the +command output as release evidence and tag only that exact commit. Do not edit +or resync either tree during the gate. These checks tie source evidence to the +candidate and to the tested distribution bytes. The success line certifies only +the native technical checks, not legal approval, a published Composer install, +untested themes, or optional skipped journeys. The rights-holder-approved module +LICENSE remains a separate release requirement. + +Fast CI retains native browser test discovery (`--list`); the separate native +workflow provisions and executes Magento. Neither discovery nor merely adding +a workflow is evidence of a passing native run. diff --git a/tests/integration/admin.spec.js b/tests/integration/admin.spec.js new file mode 100644 index 0000000..fbd5cd5 --- /dev/null +++ b/tests/integration/admin.spec.js @@ -0,0 +1,93 @@ +const { test, expect } = require("./fixtures"); +const { expectAdminCsp } = require("./csp"); + +const adminUrl = process.env.MAGENTO_ADMIN_URL; +const adminUsername = process.env.MAGENTO_ADMIN_USERNAME; +const adminPassword = process.env.MAGENTO_ADMIN_PASSWORD; + +test.skip( + !adminUrl || !adminUsername || !adminPassword, + "MAGENTO_ADMIN_URL and disposable Admin credentials are required" +); + +test("Basicrum configuration renders in Magento Admin", async ({ page }) => { + test.setTimeout(60000); + await page.goto(adminUrl, { waitUntil: "domcontentloaded" }); + + const username = page.locator("#username"); + if (await username.isVisible()) { + await username.fill(adminUsername); + await page.locator("#login").fill(adminPassword); + await page.locator("button.action-login").click(); + await expect(username).toHaveCount(0); + } + + const adminUsageDeny = page.locator( + ".admin-usage-notification .action-secondary" + ); + if (await adminUsageDeny.isVisible()) { + await adminUsageDeny.click(); + } + + const configurationMenu = page.locator('[data-ui-id="menu-magento-config-system-config"] > a'); + const configurationUrl = await configurationMenu.getAttribute("href"); + expect(configurationUrl).toBeTruthy(); + await page.goto(configurationUrl, { waitUntil: "domcontentloaded" }); + + const basicrumSection = page + .locator("#system_config_tabs a") + .filter({ hasText: "Basicrum Analytics" }); + const basicrumUrl = await basicrumSection.getAttribute("href"); + expect(basicrumUrl).toBeTruthy(); + const response = await page.goto(basicrumUrl, { waitUntil: "networkidle" }); + expectAdminCsp(response.headers()); + + const generalSettings = page.locator('a[href="#basicrum_general-link"]'); + const logo = page.locator(".basicrum-config-logo"); + await expect + .poll(async () => { + if (!(await logo.isVisible())) { + await generalSettings.click(); + } + return logo.isVisible(); + }) + .toBe(true); + await expect(page.getByText("Monitoring Status", { exact: true })).toBeVisible(); + await expect(page.getByText("Beacon Endpoint", { exact: true })).toBeVisible(); + await expect(page.getByText("Brum Site ID", { exact: true })).toBeVisible(); + await expect(page.locator("#basicrum_consent_mode")).toHaveCount(0); + + const displayRows = [ + "basicrum_general_monitoring_status", + "basicrum_general_boomerang_version", + "basicrum_consent_integration_help" + ]; + const expectDisplayOnlyRows = async () => { + for (const id of displayRows) { + const row = page.locator("#row_" + id); + await expect(row).toHaveCount(1); + await expect(row.locator('input, select, textarea, [data-config-scope]')).toHaveCount(0); + } + }; + await expectDisplayOnlyRows(); + + // Exercise Magento's own switcher and secret-key URLs, without saving. + for (const scope of ["website", "store-view"]) { + await page.locator("#store-change-button").click(); + await page.locator('.store-switcher-' + scope + ' a[data-value]').first().click(); + const confirmation = page.locator(".modal-popup.confirm .action-accept"); + await expect(confirmation).toBeVisible(); + const navigation = page.waitForNavigation({ waitUntil: "networkidle" }); + await confirmation.click(); + const scopedResponse = await navigation; + expect(scopedResponse).toBeTruthy(); + expectAdminCsp(scopedResponse.headers()); + await expectDisplayOnlyRows(); + // Real settings still inherit at both scopes; removing all checkboxes is + // not an acceptable way to fix the display-only rows. + for (const id of ["basicrum_general_beacon_endpoint", "basicrum_general_brum_site_id", + "basicrum_consent_enabled", "basicrum_privacy_strip_query_string"]) { + await expect(page.locator("#" + id + "_inherit")).toHaveCount(1); + } + } +}); 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/baseline.php b/tests/integration/baseline.php new file mode 100644 index 0000000..d0b59fd --- /dev/null +++ b/tests/integration/baseline.php @@ -0,0 +1,19 @@ + new URL(url).origin)); + await context.route("**/*", async route => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin === endpoint.origin && url.pathname === endpoint.pathname) { + beacons.push(route.request()); + return route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*" }, + body: "" + }); + } + + const parameters = requestParameters(request); + const measurement = parameters.has("brum_site_id") || parameters.get("p_gen") === "mage2"; + if (localOrigins.has(url.origin) && !measurement) { + return route.continue(); + } + + // Do not print query strings or POST bodies in failure diagnostics. + blocked.push(url.origin + url.pathname); + await route.abort("blockedbyclient"); + }); + // Boomerang uses HTTP, but do not let another script open an unguarded socket. + await context.routeWebSocket("**/*", socket => { + const url = new URL(socket.url()); + blocked.push(url.origin + url.pathname); + socket.close(); + }); + return { beacons, blocked }; +} + +module.exports = { beaconUrl, guardNetwork, requestParameters, siteId }; diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php new file mode 100644 index 0000000..a3ad88b --- /dev/null +++ b/tests/integration/bootstrap.php @@ -0,0 +1,18 @@ +getObjectManager(); +$objectManager->get(State::class)->setAreaCode(Area::AREA_ADMINHTML); +$objectManager->configure($objectManager->get(ConfigLoader::class)->load(Area::AREA_ADMINHTML)); diff --git a/tests/integration/build-artifact.sh b/tests/integration/build-artifact.sh new file mode 100644 index 0000000..ab5acf5 --- /dev/null +++ b/tests/integration/build-artifact.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eu +module_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +cd "$module_root" +mkdir -p .test-results/package +candidate_commit=$(sh tests/integration/check-candidate.sh) +git archive --format=zip --output=.test-results/package/basicrum-analytics.zip "$candidate_commit" +php tests/integration/check-artifact.php .test-results/package/basicrum-analytics.zip diff --git a/tests/integration/cache.js b/tests/integration/cache.js new file mode 100644 index 0000000..81fef14 --- /dev/null +++ b/tests/integration/cache.js @@ -0,0 +1,11 @@ +const { expect } = require("@playwright/test"); + +function expectFullPageCacheHit(response) { + expect(response.status(), "Cached storefront navigation must succeed").toBe(200); + expect(response.headers()["x-magento-cache-debug"], + "A real full-page-cache HIT is required. Enable FPC and expose X-Magento-Cache-Debug " + + "(developer mode for built-in FPC, or Magento's Varnish VCL); do not synthesize this header." + ).toBe("HIT"); +} + +module.exports = { expectFullPageCacheHit }; diff --git a/tests/integration/candidate-files.php b/tests/integration/candidate-files.php new file mode 100644 index 0000000..42c9f4e --- /dev/null +++ b/tests/integration/candidate-files.php @@ -0,0 +1,79 @@ + ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($process)) { + throw new RuntimeException('Unable to inspect the candidate commit.'); + } + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + if (proc_close($process) !== 0) { + throw new RuntimeException('Unable to inspect the candidate commit: ' . trim($error)); + } + return $output; +} + +/** Hash only committed production blobs. Ignored or untracked disk files cannot become expected files. */ +function basicrum_candidate_files(string $root): array +{ + $commit = trim(basicrum_candidate_git($root, ['rev-parse', '--verify', 'HEAD'])); + $metadata = json_decode(basicrum_candidate_git($root, ['show', $commit . ':composer.json']), true, 512, JSON_THROW_ON_ERROR); + $excluded = array_map( + static fn (string $path): string => ltrim($path, '/'), + $metadata['archive']['exclude'] + ); + $files = []; + foreach (explode("\0", rtrim(basicrum_candidate_git($root, ['ls-tree', '-rz', $commit]), "\0")) as $entry) { + [$object, $path] = explode("\t", $entry, 2); + if (in_array(explode('/', $path)[0], $excluded, true)) { + continue; + } + [$mode, $type, $id] = explode(' ', $object); + if ($type !== 'blob' || !in_array($mode, ['100644', '100755'], true)) { + throw new RuntimeException('Unsupported candidate file: ' . $path); + } + $files[$path] = hash('sha256', basicrum_candidate_git($root, ['cat-file', 'blob', $id])); + } + if (!isset($files['registration.php'], $files['composer.json'])) { + throw new RuntimeException('The candidate commit must contain registration.php and composer.json.'); + } + ksort($files); + return $files; +} + +/** A distribution installation has no development-file exceptions, even for hidden files. */ +function basicrum_installed_files(string $root): array +{ + $files = []; + $directory = new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS); + foreach (new RecursiveIteratorIterator($directory) as $file) { + $relative = substr($file->getPathname(), strlen($root) + 1); + // The module root may be a symlink; links inside the package are never supported. + if ($file->isLink() || !$file->isFile()) { + throw new RuntimeException('Unsupported candidate file: ' . $relative); + } + $hash = hash_file('sha256', $file->getPathname()); + if ($hash === false) { + throw new RuntimeException('Unable to hash candidate file: ' . $relative); + } + $files[$relative] = $hash; + } + ksort($files); + return $files; +} + +function basicrum_assert_installed_candidate(string $candidateRoot, string $installedRoot): void +{ + $candidate = basicrum_candidate_files($candidateRoot); + $installed = basicrum_installed_files($installedRoot); + foreach (array_unique(array_merge(array_keys($candidate), array_keys($installed))) as $file) { + if (($candidate[$file] ?? null) !== ($installed[$file] ?? null)) { + throw new RuntimeException('Installed Basicrum module differs from the candidate: ' . $file); + } + } +} diff --git a/tests/integration/check-artifact.php b/tests/integration/check-artifact.php new file mode 100644 index 0000000..bd00ea6 --- /dev/null +++ b/tests/integration/check-artifact.php @@ -0,0 +1,29 @@ +open($path) !== true) { + throw new RuntimeException('Pass the committed distribution ZIP to verify.'); +} +$actual = []; +for ($index = 0; $index < $zip->numFiles; $index++) { + $name = $zip->getNameIndex($index); + if (str_ends_with($name, '/')) { + continue; + } + if (isset($actual[$name])) { + throw new RuntimeException('Duplicate archive entry: ' . $name); + } + $actual[$name] = hash('sha256', $zip->getFromIndex($index)); +} +$zip->close(); +$expected = basicrum_candidate_files(dirname(__DIR__, 2)); +ksort($actual); +if ($actual !== $expected) { + throw new RuntimeException('Distribution contents differ from the candidate production manifest.'); +} +echo 'PASS: distribution ZIP contains exactly ' . count($actual) . ' candidate production files; SHA-256 ' + . hash_file('sha256', $path) . PHP_EOL; diff --git a/tests/integration/check-baseline.php b/tests/integration/check-baseline.php new file mode 100644 index 0000000..5a6e607 --- /dev/null +++ b/tests/integration/check-baseline.php @@ -0,0 +1,55 @@ +get(ProductMetadataInterface::class); +$actual = ['MAGENTO_VERSION' => $metadata->getVersion(), 'PHP_VERSION' => PHP_VERSION]; +if ($metadata->getEdition() !== 'Community') { + throw new RuntimeException('The declared baseline is Magento Open Source (Community edition).'); +} +// Refuse incompatible core runtimes before connecting to optional services. +foreach (basicrum_baseline_errors($expected, $actual) as $key => $error) { + if (isset($actual[$key])) { + throw new RuntimeException($error); + } +} + +$pipes = []; +$process = proc_open(['composer', '--no-ansi', '--version'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); +if (!is_resource($process)) { + throw new RuntimeException('Composer is required on PATH.'); +} +$composerOutput = stream_get_contents($pipes[1]); +stream_get_contents($pipes[2]); +fclose($pipes[1]); +fclose($pipes[2]); +if (proc_close($process) !== 0 || !preg_match('/Composer version (\d+\.\d+\.\d+)/', $composerOutput, $matches)) { + throw new RuntimeException('Unable to identify the installed Composer version.'); +} +$actual['COMPOSER_VERSION'] = $matches[1]; +$databaseVersion = (string) $objectManager->get(ResourceConnection::class)->getConnection()->fetchOne('SELECT VERSION()'); +if (stripos($databaseVersion, 'MariaDB') === false) { + throw new RuntimeException('The declared baseline requires MariaDB.'); +} +$actual['MARIADB_VERSION'] = $databaseVersion; +$resolver = $objectManager->get(ClientResolver::class); +if ($resolver->getCurrentEngine() !== 'opensearch') { + throw new RuntimeException('The declared baseline requires the configured OpenSearch engine.'); +} +$info = $resolver->create()->getOpenSearchClient()->info(); +if (($info['version']['distribution'] ?? '') !== 'opensearch') { + throw new RuntimeException('The configured search server did not identify itself as OpenSearch.'); +} +$actual['OPENSEARCH_VERSION'] = $info['version']['number'] ?? ''; +$errors = basicrum_baseline_errors($expected, $actual); +if ($errors !== []) { + throw new RuntimeException(implode("\n", $errors)); +} +echo 'PASS: installed Magento/PHP/Composer/MariaDB/OpenSearch match baseline.env.' . PHP_EOL; diff --git a/tests/integration/check-candidate.sh b/tests/integration/check-candidate.sh new file mode 100644 index 0000000..4977fc9 --- /dev/null +++ b/tests/integration/check-candidate.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +set -eu + +module_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd -P) +repository_root=$(git -C "$module_root" rev-parse --show-toplevel) +if [ "$repository_root" != "$module_root" ]; then + echo "Run the release gate from the module's own Git checkout." >&2 + exit 1 +fi +changes=$(git -C "$module_root" status --porcelain --untracked-files=all) +if [ -n "$changes" ]; then + echo "The release candidate must be clean: commit or remove pending changes before certification." >&2 + exit 1 +fi +git -C "$module_root" rev-parse --verify HEAD diff --git a/tests/integration/check-installed-candidate.php b/tests/integration/check-installed-candidate.php new file mode 100644 index 0000000..0dc68e8 --- /dev/null +++ b/tests/integration/check-installed-candidate.php @@ -0,0 +1,18 @@ +getPath(ComponentRegistrar::MODULE, 'Basicrum_Analytics'); +if (!$installedRoot || !is_dir($installedRoot)) { + throw new RuntimeException('Basicrum_Analytics is not registered in this Magento installation.'); +} +basicrum_assert_installed_candidate(dirname(__DIR__, 2), realpath($installedRoot)); +echo 'PASS: registered Basicrum installation exactly matches the committed production files.' . PHP_EOL; diff --git a/tests/integration/checkout-page-types.spec.js b/tests/integration/checkout-page-types.spec.js new file mode 100644 index 0000000..d93ba91 --- /dev/null +++ b/tests/integration/checkout-page-types.spec.js @@ -0,0 +1,55 @@ +const { test, expect } = require("./fixtures"); +const { requestParameters, siteId } = require("./beacons"); + +const storefrontUrl = process.env.MAGENTO_STOREFRONT_URL; +test.skip( + !storefrontUrl || process.env.BASICRUM_DISPOSABLE_MAGENTO !== "1" || process.env.MAGENTO_TEST_CHECKOUT !== "1", + "Explicit disposable-store and offline checkout opt-ins are required; this test creates an order" +); + +test("a real offline Luma checkout emits Checkout then Checkout Success", async ({ page, beaconTraffic }) => { + test.setTimeout(90000); + const { beacons } = beaconTraffic; + + async function expectPageBeacon(label) { + await page.waitForFunction(() => typeof window.OPT_IN_BASICRUM_LOADER_WRAPPER === "function"); + const before = beacons.length; + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await expect.poll(() => beacons.slice(before).some((request) => requestParameters(request).get("p_type") === label), { + timeout: 15000 + }).toBe(true); + const parameters = requestParameters(beacons.slice(before).find((request) => requestParameters(request).get("p_type") === label)); + expect(parameters.get("p_gen")).toBe("mage2"); + expect(parameters.get("brum_site_id")).toBe(siteId); + } + + await page.goto(new URL("fusion-backpack.html", storefrontUrl).toString()); + await page.getByRole("button", { name: "Add to Cart", exact: true }).click(); + await expect(page.locator(".message-success")).toContainText("Fusion Backpack"); + await page.goto(new URL("checkout/", storefrontUrl).toString()); + await expect(page.locator("#customer-email")).toBeVisible(); + await expectPageBeacon("Checkout"); + + await page.locator("#customer-email").fill("basicrum-page-types@example.test"); + await page.locator('input[name="firstname"]').fill("Basicrum"); + await page.locator('input[name="lastname"]').fill("Test"); + await page.locator('input[name="street[0]"]').fill("123 Test Street"); + await page.locator('input[name="city"]').fill("Los Angeles"); + await page.locator('select[name="country_id"]').selectOption("US"); + await page.locator('select[name="region_id"]').selectOption({ label: "California" }); + await page.locator('input[name="postcode"]').fill("90001"); + await page.locator('input[name="telephone"]').fill("5550100100"); + await page.locator('input[value="flatrate_flatrate"]').check(); + await page.getByRole("button", { name: "Next", exact: true }).click(); + + // Never choose a live payment provider. The fixture requires Check / Money order. + const offlinePayment = page.locator(".payment-method").filter({ has: page.locator("#checkmo") }); + await expect(offlinePayment).toBeVisible(); + if (await page.locator("#checkmo").isVisible()) { + await page.locator("#checkmo").check(); + } + await expect(offlinePayment).toHaveClass(/_active/); + await offlinePayment.getByRole("button", { name: "Place Order", exact: true }).click(); + await page.waitForURL(/\/checkout\/onepage\/success\//); + await expectPageBeacon("Checkout Success"); +}); diff --git a/tests/integration/config-save.php b/tests/integration/config-save.php new file mode 100644 index 0000000..233d243 --- /dev/null +++ b/tests/integration/config-save.php @@ -0,0 +1,196 @@ +get(ResourceConnection::class); +$connection = $resource->getConnection(); +$table = $resource->getTableName('core_config_data'); +$scopeConfig = $objectManager->get(ReinitableConfigInterface::class); +$cacheState = $objectManager->get(StateInterface::class); +$factory = $objectManager->get(ConfigFactory::class); +$config = $objectManager->get(Config::class); +$stores = $objectManager->get(StoreManagerInterface::class)->getStores(); +$store = reset($stores); +if (!$store || $connection->getTransactionLevel() !== 0) { + throw new RuntimeException('An installed store and an idle database connection are required.'); +} +$storeId = (int) $store->getId(); +$websiteId = (int) $store->getWebsiteId(); +$siteId = '550e8400-e29b-41d4-a716-446655440000'; +$websiteSiteId = '123e4567-e89b-42d3-a456-426614174000'; +$snapshot = static fn (): array => $connection->fetchAll( + $connection->select()->from($table)->where('path LIKE ?', 'basicrum/%')->order('config_id') +); +$original = $snapshot(); +$cacheEnabled = $cacheState->isEnabled('config'); +// Per-process only; never persist this flag. Uncommitted fixture values must +// neither read from nor be published into Magento's shared configuration cache. +$cacheState->setEnabled('config', false); + +$assertSame = static function ($expected, $actual, string $message): void { + if ($expected !== $actual) { + throw new RuntimeException($message); + } +}; +$save = static function (array $fields, array $scope = []) use ($factory): void { + $groups = []; + foreach ($fields as $path => $value) { + [$group, $field] = explode('/', $path); + $groups[$group]['fields'][$field] = $value === null ? ['inherit' => '1'] : ['value' => $value]; + } + // This is the same model/data shape used by the native Admin save action. + $factory->create(['data' => array_merge(['section' => 'basicrum', 'groups' => $groups], $scope)])->save(); +}; +$seed = static function () use ($save, $siteId): void { + $save([ + 'general/enabled' => '1', + 'general/beacon_endpoint' => 'https://default.example.test/beacon', + 'general/brum_site_id' => $siteId, + ]); +}; +$raw = static function (string $path, string $scope = 'default', int $id = 0) use ($connection, $table) { + return $connection->fetchOne($connection->select()->from($table, 'value') + ->where('path = ?', 'basicrum/' . $path)->where('scope = ?', $scope)->where('scope_id = ?', $id)); +}; +$expectInvalid = static function (array $fields) use ($save): void { + try { + $save($fields); + } catch (LocalizedException $exception) { + return; + } + throw new RuntimeException('Native configuration save accepted invalid input.'); +}; + +$tests = []; +$tests['native XML defaults and environment-specific HTTP policy'] = static function () use ($scopeConfig, $config, $assertSame, $objectManager): void { + $assertSame('0', (string) $scopeConfig->getValue(Config::XML_PATH_ENABLED, 'default'), 'enabled default'); + $assertSame('1', (string) $scopeConfig->getValue(Config::XML_PATH_CONSENT_ENABLED, 'default'), 'consent default'); + $assertSame(null, $config->getRuntimeConfig(), 'fresh native runtime gate'); + $pool = $objectManager->get(TypePool::class); + $assertSame(true, $pool->isPresent(Config::XML_PATH_DEVELOPMENT_MODE, TypePool::TYPE_ENVIRONMENT), 'HTTP exception belongs to this environment'); + $assertSame(false, $pool->isPresent(Config::XML_PATH_CONSENT_ENABLED, TypePool::TYPE_ENVIRONMENT), 'consent export classification unchanged'); +}; +$tests['native backend normalizes HTTPS and bounds the wait'] = static function () use ($save, $siteId, $raw, $config, $assertSame): void { + $save([ + 'general/enabled' => '1', 'general/brum_site_id' => $siteId, + 'general/beacon_endpoint' => ' http://collector.example.test/beacon ', + 'performance/wait_after_onload' => '1', 'performance/delay_ms' => '45000', + ]); + $assertSame('https://collector.example.test/beacon', $raw('general/beacon_endpoint'), 'saved HTTPS policy'); + $assertSame(30000, $config->getRuntimeConfig()['delay_ms'], 'saved and effective bounded wait'); +}; +$tests['native save rejects invalid endpoint without replacing its value'] = static function () use ($seed, $expectInvalid, $raw, $assertSame): void { + $seed(); + $expectInvalid(['general/beacon_endpoint' => 'not-a-url']); + $assertSame('https://default.example.test/beacon', $raw('general/beacon_endpoint'), 'invalid endpoint must not persist'); +}; +$tests['native save rejects invalid site identity without replacing its value'] = static function () use ($seed, $expectInvalid, $raw, $siteId, $assertSame): void { + $seed(); + $expectInvalid(['general/brum_site_id' => 'not-a-uuid']); + $assertSame($siteId, $raw('general/brum_site_id'), 'invalid identity must not persist'); +}; +$tests['same-form HTTP exception applies before its old saved value'] = static function () use ($seed, $save, $raw, $config, $assertSame): void { + $seed(); + $save(['general/beacon_endpoint' => 'http://dev.example.test/beacon', 'developer/development_mode' => '1']); + $assertSame('http://dev.example.test/beacon', $raw('general/beacon_endpoint'), 'same-form allow'); + $assertSame('http://dev.example.test/beacon', $config->getRuntimeConfig()['beacon_endpoint'], 'effective allow'); + $save(['general/beacon_endpoint' => 'http://dev.example.test/beacon', 'developer/development_mode' => '0']); + $assertSame('https://dev.example.test/beacon', $raw('general/beacon_endpoint'), 'same-form disallow'); +}; +$tests['store HTTP inheritance and identity resolve through the native website'] = static function () use ( + $seed, $save, $raw, $config, $websiteId, $storeId, $siteId, $websiteSiteId, $assertSame +): void { + $seed(); + $save([ + 'general/beacon_endpoint' => 'http://website.example.test/beacon', + 'general/brum_site_id' => $websiteSiteId, 'developer/development_mode' => '1', + ], ['website' => $websiteId]); + $save(['developer/development_mode' => '0'], ['store' => $storeId]); + $save([ + 'general/beacon_endpoint' => 'http://store.example.test/beacon', + 'developer/development_mode' => null, + ], ['store' => $storeId]); + $assertSame(false, $raw('developer/development_mode', 'stores', $storeId), 'inherit deletes the override'); + $runtime = $config->getRuntimeConfig('store', $storeId); + $assertSame('http://store.example.test/beacon', $runtime['beacon_endpoint'], 'store HTTP inherited from website'); + $assertSame($websiteSiteId, $runtime['brum_site_id'], 'website identity inheritance'); + $assertSame($siteId, $config->getRuntimeConfig('default')['brum_site_id'], 'default identity unchanged'); + $save(['general/beacon_endpoint' => null], ['store' => $storeId]); + $assertSame('http://website.example.test/beacon', $config->getRuntimeConfig('store', $storeId)['beacon_endpoint'], 'endpoint inheritance'); +}; +$tests['website HTTP inheritance uses the default policy in the same form'] = static function () use ( + $seed, $save, $raw, $config, $websiteId, $assertSame +): void { + $seed(); + $save(['developer/development_mode' => '1']); + $save(['developer/development_mode' => '0'], ['website' => $websiteId]); + $save([ + 'general/beacon_endpoint' => 'http://website.example.test/beacon', + 'developer/development_mode' => null, + ], ['website' => $websiteId]); + $assertSame(false, $raw('developer/development_mode', 'websites', $websiteId), 'website override deleted'); + $assertSame('http://website.example.test/beacon', $config->getRuntimeConfig('website', $websiteId)['beacon_endpoint'], 'default policy inherited'); +}; +$tests['native consent override and re-inheritance remain fail closed'] = static function () use ($seed, $save, $config, $storeId, $assertSame): void { + $seed(); + $save(['consent/enabled' => '0'], ['store' => $storeId]); + $assertSame(false, $config->getRuntimeConfig('store', $storeId)['consent_enabled'], 'deliberate immediate override'); + $save(['consent/enabled' => null], ['store' => $storeId]); + $assertSame(true, $config->getRuntimeConfig('store', $storeId)['consent_enabled'], 'inherit requires consent again'); +}; +$tests['missing identity can be saved but native rendering stays inactive'] = static function () use ($seed, $save, $config, $assertSame): void { + $seed(); + $save(['general/brum_site_id' => '']); + $assertSame(null, $config->getRuntimeConfig(), 'missing identity runtime gate'); + $assertSame('missing_site_id', $config->getStatus(), 'same Admin gate'); +}; +$tests['invalid imported values are rejected by native runtime configuration'] = static function () use ( + $seed, $connection, $table, $scopeConfig, $config, $assertSame +): void { + foreach ([Config::XML_PATH_BEACON_ENDPOINT => 'invalid_endpoint', Config::XML_PATH_BRUM_SITE_ID => 'invalid_site_id'] as $path => $status) { + $seed(); + // Simulate a CLI/import bypass of Admin validation, still inside the rollback. + $connection->update($table, ['value' => 'invalid-import'], [ + 'scope = ?' => 'default', 'scope_id = ?' => 0, 'path = ?' => $path, + ]); + $scopeConfig->reinit(); + $assertSame(null, $config->getRuntimeConfig(), 'invalid imported value'); + $assertSame($status, $config->getStatus(), 'Admin and runtime agree'); + } +}; + +try { + foreach ($tests as $name => $test) { + $connection->beginTransaction(); + try { + $connection->delete($table, ['path LIKE ?' => 'basicrum/%']); + $scopeConfig->reinit(); + $test(); + echo 'PASS: ' . $name . PHP_EOL; + } finally { + // Native Config saves use nested transactions. Roll back this + // process's outer transaction even after a backend exception. + while ($connection->getTransactionLevel() > 0) { + $connection->rollBack(); + } + $scopeConfig->reinit(); + } + } +} finally { + $cacheState->setEnabled('config', $cacheEnabled); + $assertSame($original, $snapshot(), 'Configuration was not restored by rollback.'); +} +echo count($tests) . ' native save checks passed; original configuration restored.' . PHP_EOL; diff --git a/tests/integration/configure-disposable.sh b/tests/integration/configure-disposable.sh new file mode 100755 index 0000000..3742ec9 --- /dev/null +++ b/tests/integration/configure-disposable.sh @@ -0,0 +1,58 @@ +#!/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" + +if ! enabled_modules=$("$magento" module:status --enabled); then + echo "Unable to read enabled Magento modules from ${MAGENTO_ROOT}." >&2 + exit 1 +fi + +if ! printf '%s\n' "$enabled_modules" | grep -Fxq 'Basicrum_Analytics'; then + echo "Basicrum_Analytics is not registered and enabled in ${MAGENTO_ROOT}." >&2 + echo "Install this checkout at app/code/Basicrum/Analytics with exact casing and run setup:upgrade." >&2 + exit 1 +fi + +# Fail before configuration writes instead of allowing npx to fetch an unpinned runner. +if [ ! -x "$module_root/node_modules/.bin/playwright" ]; then + echo "Pinned Playwright is missing. Run npm ci in the module checkout first." >&2 + exit 1 +fi + +"$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/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" \ +MAGENTO_BEACON_URL=https://collector.basicrum.test/beacon \ +MAGENTO_SITE_ID=550e8400-e29b-41d4-a716-446655440000 \ + "$module_root/node_modules/.bin/playwright" test --config=playwright.integration.config.js diff --git a/tests/integration/csp.js b/tests/integration/csp.js new file mode 100644 index 0000000..f2fd67a --- /dev/null +++ b/tests/integration/csp.js @@ -0,0 +1,49 @@ +const { expect } = require("@playwright/test"); +const { beaconUrl } = require("./beacons"); + +function policySources(csp, directive) { + const policy = csp.split(";") + .map((value) => value.trim().split(/\s+/)) + .find((values) => values[0] === directive); + expect(policy, directive + " must be explicitly present").toBeTruthy(); + return policy.slice(1); +} + +function expectStorefrontCsp(csp) { + expect(csp).toBeTruthy(); + for (const directive of ["connect-src", "img-src", "script-src"]) { + const sources = policySources(csp, directive); + expect(sources).toContain("'self'"); + // Stable fixture from enabled Magento_Paypal/etc/csp_whitelist.xml. + // This detects losing the core whitelist collector as well as core config. + expect(sources).toContain("www.paypal.com"); + if (directive !== "script-src") { + expect(sources).toContain(new URL(beaconUrl).origin); + } + } +} + +function expectAdminCsp(headers) { + const policies = ["content-security-policy", "content-security-policy-report-only"] + .map((name) => headers[name]).filter(Boolean); + expect(policies.length).toBeGreaterThan(0); + for (const csp of policies) { + expect(policySources(csp, "script-src")).toContain("'self'"); + for (const directive of ["connect-src", "img-src"]) { + expect(policySources(csp, directive)).not.toContain(new URL(beaconUrl).origin); + } + } +} + +function expectEnforcingScriptCsp(headers, nonce) { + const csp = headers["content-security-policy"]; + expect(csp, "An enforcing header is required, not report-only").toBeTruthy(); + expectStorefrontCsp(csp); + const sources = policySources(csp, "script-src"); + expect(sources).not.toContain("'unsafe-inline'"); + expect(typeof nonce).toBe("string"); + expect(nonce.length).toBeGreaterThan(0); + expect(sources, "Magento's inline bootstrap nonce must match the header").toContain(`'nonce-${nonce}'`); +} + +module.exports = { expectAdminCsp, expectStorefrontCsp, expectEnforcingScriptCsp }; diff --git a/tests/integration/docker/Dockerfile b/tests/integration/docker/Dockerfile new file mode 100644 index 0000000..90525ee --- /dev/null +++ b/tests/integration/docker/Dockerfile @@ -0,0 +1,13 @@ +FROM composer:2.10@sha256:a5f59b9fd2faf31218632be4809dc6491761085e8064c31dc3b84378c48c248b AS composer +FROM node:22.19.0-bookworm-slim@sha256:4a4884e8a44826194dff92ba316264f392056cbe243dcc9fd3551e71cea02b90 AS node +FROM markoshust/magento-php:8.3-fpm-7@sha256:51ef90a707f02299caadbd0ea9b8b5ceea4082b740f3a4d9f91db2db874c5ee9 +USER root +COPY --from=composer /usr/bin/composer /usr/local/bin/composer +COPY --from=node /usr/local/bin/node /usr/local/bin/node +COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright +RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ + && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \ + && npx --yes playwright@1.62.1 install --with-deps chromium +RUN git config --system --add safe.directory /module +USER app diff --git a/tests/integration/docker/compose.yaml b/tests/integration/docker/compose.yaml new file mode 100644 index 0000000..58bb89f --- /dev/null +++ b/tests/integration/docker/compose.yaml @@ -0,0 +1,57 @@ +name: basicrum-release-baseline +services: + php: + build: . + volumes: + - application:/var/www/html + - ../../..:/module + - ./php-fpm.conf:/usr/local/etc/php-fpm.conf:ro + environment: + BASICRUM_DISPOSABLE_MAGENTO: '1' + MAGENTO_ROOT: /var/www/html + MAGENTO_STOREFRONT_URL: https://web:8443/ + MAGENTO_ADMIN_URL: https://web:8443/admin/ + MAGENTO_ADMIN_USERNAME: basicrum-test + MAGENTO_ADMIN_PASSWORD: local-test-only-9471 + depends_on: + db: + condition: service_healthy + search: + condition: service_healthy + web: + image: markoshust/magento-nginx:1.28-0@sha256:12d211176a5800ef1ebdc909865a0a5ebc691d282a6df438b082ac5587f275de + ports: + - '127.0.0.1:9443:8443' + volumes: + - application:/var/www/html:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ../../../.test-results/baseline-certs:/certs:ro + depends_on: + - php + db: + image: mariadb:10.11@sha256:07c0aaff7396b74cb7975cba78257178d188e30f531a5db2b617c48beef13c41 + environment: + MARIADB_DATABASE: magento + MARIADB_USER: magento + MARIADB_PASSWORD: disposable + MARIADB_ROOT_PASSWORD: disposable-root + volumes: + - database:/var/lib/mysql + healthcheck: + test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'] + interval: 5s + retries: 30 + search: + image: opensearchproject/opensearch:2.19.4@sha256:9e0b3b3b6805811bd63d9b9503ffe34a58ba33d03cc346000e318c6ff5c05bd9 + environment: + discovery.type: single-node + DISABLE_SECURITY_PLUGIN: 'true' + DISABLE_INSTALL_DEMO_CONFIG: 'true' + OPENSEARCH_JAVA_OPTS: '-Xms512m -Xmx512m' + healthcheck: + test: ['CMD', 'curl', '-fsS', 'http://localhost:9200/_cluster/health'] + interval: 10s + retries: 60 +volumes: + application: + database: diff --git a/tests/integration/docker/install-artifact.sh b/tests/integration/docker/install-artifact.sh new file mode 100644 index 0000000..ac134e4 --- /dev/null +++ b/tests/integration/docker/install-artifact.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env sh +set -eu +test "${BASICRUM_DISPOSABLE_MAGENTO:-}" = 1 +test "${MAGENTO_ROOT:-}" = /var/www/html +module_root=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +artifact="$module_root/.test-results/package/basicrum-analytics.zip" +php "$module_root/tests/integration/check-artifact.php" "$artifact" +cd "$MAGENTO_ROOT" +# Exact destination inside this disposable stack. Do not delete stale files: +# candidate verification below rejects them instead of concealing a bad upgrade. +mkdir -p app/code/Basicrum/Analytics +unzip -qo "$artifact" -d app/code/Basicrum/Analytics +php "$module_root/tests/integration/check-installed-candidate.php" +php bin/magento module:enable Basicrum_Analytics diff --git a/tests/integration/docker/nginx.conf b/tests/integration/docker/nginx.conf new file mode 100644 index 0000000..b61b024 --- /dev/null +++ b/tests/integration/docker/nginx.conf @@ -0,0 +1,18 @@ +upstream fastcgi_backend { + server php:9000; +} +server { + listen 8443 ssl; + server_name web; + ssl_certificate /certs/cert.pem; + ssl_certificate_key /certs/key.pem; + set $MAGE_ROOT /var/www/html; + # Disposable test challenge only. Magento's standard config otherwise rejects + # arbitrary PHP files; no such location or endpoint belongs on a real store. + location ~ "^/basicrum-test-[a-f0-9]{64}\.php$" { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $MAGE_ROOT/pub$fastcgi_script_name; + fastcgi_pass fastcgi_backend; + } + include /var/www/html/nginx.conf.sample; +} diff --git a/tests/integration/docker/php-fpm.conf b/tests/integration/docker/php-fpm.conf new file mode 100644 index 0000000..137b3e9 --- /dev/null +++ b/tests/integration/docker/php-fpm.conf @@ -0,0 +1,13 @@ +[global] +daemonize = no +error_log = /proc/self/fd/2 + +[www] +listen = 9000 +pm = dynamic +pm.max_children = 8 +pm.start_servers = 2 +pm.min_spare_servers = 2 +pm.max_spare_servers = 4 +catch_workers_output = yes +clear_env = no diff --git a/tests/integration/docker/provision.sh b/tests/integration/docker/provision.sh new file mode 100644 index 0000000..a29a8ad --- /dev/null +++ b/tests/integration/docker/provision.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env sh +set -eu + +# Only this isolated Compose application is supported. Never point this at a live store. +test "${BASICRUM_DISPOSABLE_MAGENTO:-}" = 1 +test "${MAGENTO_ROOT:-}" = /var/www/html +cd "$MAGENTO_ROOT" +if [ -f app/etc/env.php ]; then + echo 'Already installed. Provisioning refuses to overwrite an existing installation.' >&2 + exit 1 +fi + +composer create-project --repository-url=https://mirror.mage-os.org/ \ + --no-install --no-interaction magento/project-community-edition . 2.4.7-p10 +composer config repositories.magento composer https://mirror.mage-os.org/ +composer config --unset repositories.0 +composer install --no-dev --prefer-dist --no-interaction + +php bin/magento setup:install --base-url="$MAGENTO_STOREFRONT_URL" \ + --base-url-secure="$MAGENTO_STOREFRONT_URL" --use-secure=1 --use-secure-admin=1 \ + --db-host=db --db-name=magento --db-user=magento --db-password=disposable \ + --backend-frontname=admin --admin-firstname=Basicrum --admin-lastname=Test \ + --admin-email=admin@example.test --admin-user="$MAGENTO_ADMIN_USERNAME" \ + --admin-password="$MAGENTO_ADMIN_PASSWORD" --language=en_US --currency=USD --timezone=UTC \ + --search-engine=opensearch --opensearch-host=search --opensearch-port=9200 +# setup:install enables modules, so disable only after installation. Braintree +# adds external scripts even without checkout; keep Magento_Paypal for CSP checks. +php bin/magento module:disable Magento_AdminAdobeImsTwoFactorAuth Magento_TwoFactorAuth \ + Magento_AdminAnalytics PayPal_Braintree PayPal_BraintreeGraphQl \ + PayPal_BraintreeCustomerBalance PayPal_BraintreeGiftCardAccount PayPal_BraintreeGiftWrapping +php bin/magento deploy:mode:set developer +php bin/magento config:set system/smtp/disable 1 +# Admin Analytics is already disabled; its admin/usage/enabled field no longer exists. +php bin/magento config:set web/secure/use_in_frontend 1 +php bin/magento cache:enable +echo 'Pinned disposable Magento is installed. Install the module artifact before running the gate.' diff --git a/tests/integration/docker/start.sh b/tests/integration/docker/start.sh new file mode 100644 index 0000000..81e4b61 --- /dev/null +++ b/tests/integration/docker/start.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env sh +set -eu +module_root=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd) +cd "$module_root" +mkdir -p .test-results/baseline-certs +# Only ignored output needs to be writable by the unprivileged container user. +chmod a+rwx .test-results +openssl req -x509 -nodes -newkey rsa:2048 \ + -keyout .test-results/baseline-certs/key.pem -out .test-results/baseline-certs/cert.pem \ + -days 7 -subj '/CN=web' -addext 'subjectAltName=DNS:web,IP:127.0.0.1' +# The key is disposable and serves only this localhost-bound test stack. +chmod 644 .test-results/baseline-certs/key.pem +docker compose -f tests/integration/docker/compose.yaml build php +docker compose -f tests/integration/docker/compose.yaml up -d db search php +docker compose -f tests/integration/docker/compose.yaml exec -T --user root php chown app:app /var/www/html +# Use the caller's host identity for the bind-mounted checkout, not container root. +docker compose -f tests/integration/docker/compose.yaml exec -T \ + --user "$(id -u):$(id -g)" -e npm_config_cache="/tmp/basicrum-npm-$(id -u)" -w /module php npm ci +docker compose -f tests/integration/docker/compose.yaml exec -T php sh /module/tests/integration/docker/provision.sh +docker compose -f tests/integration/docker/compose.yaml up -d web diff --git a/tests/integration/enforcing-csp.spec.js b/tests/integration/enforcing-csp.spec.js new file mode 100644 index 0000000..46b047c --- /dev/null +++ b/tests/integration/enforcing-csp.spec.js @@ -0,0 +1,108 @@ +const { test, expect } = require("./fixtures"); +const { expectEnforcingScriptCsp } = require("./csp"); +const { beaconUrl, requestParameters, siteId } = require("./beacons"); + +const storefrontUrl = process.env.MAGENTO_STOREFRONT_URL; +test.skip(!storefrontUrl, "MAGENTO_STOREFRONT_URL is required"); + +test.beforeEach(async ({ page }) => { + // Observe from document creation; never alter CSP, script nonces or production assets. + await page.addInitScript(() => { + window.__basicrumCspViolations = []; + document.addEventListener("securitypolicyviolation", event => { + window.__basicrumCspViolations.push({ + directive: event.effectiveDirective, + disposition: event.disposition, + blockedURI: event.blockedURI.split("?")[0], + sourceFile: event.sourceFile.split("?")[0] + }); + }); + }); +}); + +async function openEnforcingPage(page) { + const url = new URL("basicrumcsptest/", storefrontUrl); + const response = await page.goto(url.href, { waitUntil: "load" }); + expect(response.status()).toBe(200); + expect(new URL(page.url()).pathname).toBe(url.pathname); + await expect(page.locator("#basicrum-csp-test")).toBeVisible(); + // The fixture is deliberately uncacheable, like checkout. Do not certify nonce reuse on FPC HITs. + expect(response.headers()["cache-control"]).toMatch(/\bno-store\b/); + expect(response.headers()["x-magento-cache-debug"]).not.toBe("HIT"); + // Text locators exclude script content; browsers also hide the nonce attribute. + // Inspect the actual DOM text and nonce property without modifying the script. + const nonces = await page.locator("script:not([src])").evaluateAll(scripts => scripts + .filter(script => script.textContent.includes("w.basicRumBoomerangConfig =")) + .map(script => script.nonce)); + expect(nonces).toHaveLength(1); + const [nonce] = nonces; + expectEnforcingScriptCsp(response.headers(), nonce); + await page.waitForFunction(() => typeof window.OPT_IN_BASICRUM_LOADER_WRAPPER === "function"); + return nonce; +} + +test("native enforcing CSP permits first-party scripts and consent-gated beacons with fresh nonces", async ({ + page, context, beaconTraffic, assetEvidence +}) => { + const { beacons } = beaconTraffic; + const errors = []; + const bundles = []; + page.on("pageerror", error => errors.push(error.message)); + page.on("request", request => { + if (request.url().includes("Basicrum_Analytics/js/boomr/boomerang-")) bundles.push(request.url()); + }); + let previousNonce; + for (let visit = 0; visit < 2; visit++) { + const before = beacons.length; + const nonce = await openEnforcingPage(page); + expect(nonce).not.toBe(previousNonce); + previousNonce = nonce; + await page.waitForTimeout(500); + expect(bundles).toHaveLength(visit); + expect(beacons).toHaveLength(before); + expect((await context.cookies()).filter(cookie => ["RT", "BA"].includes(cookie.name))).toEqual([]); + expect(await page.evaluate(() => window.BOOMR.version)).toBeUndefined(); + expect(await page.evaluate(() => window.basicRumBoomerangConfig.beacon_url)).toBe(beaconUrl); + + const loader = page.locator('script[src*="Basicrum_Analytics/js/loaders/consent-boomerang-loader-v1-15.min.js"]'); + await expect(loader).toHaveCount(1); + expect(new URL(await loader.getAttribute("src"), page.url()).origin).toBe(new URL(storefrontUrl).origin); + await page.evaluate(() => { + window.OPT_IN_BASICRUM_LOADER_WRAPPER(); + window.OPT_IN_BASICRUM_LOADER_WRAPPER(); + }); + await expect.poll(() => beacons.length).toBeGreaterThan(before); + expect(bundles).toHaveLength(visit + 1); + expect(new URL(bundles[visit]).origin).toBe(new URL(storefrontUrl).origin); + expect(await page.evaluate(() => window.BOOMR.version)).toBe("1.815.60"); + const parameters = requestParameters(beacons[before]); + expect(parameters.get("p_gen")).toBe("mage2"); + expect(parameters.get("p_type")).toBe("unmapped_basicrumcsptest_index_index"); + expect(parameters.get("brum_site_id")).toBe(siteId); + expect(await assetEvidence()).toEqual(expect.arrayContaining([ + "js/loaders/consent-boomerang-loader-v1-15.min.js", + "js/boomr/boomerang-1.815.60.cutting-edge.min.js" + ])); + await page.evaluate(() => window.OPT_OUT_BASICRUM_LOADER_WRAPPER()); + expect((await context.cookies()).filter(cookie => ["RT", "BA"].includes(cookie.name))).toEqual([]); + expect(await page.evaluate(() => window.__basicrumCspViolations)).toEqual([]); + expect(errors).toEqual([]); + } +}); + +test("native enforcing CSP actually blocks an unnonced inline control script", async ({ page, beaconTraffic }) => { + await openEnforcingPage(page); + expect(await page.evaluate(() => window.__basicrumCspViolations)).toEqual([]); + // Deliberate negative control, not a replacement for the production bootstrap. + await page.evaluate(() => { + const script = document.createElement("script"); + script.textContent = "window.__basicrumUnnoncedControlRan = true;"; + document.body.appendChild(script); + }); + await expect.poll(() => page.evaluate(() => window.__basicrumCspViolations.some(event => + event.disposition === "enforce" && event.blockedURI === "inline" && + ["script-src", "script-src-elem"].includes(event.directive) + ))).toBe(true); + expect(await page.evaluate(() => window.__basicrumUnnoncedControlRan)).toBeUndefined(); + expect(beaconTraffic.beacons).toEqual([]); +}); diff --git a/tests/integration/fixtures.js b/tests/integration/fixtures.js new file mode 100644 index 0000000..4bc85b8 --- /dev/null +++ b/tests/integration/fixtures.js @@ -0,0 +1,57 @@ +const { test: base, expect } = require("@playwright/test"); +const { guardNetwork } = require("./beacons"); +const { localProxy } = require("./local-proxy"); +const { verifyInstallation } = require("./installation-proof"); +const { verifyAsset } = require("./served-assets"); + +// Automatic: the guard is installed before any page, popup or navigation. +const test = base.extend({ + localNetwork: async ({}, use) => { + const proxy = await localProxy([process.env.MAGENTO_STOREFRONT_URL, process.env.MAGENTO_ADMIN_URL]); + try { + await use(proxy); + // Runs after context teardown, so late requests cannot escape the check. + expect(proxy.blocked, "Unexpected browser/transport requests were blocked").toEqual([]); + } finally { + await proxy.close(); + } + }, + proxy: async ({ localNetwork }, use) => use(localNetwork.options), + verifiedInstallation: [async ({ playwright, localNetwork }, use) => { + if (process.env.MAGENTO_STOREFRONT_URL) { + const request = await playwright.request.newContext({ proxy: localNetwork.options, ignoreHTTPSErrors: true }); + try { + // The Admin path is a route, not a second document root. It must share this origin. + const storefront = new URL(process.env.MAGENTO_STOREFRONT_URL); + if (process.env.MAGENTO_ADMIN_URL) { + expect(new URL(process.env.MAGENTO_ADMIN_URL).origin).toBe(storefront.origin); + } + await verifyInstallation(request, process.env.MAGENTO_ROOT, [storefront.href]); + } finally { + await request.dispose(); + } + } + await use(); + }, { auto: true }], + assetEvidence: [async ({ context, verifiedInstallation }, use) => { + const checks = []; + context.on("response", response => { + // Capture failures as values immediately; report them after all pending reads. + checks.push(verifyAsset(response).catch(error => error)); + }); + const verify = async () => { + const results = await Promise.all(checks); + const failure = results.find(result => result instanceof Error); + if (failure) throw failure; + return results.filter(Boolean); + }; + await use(verify); + await verify(); + }, { auto: true }], + beaconTraffic: [async ({ context, localNetwork }, use) => { + const traffic = await guardNetwork(context, { blocked: localNetwork.blocked }); + await use(traffic); + }, { auto: true }] +}); + +module.exports = { test, expect }; diff --git a/tests/integration/fixtures/Basicrum/CspTest/Controller/Index/Index.php b/tests/integration/fixtures/Basicrum/CspTest/Controller/Index/Index.php new file mode 100644 index 0000000..233db78 --- /dev/null +++ b/tests/integration/fixtures/Basicrum/CspTest/Controller/Index/Index.php @@ -0,0 +1,21 @@ +pageFactory->create(); + } +} diff --git a/tests/integration/fixtures/Basicrum/CspTest/etc/config.xml b/tests/integration/fixtures/Basicrum/CspTest/etc/config.xml new file mode 100644 index 0000000..dd2266f --- /dev/null +++ b/tests/integration/fixtures/Basicrum/CspTest/etc/config.xml @@ -0,0 +1,19 @@ + + + + + + + 0 + + + + + + 0 + + + + + + diff --git a/tests/integration/fixtures/Basicrum/CspTest/etc/frontend/routes.xml b/tests/integration/fixtures/Basicrum/CspTest/etc/frontend/routes.xml new file mode 100644 index 0000000..1dcb2fa --- /dev/null +++ b/tests/integration/fixtures/Basicrum/CspTest/etc/frontend/routes.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/integration/fixtures/Basicrum/CspTest/etc/module.xml b/tests/integration/fixtures/Basicrum/CspTest/etc/module.xml new file mode 100644 index 0000000..c73e24d --- /dev/null +++ b/tests/integration/fixtures/Basicrum/CspTest/etc/module.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/integration/fixtures/Basicrum/CspTest/registration.php b/tests/integration/fixtures/Basicrum/CspTest/registration.php new file mode 100644 index 0000000..1ef657f --- /dev/null +++ b/tests/integration/fixtures/Basicrum/CspTest/registration.php @@ -0,0 +1,6 @@ + + + + Basicrum enforcing CSP test + + + + + + + + diff --git a/tests/integration/fixtures/Basicrum/CspTest/view/frontend/templates/probe.phtml b/tests/integration/fixtures/Basicrum/CspTest/view/frontend/templates/probe.phtml new file mode 100644 index 0000000..8cc14cc --- /dev/null +++ b/tests/integration/fixtures/Basicrum/CspTest/view/frontend/templates/probe.phtml @@ -0,0 +1 @@ +

Basicrum enforcing CSP test fixture

diff --git a/tests/integration/install-csp-fixture.sh b/tests/integration/install-csp-fixture.sh new file mode 100644 index 0000000..fc33c0e --- /dev/null +++ b/tests/integration/install-csp-fixture.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env sh +set -eu + +test "${BASICRUM_DISPOSABLE_MAGENTO:-}" = 1 || { + echo 'CSP fixture installation requires BASICRUM_DISPOSABLE_MAGENTO=1.' >&2 + exit 1 +} +test -n "${MAGENTO_ROOT:-}" && test -x "$MAGENTO_ROOT/bin/magento" || { + echo 'MAGENTO_ROOT must point to a disposable Magento installation.' >&2 + exit 1 +} + +module_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +MAGENTO_ROOT=$(CDPATH= cd -- "$MAGENTO_ROOT" && pwd) +source="$module_root/tests/integration/fixtures/Basicrum/CspTest" +target="$MAGENTO_ROOT/app/code/Basicrum/CspTest" +mkdir -p "$target" +cp -R "$source/." "$target/" +# Do not certify a fixture containing stale files, or silently delete them. +diff -qr "$source" "$target" || { + echo 'CSP fixture differs from the source; inspect and remove stale fixture files before retrying.' >&2 + exit 1 +} +"$MAGENTO_ROOT/bin/magento" module:enable Basicrum_CspTest +echo 'CSP test fixture installed. Run setup:upgrade and setup:di:compile before browser tests.' diff --git a/tests/integration/installation-proof.js b/tests/integration/installation-proof.js new file mode 100644 index 0000000..2fe5417 --- /dev/null +++ b/tests/integration/installation-proof.js @@ -0,0 +1,32 @@ +const { randomBytes } = require("node:crypto"); +const { writeFile, unlink, readFile } = require("node:fs/promises"); +const { join } = require("node:path"); + +// Test-only, short-lived challenge. No diagnostic endpoint is shipped by the module. +async function verifyInstallation(request, root, urls) { + if (process.env.BASICRUM_DISPOSABLE_MAGENTO !== "1" || !root) { + throw new Error("Storefront verification requires BASICRUM_DISPOSABLE_MAGENTO=1 and MAGENTO_ROOT."); + } + const nonce = randomBytes(32).toString("hex"); + const name = `basicrum-test-${nonce}.php`; + const path = join(root, "pub", name); + const baseline = await readFile(join(__dirname, "baseline.env"), "utf8"); + const phpLine = baseline.match(/^PHP_VERSION=(.+)$/m)[1]; + await writeFile(path, ` '${nonce}', 'php' => PHP_VERSION]);\n`, { flag: "wx" }); + try { + for (const base of new Set(urls.filter(Boolean))) { + const response = await request.get(new URL(name, base).href, { maxRedirects: 0 }); + if (response.status() !== 200) { + throw new Error(`The browser URL does not serve the verified MAGENTO_ROOT (challenge HTTP ${response.status()}).`); + } + const proof = await response.json(); + if (proof.nonce !== nonce || !proof.php?.startsWith(`${phpLine}.`)) { + throw new Error("Storefront installation/PHP runtime does not match the verified baseline."); + } + } + } finally { + await unlink(path); + } +} + +module.exports = { verifyInstallation }; diff --git a/tests/integration/local-proxy.js b/tests/integration/local-proxy.js new file mode 100644 index 0000000..77630f8 --- /dev/null +++ b/tests/integration/local-proxy.js @@ -0,0 +1,67 @@ +const http = require("node:http"); +const net = require("node:net"); + +// Playwright routes only the first request of a redirect chain. Keep the +// transport restricted to the disposable installation, including HTTPS tunnels. +async function localProxy(urls) { + const allowed = new Set(urls.filter(Boolean).map(value => { + const url = new URL(value); + return `${url.hostname}:${url.port || (url.protocol === "https:" ? 443 : 80)}`; + })); + const blocked = []; + const sockets = new Set(); + const accept = (hostname, port) => { + const address = `${hostname}:${port}`; + if (allowed.has(address)) return true; + blocked.push(address); + return false; + }; + const track = socket => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + return socket; + }; + const server = http.createServer((request, response) => { + const url = new URL(request.url); + if (url.protocol !== "http:" || !accept(url.hostname, url.port || 80)) { + response.writeHead(403).end(); + return; + } + const upstream = http.request(url, { method: request.method, headers: request.headers }, reply => { + response.writeHead(reply.statusCode, reply.headers); + reply.pipe(response); + }); + upstream.on("socket", track); + upstream.on("error", () => response.destroy()); + request.pipe(upstream); + }); + server.on("connection", track); + server.on("connect", (request, client, head) => { + const url = new URL(`https://${request.url}`); + if (!accept(url.hostname, url.port || 443)) { + client.end("HTTP/1.1 403 Forbidden\r\n\r\n"); + return; + } + const upstream = track(net.connect(Number(url.port || 443), url.hostname, () => { + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length) upstream.write(head); + client.pipe(upstream); + upstream.pipe(client); + })); + upstream.on("error", () => client.destroy()); + client.on("error", () => upstream.destroy()); + client.on("close", () => upstream.destroy()); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + return { + // Chromium normally bypasses proxies for loopback; tests must not. + options: { server: `http://127.0.0.1:${server.address().port}`, bypass: "<-loopback>" }, + blocked, + close: () => new Promise(resolve => { + server.close(resolve); + for (const socket of sockets) socket.destroy(); + }) + }; +} + +module.exports = { localProxy }; diff --git a/tests/integration/page-types.spec.js b/tests/integration/page-types.spec.js new file mode 100644 index 0000000..2b1a66e --- /dev/null +++ b/tests/integration/page-types.spec.js @@ -0,0 +1,51 @@ +const { test, expect } = require("./fixtures"); +const { requestParameters, siteId } = require("./beacons"); + +const storefrontUrl = process.env.MAGENTO_STOREFRONT_URL; +test.skip(!storefrontUrl, "MAGENTO_STOREFRONT_URL is required"); + +const pages = [ + ["", "Home"], + ["catalogsearch/result/?q=bag", "Search"], + ["catalogsearch/advanced/", "Advanced Search"], + ["checkout/cart/", "Cart"], + ["customer/account/login/", "Login"], + ["customer/account/create/", "Register"], + ["customer/account/logoutSuccess/", "Logout Success"], + ["contact/", "Contact"], + ["sales/guest/form/", "Orders and Returns"], + ["customer/account/forgotpassword/", "Forgot Password"], + ["basicrum-test-missing-page", "404 Not Found"], + ["search/term/popular/", "unmapped_search_term_popular"], + // Assert the rendered destination, not the originally requested URL. + ["customer/account/", "Login"], + ["checkout/onepage/success/", "Cart"], + ["checkout/", "Cart"] +]; + +function pageTypeTest(path, label, needsSampleData = false) { + test(`${path || "/"} emits ${label}`, async ({ page, beaconTraffic }) => { + test.skip(needsSampleData && process.env.MAGENTO_SAMPLE_DATA !== "1", "Magento Luma sample data is required"); + const { beacons } = beaconTraffic; + await page.goto(new URL(path, storefrontUrl).toString(), { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.OPT_IN_BASICRUM_LOADER_WRAPPER === "function"); + expect(beacons).toHaveLength(0); + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await expect.poll(() => beacons.length, { timeout: 15000 }).toBeGreaterThan(0); + const parameters = requestParameters(beacons[0]); + expect(parameters.get("p_type")).toBe(label); + expect(parameters.get("p_gen")).toBe("mage2"); + expect(parameters.get("brum_site_id")).toBe(siteId); + }); +} + +for (const [path, label] of pages) { + pageTypeTest(path, label); +} +for (const [path, label] of [ + ["about-us", "CMS Page"], + ["fusion-backpack.html", "Product"], + ["gear/bags.html", "Category"] +]) { + pageTypeTest(path, label, true); +} diff --git a/tests/integration/release-gate.sh b/tests/integration/release-gate.sh new file mode 100755 index 0000000..e91403e --- /dev/null +++ b/tests/integration/release-gate.sh @@ -0,0 +1,63 @@ +#!/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 [ "${BASICRUM_RELEASE_TAG:-}" != "0.1.0" ] && [ "${BASICRUM_RELEASE_TAG:-}" != "v0.1.0" ]; then + echo "BASICRUM_RELEASE_TAG must be the new Phase 1 tag 0.1.0 (or v0.1.0); 0.0.2 must not be reused." >&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 +MAGENTO_ROOT=$(CDPATH= cd -- "$MAGENTO_ROOT" && pwd) +export MAGENTO_ROOT + +if [ -z "${MAGENTO_STOREFRONT_URL:-}" ]; then + echo "MAGENTO_STOREFRONT_URL is required." >&2 + exit 1 +fi + +if [ -z "${MAGENTO_ADMIN_URL:-}" ]; then + echo "MAGENTO_ADMIN_URL must be the disposable installation's Admin login URL." >&2 + exit 1 +fi + +if [ -z "${MAGENTO_ADMIN_USERNAME:-}" ] || [ -z "${MAGENTO_ADMIN_PASSWORD:-}" ]; then + echo "Disposable MAGENTO_ADMIN_USERNAME and MAGENTO_ADMIN_PASSWORD values are required." >&2 + exit 1 +fi + +module_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +magento="${MAGENTO_ROOT}/bin/magento" +candidate_commit=$(sh "$module_root/tests/integration/check-candidate.sh") +echo "Checking Basicrum candidate ${candidate_commit} for ${BASICRUM_RELEASE_TAG}." +artifact=${BASICRUM_ARTIFACT:-$module_root/.test-results/package/basicrum-analytics.zip} +php "$module_root/tests/integration/check-artifact.php" "$artifact" +php "$module_root/tests/integration/check-installed-candidate.php" + +cd "$MAGENTO_ROOT" +php "$module_root/tests/integration/check-baseline.php" +sh "$module_root/tests/integration/install-csp-fixture.sh" +"$magento" setup:upgrade +"$magento" setup:di:compile +"$magento" setup:static-content:deploy -f en_US +php "$module_root/tests/integration/config-save.php" + +cd "$module_root" +BASICRUM_DEPLOY_STATIC=0 tests/integration/configure-disposable.sh + +# Do not certify a checkout or installed copy that changed while the gate ran. +final_commit=$(sh "$module_root/tests/integration/check-candidate.sh") +if [ "$final_commit" != "$candidate_commit" ]; then + echo "The candidate commit changed during the release gate; rerun it." >&2 + exit 1 +fi +php "$module_root/tests/integration/check-installed-candidate.php" +php "$module_root/tests/integration/check-artifact.php" "$artifact" +echo "PASS: release gate for ${BASICRUM_RELEASE_TAG}, candidate commit ${candidate_commit}." diff --git a/tests/integration/served-assets.js b/tests/integration/served-assets.js new file mode 100644 index 0000000..4015e86 --- /dev/null +++ b/tests/integration/served-assets.js @@ -0,0 +1,29 @@ +const { createHash } = require("node:crypto"); +const { readFile } = require("node:fs/promises"); +const { resolve } = require("node:path"); + +const assetPaths = [ + "js/loaders/consent-boomerang-loader-v1-15.js", + "js/loaders/consent-boomerang-loader-v1-15.min.js", + "js/loaders/boomerang-loader-v15.js", + "js/loaders/boomerang-loader-v15.min.js", + "js/boomr/boomerang-1.815.60.cutting-edge.min.js" +]; + +async function verifyAsset(response) { + const pathname = new URL(response.url()).pathname; + const marker = "/Basicrum_Analytics/"; + if (!pathname.includes(marker) || !pathname.endsWith(".js")) return null; + const asset = pathname.split(marker)[1]; + if (!assetPaths.includes(asset)) throw new Error(`Unrecognized Basicrum script: ${asset}`); + if (response.status() !== 200) throw new Error(`Basicrum script was not served: ${asset}`); + const expected = await readFile(resolve(__dirname, "../../view/frontend/web", asset)); + const served = await response.body(); + const hash = value => createHash("sha256").update(value).digest("hex"); + if (hash(served) !== hash(expected)) { + throw new Error(`Served Basicrum asset differs from the candidate: ${asset}`); + } + return asset; +} + +module.exports = { verifyAsset }; diff --git a/tests/integration/storefront.spec.js b/tests/integration/storefront.spec.js new file mode 100644 index 0000000..370360c --- /dev/null +++ b/tests/integration/storefront.spec.js @@ -0,0 +1,157 @@ +const { test, expect } = require("./fixtures"); +const { guardNetwork, requestParameters, siteId } = require("./beacons"); +const { expectStorefrontCsp } = require("./csp"); +const { expectFullPageCacheHit } = require("./cache"); +const { verifyAsset } = require("./served-assets"); +const { randomUUID } = require("node:crypto"); + +const storefrontUrl = process.env.MAGENTO_STOREFRONT_URL; + +function expectHomeBeacon(beacon) { + const parameters = requestParameters(beacon); + 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(`${beacon.url()}${beacon.postData() || ""}`).not.toContain("must-not-leak"); +} + +test.skip(!storefrontUrl, "MAGENTO_STOREFRONT_URL is required"); + +test("empty-cart checkout retains core and collector sources in enforcing CSP", async ({ request }) => { + // A fresh API context has no cart. Inspect checkout's own 302, not the + // report-only cart destination, without creating a quote or order. + const response = await request.get(new URL("checkout/", storefrontUrl).href, { maxRedirects: 0 }); + expect(response.status()).toBe(302); + expect(new URL(response.headers().location, storefrontUrl).pathname).toMatch(/\/checkout\/cart\/?$/); + expectStorefrontCsp(response.headers()["content-security-policy"]); +}); + +test("rendered Magento storefront stays silent until allow and sends the expected beacon", async ({ context, page, beaconTraffic, assetEvidence }) => { + const errors = []; + const { beacons } = beaconTraffic; + 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; + } + }); + const url = new URL(storefrontUrl); + url.searchParams.set("basicrum_private", "must-not-leak"); + const response = await page.goto(url.toString(), { waitUntil: "domcontentloaded" }); + const headers = response.headers(); + const csp = headers["content-security-policy"] || headers["content-security-policy-report-only"]; + // Area-level DI once replaced all core collectors. Check the merged header, + // not only Basicrum's isolated policy DTOs or the presence of a beacon. + expectStorefrontCsp(csp); + 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); + + // Warm the cookie/vary context established by the first anonymous visit. + // Routing disables browser HTTP caching; the final navigation below must hit server FPC. + await page.reload({ waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => typeof window.OPT_IN_BASICRUM_LOADER_WRAPPER === "function"); + expect(boomerangRequests).toBe(0); + expect(beacons).toHaveLength(0); + + 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); + + expectHomeBeacon(beacons[0]); + expect(await assetEvidence()).toEqual(expect.arrayContaining([ + "js/loaders/consent-boomerang-loader-v1-15.min.js", + "js/boomr/boomerang-1.815.60.cutting-edge.min.js" + ])); + + 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; + const cachedResponse = await page.reload({ waitUntil: "domcontentloaded" }); + expectFullPageCacheHit(cachedResponse); + await page.waitForFunction(() => typeof window.OPT_IN_BASICRUM_LOADER_WRAPPER === "function"); + await page.waitForTimeout(500); + expect(beacons).toHaveLength(beaconsBeforeReload); + expect(boomerangRequests).toBe(1); + 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()); + await expect.poll(() => beacons.length, { timeout: 15000 }).toBeGreaterThan(beaconsBeforeReload); + expect(boomerangRequests).toBe(2); + expectHomeBeacon(beacons[beaconsBeforeReload]); + expect(errors).toEqual([]); +}); + +test("independent visitors share cached configuration but never a consent grant", async ({ browser, page, beaconTraffic, localNetwork }) => { + const secondContext = await browser.newContext({ + proxy: localNetwork.options, ignoreHTTPSErrors: true, serviceWorkers: "block" + }); + const otherTraffic = await guardNetwork(secondContext, { blocked: localNetwork.blocked }); + const assetChecks = []; + let otherBoomerangRequests = 0; + secondContext.on("request", request => { + if (request.url().includes("Basicrum_Analytics/js/boomr/boomerang-")) otherBoomerangRequests++; + }); + secondContext.on("response", response => assetChecks.push(verifyAsset(response).catch(error => error))); + const otherPage = await secondContext.newPage(); + const consentedProbePage = await page.context().newPage(); + const configScript = p => p.locator("script:not([src])").evaluateAll(scripts => + scripts.find(script => script.textContent.includes("w.basicRumBoomerangConfig ="))?.textContent + ); + try { + // Establish the second visitor's anonymous Magento vary context before the probe. + await otherPage.goto(storefrontUrl); + await page.goto(storefrontUrl); + await page.reload(); + await page.waitForFunction(() => typeof window.OPT_IN_BASICRUM_LOADER_WRAPPER === "function"); + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await expect.poll(() => beaconTraffic.beacons.length).toBeGreaterThan(0); + expect((await page.context().cookies()).some(cookie => ["RT", "BA"].includes(cookie.name))).toBe(true); + + // Populate a new cache entry from a request carrying measurement cookies. + // A pre-consent entry cannot prove isolation from a consented visitor. + const probe = new URL(storefrontUrl); + probe.searchParams.set("basicrum_fpc_probe", randomUUID()); + // Use another tab in the same cookie jar: this isolates cache population + // from Chromium's navigation-time sendBeacon interception limitations. + const populated = await consentedProbePage.goto(probe.href); + expect(populated.status()).toBe(200); + expect(populated.headers()["x-magento-cache-debug"]).toBe("MISS"); + const cached = await otherPage.goto(probe.href); + expectFullPageCacheHit(cached); + await otherPage.waitForFunction(() => typeof window.OPT_IN_BASICRUM_LOADER_WRAPPER === "function"); + await otherPage.waitForTimeout(500); + const firstConfig = await configScript(consentedProbePage); + expect(firstConfig).toContain("w.basicRumBoomerangConfig ="); + expect(await configScript(otherPage)).toBe(firstConfig); + expect(otherTraffic.beacons).toHaveLength(0); + expect(otherBoomerangRequests).toBe(0); + expect((await secondContext.cookies()).some(cookie => ["RT", "BA"].includes(cookie.name))).toBe(false); + expect(await otherPage.evaluate(() => !!window.BOOMR?.version)).toBe(false); + await otherPage.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await expect.poll(() => otherTraffic.beacons.length).toBeGreaterThan(0); + expect(otherBoomerangRequests).toBe(1); + expect(requestParameters(otherTraffic.beacons[0]).get("brum_site_id")).toBe(siteId); + const assets = await Promise.all(assetChecks); + const error = assets.find(result => result instanceof Error); + if (error) throw error; + expect(assets).toContain("js/boomr/boomerang-1.815.60.cutting-edge.min.js"); + await page.evaluate(() => window.OPT_OUT_BASICRUM_LOADER_WRAPPER()); + await otherPage.evaluate(() => window.OPT_OUT_BASICRUM_LOADER_WRAPPER()); + } finally { + await consentedProbePage.close(); + await secondContext.close(); + } +}); diff --git a/tests/integration/test-artifact.php b/tests/integration/test-artifact.php new file mode 100644 index 0000000..0f363f6 --- /dev/null +++ b/tests/integration/test-artifact.php @@ -0,0 +1,38 @@ + static fn (ZipArchive $zip) => $zip->addFromString('.DS_Store', 'synthetic local data'), + 'development file' => static fn (ZipArchive $zip) => $zip->addFromString('docs/stale.md', 'stale development data'), + 'missing file' => static fn (ZipArchive $zip) => $zip->deleteName('registration.php'), + 'modified file' => static fn (ZipArchive $zip) => $zip->addFromString('registration.php', ' $mutate) { + $temporary = tempnam(sys_get_temp_dir(), 'basicrum-archive-'); + try { + if (!copy($artifact, $temporary)) { + throw new RuntimeException('Unable to copy the candidate ZIP.'); + } + $zip = new ZipArchive(); + if ($zip->open($temporary) !== true || !$mutate($zip) || !$zip->close()) { + throw new RuntimeException('Unable to prepare archive regression: ' . $name); + } + $process = proc_open([PHP_BINARY, __DIR__ . '/check-artifact.php', $temporary], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + if (proc_close($process) === 0 || !str_contains($stderr . $stdout, 'Distribution contents differ')) { + throw new RuntimeException('Archive verification did not reject ' . $name . ': ' . $stderr . $stdout); + } + echo 'PASS: archive rejects ' . $name . PHP_EOL; + } finally { + unlink($temporary); + } +} 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/cache-evidence.spec.js b/tests/js/cache-evidence.spec.js new file mode 100644 index 0000000..18cb3a2 --- /dev/null +++ b/tests/js/cache-evidence.spec.js @@ -0,0 +1,11 @@ +const { test, expect } = require("@playwright/test"); +const { expectFullPageCacheHit } = require("../integration/cache"); + +test("cache evidence requires a successful native HIT, not just a reload", () => { + const response = (status, value) => ({ status: () => status, headers: () => ({ "x-magento-cache-debug": value }) }); + expect(() => expectFullPageCacheHit(response(200, "HIT"))).not.toThrow(); + for (const value of [undefined, "MISS", "UNCACHEABLE", "HIT, MISS"]) { + expect(() => expectFullPageCacheHit(response(200, value))).toThrow("A real full-page-cache HIT is required"); + } + expect(() => expectFullPageCacheHit(response(500, "HIT"))).toThrow("Cached storefront navigation must succeed"); +}); 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/csp-evidence.spec.js b/tests/js/csp-evidence.spec.js new file mode 100644 index 0000000..d0d1ca9 --- /dev/null +++ b/tests/js/csp-evidence.spec.js @@ -0,0 +1,22 @@ +const { test, expect } = require("@playwright/test"); +const { expectEnforcingScriptCsp } = require("../integration/csp"); +const { beaconUrl } = require("../integration/beacons"); + +const nonce = "native-test-nonce"; +const collector = new URL(beaconUrl).origin; +const policy = `script-src 'self' www.paypal.com 'nonce-${nonce}'; ` + + `connect-src 'self' www.paypal.com ${collector}; img-src 'self' www.paypal.com ${collector};`; + +test("enforcing script evidence rejects report-only, unsafe-inline and missing or mismatched nonces", () => { + expectEnforcingScriptCsp({ "content-security-policy": policy }, nonce); + expect(() => expectEnforcingScriptCsp({ "content-security-policy-report-only": policy }, nonce)).toThrow(); + for (const incorrect of [undefined, "", "different-nonce"]) { + expect(() => expectEnforcingScriptCsp({ "content-security-policy": policy }, incorrect)).toThrow(); + } + expect(() => expectEnforcingScriptCsp({ + "content-security-policy": policy.replace("script-src", "script-src 'unsafe-inline'") + }, nonce)).toThrow(); + expect(() => expectEnforcingScriptCsp({ + "content-security-policy": policy.replace(`'nonce-${nonce}'`, "") + }, nonce)).toThrow(); +}); diff --git a/tests/js/csp-fixture.spec.js b/tests/js/csp-fixture.spec.js new file mode 100644 index 0000000..ceefafe --- /dev/null +++ b/tests/js/csp-fixture.spec.js @@ -0,0 +1,52 @@ +const { test, expect } = require("@playwright/test"); +const { spawnSync } = require("node:child_process"); +const { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join, resolve } = require("node:path"); + +test("CSP fixture installer guards the target, refreshes it and rejects stale extra files", () => { + const root = mkdtempSync(join(tmpdir(), "basicrum-csp-fixture-")); + const installer = resolve(__dirname, "../integration/install-csp-fixture.sh"); + const calls = join(root, "magento-calls"); + const target = join(root, "magento/app/code/Basicrum/CspTest"); + try { + mkdirSync(join(root, "magento/bin"), { recursive: true }); + writeFileSync(join(root, "magento/bin/magento"), + '#!/bin/sh\nprintf "%s\\n" "$*" >> "$BASICRUM_TEST_MAGENTO_CALLS"\n', { mode: 0o755 }); + const run = extra => spawnSync("sh", [installer], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + BASICRUM_DISPOSABLE_MAGENTO: "1", + MAGENTO_ROOT: "magento", // A relative installation root must also work. + BASICRUM_TEST_MAGENTO_CALLS: calls, + ...extra + } + }); + expect(run({ BASICRUM_DISPOSABLE_MAGENTO: "" }).status).not.toBe(0); + expect(run({ MAGENTO_ROOT: "missing" }).status).not.toBe(0); + expect(existsSync(target)).toBe(false); + expect(existsSync(calls)).toBe(false); + + const installed = run(); + expect(installed.status, installed.stdout + installed.stderr).toBe(0); + const registration = join(target, "registration.php"); + const expected = readFileSync(registration, "utf8"); + writeFileSync(registration, "outdated fixture"); + const refreshed = run(); + expect(refreshed.status, refreshed.stdout + refreshed.stderr).toBe(0); + expect(readFileSync(registration, "utf8")).toBe(expected); + expect(readFileSync(calls, "utf8")).toBe("module:enable Basicrum_CspTest\n".repeat(2)); + + const stale = join(target, "stale.php"); + writeFileSync(stale, "stale fixture file"); + const rejected = run(); + expect(rejected.status).not.toBe(0); + expect(rejected.stderr).toContain("inspect and remove stale fixture files"); + expect(readFileSync(stale, "utf8")).toBe("stale fixture file"); + expect(readFileSync(calls, "utf8")).toBe("module:enable Basicrum_CspTest\n".repeat(2)); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/js/installation-evidence.spec.js b/tests/js/installation-evidence.spec.js new file mode 100644 index 0000000..4649bb3 --- /dev/null +++ b/tests/js/installation-evidence.spec.js @@ -0,0 +1,52 @@ +const { test, expect } = require("@playwright/test"); +const { mkdtemp, mkdir, readdir, rm, readFile } = require("node:fs/promises"); +const { join } = require("node:path"); +const { tmpdir } = require("node:os"); +const { verifyInstallation } = require("../integration/installation-proof"); +const { verifyAsset } = require("../integration/served-assets"); + +test("installation challenge binds URL and web PHP to the local root and is always removed", async () => { + const root = await mkdtemp(join(tmpdir(), "basicrum-installation-")); + const previous = process.env.BASICRUM_DISPOSABLE_MAGENTO; + await mkdir(join(root, "pub")); + process.env.BASICRUM_DISPOSABLE_MAGENTO = "1"; + try { + for (const condition of ["match", "wrong-root", "wrong-php", "redirect"]) { + const request = { get: async url => { + const name = new URL(url).pathname.slice(1); + const nonce = name.match(/^basicrum-test-([a-f0-9]+)\.php$/)[1]; + expect(await readFile(join(root, "pub", name), "utf8")).toContain(nonce); + return { + status: () => condition === "redirect" ? 302 : 200, + json: async () => ({ nonce: condition === "wrong-root" ? "stale" : nonce, + php: condition === "wrong-php" ? "8.5.1" : "8.3.31" }) + }; + } }; + const check = verifyInstallation(request, root, ["https://store.test/"]); + if (condition === "match") await check; + else await expect(check).rejects.toThrow(/installation|MAGENTO_ROOT/); + expect(await readdir(join(root, "pub"))).toEqual([]); + } + process.env.BASICRUM_DISPOSABLE_MAGENTO = "0"; + await expect(verifyInstallation({}, root, [])).rejects.toThrow("DISPOSABLE"); + expect(await readdir(join(root, "pub"))).toEqual([]); + } finally { + if (previous === undefined) delete process.env.BASICRUM_DISPOSABLE_MAGENTO; + else process.env.BASICRUM_DISPOSABLE_MAGENTO = previous; + await rm(root, { recursive: true, force: true }); + } +}); + +test("served asset evidence rejects stale bytes, unexpected scripts and error responses", async () => { + const asset = "js/loaders/consent-boomerang-loader-v1-15.min.js"; + const bytes = await readFile(join(__dirname, "../../view/frontend/web", asset)); + const response = (body, status = 200, name = asset) => ({ + url: () => `https://store.test/static/version123/frontend/Magento/luma/en_US/Basicrum_Analytics/${name}`, + status: () => status, + body: async () => body + }); + expect(await verifyAsset(response(bytes))).toBe(asset); + await expect(verifyAsset(response(Buffer.from("stale")))).rejects.toThrow("differs"); + await expect(verifyAsset(response(bytes, 404))).rejects.toThrow("not served"); + await expect(verifyAsset(response(bytes, 200, "js/unexpected.js"))).rejects.toThrow("Unrecognized"); +}); 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/network-guard.spec.js b/tests/js/network-guard.spec.js new file mode 100644 index 0000000..a6fbb48 --- /dev/null +++ b/tests/js/network-guard.spec.js @@ -0,0 +1,121 @@ +const { createServer } = require("node:http"); +const { connect } = require("node:net"); +const { test: base, expect } = require("@playwright/test"); +const { guardNetwork } = require("../integration/beacons"); +const { localProxy } = require("../integration/local-proxy"); + +// Both hostnames resolve to this loopback-only server. Unexpected requests can +// be observed without risking traffic to a real collector, even if the guard fails. +const test = base.extend({ + localNetwork: async ({ localStore }, use) => { + const proxy = await localProxy([localStore.url]); + try { + await use(proxy); + } finally { + await proxy.close(); + } + }, + proxy: async ({ localNetwork }, use) => use(localNetwork.options), + localStore: async ({}, use) => { + const requests = []; + const server = createServer((request, response) => { + requests.push(request.url); + if (request.url === "/redirect-local") { + response.writeHead(302, { location: "/redirect-external" }); + } else if (request.url === "/redirect-external") { + response.writeHead(302, { location: `http://localhost:${server.address().port}/outside` }); + } else { + response.writeHead(200, { "content-type": "text/html" }); + } + response.end("Local fixture"); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + try { + await use({ + url: `http://127.0.0.1:${server.address().port}`, + otherOrigin: `http://localhost:${server.address().port}`, + requests + }); + } finally { + await new Promise(resolve => { + server.close(resolve); + server.closeAllConnections(); + }); + } + } +}); + +test("network guard intercepts the expected beacon and blocks unexpected destinations", async ({ context, page, localStore }) => { + const traffic = await guardNetwork(context, { + storefrontUrl: localStore.url, + endpointUrl: `${localStore.otherOrigin}/beacon` + }); + await page.goto(localStore.url); + const results = await page.evaluate(async ({ url, otherOrigin }) => { + const urls = [ + `${otherOrigin}/beacon`, + `${otherOrigin}/wrong-path`, + `${url}/unexpected-collector` + ]; + return Promise.all(urls.map(async url => { + try { + await fetch(url, { method: "POST", body: "p_gen=mage2&brum_site_id=synthetic" }); + return "sent"; + } catch { + return "blocked"; + } + })); + }, localStore); + expect(results).toEqual(["sent", "blocked", "blocked"]); + expect(traffic.beacons).toHaveLength(1); + expect(traffic.blocked).toHaveLength(2); + expect(localStore.requests).toEqual(["/"]); +}); + +test("network guard also applies to popup first requests", async ({ context, page, localStore }) => { + const traffic = await guardNetwork(context, { storefrontUrl: localStore.url }); + const popup = context.waitForEvent("page"); + await page.evaluate(url => window.open(`${url}/popup`, "_blank"), localStore.otherOrigin); + await popup; + await expect.poll(() => traffic.blocked).toEqual([`${localStore.otherOrigin}/popup`]); + expect(localStore.requests).toEqual([]); +}); + +test("same-origin redirect chains cannot escape to another origin", async ({ context, page, localStore, localNetwork }) => { + const traffic = await guardNetwork(context, { storefrontUrl: localStore.url }); + const response = await page.goto(`${localStore.url}/redirect-local`); + expect(response.status()).toBe(403); + expect(traffic.blocked).toEqual([]); + expect(localNetwork.blocked).toEqual([new URL(localStore.otherOrigin).host]); + expect(localStore.requests).toEqual(["/redirect-local", "/redirect-external"]); +}); + +test("network guard closes WebSockets without connecting to their server", async ({ context, page, localStore }) => { + const traffic = await guardNetwork(context, { storefrontUrl: localStore.url }); + await page.goto(localStore.url); + await page.evaluate(url => new Promise(resolve => { + const socket = new WebSocket(url.replace("http:", "ws:") + "/socket"); + socket.onclose = resolve; + socket.onerror = resolve; + }), localStore.url); + expect(traffic.blocked).toEqual([localStore.url.replace("http:", "ws:") + "/socket"]); + expect(localStore.requests).toEqual(["/"]); +}); + +test("proxy refuses an unexpected HTTPS tunnel before connecting", async ({ localNetwork, localStore }) => { + const proxy = new URL(localNetwork.options.server); + const destination = new URL(localStore.otherOrigin).host; + const reply = await new Promise((resolve, reject) => { + const socket = connect(Number(proxy.port), proxy.hostname, () => { + socket.write(`CONNECT ${destination} HTTP/1.1\r\nHost: ${destination}\r\n\r\n`); + }); + socket.once("error", reject); + socket.once("data", data => { + socket.destroy(); + resolve(data.toString()); + }); + }); + expect(reply).toContain("403 Forbidden"); + expect(localNetwork.blocked).toEqual([destination]); + expect(localStore.requests).toEqual([]); +}); diff --git a/tests/js/provisioning.spec.js b/tests/js/provisioning.spec.js new file mode 100644 index 0000000..361095a --- /dev/null +++ b/tests/js/provisioning.spec.js @@ -0,0 +1,70 @@ +const { test, expect } = require("@playwright/test"); +const { spawnSync } = require("node:child_process"); +const { mkdtempSync, rmSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join } = require("node:path"); + +// Exercise the real shell script with CLI doubles, without Magento or network +// access. Remap its guarded /var/www/html cd to an empty temporary directory. +const harness = ` +basicrum_test_analytics_disabled=0 +cd() { command cd "$BASICRUM_TEST_ROOT"; } +composer() { printf 'composer %s\\n' "$*"; } +php() { + printf 'php %s\\n' "$*" + if [ "$2" = module:disable ]; then + for argument in "$@"; do + if [ "$argument" = Magento_AdminAnalytics ]; then basicrum_test_analytics_disabled=1; fi + done + fi + if [ "$2" = config:set ]; then + if [ "$3" = admin/usage/enabled ] && [ "$basicrum_test_analytics_disabled" = 1 ]; then + echo 'The "admin/usage/enabled" path does not exist.' >&2 + return 1 + fi + if [ "$3" = "$BASICRUM_TEST_FAIL_CONFIG" ]; then + echo 'Synthetic configuration failure' >&2 + return 42 + fi + fi +} +. "$BASICRUM_PROVISION_SCRIPT" +`; + +test("fresh provisioning keeps Admin Analytics disabled without writing its removed field", () => { + const root = mkdtempSync(join(tmpdir(), "basicrum-provisioning-")); + try { + const run = extra => spawnSync("sh", ["-c", harness], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + BASICRUM_TEST_ROOT: root, + BASICRUM_PROVISION_SCRIPT: join(__dirname, "../integration/docker/provision.sh"), + BASICRUM_DISPOSABLE_MAGENTO: "1", + MAGENTO_ROOT: "/var/www/html", + MAGENTO_STOREFRONT_URL: "https://web:8443/", + MAGENTO_ADMIN_USERNAME: "synthetic", + MAGENTO_ADMIN_PASSWORD: "synthetic", + BASICRUM_TEST_FAIL_CONFIG: "", + ...extra + } + }); + const success = run({}); + expect(success.status, success.stderr).toBe(0); + expect(success.stdout).toMatch(/module:disable .*Magento_AdminAnalytics/); + expect(success.stdout).not.toContain("config:set admin/usage/enabled"); + expect(success.stdout).toContain("config:set system/smtp/disable 1"); + expect(success.stdout).toContain("config:set web/secure/use_in_frontend 1"); + expect(success.stdout).toContain("php bin/magento cache:enable"); + expect(success.stdout).toContain("Pinned disposable Magento is installed."); + + const failure = run({ BASICRUM_TEST_FAIL_CONFIG: "web/secure/use_in_frontend" }); + expect(failure.status).toBe(42); + expect(failure.stderr).toContain("Synthetic configuration failure"); + expect(failure.stdout).not.toContain("php bin/magento cache:enable"); + expect(failure.stdout).not.toContain("Pinned disposable Magento is installed."); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/js/real-boomerang.spec.js b/tests/js/real-boomerang.spec.js new file mode 100644 index 0000000..36f5b25 --- /dev/null +++ b/tests/js/real-boomerang.spec.js @@ -0,0 +1,270 @@ +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://shop.test/static/version123/Basicrum_Analytics/js/boomr/boomerang-1.815.60.cutting-edge.min.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); + // This is freshly rendered by the real Config, Footer and footer.phtml in + // global setup, not a JavaScript reimplementation of the PHP wait plugin. + const fixtures = JSON.parse(fs.readFileSync(path.join(root, ".test-results/rendered-footer.json"), "utf8")); + const fixture = options.fixture || (options.stripQueryString ? "redacted" : "default"); + expect(fixtures[fixture]).toBeTruthy(); + await page.addScriptTag({ content: fixtures[fixture] }); + + 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"); +} + +async function pauseWaitClock(page) { + await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") }); + await page.clock.pauseAt(new Date("2026-01-01T00:00:01Z")); +} + +async function waitForRenderedTimer(page) { + await expect.poll(async () => { + // Advance deferred Boomerang initialization, without advancing the 1s wait. + await page.clock.runFor(10); + return page.evaluate(() => { + const plugin = window.BOOMR?.plugins?.WaitAfterOnload; + return Boolean(plugin && plugin.timer !== null && !plugin.complete); + }); + }).toBe(true); +} + +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("the rendered wait plugin delays the beacon, then completes", async ({ page }) => { + await pauseWaitClock(page); + const gate = await prepareRealPage(page, { fixture: "delayed" }); + await page.addScriptTag({ path: loaderPath(consentLoader) }); + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await gate.downloadStarted; + await waitForRealBoomerang(page); + await waitForRenderedTimer(page); + await page.clock.runFor(500); + expect(gate.beaconRequests()).toBe(0); + expect(await page.evaluate(() => window.BOOMR.plugins.WaitAfterOnload.is_complete())).toBe(false); + await page.clock.runFor(600); + await expect.poll(() => gate.beaconRequests()).toBe(1); + expect(await page.evaluate(() => window.BOOMR.plugins.WaitAfterOnload.is_complete())).toBe(true); + expect(await page.evaluate(() => window.BOOMR.plugins.WaitAfterOnload.timer)).toBe(null); + expect(requestParameters(gate.beaconRequestData()[0]).get("brum_site_id")).toBe(siteId); + }); + + for (const fixture of ["default", "disabledWait", "zeroWait"]) { + test(`rendered ${fixture} configuration omits the wait plugin`, async ({ page }) => { + const gate = await prepareRealPage(page, { fixture }); + expect(await page.evaluate(() => window.BOOMR.plugins?.WaitAfterOnload)).toBeUndefined(); + await page.addScriptTag({ path: loaderPath(consentLoader) }); + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await expect.poll(() => gate.beaconRequests()).toBeGreaterThan(0); + }); + } + + 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 }) => { + await pauseWaitClock(page); + const gate = await prepareRealPage(page, { fixture: "delayed" }); + await page.addScriptTag({ path: loaderPath(consentLoader) }); + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await gate.downloadStarted; + await waitForRealBoomerang(page); + await waitForRenderedTimer(page); + await page.evaluate(() => window.OPT_OUT_BASICRUM_LOADER_WRAPPER()); + expect(await page.evaluate(() => window.BOOMR.plugins.WaitAfterOnload.timer)).toBe(null); + await page.evaluate(() => window.OPT_IN_BASICRUM_LOADER_WRAPPER()); + await page.clock.runFor(1500); + expect(await page.evaluate(() => window.BOOMR.plugins.WaitAfterOnload.is_complete())).toBe(false); + + 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/js/release-candidate.spec.js b/tests/js/release-candidate.spec.js new file mode 100644 index 0000000..0e69089 --- /dev/null +++ b/tests/js/release-candidate.spec.js @@ -0,0 +1,44 @@ +const { test, expect } = require("@playwright/test"); +const { spawnSync } = require("node:child_process"); +const { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, rmSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join } = require("node:path"); + +test("candidate guard requires its own clean committed checkout", () => { + const root = mkdtempSync(join(tmpdir(), "basicrum-release-candidate-")); + const script = join(root, "tests/integration/check-candidate.sh"); + const run = (command, args) => spawnSync(command, args, { cwd: root, encoding: "utf8" }); + const git = (...args) => { + const result = run("git", args); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trim(); + }; + const commit = () => git("-c", "user.name=Basicrum Test", "-c", "user.email=test@example.test", "commit", "--no-gpg-sign", "-m", "fixture"); + const check = () => run("sh", [script]); + try { + mkdirSync(join(root, "tests/integration"), { recursive: true }); + copyFileSync(join(__dirname, "../integration/check-candidate.sh"), script); + git("init", "--quiet"); + git("config", "core.hooksPath", "/dev/null"); + expect(check().status).not.toBe(0); // No committed candidate yet. + git("add", "."); + commit(); + const clean = check(); + expect(clean.status, clean.stderr).toBe(0); + expect(clean.stdout.trim()).toBe(git("rev-parse", "HEAD")); + writeFileSync(join(root, "extra.php"), "]*)>([\s\S]*?)<\/script>/g)]; + assert.equal(tags.length, 2, name + ": expected bootstrap and loader"); + const inline = tags.filter((tag) => !/\bsrc=/.test(tag[1])); + assert.equal(inline.length, 1, name + ": expected one inline bootstrap"); + scripts[name] = inline[0][2]; + } + fs.mkdirSync(path.dirname(output), { recursive: true }); + fs.writeFileSync(output, JSON.stringify(scripts)); +}; diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php new file mode 100644 index 0000000..3b9f962 --- /dev/null +++ b/tests/php/bootstrap.php @@ -0,0 +1,369 @@ +_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 { + class Area + { + public const AREA_FRONTEND = 'frontend'; + } + + class State + { + public function __construct(private ?string $areaCode) + { + } + + public function getAreaCode(): string + { + if ($this->areaCode === null) { + throw new \Magento\Framework\Exception\LocalizedException('Area code is not set'); + } + return $this->areaCode; + } + } + +} + +namespace Magento\Framework\App\Request { + class Http + { + public function __construct(private string $fullActionName = '') + { + } + + public function getFullActionName(): string + { + return $this->fullActionName; + } + } +} + +namespace Magento\Framework\App\Response { + class Http + { + public function __construct(private int $statusCode = 200) + { + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + } +} + +namespace Magento\Csp\Api { + interface PolicyCollectorInterface + { + public function collect(array $defaultPolicies = []): array; + } +} + +namespace Magento\Csp\Model\Policy { + class FetchPolicy + { + /** @param string[] $hostSources */ + public function __construct( + private string $id, + private bool $noneAllowed = true, + private array $hostSources = [] + ) { + } + + public function getId(): string + { + return $this->id; + } + + /** @return string[] */ + public function getHostSources(): array + { + return $this->hostSources; + } + + public function isNoneAllowed(): bool + { + return $this->noneAllowed; + } + } +} + +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); + public function getWebsite($websiteId = 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 int $websiteId) + { + } + + public function getWebsiteId(): int + { + return $this->websiteId; + } + } + + final class BasicrumTestStoreManager implements \Magento\Store\Model\StoreManagerInterface + { + private array $websites; + + /** @param array $storeWebsites */ + public function __construct(private array $storeWebsites = []) + { + $this->websites = array_values(array_unique(['base', ...array_values($storeWebsites)])); + } + + public function getStore($code = null): BasicrumTestStore + { + $websiteCode = $this->storeWebsites[(string) $code] ?? 'base'; + return new BasicrumTestStore(array_search($websiteCode, $this->websites, true) + 1); + } + + public function getWebsite($websiteId = null): BasicrumTestWebsite + { + return new BasicrumTestWebsite($this->websites[$websiteId - 1]); + } + } + + 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/check-classmap.php b/tests/php/check-classmap.php new file mode 100644 index 0000000..ac46d66 --- /dev/null +++ b/tests/php/check-classmap.php @@ -0,0 +1,17 @@ + $file) { + if (str_starts_with($class, 'BasicrumTest') + || str_starts_with(str_replace('\\', '/', $file), $root . '/tests/') + ) { + throw new RuntimeException('Test code leaked into the production classmap: ' . $class); + } +} +echo "PASS: optimized classmap contains production classes, not test doubles.\n"; diff --git a/tests/php/footer-fixture.php b/tests/php/footer-fixture.php new file mode 100644 index 0000000..bafdeac --- /dev/null +++ b/tests/php/footer-fixture.php @@ -0,0 +1,44 @@ + $values + */ +function basicrum_render_footer(array $values, PageTypeDetectorInterface $detector): string +{ + $footer = new Footer($detector, new Config(new BasicrumTestScopeConfig($values))); + $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(); + try { + include dirname(__DIR__, 2) . '/view/frontend/templates/footer.phtml'; + return (string) ob_get_contents(); + } finally { + ob_end_clean(); + } +} diff --git a/tests/php/render-browser-fixtures.php b/tests/php/render-browser-fixtures.php new file mode 100644 index 0000000..0c79a98 --- /dev/null +++ b/tests/php/render-browser-fixtures.php @@ -0,0 +1,28 @@ + $settings) { + $values = []; + foreach ($settings as $path => $value) { + $values[basicrum_test_key('default', 0, $path)] = $value; + } + $rendered[$name] = basicrum_render_footer( + $values, + new PageTypeDetector(new HttpRequest('catalog_product_view'), new HttpResponse()) + ); +} +echo json_encode($rendered, JSON_THROW_ON_ERROR); diff --git a/tests/php/run.php b/tests/php/run.php new file mode 100644 index 0000000..6ce9d09 --- /dev/null +++ b/tests/php/run.php @@ -0,0 +1,1012 @@ + $tests */ +$tests = []; + +$tests['release baseline rejects wrong or missing platform versions'] = function () use ($root): void { + $expected = parse_ini_file($root . '/tests/integration/baseline.env', false, INI_SCANNER_RAW); + $matching = [ + 'MAGENTO_VERSION' => '2.4.7-p10', + 'PHP_VERSION' => '8.3.30', + 'COMPOSER_VERSION' => '2.10.0', + 'MARIADB_VERSION' => '10.11.14-MariaDB', + 'OPENSEARCH_VERSION' => '2.19.4', + ]; + basicrum_assert_same([], basicrum_baseline_errors($expected, $matching), 'declared platform passes'); + foreach ([ + 'MAGENTO_VERSION' => '2.4.9', + 'PHP_VERSION' => '8.5.6', + 'COMPOSER_VERSION' => '2.1.0', + 'MARIADB_VERSION' => '11.4.0-MariaDB', + 'OPENSEARCH_VERSION' => '2.190.0', + ] as $key => $version) { + $wrong = array_replace($matching, [$key => $version]); + basicrum_assert_same([$key], array_keys(basicrum_baseline_errors($expected, $wrong)), $key . ' mismatch'); + } + basicrum_assert_same(array_keys($matching), array_keys(basicrum_baseline_errors($expected, [])), 'missing versions fail closed'); +}; + +/** + * @return array{status: int, stdout: string, stderr: string} + */ +$runDisposableGuard = static function (string $moduleOutput, int $moduleStatus, bool $withRunner = true) use ($root): array { + $temporaryRoot = sys_get_temp_dir() . '/basicrum-magento-guard-' . bin2hex(random_bytes(6)); + $temporaryBin = $temporaryRoot . '/bin'; + basicrum_assert_true(mkdir($temporaryBin, 0700, true), 'create temporary Magento root'); + $module = $temporaryRoot . '/module'; + mkdir($module . '/tests/integration', 0700, true); + mkdir($module . '/node_modules/.bin', 0700, true); + copy($root . '/tests/integration/configure-disposable.sh', $module . '/tests/integration/configure-disposable.sh'); + if ($withRunner) { + file_put_contents($module . '/node_modules/.bin/playwright', "#!/bin/sh\nexit 74\n"); + chmod($module . '/node_modules/.bin/playwright', 0700); + } + + $fakeMagento = $temporaryBin . '/magento'; + $fakeMagentoScript = <<<'SH' +#!/bin/sh +if [ "$1" = "module:status" ] && [ "$2" = "--enabled" ]; then + printf '%s\n' "${BASICRUM_FAKE_MODULE_OUTPUT:-}" + exit "${BASICRUM_FAKE_MODULE_STATUS:-0}" +fi +echo "configuration mutation: $*" >&2 +exit 73 +SH; + basicrum_assert_true(file_put_contents($fakeMagento, $fakeMagentoScript) !== false, 'write fake Magento CLI'); + basicrum_assert_true(chmod($fakeMagento, 0700), 'make fake Magento CLI executable'); + + $pipes = []; + try { + $process = proc_open( + ['/bin/sh', $module . '/tests/integration/configure-disposable.sh'], + [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + $root, + [ + 'PATH' => (string) getenv('PATH'), + 'BASICRUM_DISPOSABLE_MAGENTO' => '1', + 'BASICRUM_FAKE_MODULE_OUTPUT' => $moduleOutput, + 'BASICRUM_FAKE_MODULE_STATUS' => (string) $moduleStatus, + 'MAGENTO_ROOT' => $temporaryRoot, + 'MAGENTO_STOREFRONT_URL' => 'https://magento.test/', + ] + ); + basicrum_assert_true(is_resource($process), 'start disposable integration guard'); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return [ + 'status' => proc_close($process), + 'stdout' => $stdout, + 'stderr' => $stderr, + ]; + } finally { + if (isset($pipes[1]) && is_resource($pipes[1])) { + fclose($pipes[1]); + } + if (isset($pipes[2]) && is_resource($pipes[2])) { + fclose($pipes[2]); + } + if (is_file($fakeMagento)) { + unlink($fakeMagento); + } + if (is_dir($temporaryBin)) { + rmdir($temporaryBin); + } + if ($withRunner) { + unlink($module . '/node_modules/.bin/playwright'); + } + unlink($module . '/tests/integration/configure-disposable.sh'); + rmdir($module . '/tests/integration'); + rmdir($module . '/tests'); + rmdir($module . '/node_modules/.bin'); + rmdir($module . '/node_modules'); + rmdir($module); + if (is_dir($temporaryRoot)) { + rmdir($temporaryRoot); + } + } +}; + +/** + * @return array{status: int, stdout: string, stderr: string} + */ +$runReleaseGateGuard = static function (string $releaseTag, array $environment = []) use ($root): array { + $pipes = []; + try { + $process = proc_open( + ['/bin/sh', $root . '/tests/integration/release-gate.sh'], + [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + $root, + array_merge([ + 'PATH' => (string) getenv('PATH'), + 'BASICRUM_DISPOSABLE_MAGENTO' => '1', + 'BASICRUM_RELEASE_TAG' => $releaseTag, + ], $environment) + ); + basicrum_assert_true(is_resource($process), 'start native release gate guard'); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + return [ + 'status' => proc_close($process), + 'stdout' => $stdout, + 'stderr' => $stderr, + ]; + } finally { + if (isset($pipes[1]) && is_resource($pipes[1])) { + fclose($pipes[1]); + } + if (isset($pipes[2]) && is_resource($pipes[2])) { + fclose($pipes[2]); + } + } +}; + +$tests['fresh-install defaults fail closed and match config.xml'] = function () use ($root): void { + $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('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'); + basicrum_assert_same('0', (string) $xml->default->basicrum->developer->development_mode, 'HTTP default'); + + // Exercise the defaults Magento actually merges, not an unused parallel array. + $values = []; + foreach ($xml->default->basicrum->children() as $group => $fields) { + foreach ($fields->children() as $field => $value) { + $values[basicrum_test_key('default', 0, 'basicrum/' . $group . '/' . $field)] = (string) $value; + } + } + basicrum_assert_same(null, (new Config(new BasicrumTestScopeConfig($values)))->getRuntimeConfig(), 'fresh install inactive'); + $values[basicrum_test_key('default', 0, Config::XML_PATH_ENABLED)] = '1'; + $values[basicrum_test_key('default', 0, Config::XML_PATH_BEACON_ENDPOINT)] = 'http://collector.test/beacon'; + $values[basicrum_test_key('default', 0, Config::XML_PATH_BRUM_SITE_ID)] = '550e8400-e29b-41d4-a716-446655440000'; + $runtime = (new Config(new BasicrumTestScopeConfig($values)))->getRuntimeConfig(); + basicrum_assert_true($runtime['consent_enabled'], 'consent required by default'); + basicrum_assert_false($runtime['strip_query_string'], 'redaction opt-in'); + basicrum_assert_false($runtime['wait_after_onload'], 'wait opt-in'); + basicrum_assert_same(0, $runtime['delay_ms'], 'zero wait'); + basicrum_assert_same('https://collector.test/beacon', $runtime['beacon_endpoint'], 'default HTTPS policy'); +}; + +$tests['technical identity and direct Magento dependencies are declared consistently'] = function () use ($root): void { + $composer = json_decode( + (string) file_get_contents($root . '/composer.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + basicrum_assert_same('^102.0', $composer['require']['magento/module-backend'], 'Backend dependency'); + basicrum_assert_same('^101.2', $composer['require']['magento/module-config'], 'Config dependency'); + basicrum_assert_same('^100.4', $composer['require']['magento/module-csp'], 'CSP dependency'); + basicrum_assert_same('^101.1', $composer['require']['magento/module-store'], 'Store dependency'); + basicrum_assert_same( + ['/tests/', '/.test-results/', '/node_modules/', '/test-results/', '/playwright-report/'], + $composer['autoload']['exclude-from-classmap'], + 'test doubles and generated verification trees excluded from production classmap' + ); + $quality = json_decode(file_get_contents($root . '/tests/quality/composer.json'), true, 512, JSON_THROW_ON_ERROR); + foreach ($composer['require'] as $package => $constraint) { + basicrum_assert_same($constraint, $quality['require'][$package] ?? null, 'quality constraint matches ' . $package); + } + $exportExclusions = array_map( + static fn (string $line): string => explode(' ', trim($line))[0], + file($root . '/.gitattributes', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + basicrum_assert_same($composer['archive']['exclude'], $exportExclusions, 'Composer and Git production boundaries match'); + basicrum_assert_same( + ['Basicrum\\Analytics\\' => ''], + $composer['autoload']['psr-4'], + 'Composer namespace uses the canonical Basicrum spelling' + ); + basicrum_assert_false( + array_key_exists('version', $composer), + 'Composer package version must come from an immutable VCS tag' + ); + + $module = simplexml_load_file($root . '/etc/module.xml'); + basicrum_assert_same('Basicrum_Analytics', (string) $module->module['name'], 'Magento module identifier'); + basicrum_assert_same( + ['Magento_Backend', 'Magento_Config', 'Magento_Csp', 'Magento_Store'], + array_map( + static fn (SimpleXMLElement $dependency): string => (string) $dependency['name'], + iterator_to_array($module->module->sequence->module, false) + ), + 'module sequence' + ); + basicrum_assert_contains( + "'Basicrum_Analytics'", + (string) file_get_contents($root . '/registration.php'), + 'registration identifier' + ); + + $acl = simplexml_load_file($root . '/etc/acl.xml'); + $aclResources = $acl->xpath('//resource[@id="Basicrum_Analytics::basicrum_analytics"]'); + basicrum_assert_same(1, count($aclResources), 'canonical ACL resource'); + + $system = simplexml_load_file($root . '/etc/adminhtml/system.xml'); + basicrum_assert_same( + (string) $aclResources[0]['id'], + (string) $system->system->section->resource, + 'system configuration and ACL resource must match' + ); + + $di = simplexml_load_file($root . '/etc/di.xml'); + $environment = $di->xpath('//type[@name="Magento\\Config\\Model\\Config\\TypePool"]/arguments/argument[@name="environment"]/item'); + basicrum_assert_same(1, count($environment), 'only the development HTTP exception changes export classification'); + basicrum_assert_same(Config::XML_PATH_DEVELOPMENT_MODE, (string) $environment[0]['name'], 'environment-specific path'); + basicrum_assert_same('1', trim((string) $environment[0]), 'HTTP exception excluded from shared configuration'); + basicrum_assert_same( + 'Basicrum\\Analytics\\Api\\PageTypeDetectorInterface', + (string) $di->preference['for'], + 'DI preference interface' + ); + basicrum_assert_same( + 'Basicrum\\Analytics\\Model\\PageTypeDetector', + (string) $di->preference['type'], + 'DI preference implementation' + ); + + $collector = $di->xpath('//type[@name="Magento\\Csp\\Model\\CompositePolicyCollector"]/arguments/argument[@name="collectors"]/item[@name="basicrum_beacon"]'); + basicrum_assert_same(1, count($collector), 'CSP collector merges at the global DI stage'); + basicrum_assert_same( + 'Basicrum\\Analytics\\Model\\Csp\\BeaconPolicyCollector', + trim((string) $collector[0]), + 'frontend CSP collector class' + ); + foreach (glob($root . '/etc/*/di.xml') as $areaDiFile) { + $areaDi = simplexml_load_file($areaDiFile); + basicrum_assert_same( + [], + $areaDi->xpath('//type[@name="Magento\\Csp\\Model\\CompositePolicyCollector"]/arguments/argument[@name="collectors"]'), + $areaDiFile . ' must not replace core CSP collectors' + ); + } + + $frontendLayout = simplexml_load_file($root . '/view/frontend/layout/default.xml'); + $footerBlock = $frontendLayout->xpath('//block[@name="basicrum.analytics.footer"]'); + basicrum_assert_same(1, count($footerBlock), 'footer block declaration'); + basicrum_assert_same( + 'Basicrum_Analytics::footer.phtml', + (string) $footerBlock[0]['template'], + 'footer template alias' + ); + $footerViewModel = $frontendLayout->xpath( + '//block[@name="basicrum.analytics.footer"]/arguments/argument[@name="view_model"]' + ); + basicrum_assert_same( + 'Basicrum\\Analytics\\ViewModel\\Footer', + trim((string) $footerViewModel[0]), + 'footer view model' + ); + + $adminLayout = simplexml_load_file($root . '/view/adminhtml/layout/adminhtml_system_config_edit.xml'); + basicrum_assert_same( + 'Basicrum_Analytics::css/basicrum-config.css', + (string) $adminLayout->head->css['src'], + 'Admin stylesheet alias' + ); + + $logoBlock = (string) file_get_contents($root . '/Block/Adminhtml/System/Config/Logo.php'); + basicrum_assert_contains( + 'Basicrum_Analytics::system/config/logo.phtml', + $logoBlock, + 'Admin logo template alias' + ); + basicrum_assert_contains( + 'Basicrum_Analytics::images/basicrum-logo.png', + $logoBlock, + 'Admin logo asset alias' + ); + + foreach ([ + 'Model/Csp/BeaconPolicyCollector.php', + 'etc/di.xml', + 'view/adminhtml/layout/adminhtml_system_config_edit.xml', + 'view/adminhtml/templates/system/config/logo.phtml', + 'view/adminhtml/web/css/basicrum-config.css', + 'view/adminhtml/web/images/basicrum-logo.png', + 'tests/integration/admin.spec.js', + 'tests/integration/release-gate.sh', + 'CHANGELOG.md', + ] as $packagedFile) { + basicrum_assert_true(is_file($root . '/' . $packagedFile), 'required package file ' . $packagedFile); + } + +}; + +$tests['disposable integration guard rejects disabled modules and CLI failures before mutation'] = + function () use ($runDisposableGuard): void { + $disabled = $runDisposableGuard("Magento_Store", 0); + basicrum_assert_same(1, $disabled['status'], 'disabled module must fail before configuration mutation'); + basicrum_assert_contains( + 'Basicrum_Analytics is not registered and enabled', + $disabled['stderr'], + 'actionable disabled-module error' + ); + basicrum_assert_not_contains( + 'configuration mutation', + $disabled['stdout'] . $disabled['stderr'], + 'disabled module causes no Magento config mutation' + ); + + $failedStatus = $runDisposableGuard("Basicrum_Analytics", 42); + basicrum_assert_same(1, $failedStatus['status'], 'failed module query must fail closed'); + basicrum_assert_contains( + 'Unable to read enabled Magento modules', + $failedStatus['stderr'], + 'actionable Magento CLI failure' + ); + basicrum_assert_not_contains( + 'configuration mutation', + $failedStatus['stdout'] . $failedStatus['stderr'], + 'failed module query causes no Magento config mutation' + ); + }; + +$tests['disposable integration guard accepts an enabled canonical module'] = function () use ($runDisposableGuard): void { + $enabled = $runDisposableGuard("Magento_Store\nBasicrum_Analytics", 0); + basicrum_assert_same(73, $enabled['status'], 'enabled module must progress beyond the guard'); + basicrum_assert_contains( + 'configuration mutation: config:set basicrum/general/enabled 1', + $enabled['stderr'], + 'positive guard reaches the first intended configuration write' + ); + basicrum_assert_not_contains( + 'is not registered and enabled', + $enabled['stdout'] . $enabled['stderr'], + 'enabled module is accepted' + ); + $missingRunner = $runDisposableGuard('Basicrum_Analytics', 0, false); + basicrum_assert_same(1, $missingRunner['status'], 'missing pinned runner fails before configuration writes'); + basicrum_assert_contains('Run npm ci', $missingRunner['stderr'], 'actionable dependency error'); + basicrum_assert_not_contains('configuration mutation', $missingRunner['stderr'], 'no mutation without the runner'); +}; + +$tests['native release gate requires a new 0.1.0 tag identity'] = function () use ($runReleaseGateGuard): void { + $oldTag = $runReleaseGateGuard('0.0.2'); + basicrum_assert_same(1, $oldTag['status'], 'previous release tag must fail'); + basicrum_assert_contains('0.0.2 must not be reused', $oldTag['stderr'], 'actionable old-tag error'); + basicrum_assert_not_contains('MAGENTO_ROOT', $oldTag['stderr'], 'old tag fails before native work'); + + $phaseOneTag = $runReleaseGateGuard('0.1.0'); + basicrum_assert_same(1, $phaseOneTag['status'], 'missing Magento root must still fail closed'); + basicrum_assert_contains('MAGENTO_ROOT must point', $phaseOneTag['stderr'], '0.1.0 passes the tag guard'); +}; + +$tests['native release gate stops before mutations when identity or baseline checks fail'] = function () use ($runReleaseGateGuard): void { + $temporaryRoot = sys_get_temp_dir() . '/basicrum-baseline-guard-' . bin2hex(random_bytes(6)); + $bin = $temporaryRoot . '/bin'; + basicrum_assert_true(mkdir($bin, 0700, true), 'create temporary command directory'); + try { + // Only stub the external commands, not the release script being tested. + file_put_contents($bin . '/php', <<<'SH' +#!/bin/sh +case "$1" in + */check-artifact.php) + if [ "${BASICRUM_FAKE_ARTIFACT_MISMATCH:-0}" = "1" ]; then + echo 'artifact mismatch' >&2 + exit 67 + fi + exit 0 ;; + */check-installed-candidate.php) + if [ "${BASICRUM_FAKE_SOURCE_MISMATCH:-0}" = "1" ]; then + echo 'installed candidate mismatch' >&2 + exit 68 + fi + exit 0 ;; + */check-baseline.php) echo "baseline rejected: $1" >&2; exit 69 ;; +esac +exit 75 +SH); + file_put_contents($bin . '/git', <<<'SH' +#!/bin/sh +case "$3 $4" in + 'rev-parse --show-toplevel') printf '%s\n' "$2" ;; + 'status --porcelain') exit 0 ;; + 'rev-parse --verify') echo '1111111111111111111111111111111111111111' ;; + *) exit 76 ;; +esac +SH); + file_put_contents($bin . '/magento', "#!/bin/sh\necho 'unexpected Magento mutation' >&2\nexit 73\n"); + chmod($bin . '/php', 0700); + chmod($bin . '/git', 0700); + chmod($bin . '/magento', 0700); + $environment = [ + 'PATH' => $bin . ':' . getenv('PATH'), + 'MAGENTO_ROOT' => $temporaryRoot, + 'MAGENTO_STOREFRONT_URL' => 'https://magento.test/', + 'MAGENTO_ADMIN_URL' => 'https://magento.test/admin/', + 'MAGENTO_ADMIN_USERNAME' => 'synthetic', + 'MAGENTO_ADMIN_PASSWORD' => 'synthetic', + ]; + $artifactMismatch = $runReleaseGateGuard('0.1.0', $environment + ['BASICRUM_FAKE_ARTIFACT_MISMATCH' => '1']); + basicrum_assert_same(67, $artifactMismatch['status'], 'artifact mismatch propagates'); + basicrum_assert_not_contains('unexpected Magento mutation', $artifactMismatch['stderr'], 'no mutation on artifact mismatch'); + $mismatched = $runReleaseGateGuard('0.1.0', $environment + ['BASICRUM_FAKE_SOURCE_MISMATCH' => '1']); + basicrum_assert_same(68, $mismatched['status'], 'installed candidate mismatch propagates'); + basicrum_assert_contains('installed candidate mismatch', $mismatched['stderr'], 'installed identity was checked'); + basicrum_assert_not_contains('baseline rejected', $mismatched['stderr'], 'identity fails before native baseline bootstrap'); + basicrum_assert_not_contains('unexpected Magento mutation', $mismatched['stderr'], 'no mutation on identity mismatch'); + $result = $runReleaseGateGuard('0.1.0', $environment); + basicrum_assert_same(69, $result['status'], 'baseline failure propagates'); + basicrum_assert_contains('check-baseline.php', $result['stderr'], 'baseline check was executed'); + basicrum_assert_not_contains('unexpected Magento mutation', $result['stderr'], 'no native mutation ran'); + } finally { + unlink($bin . '/php'); + unlink($bin . '/git'); + unlink($bin . '/magento'); + rmdir($bin); + rmdir($temporaryRoot); + } +}; + +$tests['committed candidate excludes ignored files and strictly checks the installed distribution'] = function (): void { + $temporaryRoot = sys_get_temp_dir() . '/basicrum-candidate-' . bin2hex(random_bytes(6)); + foreach (['candidate', 'installed'] as $name) { + $directory = $temporaryRoot . '/' . $name; + mkdir($directory . '/view', 0700, true); + file_put_contents($directory . '/registration.php', 'getMessage(), 'identify mismatched package file'); + return; + } + throw new RuntimeException('Incorrect installed candidate was accepted.'); + }; + try { + mkdir($candidate . '/docs', 0700); + file_put_contents($candidate . '/docs/notes.md', 'development only'); + file_put_contents($candidate . '/.gitignore', "*.secret\n"); + file_put_contents($candidate . '/.gitattributes', "/docs export-ignore\n/.gitignore export-ignore\n/.gitattributes export-ignore\n"); + basicrum_candidate_git($candidate, ['init', '--quiet', '--template=']); + basicrum_candidate_git($candidate, ['add', '.']); + basicrum_candidate_git($candidate, [ + '-c', 'user.name=Basicrum Test', '-c', 'user.email=test@example.test', '-c', 'core.hooksPath=/dev/null', + 'commit', '--quiet', '--no-gpg-sign', '-m', 'Synthetic package fixture' + ]); + $expected = basicrum_candidate_files($candidate); + basicrum_assert_same(['composer.json', 'registration.php', 'view/loader.js'], array_keys($expected), 'committed production boundary'); + // Also cover a locally ignored file, independently of the tracked .gitignore. + mkdir($candidate . '/.git/info', 0700); + file_put_contents($candidate . '/.git/info/exclude', "local-only.txt\n"); + file_put_contents($candidate . '/local-only.txt', 'synthetic local data'); + file_put_contents($candidate . '/fixture.secret', 'synthetic, not a credential'); + basicrum_assert_same('', basicrum_candidate_git($candidate, ['status', '--porcelain']), 'ignored files do not dirty Git'); + basicrum_assert_same($expected, basicrum_candidate_files($candidate), 'ignored files cannot enter expected manifest'); + $archive = $temporaryRoot . '/package.tar'; + basicrum_candidate_git($candidate, ['archive', '--format=tar', '--output=' . $archive, 'HEAD']); + mkdir($temporaryRoot . '/exported', 0700); + (new PharData($archive))->extractTo($temporaryRoot . '/exported'); + basicrum_assert_installed_candidate($candidate, $temporaryRoot . '/exported'); + file_put_contents($candidate . '/view/loader.js', '// uncommitted change'); + basicrum_assert_same($expected, basicrum_candidate_files($candidate), 'manifest hashes committed blobs, not dirty files'); + basicrum_assert_installed_candidate($candidate, $installed); + mkdir($installed . '/docs', 0700); + file_put_contents($installed . '/docs/notes.md', 'stale development file'); + $expectMismatch('docs/notes.md'); + unlink($installed . '/docs/notes.md'); + rmdir($installed . '/docs'); + file_put_contents($installed . '/.unexpected', 'extra hidden file'); + $expectMismatch('.unexpected'); + unlink($installed . '/.unexpected'); + file_put_contents($installed . '/view/loader.js', '// stale loader'); + $expectMismatch('view/loader.js'); + unlink($installed . '/view/loader.js'); + $expectMismatch('view/loader.js'); + file_put_contents($installed . '/view/loader.js', '// reviewed loader'); + file_put_contents($installed . '/view/extra.js', '// unexpected asset'); + $expectMismatch('view/extra.js'); + unlink($installed . '/view/extra.js'); + symlink($candidate . '/view/loader.js', $installed . '/view/extra.js'); + $expectMismatch('view/extra.js'); + unlink($installed . '/view/extra.js'); + unlink($installed . '/registration.php'); + $expectMismatch('registration.php'); + } finally { + foreach (new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($temporaryRoot, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ) as $file) { + $file->isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($temporaryRoot); + } +}; + +$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_false( + Config::isValidBeaconEndpoint('https://user:secret@collector.test/beacon'), + 'embedded endpoint credentials' + ); + basicrum_assert_false( + Config::isValidBeaconEndpoint('https://collector.test/beacon#client-only'), + 'endpoint fragment' + ); + 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'); + + $invalidEndpoint = new BeaconEndpoint($context, $registry, $scopeConfig, $cacheTypeList, $storeManager); + $invalidEndpoint->setValue('https://user:secret@collector.example.test/beacon'); + try { + $invalidEndpoint->beforeSave(); + throw new RuntimeException('credential-bearing endpoint did not throw'); + } catch (LocalizedException $exception) { + basicrum_assert_contains('without embedded credentials', $exception->getMessage(), 'endpoint validation error'); + } + + $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, 'basicrum/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), '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'; + $base[basicrum_test_key($default, 0, Config::XML_PATH_BEACON_ENDPOINT)] = + 'https://collector.test/beacon#client-only'; + $badEndpoint = new Config(new BasicrumTestScopeConfig($base)); + basicrum_assert_same(null, $badEndpoint->getRuntimeConfig($default), 'fragment endpoint must be inactive'); + basicrum_assert_same('invalid_endpoint', $badEndpoint->getStatus($default), 'admin invalid endpoint'); + + $base[basicrum_test_key($default, 0, Config::XML_PATH_BEACON_ENDPOINT)] = 'https://collector.test/beacon'; + $valid = new Config(new BasicrumTestScopeConfig($base)); + $runtime = $valid->getRuntimeConfig($default); + basicrum_assert_false(array_key_exists('consent_mode', $runtime), 'obsolete consent metadata is ignored'); + basicrum_assert_true($runtime['consent_enabled'], 'legacy mode must not grant consent'); + basicrum_assert_same('active_consent', $valid->getStatus($default), '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'); + $getField = static function (SimpleXMLElement $config, string $groupId, string $fieldId): SimpleXMLElement { + $matches = $config->xpath(sprintf( + '/config/system/section[@id="basicrum"]/group[@id="%s"]/field[@id="%s"]', + $groupId, + $fieldId + )); + basicrum_assert_same(1, count($matches), $groupId . '/' . $fieldId . ' field'); + return $matches[0]; + }; + $fields = [ + $getField($xml, 'general', 'beacon_endpoint'), + $getField($xml, 'general', 'brum_site_id'), + $getField($xml, 'consent', 'enabled'), + $getField($xml, 'privacy', 'strip_query_string'), + $getField($xml, 'performance', 'wait_after_onload'), + $getField($xml, 'performance', 'delay_ms'), + $getField($xml, 'developer', 'development_mode'), + ]; + + 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) $getField($xml, 'general', 'beacon_endpoint')->backend_model, + 'endpoint save validator' + ); + basicrum_assert_same( + 'Basicrum\\Analytics\\Model\\System\\Config\\Backend\\BrumSiteId', + (string) $getField($xml, 'general', 'brum_site_id')->backend_model, + 'Site ID save validator' + ); + foreach (['monitoring_status' => 'Status', 'boomerang_version' => 'BoomerangVersion'] as $id => $renderer) { + basicrum_assert_same( + 'Basicrum\\Analytics\\Block\\Adminhtml\\System\\Config\\' . $renderer, + (string) $getField($xml, 'general', $id)->frontend_model, + $id . ' display renderer' + ); + } + basicrum_assert_same( + 'Basicrum\\Analytics\\Block\\Adminhtml\\System\\Config\\ReadOnlyField', + (string) $getField($xml, 'consent', 'integration_help')->frontend_model, + 'callback instructions use the display-only renderer' + ); +}; + +$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 fn (array $values): string => basicrum_render_footer($values, $detector); + + $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('Basicrum_Analytics/js/', $consent, 'canonical static asset module identifier'); + 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