diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 1773f13..5c0fd1c 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -14,9 +14,21 @@ jobs: matrix: php: ['7.4', '8.1', '8.2', '8.3'] steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # 2.35.5 with: php-version: ${{ matrix.php }} - name: Lint PHP files run: find . -type f -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l + + browser-security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..09dc5bd --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,67 @@ +name: Build release candidate + +on: + workflow_dispatch: + pull_request: + push: + tags: ['v*'] + +permissions: + contents: read + +jobs: + transport-package: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: modx + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -proot" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # 2.35.5 + with: + php-version: '8.2' + extensions: pdo_mysql, zip, mbstring, json + tools: composer:v2 + - name: Install pinned MODX build runtime + run: composer create-project modx/revolution /tmp/modx 3.2.3-pl --no-interaction --prefer-dist + - name: Install MODX non-interactively + run: | + cp tests/fixtures/modx-config.xml /tmp/modx/setup/config.xml + php /tmp/modx/setup/index.php --installmode=new --config=/tmp/modx/setup/config.xml + - name: Build twice and verify reproducibility + run: | + cp _build/build.config.sample.php _build/build.config.php + mkdir -p dist + MODX_BASE_PATH=/tmp/modx/ php _build/build.transport.php + python3 scripts/normalize-transport.py /tmp/modx/core/packages/amwebconsent-1.0.0-rc2.transport.zip /tmp/first.transport.zip + MODX_BASE_PATH=/tmp/modx/ php _build/build.transport.php + python3 scripts/normalize-transport.py /tmp/modx/core/packages/amwebconsent-1.0.0-rc2.transport.zip /tmp/second.transport.zip + cmp /tmp/first.transport.zip /tmp/second.transport.zip + cp /tmp/first.transport.zip dist/amwebconsent-1.0.0-rc2.transport.zip + sha256sum dist/amwebconsent-1.0.0-rc2.transport.zip > dist/amwebconsent-1.0.0-rc2.transport.zip.sha256 + - name: Verify package lifecycle on clean MODX + run: MODX_BASE_PATH=/tmp/modx/ php tests/integration/package-lifecycle.php "$PWD/dist/amwebconsent-1.0.0-rc2.transport.zip" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: amwebconsent-transport + path: dist/* + if-no-files-found: error + - name: Attest build provenance + if: startsWith(github.ref, 'refs/tags/') + uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 # v3 + with: + subject-path: dist/amwebconsent-1.0.0-rc2.transport.zip diff --git a/.gitignore b/.gitignore index 775a1ba..d2cdd83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /.DS_Store /_build/build.config.php /dist/ +/node_modules/ +/playwright-report/ +/test-results/ /vendor/ diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 0000000..5cdcd6c --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,15 @@ +# amWeb Consent (powered by Klaro) + +Open-source MODX 3 Extra by amWeb that integrates the vendored Klaro consent manager. The project targets MODX 3.x and PHP 7.4+, is licensed under MIT, and is hosted at https://github.com/amWeb-DevTeam/amweb-consent. + +## Product constraints + +- Consent-controlled services must not contact third parties before opt-in. +- Runtime paths come from MODX constants/options, not editable component settings. +- Dynamic JavaScript values use JSON encoding. +- Strict CSP deployments can pass a nonce to every generated script. +- Release candidates are built and tested on GitHub before MODX submission. + +## Current milestone + +Prepare `1.0.0-rc2` as the security-hardened public release candidate. diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 0000000..22e2025 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,12 @@ +# Requirements + +## Release candidate 1.0.0-rc2 + +- [x] SEC-01 Escape reCAPTCHA values according to URL and JavaScript context. +- [x] SEC-02 Support nonce-based CSP and remove inline event handlers. +- [x] SEC-03 Reject unsupported MODX/PHP versions during installation. +- [x] SEC-04 Prove in a browser that blocked services make no request before consent. +- [x] SEC-05 Pin CI actions and produce a normalized, reproducible transport package. +- [x] REL-01 Pass the clean MODX install/uninstall job on GitHub Actions. +- [ ] REL-02 Review and merge the hardening pull request. +- [ ] REL-03 Publish and verify the `1.0.0-rc2` prerelease asset and checksum. diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 0000000..b98da5a --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,15 @@ +# Roadmap + +## Phase 1 — Public RC foundation (complete) + +Repository, MIT licensing, English documentation, MODX 3 package structure, and `1.0.0-rc1` prerelease. + +## Phase 2 — Security hardening (in progress) + +Harden render contexts and paths, add CSP support and install validation, automate browser security tests, and make the release build reproducible. + +Success criteria: SEC-01 through SEC-05 pass locally and REL-01 passes in GitHub Actions. + +## Phase 3 — Official MODX submission (pending) + +Publish the verified release candidate, collect acceptance feedback, then submit the Extra to the MODX provider. diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 0000000..df83026 --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,25 @@ +# Project state + +- Milestone: `1.0.0-rc2` +- Active phase: 2 — Security hardening +- Active branch: `codex/security-hardening-rc2` +- Last verified: 2026-08-05 +- Next action: review and merge draft PR #1, then publish `1.0.0-rc2`. + +## Verified locally + +- npm audit: 0 vulnerabilities +- Playwright: 3/3 passed +- PHP syntax: passed in the MODX DDEV runtime +- MODX transport build: passed twice +- Normalized artifacts: byte-identical +- Clean MODX 3.2.3 install/uninstall: passed in GitHub Actions run 30997904144 +- Current artifact SHA-256: `fea322a43937f6945efd908a881463fac199a2aca2eede188ca849c2d1f321a9` + +## Decisions + +- Only MODX 3.x is supported. +- Minimum PHP is 7.4. +- Component `core_path` and `assets_url` settings are no longer trusted or used. +- Config chunks contain JavaScript only; the renderer owns the script tag and nonce. +- Playwright is pinned to 1.62.1 due to GHSA-7mvr-c777-76hp in older versions. diff --git a/.planning/quick/260805-sec-security-hardening-rc2/PLAN.md b/.planning/quick/260805-sec-security-hardening-rc2/PLAN.md new file mode 100644 index 0000000..9bcac04 --- /dev/null +++ b/.planning/quick/260805-sec-security-hardening-rc2/PLAN.md @@ -0,0 +1,14 @@ +# Quick task: Security hardening for rc2 + +## Goal + +Resolve the five security review findings with minimal process overhead while preserving verifiable release gates. + +## Tasks + +1. Harden context encoding and eliminate configurable runtime paths. +2. Add nonce-based CSP output and externalize the settings-link handler. +3. Add MODX/PHP package requirements and an install validator. +4. Add browser tests for consent network blocking and strict CSP. +5. Pin CI dependencies, normalize MODX randomness, and verify duplicate builds. +6. Add a clean MODX install/uninstall gate to the release workflow. diff --git a/.planning/quick/260805-sec-security-hardening-rc2/SUMMARY.md b/.planning/quick/260805-sec-security-hardening-rc2/SUMMARY.md new file mode 100644 index 0000000..0d1940e --- /dev/null +++ b/.planning/quick/260805-sec-security-hardening-rc2/SUMMARY.md @@ -0,0 +1,14 @@ +# Summary + +All five requested hardening areas were implemented for `1.0.0-rc2`. + +- JavaScript values are JSON-encoded; the reCAPTCHA URL key is URL-encoded. +- Generated scripts accept a CSP nonce; inline `onclick` was replaced by an external listener. +- Editable component path settings were removed from runtime use and package settings. +- Package metadata and a PHP validator enforce MODX 3.x, PHP 7.4+, and JSON support. +- Playwright proves no pre-consent/rejected network request and validates strict CSP operation. +- GitHub actions and Node test dependencies are pinned. +- MODX-generated random vehicle IDs, ZIP timestamps, metadata, and ordering are normalized. +- The release workflow builds twice, compares artifacts, tests clean install/uninstall, uploads a checksum, and emits provenance for tag builds. + +The GitHub-hosted clean MODX lifecycle run passed. Only review/merge and publishing the release candidate remain. diff --git a/.planning/quick/260805-sec-security-hardening-rc2/VERIFICATION.md b/.planning/quick/260805-sec-security-hardening-rc2/VERIFICATION.md new file mode 100644 index 0000000..3ce0e8a --- /dev/null +++ b/.planning/quick/260805-sec-security-hardening-rc2/VERIFICATION.md @@ -0,0 +1,15 @@ +# Verification + +Status: passed locally and in the hosted lifecycle gate. + +| Gate | Result | +|---|---| +| npm audit | PASS — 0 vulnerabilities | +| Browser consent tests | PASS — 3/3 | +| Strict CSP browser test | PASS | +| PHP syntax | PASS | +| MODX package build | PASS | +| Reproducible normalized ZIP | PASS | +| Clean MODX install/uninstall | PASS — GitHub Actions run 30997904144 | + +Artifact SHA-256: `fea322a43937f6945efd908a881463fac199a2aca2eede188ca849c2d1f321a9` diff --git a/CHANGELOG.md b/CHANGELOG.md index b3dba27..131d571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 1.0.0-rc2 + +- Fix the generated configuration script so it contains real newlines instead of invalid literal `\\n` tokens. +- Use `amWeb Consent (powered by Klaro)` as the installed package display name. +- Safely JSON-encode dynamic JavaScript values and reCAPTCHA settings. +- Remove configurable PHP and asset paths from runtime loading. +- Add CSP nonce support and replace inline click handlers. +- Route split head/footer rendering through the secure PHP renderer. +- Add MODX 3, PHP 7.4 and JSON installation validation. +- Add browser tests proving trackers stay blocked before consent and rejection. +- Pin GitHub Actions and add reproducible package builds with provenance. + ## 1.0.0-rc1 - First public release candidate as amWeb Consent (powered by Klaro). diff --git a/README.md b/README.md index f37c2cf..946185b 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Snippets: - `amWebConsent` - `amWebConsentLink` +- `amWebConsentJson` (internal output filter) Chunks: @@ -69,8 +70,6 @@ System settings: - `amwebconsent.load_css` - `amwebconsent.load_js` - `amwebconsent.debug` -- `amwebconsent.assets_url` -- `amwebconsent.core_path` ## Basic Installation @@ -98,6 +97,14 @@ Split head/footer integration: [[$amwebconsent.footer]] ``` +The split chunks accept a request-specific `nonce` placeholder. Pass the same +nonce used in your Content Security Policy header: + +```html +[[$amwebconsent.head? &nonce=`[[+cspNonce]]`]] +[[$amwebconsent.footer? &nonce=`[[+cspNonce]]`]] +``` + Manual consent settings link: ```html @@ -123,10 +130,12 @@ Button variant: | Property | Default | Description | | --- | --- | --- | -| `configChunk` | `amwebconsent.config` | Chunk that returns `window.klaroConfig`. | +| `configChunk` | `amwebconsent.config` | Chunk that returns JavaScript only; the snippet creates the script element. | | `loadCss` | system setting | Load local Klaro CSS. | | `loadJs` | system setting | Load local Klaro JS. | +| `renderConfig` | `1` | Render the Klaro configuration script. Set to `0` in footer-only calls. | | `autoLoad` | `1` | If `0`, writes `noAutoLoad: true` to the config. | +| `nonce` | empty | Request-specific CSP nonce applied to every script element. | | `debug` | system setting | Adds a small HTML comment and logs empty config output. | Example: @@ -137,9 +146,13 @@ Example: &loadCss=`1` &loadJs=`1` &autoLoad=`1` + &nonce=`[[+cspNonce]]` ]] ``` +Custom configuration chunks must return JavaScript only, without surrounding +` diff --git a/core/components/amwebconsent/elements/chunks/amwebconsent.footer.tpl b/core/components/amwebconsent/elements/chunks/amwebconsent.footer.tpl index a13d060..4f5a952 100644 --- a/core/components/amwebconsent/elements/chunks/amwebconsent.footer.tpl +++ b/core/components/amwebconsent/elements/chunks/amwebconsent.footer.tpl @@ -1 +1,6 @@ - +[[!amWebConsent? + &loadCss=`0` + &loadJs=`1` + &renderConfig=`0` + &nonce=`[[+nonce]]` +]] diff --git a/core/components/amwebconsent/elements/chunks/amwebconsent.head.tpl b/core/components/amwebconsent/elements/chunks/amwebconsent.head.tpl index a98f221..87c4d59 100644 --- a/core/components/amwebconsent/elements/chunks/amwebconsent.head.tpl +++ b/core/components/amwebconsent/elements/chunks/amwebconsent.head.tpl @@ -1,4 +1,5 @@ [[!amWebConsent? &loadCss=`1` &loadJs=`0` + &nonce=`[[+nonce]]` ]] diff --git a/core/components/amwebconsent/elements/chunks/examples/amwebconsent.example.recaptcha-v3.tpl b/core/components/amwebconsent/elements/chunks/examples/amwebconsent.example.recaptcha-v3.tpl index 30c0a53..ea76bd4 100644 --- a/core/components/amwebconsent/elements/chunks/examples/amwebconsent.example.recaptcha-v3.tpl +++ b/core/components/amwebconsent/elements/chunks/examples/amwebconsent.example.recaptcha-v3.tpl @@ -2,11 +2,11 @@ type="text/plain" data-type="text/javascript" data-name="google-recaptcha" - data-src="https://www.google.com/recaptcha/api.js?render=[[++recaptcha.site_key]]"> + data-src="https://www.google.com/recaptcha/api.js?render=[[++recaptcha.site_key:urlencode]]"> '; + } } - $output[] = $config; if ($loadJs) { - $output[] = ''; + $output[] = ''; + $output[] = ''; } return implode("\n", array_filter($output)) . "\n"; @@ -110,6 +117,15 @@ protected function normalizeLanguage($language): string return 'de'; } + protected function normalizeStorageMethod($storageMethod): string + { + $storageMethod = (string) $storageMethod; + + return in_array($storageMethod, ['cookie', 'localStorage', 'sessionStorage', 'test'], true) + ? $storageMethod + : 'cookie'; + } + protected function getSetting(string $key, $default = null) { return $this->modx->getOption($key, $this->config, $default); @@ -144,12 +160,13 @@ protected function escapeHtml($value): string return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); } - protected function escapeJs($value): string + protected function encodeJson($value): string { - return str_replace( - ["\\", "'", "\r", "\n", '=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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6a1b2ce --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "amweb-consent", + "version": "1.0.0-rc.2", + "private": true, + "scripts": { + "test": "playwright test", + "test:e2e": "playwright test tests/e2e" + }, + "devDependencies": { + "@playwright/test": "1.62.1" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..ad79819 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,18 @@ +const { defineConfig } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + browserName: 'chromium', + }, + webServer: { + command: 'node tests/server.mjs', + url: 'http://127.0.0.1:4173/health', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/scripts/normalize-transport.py b/scripts/normalize-transport.py new file mode 100644 index 0000000..cf50376 --- /dev/null +++ b/scripts/normalize-transport.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Rewrite a MODX transport ZIP with stable IDs, order and metadata.""" + +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo +import hashlib +import re +import sys + +RANDOM_ID = re.compile(rb'(? bytes: + return RANDOM_ID.sub(lambda match: replacements.get(match.group(), match.group()), value) + + +def canonical_id(index: int) -> bytes: + return hashlib.sha256(f'amwebconsent-vehicle-{index}'.encode()).hexdigest()[:32].encode() + + +def normalize(source: Path, destination: Path) -> None: + with ZipFile(source, 'r') as archive: + records = [(archive.getinfo(name), archive.read(name)) for name in archive.namelist()] + + metadata = [] + for original, content in records: + name = original.filename.encode() + if original.filename.endswith(TRANSPORT_METADATA): + metadata.append((RANDOM_ID.sub(b'', name), RANDOM_ID.sub(b'', content), name, content)) + + replacements: dict[bytes, bytes] = {} + for _, _, name, content in sorted(metadata): + for token in RANDOM_ID.findall(name) + RANDOM_ID.findall(content): + if token not in replacements: + replacements[token] = canonical_id(len(replacements)) + + normalized = [] + for original, content in records: + name = replace_ids(original.filename.encode(), replacements).decode() + if original.filename.endswith(TRANSPORT_METADATA): + content = replace_ids(content, replacements) + normalized.append((name, original, content)) + + with ZipFile(destination, 'w', compression=ZIP_DEFLATED, compresslevel=9) as output: + for name, original, content in sorted(normalized, key=lambda record: record[0]): + info = ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = ZIP_DEFLATED + info.create_system = 3 + info.external_attr = original.external_attr + info.flag_bits = original.flag_bits + output.writestr(info, content) + + +if __name__ == '__main__': + if len(sys.argv) != 3: + raise SystemExit('usage: normalize-transport.py SOURCE DESTINATION') + normalize(Path(sys.argv[1]), Path(sys.argv[2])) diff --git a/tests/e2e/consent.spec.js b/tests/e2e/consent.spec.js new file mode 100644 index 0000000..12a9ab2 --- /dev/null +++ b/tests/e2e/consent.spec.js @@ -0,0 +1,60 @@ +const { expect, test } = require('@playwright/test'); + +test('does not request a blocked tracker before consent', async ({ page }) => { + const requests = []; + page.on('request', request => { + if (request.url().startsWith('https://tracker.invalid/')) { + requests.push(request.url()); + } + }); + + await page.goto('/tests/fixtures/consent.html'); + await page.waitForFunction(() => window.klaro && window.klaro.getManager()); + await page.waitForTimeout(250); + expect(requests).toEqual([]); + + await page.evaluate(() => { + const manager = window.klaro.getManager(); + manager.changeAll(true); + manager.saveAndApplyConsents('playwright'); + }); + + await expect.poll(() => requests.length).toBe(1); +}); + +test('keeps a blocked tracker disabled after rejection', async ({ page }) => { + const requests = []; + page.on('request', request => { + if (request.url().startsWith('https://tracker.invalid/')) { + requests.push(request.url()); + } + }); + + await page.goto('/tests/fixtures/consent.html'); + await page.waitForFunction(() => window.klaro && window.klaro.getManager()); + await page.evaluate(() => { + const manager = window.klaro.getManager(); + manager.changeAll(false); + manager.saveAndApplyConsents('playwright'); + }); + await page.waitForTimeout(250); + + expect(requests).toEqual([]); +}); + +test('works under a nonce-based strict CSP without inline handlers', async ({ page }) => { + const violations = []; + await page.exposeFunction('recordCspViolation', violation => violations.push(violation)); + await page.addInitScript(() => { + document.addEventListener('securitypolicyviolation', event => { + window.recordCspViolation({blockedURI: event.blockedURI, directive: event.violatedDirective}); + }); + }); + + await page.goto('/tests/fixtures/csp.html'); + await page.waitForFunction(() => window.klaro && window.klaro.getManager()); + expect(await page.locator('[data-amweb-consent-open]').getAttribute('onclick')).toBeNull(); + await page.locator('[data-amweb-consent-open]').click(); + await expect(page.locator('#klaro')).toBeVisible(); + expect(violations).toEqual([]); +}); diff --git a/tests/fixtures/consent.html b/tests/fixtures/consent.html new file mode 100644 index 0000000..be95395 --- /dev/null +++ b/tests/fixtures/consent.html @@ -0,0 +1,18 @@ + + + + + amWeb Consent request blocking test + + + + + + diff --git a/tests/fixtures/csp.html b/tests/fixtures/csp.html new file mode 100644 index 0000000..712238d --- /dev/null +++ b/tests/fixtures/csp.html @@ -0,0 +1,20 @@ + + + + + amWeb Consent CSP test + + + + + + Settings + + diff --git a/tests/fixtures/modx-config.xml b/tests/fixtures/modx-config.xml new file mode 100644 index 0000000..091e9e6 --- /dev/null +++ b/tests/fixtures/modx-config.xml @@ -0,0 +1,27 @@ + + mysql + 127.0.0.1 + modx + root + root + utf8mb4 + utf8mb4 + utf8mb4_unicode_ci + modx_ + 443 + localhost + 1 + 1 + en + ci-admin + ci-only-not-a-secret + ci@example.invalid + /tmp/modx/core/ + /tmp/modx/manager/ + /manager/ + /tmp/modx/connectors/ + /connectors/ + /tmp/modx/ + / + 0 + diff --git a/tests/integration/package-lifecycle.php b/tests/integration/package-lifecycle.php new file mode 100644 index 0000000..c662ee5 --- /dev/null +++ b/tests/integration/package-lifecycle.php @@ -0,0 +1,89 @@ +initialize('mgr'); +$modx->setLogTarget('ECHO'); + +$workspace = $modx->getObject(modWorkspace::class, 1); +if (!$workspace) { + throw new RuntimeException('Default MODX workspace is missing.'); +} + +$package = $modx->newObject(modTransportPackage::class); +$package->fromArray([ + 'signature' => $signature, + 'source' => $signature . '.transport.zip', + 'state' => 1, + 'workspace' => 1, + 'package_name' => 'amwebconsent', + 'version_major' => 1, + 'version_minor' => 0, + 'version_patch' => 0, + 'release' => 'rc', + 'release_index' => 2, + 'created' => date('Y-m-d H:i:s'), +], '', true, true); + +if (!$package->save() || !$package->install()) { + throw new RuntimeException('Transport package installation failed.'); +} + +foreach (['amWebConsent', 'amWebConsentLink', 'amWebConsentJson'] as $name) { + if (!$modx->getObject(modSnippet::class, ['name' => $name])) { + throw new RuntimeException('Missing installed snippet: ' . $name); + } +} +if (!$modx->getObject(modChunk::class, ['name' => 'amwebconsent.config'])) { + throw new RuntimeException('Missing installed configuration chunk.'); +} +$displayName = $modx->query( + "SELECT package_name FROM {$modx->getTableName(modTransportPackage::class)} WHERE signature = " + . $modx->quote($signature) +)->fetchColumn(); +if ($displayName !== 'amWeb Consent (powered by Klaro)') { + throw new RuntimeException('Package display name was not updated.'); +} +if ($modx->getObject(modSystemSetting::class, ['key' => 'amwebconsent.core_path'])) { + throw new RuntimeException('Unsafe core_path setting must not be installed.'); +} + +if (!$package->uninstall()) { + throw new RuntimeException('Transport package uninstall failed.'); +} + +echo "Package install and uninstall succeeded.\n"; diff --git a/tests/server.mjs b/tests/server.mjs new file mode 100644 index 0000000..9de6c0f --- /dev/null +++ b/tests/server.mjs @@ -0,0 +1,45 @@ +import { createReadStream, statSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, join, normalize } from 'node:path'; + +const root = process.cwd(); +const contentTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', +}; + +createServer((request, response) => { + const pathname = new URL(request.url, 'http://127.0.0.1').pathname; + if (pathname === '/health') { + response.writeHead(204); + response.end(); + return; + } + + const relativePath = normalize(pathname).replace(/^[/\\]+/, ''); + const filePath = join(root, relativePath); + if (!filePath.startsWith(root) || !statIsFile(filePath)) { + response.writeHead(404); + response.end('Not found'); + return; + } + + if (pathname.endsWith('/csp.html')) { + response.setHeader( + 'Content-Security-Policy', + "default-src 'none'; script-src 'self' 'nonce-test-nonce'; style-src 'self'; connect-src 'self' https://tracker.invalid; img-src 'self'; base-uri 'none'; frame-ancestors 'none'" + ); + } + + response.setHeader('Content-Type', contentTypes[extname(filePath)] || 'application/octet-stream'); + createReadStream(filePath).pipe(response); +}).listen(4173, '127.0.0.1'); + +function statIsFile(filePath) { + try { + return statSync(filePath).isFile(); + } catch { + return false; + } +}