diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 49a734c..cb7884f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,7 +15,7 @@ concurrency: jobs: deploy: - name: Build and deploy theme + name: Build and deploy production assets runs-on: ubuntu-latest environment: production defaults: @@ -39,7 +39,9 @@ jobs: with: node-version: 20 cache: npm - cache-dependency-path: wp-content/themes/3w-2025/package-lock.json + cache-dependency-path: | + package-lock.json + wp-content/themes/3w-2025/package-lock.json - name: Install lftp run: | @@ -65,6 +67,17 @@ jobs: - name: Check theme PHP files run: find wp-content/themes/3w-2025 -name '*.php' -print0 | xargs -0 -n1 php -l + - name: Install newsletter regression dependencies + run: npm ci + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Run newsletter popup regression + run: | + npx playwright test tests/e2e/newsletter-popup.spec.js --reporter=line + bash tests/scripts/deploy-file.test.sh + - name: Verify deployment secrets run: | missing=0 @@ -85,3 +98,62 @@ jobs: - name: Deploy theme with existing script run: ./scripts/deploy-theme.sh --target production + + - name: Resolve newsletter popup asset path + run: | + theme_dir="${THREEW_PROD_REMOTE_THEME_DIR%/}" + suffix="/wp-content/themes/3w-2025" + if [[ "${theme_dir}" != *"${suffix}" ]]; then + echo "Remote theme directory must end with ${suffix}; refusing to derive a plugin path" >&2 + exit 1 + fi + site_root="${theme_dir%${suffix}}" + echo "NEWSLETTER_REMOTE_FILE=${site_root}/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js" >> "${GITHUB_ENV}" + + - name: Deploy newsletter popup fix with rollback evidence + env: + BACKUP_FILE: ${{ runner.temp }}/newsletter-popup.js.before + VERIFIED_FILE: ${{ runner.temp }}/newsletter-popup.js.after + run: | + ./scripts/deploy-file.sh \ + --target production \ + --local-file deploy/production/3w-newsletter-popup/assets/newsletter-popup.js \ + --remote-file "${NEWSLETTER_REMOTE_FILE}" \ + --backup-file "${BACKUP_FILE}" \ + --verified-file "${VERIFIED_FILE}" \ + --expected-remote-sha256 f3873bf2cdef7967a6d227a66d46ce6076a42ec6b660416e0b6a8379f1134cc3 \ + --expected-remote-sha256 b6af9603cef2cf5cc82541ef256a720a157c2c2e9456eef55ca059711c6e207b + + - name: Verify newsletter popup bytes at the origin + run: | + expected=$(sha256sum deploy/production/3w-newsletter-popup/assets/newsletter-popup.js | awk '{print $1}') + curl --fail --silent --show-error --retry 4 \ + "https://shop.3wdistributing.com/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js?deploy=${GITHUB_SHA}" \ + --output "${RUNNER_TEMP}/newsletter-popup.js.public" + actual=$(sha256sum "${RUNNER_TEMP}/newsletter-popup.js.public" | awk '{print $1}') + if [[ "${actual}" != "${expected}" ]]; then + echo "Public cache-busted asset verification failed: expected ${expected}, got ${actual}" >&2 + exit 1 + fi + + curl --fail --silent --show-error \ + "https://shop.3wdistributing.com/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js?ver=1.0.1" \ + --output "${RUNNER_TEMP}/newsletter-popup.js.canonical" + canonical=$(sha256sum "${RUNNER_TEMP}/newsletter-popup.js.canonical" | awk '{print $1}') + if [[ "${canonical}" != "${expected}" ]]; then + echo "The canonical ?ver=1.0.1 URL is still cached. Purge that exact Cloudflare URL, then rerun the workflow." >&2 + exit 1 + fi + + - name: Archive newsletter popup rollback evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: newsletter-popup-deploy-${{ github.run_id }} + path: | + ${{ runner.temp }}/newsletter-popup.js.before + ${{ runner.temp }}/newsletter-popup.js.after + ${{ runner.temp }}/newsletter-popup.js.public + ${{ runner.temp }}/newsletter-popup.js.canonical + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 096e4dd..52ba7f0 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -55,6 +55,35 @@ jobs: - name: Check theme PHP files run: find wp-content/themes/3w-2025 -name '*.php' -print0 | xargs -0 -n1 php -l + newsletter-popup: + name: Newsletter popup regression + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install test dependencies + run: npm ci + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Check deployment scripts and asset syntax + run: | + bash -n scripts/deploy-file.sh + node --check deploy/production/3w-newsletter-popup/assets/newsletter-popup.js + bash tests/scripts/deploy-file.test.sh + + - name: Run newsletter popup regression + run: npx playwright test tests/e2e/newsletter-popup.spec.js --reporter=line + npm-audit: name: npm audit runs-on: ubuntu-latest diff --git a/deploy/production/3w-newsletter-popup/assets/newsletter-popup.js b/deploy/production/3w-newsletter-popup/assets/newsletter-popup.js new file mode 100644 index 0000000..8b74f38 --- /dev/null +++ b/deploy/production/3w-newsletter-popup/assets/newsletter-popup.js @@ -0,0 +1,193 @@ +/* Production override for 3w-newsletter-popup/assets/newsletter-popup.js. + * Base: deployed ?ver=1.0.1 asset (sha256 f3873bf2…). Adds Escape/backdrop + * dismissal and modal focus management; existing timing and signup behavior stay intact. + */ +(function () { + 'use strict'; + + var cfg = window.threewNewsletterPopup || {}; + var key = 'threewNewsletterPopupSuppressedAt'; + var dayMs = 24 * 60 * 60 * 1000; + var suppressMs = (parseInt(cfg.suppressionDays || 30, 10) || 30) * dayMs; + var showTimer = null; + var retryTimer = null; + var lastFocused = null; + + function suppressed() { + var last = parseInt(localStorage.getItem(key) || '0', 10); + return last && Date.now() - last < suppressMs; + } + + function suppress() { + localStorage.setItem(key, String(Date.now())); + } + + function modal() { + return document.getElementById('threew-newsletter-popup'); + } + + function isOpen() { + var modalElement = modal(); + return !!(modalElement && !modalElement.hidden); + } + + function show() { + var modalElement = modal(); + if (!modalElement) return; + if (hasBlockingConsentUi()) { + scheduleRetry(1200); + return; + } + lastFocused = document.activeElement; + modalElement.hidden = false; + document.documentElement.classList.add('threew-newsletter-open'); + suppress(); + focusFirstControl(); + } + + function hide(restoreFocus) { + var modalElement = modal(); + if (modalElement) modalElement.hidden = true; + document.documentElement.classList.remove('threew-newsletter-open'); + if (restoreFocus && lastFocused && typeof lastFocused.focus === 'function') { + try { lastFocused.focus(); } catch (error) { /* detached node */ } + } + lastFocused = null; + } + + function dismiss() { + suppress(); + hide(true); + } + + function focusable() { + var modalElement = modal(); + if (!modalElement) return []; + var selector = 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + return Array.prototype.slice.call(modalElement.querySelectorAll(selector)).filter(function (element) { + return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); + }); + } + + function focusFirstControl() { + var controls = focusable(); + if (controls.length) controls[0].focus(); + } + + function trapTab(event) { + var modalElement = modal(); + var controls = focusable(); + if (!modalElement || !controls.length) return; + var first = controls[0]; + var last = controls[controls.length - 1]; + var active = document.activeElement; + if (event.shiftKey && (active === first || !modalElement.contains(active))) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (active === last || !modalElement.contains(active))) { + event.preventDefault(); + first.focus(); + } + } + + function readCookie(name) { + var match = document.cookie.match( + new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)') + ); + return match ? decodeURIComponent(match[1]) : ''; + } + + function hasConsentCookie() { + var cookieName = cfg.consentCookie || 'threew_consent_v1'; + return !!readCookie(cookieName); + } + + function hasBlockingConsentUi() { + var banner = document.querySelector('[data-threew-banner]'); + var modalElement = document.querySelector('[data-threew-modal]'); + var bannerVisible = !!(banner && !banner.hidden); + var modalVisible = !!(modalElement && !modalElement.hidden); + return bannerVisible || modalVisible || !hasConsentCookie(); + } + + function scheduleRetry(delay) { + if (retryTimer) window.clearTimeout(retryTimer); + retryTimer = window.setTimeout(show, delay); + } + + document.addEventListener('DOMContentLoaded', function () { + if (suppressed()) return; + + var form = document.getElementById('threew-newsletter-popup-form'); + var close = document.getElementById('threew-newsletter-popup-close'); + var status = document.getElementById('threew-newsletter-popup-status'); + var modalElement = modal(); + + showTimer = window.setTimeout(show, (parseInt(cfg.delaySeconds || 7, 10) || 0) * 1000); + + if (close) close.addEventListener('click', dismiss); + + if (modalElement) { + // Clicking the scrim (anywhere outside the dialog) dismisses the popup. + modalElement.addEventListener('click', function (event) { + if (event.target === modalElement) dismiss(); + }); + } + + document.addEventListener('keydown', function (event) { + if (!isOpen()) return; + var keyName = event.key; + if (keyName === 'Escape' || keyName === 'Esc') { + event.preventDefault(); + dismiss(); + return; + } + if (keyName === 'Tab') trapTab(event); + }); + + document.addEventListener('threew:consent-updated', function () { + if (suppressed()) return; + if (showTimer) window.clearTimeout(showTimer); + scheduleRetry(600); + }); + if (!form) return; + + form.addEventListener('submit', function (event) { + event.preventDefault(); + + var email = form.querySelector('[name="email"]').value.trim(); + var consent = form.querySelector('[name="consent"]').checked; + if (!email || !consent) { + status.textContent = 'Enter your email and accept newsletter signup.'; + return; + } + + var body = new URLSearchParams(); + body.set('action', 'threew_np_subscribe'); + body.set('nonce', cfg.nonce || ''); + body.set('email', email); + body.set('consent', consent ? '1' : '0'); + + form.querySelector('button[type="submit"]').disabled = true; + status.textContent = 'Submitting...'; + + fetch(cfg.ajaxUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString() + }) + .then(function (response) { return response.json(); }) + .then(function (data) { + if (!data.success) throw new Error(data.data && data.data.message ? data.data.message : 'Signup failed.'); + suppress(); + form.innerHTML = '

'; + form.querySelector('.threew-np-success').textContent = 'Success. Use code ' + data.data.couponCode + ' for ' + data.data.discountPercent + '% off.'; + }) + .catch(function (error) { + status.textContent = error.message; + form.querySelector('button[type="submit"]').disabled = false; + }); + }); + }); +})(); diff --git a/docs/deployment-pipeline.md b/docs/deployment-pipeline.md index bc3b2aa..c27ebcd 100644 --- a/docs/deployment-pipeline.md +++ b/docs/deployment-pipeline.md @@ -15,8 +15,8 @@ Runs on pull requests targeting `main`: - JavaScript lint with `npm run lint:js` - Theme asset build with `npm run build` - PHP syntax validation for theme PHP files +- Newsletter-popup asset syntax, deployment-script safety tests and desktop/mobile Playwright regression - Production dependency audit with `npm audit --omit=dev --audit-level=moderate` -- GitHub dependency review, failing on moderate or higher severity - Gitleaks secret scan - CodeQL JavaScript/TypeScript analysis @@ -47,10 +47,13 @@ The workflow performs quality gates before calling the script: - `npm run lint:js` - `npm run build` - PHP syntax validation +- newsletter-popup deployment-script safety and desktop/mobile Playwright regression - required deployment secret presence check The deploy script then builds the theme again and mirrors runtime-safe theme files through `lftp`, using the script's existing exclude rules. +The workflow also reruns the newsletter-popup browser regression and deploys the reviewed JavaScript override with `scripts/deploy-file.sh`. This is deliberately a one-file upload: it backs up and hash-checks the existing remote asset, rejects unsafe path segments, never mirrors or deletes the plugin directory, automatically rolls back failed read-back verification, verifies the uploaded bytes, and archives rollback evidence for 14 days. See `docs/newsletter-popup-fix.md`. + ## Required GitHub configuration Create a protected GitHub environment named `production`. @@ -78,6 +81,7 @@ Do not add `.env`, passwords, deploy keys, or host credentials to git. ## Notes - `scripts/deploy-theme.sh` loads `.env` for local runs only. GitHub Actions supplies the same variable names from GitHub Secrets. -- The npm audit gate omits development dependencies because the WordPress build toolchain currently reports dev-only advisories that require breaking tool upgrades; production dependency exposure is still gated, while CodeQL, Gitleaks, dependency review, linting, and builds cover PR quality/security. +- The npm audit gate omits development dependencies because the WordPress build toolchain currently reports dev-only advisories that require breaking tool upgrades; production dependency exposure is still gated, while CodeQL, Gitleaks, linting, builds and the popup regression cover PR quality/security. - The production deploy job is attached to the `production` environment so GitHub can enforce approvals before secrets are exposed to the runner. +- `scripts/deploy-file.sh` accepts only a reviewed remote path, safe path characters and an allowlist of expected remote hashes. Use it for bounded production assets only; do not replace plugin directories with the theme mirror command. - If SFTP key auth is required later, update `scripts/deploy-theme.sh` to support a production-specific key secret materialized into a temporary runner file, then pass `THREEW_SSH_KEY_PATH` to the script. diff --git a/docs/newsletter-popup-fix.md b/docs/newsletter-popup-fix.md new file mode 100644 index 0000000..9d7902c --- /dev/null +++ b/docs/newsletter-popup-fix.md @@ -0,0 +1,48 @@ +# Newsletter popup dismissal fix + +## Production defect + +The production-only `3w-newsletter-popup` plugin shows its modal seven seconds after cookie consent is resolved. The deployed 1.0.1 script has three accessibility and interaction defects: + +- Escape does not dismiss the modal. +- Clicking the dimmed backdrop does not dismiss it. +- Focus remains on the page behind the modal and can tab out of the dialog despite `aria-modal="true"`. + +While the modal is open, its full-viewport scrim correctly prevents activation of controls behind it. Without Escape or backdrop dismissal, however, the small close button is the only exit. The popup is absent from cart and checkout pages, so checkout is not blocked. + +## Implemented behavior + +`deploy/production/3w-newsletter-popup/assets/newsletter-popup.js` is a reviewed replacement for the live 1.0.1 asset. It preserves the existing delay, consent gate, signup request, design and 30-day suppression while adding: + +- Escape dismissal; +- backdrop-click dismissal without activating the control underneath; +- focus placement on the first modal control; +- Tab/Shift+Tab wrapping inside the dialog; +- focus restoration after dismissal. + +The replacement is verified by `tests/e2e/newsletter-popup.spec.js` at desktop and mobile viewports. + +## Deployment and rollback + +The production workflow reruns the desktop/mobile popup regression, deploys the theme as before, then derives the plugin path from the configured production theme directory. `scripts/deploy-file.sh`: + +1. downloads the current remote JS as a rollback artifact; +2. refuses to overwrite any hash except the reviewed original or this exact override (the latter makes retries idempotent); +3. rejects remote paths containing empty, `.` or `..` segments; +4. uploads exactly one file (no mirror and no delete); +5. downloads it again and verifies its SHA-256; +6. automatically restores and verifies the backup if upload/read-back verification fails. A later public-cache verification failure intentionally leaves the verified origin file in place; use the archived backup for manual rollback only if the new origin bytes themselves are unwanted. + +The workflow then verifies the public asset through a cache-busting query and archives before/after files for 14 days. + +To roll back, upload the workflow artifact `newsletter-popup.js.before` to: + +```text +wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js +``` + +The production script is still referenced as `?ver=1.0.1` and Cloudflare caches it for seven days. The first workflow run is expected to turn red after a successful origin upload when that canonical URL is still stale. This failure deliberately leaves the verified fix at the origin. Purge only this exact URL and rerun the workflow (or wait for its TTL); the idempotent override hash is allowlisted for that rerun. A green deployment therefore cannot be mistaken for a customer-visible fix: + +```text +https://shop.3wdistributing.com/wp-content/plugins/3w-newsletter-popup/assets/newsletter-popup.js?ver=1.0.1 +``` diff --git a/playwright.config.js b/playwright.config.js index a40eeeb..9a6bd6b 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -5,6 +5,9 @@ module.exports = { use: { baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080', headless: true, + launchOptions: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH } + : {}, }, - reporter: [['list'], ['html', { outputFolder: 'test-results' }]], + reporter: [['list'], ['html', { outputFolder: 'playwright-report' }]], }; diff --git a/scripts/deploy-file.sh b/scripts/deploy-file.sh new file mode 100755 index 0000000..99a80cb --- /dev/null +++ b/scripts/deploy-file.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# Upload exactly one file through the same FTP/FTPS/SFTP credentials used by the +# production theme deployment. The remote file is backed up and hash-checked +# before overwrite, then downloaded again to verify the deployed bytes. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: deploy-file.sh --target staging|production --local-file PATH --remote-file PATH \ + --backup-file PATH --verified-file PATH --expected-remote-sha256 HASH [...] +EOF +} + +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +TARGET="${THREEW_DEPLOY_TARGET:-staging}" +LOCAL_FILE="" +REMOTE_FILE="" +BACKUP_FILE="" +VERIFIED_FILE="" +EXPECTED_REMOTE_HASHES=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --target) + TARGET=${2:-} + shift 2 + ;; + --local-file) + LOCAL_FILE=${2:-} + shift 2 + ;; + --remote-file) + REMOTE_FILE=${2:-} + shift 2 + ;; + --backup-file) + BACKUP_FILE=${2:-} + shift 2 + ;; + --verified-file) + VERIFIED_FILE=${2:-} + shift 2 + ;; + --expected-remote-sha256) + EXPECTED_REMOTE_HASHES+=("${2:-}") + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +TARGET=$(echo "${TARGET,,}" | sed 's/^stage$/staging/; s/^prod$/production/; s/^live$/production/') +if [[ "$TARGET" != "staging" && "$TARGET" != "production" ]]; then + echo "Unsupported deploy target '$TARGET'. Use staging or production." >&2 + exit 1 +fi + +for value_name in LOCAL_FILE REMOTE_FILE BACKUP_FILE VERIFIED_FILE; do + if [[ -z "${!value_name}" ]]; then + echo "Missing required argument for ${value_name}" >&2 + usage >&2 + exit 1 + fi +done + +if [[ ! -f "$LOCAL_FILE" ]]; then + echo "Local deploy file not found: $LOCAL_FILE" >&2 + exit 1 +fi + +# Remote paths are interpolated into an lftp command stream. Limit the accepted +# alphabet so a caller cannot turn a path into another command. +if [[ ! "$REMOTE_FILE" =~ ^[A-Za-z0-9._/-]+$ || "$REMOTE_FILE" != */* || "$REMOTE_FILE" == *"//"* ]]; then + echo "Remote file path contains unsupported characters: $REMOTE_FILE" >&2 + exit 1 +fi + +REMOTE_PATH_WITHOUT_ROOT=${REMOTE_FILE#/} +IFS='/' read -r -a REMOTE_PATH_SEGMENTS <<< "$REMOTE_PATH_WITHOUT_ROOT" +for path_segment in "${REMOTE_PATH_SEGMENTS[@]}"; do + if [[ -z "$path_segment" || "$path_segment" == "." || "$path_segment" == ".." ]]; then + echo "Remote file path contains an unsafe segment: $REMOTE_FILE" >&2 + exit 1 + fi +done + +if [[ ${#EXPECTED_REMOTE_HASHES[@]} -eq 0 ]]; then + echo "At least one --expected-remote-sha256 allowlist entry is required" >&2 + exit 1 +fi +for hash in "${EXPECTED_REMOTE_HASHES[@]}"; do + if [[ ! "$hash" =~ ^[0-9a-f]{64}$ ]]; then + echo "Invalid expected SHA-256: $hash" >&2 + exit 1 + fi +done + +if [[ -f "$REPO_ROOT/.env" ]]; then + # shellcheck source=/dev/null + set -a + source "$REPO_ROOT/.env" + set +a +fi + +for command_name in lftp sha256sum; do + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "$command_name is required" >&2 + exit 1 + fi +done + +FTP_HOST=${THREEW_FTP_HOST:-147.79.122.118} +DEPLOY_SCHEME=${THREEW_DEPLOY_SCHEME:-ftps} +SSL_VERIFY=${THREEW_SSL_VERIFY:-yes} +FTP_USER=${THREEW_FTP_USER:-u659513315.thrwdiststaging} +FTP_PASS=${THREEW_FTP_PASS:-} +SSH_KEY_PATH=${THREEW_SSH_KEY_PATH:-} +TARGET_LABEL=staging + +if [[ "$TARGET" == "production" ]]; then + FTP_HOST=${THREEW_PROD_FTP_HOST:-$FTP_HOST} + DEPLOY_SCHEME=${THREEW_PROD_DEPLOY_SCHEME:-$DEPLOY_SCHEME} + SSL_VERIFY=${THREEW_PROD_SSL_VERIFY:-$SSL_VERIFY} + FTP_USER=${THREEW_PROD_FTP_USER:-$FTP_USER} + FTP_PASS=${THREEW_PROD_FTP_PASS:-$FTP_PASS} + TARGET_LABEL=production +fi + +case "$DEPLOY_SCHEME" in + ftp|ftps) DEFAULT_PORT=21 ;; + sftp) DEFAULT_PORT=22 ;; + *) + echo "Unsupported THREEW_DEPLOY_SCHEME '$DEPLOY_SCHEME'. Use ftp, ftps, or sftp." >&2 + exit 1 + ;; +esac + +FTP_PORT=${THREEW_FTP_PORT:-$DEFAULT_PORT} +if [[ "$TARGET" == "production" ]]; then + FTP_PORT=${THREEW_PROD_FTP_PORT:-$FTP_PORT} +fi + +if [[ -z "$FTP_PASS" && ! ( "$DEPLOY_SCHEME" == "sftp" && -n "$SSH_KEY_PATH" ) ]]; then + if [[ ! -t 0 ]]; then + echo "No password or SFTP key supplied for ${TARGET_LABEL} deployment" >&2 + exit 1 + fi + read -r -s -p "FTP password for ${FTP_USER}@${FTP_HOST}: " FTP_PASS + echo +fi + +LFTP_URI="${DEPLOY_SCHEME}://${FTP_HOST}:${FTP_PORT}" +FTP_SETUP_CMDS="set cmd:fail-exit yes +set ssl:verify-certificate $SSL_VERIFY +set xfer:clobber yes" + +if [[ "$DEPLOY_SCHEME" == "sftp" ]]; then + FTP_SETUP_CMDS+=$'\n'"set sftp:auto-confirm yes" + CONNECT_PROGRAM="ssh -o BatchMode=yes" + if [[ -n "$SSH_KEY_PATH" ]]; then + [[ -f "$SSH_KEY_PATH" ]] || { echo "SSH key not found: $SSH_KEY_PATH" >&2; exit 1; } + CONNECT_PROGRAM+=" -i $SSH_KEY_PATH" + fi + FTP_SETUP_CMDS+=$'\n'"set sftp:connect-program \"$CONNECT_PROGRAM\"" +else + FTP_SETUP_CMDS+=$'\n'"set ftp:passive-mode on" + if [[ "$DEPLOY_SCHEME" == "ftps" ]]; then + FTP_SETUP_CMDS+=$'\n'"set ftp:ssl-force true +set ftp:ssl-protect-data true +set ftp:ssl-auth TLS" + else + FTP_SETUP_CMDS+=$'\n'"set ftp:ssl-force false +set ftp:ssl-protect-data false" + fi +fi + +mkdir -p "$(dirname "$BACKUP_FILE")" "$(dirname "$VERIFIED_FILE")" +rm -f "$BACKUP_FILE" "$VERIFIED_FILE" + +echo "Backing up ${TARGET_LABEL}:${REMOTE_FILE}" +lftp -u "$FTP_USER","$FTP_PASS" "$LFTP_URI" <&2 + exit 1 +fi + +restore_backup() { + local rollback_verified="${VERIFIED_FILE}.rollback" + rm -f "$rollback_verified" + echo "Restoring backup to ${TARGET_LABEL}:${REMOTE_FILE}" >&2 + lftp -u "$FTP_USER","$FTP_PASS" "$LFTP_URI" <&2 + return 1 + fi + echo "Rollback verified ($rollback_hash)" >&2 +} + +LOCAL_HASH=$(sha256sum "$LOCAL_FILE" | awk '{print $1}') +echo "Uploading one file to ${TARGET_LABEL}:${REMOTE_FILE}" +if ! lftp -u "$FTP_USER","$FTP_PASS" "$LFTP_URI" <&2 + if ! restore_backup; then + echo "Automatic rollback also failed; use the preserved backup file for manual recovery" >&2 + fi + exit 1 +fi + +VERIFIED_HASH=$(sha256sum "$VERIFIED_FILE" | awk '{print $1}') +if [[ "$VERIFIED_HASH" != "$LOCAL_HASH" ]]; then + echo "Remote verification failed: expected $LOCAL_HASH, got $VERIFIED_HASH" >&2 + restore_backup + exit 1 +fi + +echo "One-file deployment verified: $REMOTE_FILE ($VERIFIED_HASH)" diff --git a/tests/e2e/newsletter-popup.spec.js b/tests/e2e/newsletter-popup.spec.js new file mode 100644 index 0000000..a29dbfc --- /dev/null +++ b/tests/e2e/newsletter-popup.spec.js @@ -0,0 +1,123 @@ +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); + +const popupScript = fs.readFileSync( + path.resolve(__dirname, '../../deploy/production/3w-newsletter-popup/assets/newsletter-popup.js'), + 'utf8' +); + +function fixtureHtml() { + return ` + + + + + + + + + + + +`; +} + +async function openFixture(page) { + await page.route('http://newsletter.test/fixture', route => route.fulfill({ + status: 200, + contentType: 'text/html', + body: fixtureHtml(), + })); + await page.context().addCookies([ + { name: 'threew_consent_v1', value: 'test', url: 'http://newsletter.test' }, + ]); + await page.goto('http://newsletter.test/fixture'); + await expect(page.locator('#threew-newsletter-popup')).toBeVisible({ timeout: 5000 }); +} + +test('newsletter signup request contract remains intact', async ({ page }) => { + await page.route('http://newsletter.test/noop', route => route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: { couponCode: 'TEST5', discountPercent: 5 }, + }), + })); + await openFixture(page); + + await page.locator('[name="email"]').fill('customer@example.com'); + await page.locator('[name="consent"]').check(); + await page.locator('#threew-newsletter-popup-form button[type="submit"]').click(); + + await expect(page.locator('.threew-np-success')).toContainText('Success. Use code TEST5 for 5% off.'); +}); + +for (const viewport of [ + { name: 'desktop', width: 1280, height: 800 }, + { name: 'mobile', width: 390, height: 844 }, +]) { + test.describe(viewport.name, () => { + test.use({ viewport }); + + test('Escape closes, traps focus, and restores the trigger', async ({ page }) => { + await openFixture(page); + + await expect(page.locator('#threew-newsletter-popup-close')).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(page.locator('#threew-newsletter-popup-form button[type="submit"]')).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(page.locator('#threew-newsletter-popup-close')).toBeFocused(); + + await page.keyboard.press('Escape'); + await expect(page.locator('#threew-newsletter-popup')).toBeHidden(); + await expect(page.locator('#bg-add')).toBeFocused(); + + await page.reload(); + await page.waitForTimeout(1500); + await expect(page.locator('#threew-newsletter-popup')).toBeHidden(); + }); + + test('a backdrop click dismisses without activating the page behind', async ({ page }) => { + await openFixture(page); + const addButton = page.locator('#bg-add'); + const box = await addButton.boundingBox(); + + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + await expect(page.locator('#threew-newsletter-popup')).toBeHidden(); + await expect.poll(() => page.evaluate(() => window.__bgClicks)).toBe(0); + + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + await expect.poll(() => page.evaluate(() => window.__bgClicks)).toBe(1); + }); + }); +} diff --git a/tests/scripts/deploy-file.test.sh b/tests/scripts/deploy-file.test.sh new file mode 100755 index 0000000..d8c7edb --- /dev/null +++ b/tests/scripts/deploy-file.test.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +SCRIPT="$REPO_ROOT/scripts/deploy-file.sh" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +mkdir -p "$TMP/bin" +cat > "$TMP/bin/lftp" <<'FAKE_LFTP' +#!/usr/bin/env bash +set -euo pipefail +did_put=false +while IFS= read -r line; do + if [[ "$line" =~ ^get\ \"([^\"]+)\"\ -o\ \"([^\"]+)\"$ ]]; then + destination=${BASH_REMATCH[2]} + if [[ "$did_put" == true && -n "${FAKE_CORRUPT_READBACK_ONCE:-}" && -f "${FAKE_CORRUPT_STATE}" ]]; then + printf 'corrupt read-back' > "$destination" + rm -f "$FAKE_CORRUPT_STATE" + else + cp "$FAKE_REMOTE_STORE" "$destination" + fi + elif [[ "$line" =~ ^put\ \"([^\"]+)\"\ -o\ \"([^\"]+)\"$ ]]; then + cp "${BASH_REMATCH[1]}" "$FAKE_REMOTE_STORE" + did_put=true + if [[ -n "${FAKE_FAIL_AFTER_PUT_ONCE:-}" && -f "${FAKE_FAIL_STATE}" ]]; then + rm -f "$FAKE_FAIL_STATE" + exit 42 + fi + fi +done +FAKE_LFTP +chmod +x "$TMP/bin/lftp" + +printf 'reviewed original' > "$TMP/remote.js" +printf 'reviewed replacement' > "$TMP/local.js" +ORIGINAL_HASH=$(sha256sum "$TMP/remote.js" | awk '{print $1}') +LOCAL_HASH=$(sha256sum "$TMP/local.js" | awk '{print $1}') + +COMMON_ENV=( + "PATH=$TMP/bin:$PATH" + "THREEW_FTP_HOST=fake.invalid" + "THREEW_FTP_USER=test-user" + "THREEW_FTP_PASS=test-pass" + "THREEW_DEPLOY_SCHEME=ftp" + "FAKE_REMOTE_STORE=$TMP/remote.js" + "FAKE_CORRUPT_STATE=$TMP/corrupt-once" + "FAKE_FAIL_STATE=$TMP/fail-after-put-once" +) + +run_deploy() { + env "${COMMON_ENV[@]}" "$SCRIPT" \ + --target staging \ + --local-file "$TMP/local.js" \ + --remote-file public_html/wp-content/plugins/example/assets/file.js \ + --backup-file "$TMP/backup.js" \ + --verified-file "$TMP/verified.js" \ + --expected-remote-sha256 "$ORIGINAL_HASH" \ + --expected-remote-sha256 "$LOCAL_HASH" +} + +# Successful bounded upload. +run_deploy +[[ $(sha256sum "$TMP/remote.js" | awk '{print $1}') == "$LOCAL_HASH" ]] + +# Re-running against an already-deployed override is idempotent. +run_deploy +[[ $(sha256sum "$TMP/remote.js" | awk '{print $1}') == "$LOCAL_HASH" ]] + +# Unknown remote bytes must fail before upload. +printf 'unreviewed remote edit' > "$TMP/remote.js" +if run_deploy >/dev/null 2>&1; then + echo 'Expected unknown remote hash to fail' >&2 + exit 1 +fi +[[ $(cat "$TMP/remote.js") == 'unreviewed remote edit' ]] + +# Failed read-back verification must restore the reviewed backup. +printf 'reviewed original' > "$TMP/remote.js" +touch "$TMP/corrupt-once" +if FAKE_CORRUPT_READBACK_ONCE=1 run_deploy >/dev/null 2>&1; then + echo 'Expected corrupt read-back to fail' >&2 + exit 1 +fi +[[ $(sha256sum "$TMP/remote.js" | awk '{print $1}') == "$ORIGINAL_HASH" ]] + +# A transport failure after upload must also restore the reviewed backup. +printf 'reviewed original' > "$TMP/remote.js" +touch "$TMP/fail-after-put-once" +if FAKE_FAIL_AFTER_PUT_ONCE=1 run_deploy >/dev/null 2>&1; then + echo 'Expected transport failure after put to fail' >&2 + exit 1 +fi +[[ $(sha256sum "$TMP/remote.js" | awk '{print $1}') == "$ORIGINAL_HASH" ]] + +# Traversal-like remote paths must fail before lftp is called. +for unsafe_path in \ + public_html/wp-content/../file.js \ + public_html/wp-content/./file.js \ + public_html//wp-content/file.js; do + if env "${COMMON_ENV[@]}" "$SCRIPT" \ + --target staging \ + --local-file "$TMP/local.js" \ + --remote-file "$unsafe_path" \ + --backup-file "$TMP/backup.js" \ + --verified-file "$TMP/verified.js" \ + --expected-remote-sha256 "$ORIGINAL_HASH" >/dev/null 2>&1; then + echo "Expected unsafe remote path to fail: $unsafe_path" >&2 + exit 1 + fi +done + +# The allowlist is mandatory even when every other argument is valid. +if env "${COMMON_ENV[@]}" "$SCRIPT" \ + --target staging \ + --local-file "$TMP/local.js" \ + --remote-file public_html/wp-content/plugins/example/assets/file.js \ + --backup-file "$TMP/backup.js" \ + --verified-file "$TMP/verified.js" >/dev/null 2>&1; then + echo 'Expected a missing remote hash allowlist to fail' >&2 + exit 1 +fi + +echo 'deploy-file safety tests passed'