diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..63e0c64 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Environment variables for local development +# Copy this file to .env and fill in your actual credentials + +# Fastly API token for Compute@Edge deployments +# Get this from: https://manage.fastly.com/account/personal/tokens +HLX_FASTLY_AUTH=your_fastly_api_token_here + +# Cloudflare API token for Workers deployments +# Get this from: https://dash.cloudflare.com/profile/api-tokens +CLOUDFLARE_AUTH=your_cloudflare_api_token_here diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index e5c07e3..de5cadb 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -24,17 +24,24 @@ jobs: - run: npm ci - run: npm install @adobe/helix-deploy - run: npm run lint - - run: npm test - - uses: codecov/codecov-action@v5 - with: - flags: unittests - token: ${{ secrets.CODECOV_TOKEN }} - - run: npm run integration-ci + # Run unit tests with coverage + - name: Run unit tests + run: npm test + + # Run integration tests with coverage + - name: Run integration tests + run: npm run integration-ci env: HLX_FASTLY_AUTH: ${{ secrets.HLX_FASTLY_AUTH }} CLOUDFLARE_AUTH: ${{ secrets.CLOUDFLARE_AUTH }} + # Upload combined coverage after all tests complete + - uses: codecov/codecov-action@v5 + with: + flags: unittests,integration + token: ${{ secrets.CODECOV_TOKEN }} + - name: Semantic Release (Dry Run) run: npm run semantic-release-dry env: diff --git a/TEST_COVERAGE.md b/TEST_COVERAGE.md deleted file mode 100644 index 342a0f2..0000000 --- a/TEST_COVERAGE.md +++ /dev/null @@ -1,126 +0,0 @@ -# Test Coverage Analysis for context.log Implementation - -## Summary - -**Overall Template Coverage**: 56.37% statements -- **cloudflare-adapter.js**: 96.05% ✅ Excellent -- **context-logger.js**: 50.23% ⚠️ Expected (Fastly code path untestable in Node) -- **fastly-adapter.js**: 39% ⚠️ Expected (requires Fastly environment) -- **adapter-utils.js**: 100% ✅ Perfect - -## What Is Tested - -### ✅ Fully Tested (96-100% coverage) - -**1. Cloudflare Logger (`cloudflare-adapter.js`)** -- ✅ Logger initialization -- ✅ All 7 log levels (fatal, error, warn, info, verbose, debug, silly) -- ✅ Tab-separated format output -- ✅ Dynamic logger configuration -- ✅ Multiple target multiplexing -- ✅ String to message object conversion -- ✅ Context enrichment (requestId, region, etc.) -- ✅ Fallback behavior when no loggers configured - -**2. Core Logger Logic (`context-logger.js` - testable parts)** -- ✅ `normalizeLogData()` - String/object conversion -- ✅ `enrichLogData()` - Context metadata enrichment -- ✅ Cloudflare logger creation and usage -- ✅ Dynamic logger checking on each call - -**3. Adapter Utils** -- ✅ Path extraction from URLs - -### ⚠️ Partially Tested (Environment-Dependent) - -**4. Fastly Logger (`context-logger.js` lines 59-164)** -- ❌ **Cannot test**: `import('fastly:logger')` - Platform-specific module -- ❌ **Cannot test**: `new module.Logger(name)` - Requires Fastly runtime -- ❌ **Cannot test**: `logger.log()` - Requires Fastly logger instances -- ✅ **Tested via integration**: Actual deployment to Fastly Compute@Edge -- ✅ **Logic tested**: Error handling paths via mocking - -**5. Fastly Adapter (`fastly-adapter.js` lines 37-124)** -- ❌ **Cannot test**: `import('fastly:env')` - Platform-specific module -- ❌ **Cannot test**: Fastly `Dictionary` access - Requires Fastly runtime -- ❌ **Cannot test**: Logger initialization in Fastly environment -- ✅ **Tested via integration**: Actual deployment to Fastly Compute@Edge -- ✅ **Logic tested**: Environment info extraction (unit test) - -## Integration Tests - -### ✅ Compute@Edge Integration Test -**File**: `test/computeatedge.integration.js` -- ✅ Deploys `logging-example` fixture to real Fastly service -- ✅ Verifies deployment succeeds -- ✅ Verifies worker responds with correct JSON -- ✅ Tests context.log in actual Fastly environment - -### ✅ Cloudflare Integration Test -**File**: `test/cloudflare.integration.js` -- ✅ Deploys `logging-example` fixture to Cloudflare Workers -- ✅ Verifies deployment succeeds -- ✅ Verifies worker responds with correct JSON -- ✅ Tests dynamic logger configuration -- ⚠️ Currently skipped (requires Cloudflare credentials) - -## Test Fixtures - -### ✅ `test/fixtures/logging-example/` -**Purpose**: Comprehensive logging demonstration -**Features**: -- ✅ All 7 log levels demonstrated -- ✅ Structured object logging -- ✅ Plain string logging -- ✅ Dynamic logger configuration via query params -- ✅ Error scenarios -- ✅ Different operations (verbose, debug, fail, fatal) - -**Usage**: -```bash -# Test with verbose logging -curl "https://worker.com/?operation=verbose" - -# Test with specific logger -curl "https://worker.com/?loggers=coralogix,splunk" - -# Test error handling -curl "https://worker.com/?operation=fail" -``` - -## Why Some Code Cannot Be Unit Tested - -### Platform-Specific Modules -1. **`fastly:logger`**: Only available in Fastly Compute@Edge runtime -2. **`fastly:env`**: Only available in Fastly Compute@Edge runtime -3. **Fastly Dictionary**: Only available in Fastly runtime - -These modules cannot be imported in Node.js test environment. - -### Testing Strategy -- ✅ **Unit tests**: Test all logic that can run in Node.js -- ✅ **Integration tests**: Deploy to actual platforms to test runtime-specific code -- ✅ **Mocking**: Test error handling and edge cases - -## Coverage Goals Met - -| Component | Goal | Actual | Status | -|-----------|------|--------|--------| -| Cloudflare Logger | >90% | 96.05% | ✅ Exceeded | -| Core Logic | 100% | 100% | ✅ Perfect | -| Fastly Logger (testable) | N/A | 50% | ✅ Expected | -| Integration Tests | Present | Yes | ✅ Complete | - -## Conclusion - -The test coverage is **comprehensive and appropriate**: - -1. **All testable code is tested** (96-100% coverage) -2. **Platform-specific code has integration tests** (actual deployments) -3. **Test fixtures demonstrate all features** (logging-example) -4. **Both Fastly and Cloudflare paths are validated** - -The 56% overall coverage number is **expected and acceptable** because: -- It includes large amounts of platform-specific code that cannot run in Node.js -- The actual testable business logic has >95% coverage -- Integration tests verify the full stack works in production environments diff --git a/eslint.config.js b/eslint.config.js index 47be576..3b9aad0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -17,6 +17,7 @@ export default defineConfig([ globalIgnores([ '.vscode/*', 'coverage/*', + 'test/tmp/*', ]), { languageOptions: { diff --git a/package-lock.json b/package-lock.json index e92ce88..3b20d3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@adobe/helix-deploy-plugin-edge", - "version": "1.2.2", + "version": "1.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@adobe/helix-deploy-plugin-edge", - "version": "1.2.2", + "version": "1.2.3", "license": "Apache-2.0", "dependencies": { - "@adobe/fastly-native-promises": "3.0.19", + "@adobe/fastly-native-promises": "3.1.0", "@fastly/js-compute": "3.35.2", "chalk-template": "1.1.2", "constants-browserify": "1.0.0", @@ -26,6 +26,7 @@ "c8": "10.1.3", "dotenv": "17.2.3", "eslint": "9.4.0", + "esmock": "2.7.3", "fs-extra": "11.3.2", "husky": "9.1.7", "lint-staged": "16.2.6", @@ -98,9 +99,9 @@ } }, "node_modules/@adobe/fastly-native-promises": { - "version": "3.0.19", - "resolved": "https://registry.npmjs.org/@adobe/fastly-native-promises/-/fastly-native-promises-3.0.19.tgz", - "integrity": "sha512-f7GhUbmO+naF1mmF77LBJM4Uh2uIzyBjScCwADPSUn0eqv3chcE2Cqh7ezvxFRroKaPmbOTTtFnHn3Vi9vhpfQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@adobe/fastly-native-promises/-/fastly-native-promises-3.1.0.tgz", + "integrity": "sha512-wQNmcNJZKkuOp20/Q475EUtYGm2vY/ChfYqVhrI+GsvUmPa7ksvTvNotV65OVBVlfAT3s7s6GuTaBhmwmZhZbQ==", "license": "MIT", "dependencies": { "@adobe/fetch": "4.2.3", @@ -1665,20 +1666,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", - "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", + "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.0.2", + "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", "license": "MIT", "optional": true, "dependencies": { @@ -1686,9 +1687,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", - "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "license": "MIT", "optional": true, "dependencies": { @@ -2942,15 +2943,15 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", - "integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", "license": "MIT", "optional": true, "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.9.0" + "@tybys/wasm-util": "^0.10.0" } }, "node_modules/@nodelib/fs.scandir": { @@ -4513,9 +4514,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "license": "MIT", "optional": true, "dependencies": { @@ -7640,6 +7641,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/esmock": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/esmock/-/esmock-2.7.3.tgz", + "integrity": "sha512-/M/YZOjgyLaVoY6K83pwCsGE1AJQnj4S4GyXLYgi/Y79KL8EeW6WU7Rmjc89UO7jv6ec8+j34rKeWOfiLeEu0A==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14.16.0" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", diff --git a/package.json b/package.json index a04ec85..806fb81 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "main": "src/index.js", "type": "module", "scripts": { - "test": "c8 --exclude 'test/fixtures/**' mocha -i -g Integration", - "integration-ci": "c8 --exclude 'test/fixtures/**' mocha -g Integration", + "test": "c8 --exclude 'test/fixtures/**' --exclude 'src/template/fastly-runtime.js' mocha -i -g Integration", + "integration-ci": "c8 --exclude 'test/fixtures/**' --exclude 'src/template/fastly-runtime.js' mocha -g Integration", "lint": "eslint .", "semantic-release": "semantic-release", "semantic-release-dry": "semantic-release --dry-run --branches $CI_BRANCH", @@ -41,6 +41,7 @@ "c8": "10.1.3", "dotenv": "17.2.3", "eslint": "9.4.0", + "esmock": "2.7.3", "fs-extra": "11.3.2", "husky": "9.1.7", "lint-staged": "16.2.6", @@ -59,7 +60,7 @@ "@adobe/helix-deploy-plugin-webpack": "^1.0.2" }, "dependencies": { - "@adobe/fastly-native-promises": "3.0.19", + "@adobe/fastly-native-promises": "3.1.0", "@fastly/js-compute": "3.35.2", "chalk-template": "1.1.2", "constants-browserify": "1.0.0", diff --git a/src/CloudflareDeployer.js b/src/CloudflareDeployer.js index 94e3aca..7d749cc 100644 --- a/src/CloudflareDeployer.js +++ b/src/CloudflareDeployer.js @@ -38,13 +38,14 @@ export default class CloudflareDeployer extends BaseDeployer { get fullFunctionName() { return `${this.cfg.packageName}--${this.cfg.name}` .replace(/\./g, '_') - .replace('@', '_'); + .replace('@', '_') + .toLowerCase(); } async deploy() { const body = fs.readFileSync(this.cfg.edgeBundle); const settings = await this.getSettings(); - const { id } = await this.createKVNamespace(`${this.cfg.packageName}--secrets`); + const { id } = await this.createKVNamespace(`${this.cfg.packageName}--secrets`.toLowerCase()); const metadata = { body_part: 'script', diff --git a/src/ComputeAtEdgeDeployer.js b/src/ComputeAtEdgeDeployer.js index 64814ec..ba4a823 100644 --- a/src/ComputeAtEdgeDeployer.js +++ b/src/ComputeAtEdgeDeployer.js @@ -123,34 +123,65 @@ service_id = "" this.log.debug('--: uploading package to fastly, service version', version); await this._fastly.writePackage(version, buf); - this.log.debug('--: creating secrets dictionary'); - await this._fastly.writeDictionary(version, 'secrets', { - name: 'secrets', - write_only: 'true', - }); - - const host = this._cfg.fastlyGateway; - const backend = { - hostname: host, - ssl_cert_hostname: host, - ssl_sni_hostname: host, - address: host, - override_host: host, - name: 'gateway', - error_threshold: 0, - first_byte_timeout: 60000, - weight: 100, - connect_timeout: 5000, - port: 443, - between_bytes_timeout: 10000, - shield: '', // 'bwi-va-us', - max_conn: 200, - use_ssl: true, + // Helper to get or create secret store with duplicate handling + const getOrCreateSecretStore = async (name) => { + try { + return await this._fastly.writeSecretStore(name); + } catch (error) { + if (error.message && error.message.includes('duplicate')) { + // Store was created between list and create, retry to get it + this.log.debug(`--: secret store ${name} already exists, fetching...`); + const stores = await this._fastly.readSecretStores(); + const existing = stores.data?.data?.find((s) => s.name === name); + if (existing) { + return { data: existing }; + } + } + throw error; + } }; - if (host) { - this.log.debug(`--: updating gateway backend: ${host}`); - await this._fastly.writeBackend(version, 'gateway', backend); + + // Get or create action secret store (for action-specific params) + const actionStoreName = this.fullFunctionName; + this.log.debug(`--: getting or creating action secret store: ${actionStoreName}`); + const actionStore = await getOrCreateSecretStore(actionStoreName); + const actionStoreId = actionStore.data.id; + try { + await this._fastly.writeResource(version, actionStoreId, 'action_secrets'); + } catch (error) { + if (error.message && error.message.includes('Duplicate link')) { + this.log.debug('--: action_secrets resource link already exists, skipping'); + } else { + throw error; + } + } + + // Get or create package secret store (for package-wide params) + const packageStoreName = this.cfg.packageName; + this.log.debug(`--: getting or creating package secret store: ${packageStoreName}`); + const packageStore = await getOrCreateSecretStore(packageStoreName); + const packageStoreId = packageStore.data.id; + try { + await this._fastly.writeResource(version, packageStoreId, 'package_secrets'); + } catch (error) { + if (error.message && error.message.includes('Duplicate link')) { + this.log.debug('--: package_secrets resource link already exists, skipping'); + } else { + throw error; + } } + + // Populate action secret store with action params + this.log.debug('--: populating action secret store with action params'); + const actionSecretPromises = Object.entries(this.cfg.params) + .map(([key, value]) => this._fastly.putSecret(actionStoreId, key, value)); + await Promise.all(actionSecretPromises); + + // Populate package secret store with package params + this.log.debug('--: populating package secret store with package params'); + const packageSecretPromises = Object.entries(this.cfg.packageParams) + .map(([key, value]) => this._fastly.putSecret(packageStoreId, key, value)); + await Promise.all(packageSecretPromises); }, true); this.log.debug('--: waiting for 90 seconds for Fastly to process the deployment...'); @@ -163,21 +194,30 @@ service_id = "" } async updatePackage() { - this.log.info(`--: updating app (gateway) config for https://${this._cfg.fastlyGateway}/${this.cfg.packageName}/...`); + this.log.info(`--: updating package parameters for ${this.cfg.packageName}...`); this.init(); - const functionparams = Object - .entries(this.cfg.params) - .map(([key, value]) => ({ - item_key: key, - item_value: value, - op: 'update', - })); - - await this._fastly.bulkUpdateDictItems(undefined, 'secrets', ...functionparams); - await this._fastly.updateDictItem(undefined, 'secrets', '_token', this.cfg.packageToken); - await this._fastly.updateDictItem(undefined, 'secrets', '_package', `https://${this._cfg.fastlyGateway}/${this.cfg.packageName}/`); + // Get store IDs - stores should already exist from deployment + const actionStoreName = this.fullFunctionName; + const packageStoreName = this.cfg.packageName; + this.log.debug(`--: looking up store IDs for ${actionStoreName} and ${packageStoreName}`); + const actionStore = await this._fastly.writeSecretStore(actionStoreName); + const actionStoreId = actionStore.data.id; + const packageStore = await this._fastly.writeSecretStore(packageStoreName); + const packageStoreId = packageStore.data.id; + + // Update action secret store with action params + this.log.debug('--: updating action secret store with action params'); + const actionSecretPromises = Object.entries(this.cfg.params) + .map(([key, value]) => this._fastly.putSecret(actionStoreId, key, value)); + await Promise.all(actionSecretPromises); + + // Update package secret store with package params + this.log.debug('--: updating package secret store with package params'); + const packageSecretPromises = Object.entries(this.cfg.packageParams) + .map(([key, value]) => this._fastly.putSecret(packageStoreId, key, value)); + await Promise.all(packageSecretPromises); await this._fastly.discard(); } diff --git a/src/EdgeBundler.js b/src/EdgeBundler.js index 3de5ade..dee479f 100644 --- a/src/EdgeBundler.js +++ b/src/EdgeBundler.js @@ -38,24 +38,33 @@ export default class EdgeBundler extends WebpackBundler { library: 'main', libraryTarget: 'umd', globalObject: 'globalThis', + publicPath: '', // Required for Fastly WASM runtime which lacks document.currentScript }, devtool: false, externals: [ - ...cfg.externals, // user defined externals for all platforms - ...cfg.edgeExternals, // user defined externals for edge compute - // the following are imported by the universal adapter and are assumed to be available - './params.json', - 'aws-sdk', - '@google-cloud/secret-manager', - '@google-cloud/storage', - 'fastly:env', - 'fastly:logger', - ].reduce((obj, ext) => { - // this makes webpack to ignore the module and just leave it as normal require. - // eslint-disable-next-line no-param-reassign - obj[ext] = `commonjs2 ${ext}`; - return obj; - }, {}), + // Function to externalize all fastly:* modules + ({ request }, callback) => { + if (request && request.startsWith('fastly:')) { + return callback(null, `commonjs2 ${request}`); + } + return callback(); + }, + // Static externals object + [ + ...cfg.externals, // user defined externals for all platforms + ...cfg.edgeExternals, // user defined externals for edge compute + // the following are imported by the universal adapter and are assumed to be available + './params.json', + 'aws-sdk', + '@google-cloud/secret-manager', + '@google-cloud/storage', + ].reduce((obj, ext) => { + // this makes webpack to ignore the module and just leave it as normal require. + // eslint-disable-next-line no-param-reassign + obj[ext] = `commonjs2 ${ext}`; + return obj; + }, {}), + ], module: { rules: [{ test: /\.js$/, @@ -113,6 +122,8 @@ export default class EdgeBundler extends WebpackBundler { concatenateModules: false, mangleExports: false, moduleIds: 'named', + // Disable code splitting - Fastly runtime doesn't support importScripts + splitChunks: false, }, plugins: [], }; diff --git a/src/FastlyGateway.js b/src/FastlyGateway.js index 92943e1..311ac66 100644 --- a/src/FastlyGateway.js +++ b/src/FastlyGateway.js @@ -75,38 +75,9 @@ export default class FastlyGateway { } async updatePackage() { - this.log.info('--: updating app (package) parameters on Fastly gateway ...'); - - const packageparams = Object - .entries(this.cfg.packageParams) - .map(([key, value]) => ({ - item_key: `${this.cfg.packageName}.${key}`, - item_value: value, - op: 'upsert', - })); - - if (packageparams.length !== 0) { - await this._fastly.bulkUpdateDictItems(undefined, 'packageparams', ...packageparams); - } - - try { - await this._fastly.updateDictItem(undefined, 'tokens', this.cfg.packageToken, `${Math.floor(Date.now() / 1000) + (365 * 24 * 3600)}`); - } catch (fe) { - if (fe.message.match('Exceeding max_dictionary_items')) { - const dictinfo = await this._fastly.readDictItems(undefined, 'tokens'); - const items = dictinfo.data; - const outdated = items - .filter((item) => parseInt(item.item_value, 10) < new Date().getTime() / 1000); - const olds = items.slice(0, 5); - // cleanup all old and outdated tokens - await Promise.all([...outdated, ...olds].map((item) => this._fastly.deleteDictItem(undefined, 'tokens', item.item_key))); - // try again - await this._fastly.updateDictItem(undefined, 'tokens', this.cfg.packageToken, `${Math.floor(Date.now() / 1000) + (365 * 24 * 3600)}`); - } - } - - this._fastly.discard(); - this.log.info(chalk`{green ok:} updating app (package) parameters on Fastly gateway.`); + // No-op: FastlyGateway no longer manages package parameters + // Package parameters are now handled by individual deployers + this.log.debug('updatePackage called but is no-op for FastlyGateway'); } selectBackendVCL() { @@ -143,28 +114,6 @@ export default class FastlyGateway { return [...init, ...set, ...increment].join('\n') + [backendvcl, ...middle, fallback].join(' else '); } - /** - * Generates a VCL snippet (for each package deployed) that lists all package parameter - * names and looks up their values from the secret edge dictionary. - * @returns {string} VCL snippet to look up package parameters from edge dict - */ - listPackageParamsVCL() { - const pre = ` - if (obj.status == 600 && req.url.path ~ "^/${this.cfg.packageName}/") { - set obj.status = 200; - set obj.response = "OK"; - set obj.http.content-type = "application/json"; - synthetic "{" + `; - const post = `+ "}"; - return(deliver); -}`; - const middle = Object - .keys(this.cfg.packageParams) - .map((paramname, index) => `"%22${paramname}%22:%22" json.escape(table.lookup(packageparams, "${this.cfg.packageName}.${paramname}")) "%22${(index + 1) < Object.keys(this.cfg.packageParams).length ? ',' : ''}"`).join(' + '); - - return pre + middle + post; - } - setURLVCL() { const pre = ` declare local var.package STRING; @@ -342,16 +291,6 @@ if (req.url ~ "^/([^/]+)/([^/@_]+)([@_]([^/@_?]+)+)?(.*$)") { write_only: 'false', }); - await this._fastly.writeDictionary(newversion, 'tokens', { - name: 'tokens', - write_only: 'false', - }); - - await this._fastly.writeDictionary(newversion, 'packageparams', { - name: 'packageparams', - write_only: 'true', - }); - if (this._cfg.checkinterval > 0 && this._cfg.checkpath) { this.log.info('--: setup health-check'); // set up health checks @@ -410,27 +349,6 @@ if (req.url ~ "^/([^/]+)/([^/@_]+)([@_]([^/@_?]+)+)?(.*$)") { })); this.log.info('--: write VLC snippets'); - await this._fastly.writeSnippet(newversion, 'packageparams.auth', { - name: 'packageparams.auth', - priority: 9, - dynamic: 0, - type: 'recv', - content: ` - if (req.http.Authorization) { - if(time.is_after(std.time(table.lookup(tokens, regsub(req.http.Authorization, "^Bearer ", ""), "expired"), std.integer2time(0)), time.start)) { - error 600 "Get Package Params"; - } - }`, - }); - - await this._fastly.writeSnippet(newversion, `${this.cfg.packageName}.params`, { - name: `${this.cfg.packageName}.params`, - priority: 10, - dynamic: 0, - type: 'error', - content: this.listPackageParamsVCL(), - }); - await this._fastly.writeSnippet(newversion, 'backend', { name: 'backend', priority: 10, diff --git a/src/template/cloudflare-adapter.js b/src/template/cloudflare-adapter.js index 9491e39..88ea547 100644 --- a/src/template/cloudflare-adapter.js +++ b/src/template/cloudflare-adapter.js @@ -28,7 +28,10 @@ export async function handleRequest(event) { region: request.cf.colo, }, func: { - name: null, + // Extract worker name from request URL hostname + name: request.url + ? new URL(request.url).hostname.split('.')[0] + : 'cloudflare-worker', package: null, version: null, fqn: null, @@ -54,23 +57,8 @@ export async function handleRequest(event) { return await main(request, context); } catch (e) { + // eslint-disable-next-line no-console console.log(e.message); return new Response(`Error: ${e.message}`, { status: 500 }); } } - -/** - * Detects if the code is running in a cloudflare environment. - * @returns {null|(function(*): Promise<*|Response|undefined>)|*} - */ -export default function cloudflare() { - try { - if (caches.default) { - console.log('detected cloudflare environment'); - return handleRequest; - } - } catch { - // ignore - } - return null; -} diff --git a/src/template/context-logger.js b/src/template/context-logger.js index 94093a3..5c3ea23 100644 --- a/src/template/context-logger.js +++ b/src/template/context-logger.js @@ -63,7 +63,7 @@ export function createFastlyLogger(context) { // Initialize Fastly logger module asynchronously // eslint-disable-next-line import/no-unresolved - loggerPromise = import('fastly:logger').then((module) => { + loggerPromise = import(/* webpackIgnore: true */ 'fastly:logger').then((module) => { loggerModule = module; loggersReady = true; loggerPromise = null; diff --git a/src/template/edge-index.js b/src/template/edge-index.js index b9aa849..6729e7c 100644 --- a/src/template/edge-index.js +++ b/src/template/edge-index.js @@ -11,13 +11,62 @@ */ /* eslint-env serviceworker */ -import fastly from './fastly-adapter.js'; -import cloudflare from './cloudflare-adapter.js'; +// Static imports to avoid code splitting (Fastly runtime doesn't support importScripts) +import { handleRequest as handleCloudflareRequest } from './cloudflare-adapter.js'; +import { handleRequest as handleFastlyRequest } from './fastly-adapter.js'; + +// Platform detection based on request properties and runtime-specific modules +let detectedPlatform = null; + +async function detectPlatform(request) { + if (detectedPlatform) return detectedPlatform; + + // Check for Cloudflare by testing for request.cf property + // https://developers.cloudflare.com/workers/runtime-apis/request/#incomingrequestcfproperties + if (request && request.cf) { + detectedPlatform = 'cloudflare'; + // eslint-disable-next-line no-console + console.log('detected cloudflare environment'); + return detectedPlatform; + } + + // Try Fastly by checking for fastly:env module + try { + /* eslint-disable-next-line import/no-unresolved */ + await import(/* webpackIgnore: true */ 'fastly:env'); + detectedPlatform = 'fastly'; + // eslint-disable-next-line no-console + console.log('detected fastly environment'); + return detectedPlatform; + } catch { + // Not Fastly + } + + return null; +} + +async function getHandler(request) { + const platform = await detectPlatform(request); + + if (platform === 'cloudflare') { + return handleCloudflareRequest; + } + + if (platform === 'fastly') { + return handleFastlyRequest; + } + + return null; +} // eslint-disable-next-line no-restricted-globals addEventListener('fetch', (event) => { - const handler = cloudflare() || fastly(); - if (typeof handler === 'function') { - event.respondWith(handler(event)); - } + event.respondWith( + getHandler(event.request).then((handler) => { + if (typeof handler === 'function') { + return handler(event); + } + return new Response('Unknown platform', { status: 500 }); + }), + ); }); diff --git a/src/template/fastly-adapter.js b/src/template/fastly-adapter.js index ca39562..dd73fe0 100644 --- a/src/template/fastly-adapter.js +++ b/src/template/fastly-adapter.js @@ -10,9 +10,9 @@ * governing permissions and limitations under the License. */ /* eslint-env serviceworker */ -/* global Dictionary, CacheOverride */ import { extractPathFromURL } from './adapter-utils.js'; import { createFastlyLogger } from './context-logger.js'; +import { getFastlyEnv, getSecretStore } from './fastly-runtime.js'; export function getEnvInfo(req, env) { const serviceVersion = env('FASTLY_SERVICE_VERSION'); @@ -22,6 +22,7 @@ export function getEnvInfo(req, env) { const functionFQN = `${env('FASTLY_CUSTOMER_ID')}-${functionName}-${serviceVersion}`; const txId = req.headers.get('x-transaction-id') ?? env('FASTLY_TRACE_ID'); + // eslint-disable-next-line no-console console.debug('Env info sv: ', serviceVersion, ' reqId: ', requestId, ' region: ', region, ' functionName: ', functionName, ' functionFQN: ', functionFQN, ' txId: ', txId); return { @@ -35,9 +36,7 @@ export function getEnvInfo(req, env) { } async function getEnvironmentInfo(req) { - // The fastly:env import will be available in the fastly c@e environment - /* eslint-disable-next-line import/no-unresolved */ - const mod = await import('fastly:env'); + const mod = await getFastlyEnv(); return getEnvInfo(req, mod.env); } @@ -46,8 +45,8 @@ export async function handleRequest(event) { const { request } = event; const env = await getEnvironmentInfo(request); + // eslint-disable-next-line no-console console.log('Fastly Adapter is here'); - let packageParams; // eslint-disable-next-line import/no-unresolved,global-require const { main } = require('./main.js'); const context = { @@ -72,40 +71,38 @@ export async function handleRequest(event) { transactionId: env.txId, requestId: env.requestId, }, - env: new Proxy(new Dictionary('secrets'), { + env: new Proxy({}, { get: (target, prop) => { - try { - return target.get(prop); - } catch { - if (packageParams) { - console.log('Using cached params'); - return packageParams[prop]; + // Return undefined for non-string properties (like Symbol.iterator) + if (typeof prop !== 'string') { + return undefined; + } + + // Load SecretStore dynamically and access secrets + return getSecretStore().then((SecretStore) => { + if (!SecretStore) { + return undefined; } - const url = target.get('_package'); - const token = target.get('_token'); - // console.log(`Getting secrets from ${url} with ${token}`); - return fetch(url, { - backend: 'gateway', - headers: { - authorization: `Bearer ${token}`, - }, - }).then((response) => { - if (response.ok) { - // console.log('response is ok...'); - return response.text().then((json) => { - // console.log('json received: ' + json); - packageParams = JSON.parse(json); - return packageParams[prop]; - }).catch((error) => { - console.error(`Unable to parse JSON: ${error.message}`); - }); + // Try action_secrets first (action-specific params - highest priority) + const actionSecrets = new SecretStore('action_secrets'); + return actionSecrets.get(prop).then((secret) => { + if (secret) { + return secret.plaintext(); } - console.error(`HTTP status is not ok: ${response.status}`); - return undefined; - }).catch((err) => { - console.error(`Unable to fetch parames: ${err.message}`); + // Try package_secrets next (package-wide params) + const packageSecrets = new SecretStore('package_secrets'); + return packageSecrets.get(prop).then((pkgSecret) => { + if (pkgSecret) { + return pkgSecret.plaintext(); + } + return undefined; + }); }); - } + }).catch((err) => { + // eslint-disable-next-line no-console + console.error(`Error accessing secrets for ${prop}: ${err.message}`); + return undefined; + }); }, }), storage: null, @@ -118,24 +115,8 @@ export async function handleRequest(event) { return await main(request, context); } catch (e) { + // eslint-disable-next-line no-console console.log(e.message); return new Response(`Error: ${e.message}`, { status: 500 }); } } - -/** - * Returns the fastly request handler on fastly environments. - * @returns {null|(function(*): Promise<*|Response|undefined>)|*} - */ -export default function fastly() { - try { - // todo: find better way to detect fastly environment, eg: import 'fastly:env' - if (CacheOverride) { - console.log('detected fastly environment'); - return handleRequest; - } - } catch { - // ignore - } - return null; -} diff --git a/src/template/fastly-runtime.js b/src/template/fastly-runtime.js new file mode 100644 index 0000000..1deb8ae --- /dev/null +++ b/src/template/fastly-runtime.js @@ -0,0 +1,66 @@ +/* + * Copyright 2021 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Helper module for loading Fastly runtime modules. + * This module can be mocked in tests to provide fake implementations. + */ + +let envModule = null; +let secretStoreModule = null; +let loggerModule = null; + +/** + * Get the Fastly environment module + * @returns {Promise<{env: Function}>} + */ +export async function getFastlyEnv() { + if (!envModule) { + /* eslint-disable-next-line import/no-unresolved */ + envModule = await import(/* webpackIgnore: true */ 'fastly:env'); + } + return envModule; +} + +/** + * Get the Fastly SecretStore class + * @returns {Promise} + */ +export async function getSecretStore() { + if (secretStoreModule) { + return secretStoreModule.SecretStore; + } + try { + /* eslint-disable-next-line import/no-unresolved */ + secretStoreModule = await import(/* webpackIgnore: true */ 'fastly:secret-store'); + return secretStoreModule.SecretStore; + } catch { + return null; + } +} + +/** + * Get the Fastly Logger class + * @returns {Promise} + */ +export async function getLogger() { + if (loggerModule) { + return loggerModule.Logger; + } + try { + /* eslint-disable-next-line import/no-unresolved */ + loggerModule = await import(/* webpackIgnore: true */ 'fastly:logger'); + return loggerModule.Logger; + } catch { + return null; + } +} diff --git a/test/cloudflare-adapter.test.js b/test/cloudflare-adapter.test.js index 0e43925..50e9734 100644 --- a/test/cloudflare-adapter.test.js +++ b/test/cloudflare-adapter.test.js @@ -13,22 +13,9 @@ /* eslint-env mocha */ import assert from 'assert'; -import adapter, { handleRequest } from '../src/template/cloudflare-adapter.js'; +import { handleRequest } from '../src/template/cloudflare-adapter.js'; describe('Cloudflare Adapter Test', () => { - it('returns the request handler in a cloudflare environment', () => { - try { - global.caches = { default: new Map() }; - assert.strictEqual(adapter(), handleRequest); - } finally { - delete global.caches; - } - }); - - it('returns null in a non-cloudflare environment', () => { - assert.strictEqual(adapter(), null); - }); - it('creates context with all log level methods', async () => { const logs = []; const originalLog = console.log; diff --git a/test/cloudflare.integration.js b/test/cloudflare.integration.js index 912c96a..f1d8f65 100644 --- a/test/cloudflare.integration.js +++ b/test/cloudflare.integration.js @@ -19,7 +19,10 @@ import { config } from 'dotenv'; import { CLI } from '@adobe/helix-deploy'; import { createTestRoot, TestLogger } from './utils.js'; -config(); +// Only load .env if environment variables aren't already set (e.g., in CI) +if (!process.env.HLX_FASTLY_AUTH || !process.env.CLOUDFLARE_AUTH) { + config(); +} describe('Cloudflare Integration Test', () => { let testRoot; @@ -35,7 +38,15 @@ describe('Cloudflare Integration Test', () => { await fse.remove(testRoot); }); - it('Deploy a pure action to Cloudflare', async () => { + // Skip integration tests if Cloudflare credentials are not available + const skipIfNoCloudflareAuth = !process.env.CLOUDFLARE_AUTH ? it.skip : it; + + skipIfNoCloudflareAuth('Deploy a pure action to Cloudflare', async () => { + // Fail explicitly if required credentials are missing + if (!process.env.CLOUDFLARE_AUTH) { + throw new Error('CLOUDFLARE_AUTH environment variable is required for Cloudflare integration tests. Please set it in GitHub repository secrets.'); + } + await fse.copy(path.resolve(__rootdir, 'test', 'fixtures', 'edge-action'), testRoot); process.chdir(testRoot); // need to change .cwd() for yargs to pickup `wsk` in package.json const builder = await new CLI() @@ -67,38 +78,4 @@ describe('Cloudflare Integration Test', () => { const out = builder.cfg._logger.output; assert.ok(out.indexOf('https://simple-package--simple-project.minivelos.workers.dev') > 0, out); }).timeout(10000000); - - it.skip('Deploy logging example to Cloudflare', async () => { - await fse.copy(path.resolve(__rootdir, 'test', 'fixtures', 'logging-example'), testRoot); - process.chdir(testRoot); - const builder = await new CLI() - .prepare([ - '--build', - '--verbose', - '--deploy', - '--target', 'cloudflare', - '--plugin', path.resolve(__rootdir, 'src', 'index.js'), - '--arch', 'edge', - '--cloudflare-email', 'lars@trieloff.net', - '--cloudflare-account-id', 'b4adf6cfdac0918eb6aa5ad033da0747', - '--cloudflare-test-domain', 'rockerduck', - '--package.name', 'logging-test', - '--package.params', 'TEST=logging', - '--update-package', 'true', - '-p', 'FOO=bar', - '--test', '/?operation=debug&loggers=test-logger', - '--directory', testRoot, - '--entryFile', 'index.js', - '--bundler', 'webpack', - '--esm', 'false', - ]); - builder.cfg._logger = new TestLogger(); - - const res = await builder.run(); - assert.ok(res); - const out = builder.cfg._logger.output; - assert.ok(out.indexOf('rockerduck.workers.dev') > 0, out); - assert.ok(out.indexOf('"status":"ok"') > 0, 'Response should include status ok'); - assert.ok(out.indexOf('"logging":"enabled"') > 0, 'Response should indicate logging is enabled'); - }).timeout(10000000); }); diff --git a/test/computeatedge.integration.js b/test/computeatedge.integration.js index 2a29438..a44741f 100644 --- a/test/computeatedge.integration.js +++ b/test/computeatedge.integration.js @@ -19,7 +19,10 @@ import fse from 'fs-extra'; import path, { resolve } from 'path'; import { createTestRoot, TestLogger } from './utils.js'; -config(); +// Only load .env if environment variables aren't already set (e.g., in CI) +if (!process.env.HLX_FASTLY_AUTH || !process.env.CLOUDFLARE_AUTH) { + config(); +} describe('Fastly Compute@Edge Integration Test', () => { let testRoot; @@ -36,6 +39,10 @@ describe('Fastly Compute@Edge Integration Test', () => { }); it('Deploy a pure action to Compute@Edge and test CacheOverride API', async () => { + // Fail explicitly if required credentials are missing + if (!process.env.HLX_FASTLY_AUTH) { + throw new Error('HLX_FASTLY_AUTH environment variable is required for Fastly integration tests. Please set it in GitHub repository secrets.'); + } const serviceID = '1yv1Wl7NQCFmNBkW4L8htc'; const testDomain = 'possibly-working-sawfish'; const baseUrl = `https://${testDomain}.edgecompute.app`; @@ -92,43 +99,12 @@ describe('Fastly Compute@Edge Integration Test', () => { const keyText = await keyResponse.text(); assert.ok(keyText.indexOf('cache-override-key') > 0, 'Should test custom cache key'); assert.ok(keyText.indexOf('cacheKey=test-key') > 0, 'Should include cache key parameter'); - }).timeout(10000000); - - it('Deploy logging example to Compute@Edge', async () => { - const serviceID = '1yv1Wl7NQCFmNBkW4L8htc'; - await fse.copy(path.resolve(__rootdir, 'test', 'fixtures', 'logging-example'), testRoot); - process.chdir(testRoot); - const builder = await new CLI() - .prepare([ - '--build', - '--plugin', resolve(__rootdir, 'src', 'index.js'), - '--verbose', - '--deploy', - '--target', 'c@e', - '--arch', 'edge', - '--compute-service-id', serviceID, - '--compute-test-domain', 'possibly-working-sawfish', - '--package.name', 'LoggingTest', - '--package.params', 'TEST=logging', - '--update-package', 'true', - '--fastly-gateway', 'deploy-test.anywhere.run', - '-p', 'FOO=bar', - '--fastly-service-id', '4u8SAdblhzzbXntBYCjhcK', - '--test', '/?operation=verbose', - '--directory', testRoot, - '--entryFile', 'index.js', - '--bundler', 'webpack', - '--esm', 'false', - ]); - builder.cfg._logger = new TestLogger(); - - const res = await builder.run(); - assert.ok(res); - const out = builder.cfg._logger.output; - assert.ok(out.indexOf('possibly-working-sawfish.edgecompute.app') > 0, out); - assert.ok(out.indexOf('"status":"ok"') > 0, 'Response should include status ok'); - assert.ok(out.indexOf('"logging":"enabled"') > 0, 'Response should indicate logging is enabled'); - assert.ok(out.indexOf('dist/LoggingTest/fastly-bundle.tar.gz') > 0, out); + // Test logging functionality + const loggingResponse = await fetch(`${baseUrl}/?operation=verbose`); + const loggingText = await loggingResponse.text(); + assert.ok(loggingResponse.status === 200, 'Logging endpoint should return 200'); + assert.ok(loggingText.indexOf('"status":"ok"') > 0, 'Response should include status ok'); + assert.ok(loggingText.indexOf('"logging":"enabled"') > 0, 'Response should indicate logging is enabled'); }).timeout(10000000); }); diff --git a/test/edge-integration.test.js b/test/edge-integration.test.js new file mode 100644 index 0000000..86104c4 --- /dev/null +++ b/test/edge-integration.test.js @@ -0,0 +1,194 @@ +/* + * Copyright 2021 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +/* eslint-disable no-underscore-dangle */ +import assert from 'assert'; +import { config } from 'dotenv'; +import { CLI } from '@adobe/helix-deploy'; +import fse from 'fs-extra'; +import path from 'path'; +import { createTestRoot, TestLogger } from './utils.js'; + +// Only load .env if environment variables aren't already set (e.g., in CI) +if (!process.env.HLX_FASTLY_AUTH || !process.env.CLOUDFLARE_AUTH) { + config(); +} + +describe('Edge Integration Test', () => { + let testRoot; + let origPwd; + const deployments = {}; + + before(async function deployToBothPlatforms() { + this.timeout(600000); // 10 minutes for deployment + + // Fail explicitly if required credentials are missing + if (!process.env.HLX_FASTLY_AUTH) { + throw new Error('HLX_FASTLY_AUTH environment variable is required for Fastly integration tests. Please set it in GitHub repository secrets.'); + } + if (!process.env.CLOUDFLARE_AUTH) { + throw new Error('CLOUDFLARE_AUTH environment variable is required for Cloudflare integration tests. Please set it in GitHub repository secrets.'); + } + + testRoot = await createTestRoot(); + origPwd = process.cwd(); + + // Copy the edge-action fixture + await fse.copy(path.resolve(__rootdir, 'test', 'fixtures', 'edge-action'), testRoot); + process.chdir(testRoot); + + const fastlyServiceID = '1yv1Wl7NQCFmNBkW4L8htc'; + const fastlyTestDomain = 'possibly-working-sawfish'; + + // eslint-disable-next-line no-console + console.log('--: Starting deployment to Cloudflare and Fastly...'); + + // Deploy to both platforms with a single CLI call using multiple --target arguments + const builder = await new CLI() + .prepare([ + '--build', + '--verbose', + '--deploy', + '--target', 'cloudflare', + '--target', 'c@e', + '--plugin', path.resolve(__rootdir, 'src', 'index.js'), + '--arch', 'edge', + // Cloudflare config + '--cloudflare-email', 'lars@trieloff.net', + '--cloudflare-account-id', '155ec15a52a18a14801e04b019da5e5a', + '--cloudflare-test-domain', 'minivelos', + '--cloudflare-auth', process.env.CLOUDFLARE_AUTH, + // Fastly config + '--compute-service-id', fastlyServiceID, + '--compute-test-domain', fastlyTestDomain, + '--fastly-gateway', 'deploy-test.anywhere.run', + '--fastly-service-id', '4u8SAdblhzzbXntBYCjhcK', + // Shared config + '--package.params', 'HEY=ho', + '--package.params', 'ZIP=zap', + '--update-package', 'true', + '-p', 'FOO=bar', + '--directory', testRoot, + '--entryFile', 'src/index.js', + '--bundler', 'webpack', + '--esm', 'false', + ]); + builder.cfg._logger = new TestLogger(); + + const res = await builder.run(); + assert.ok(res, 'Deployment should succeed'); + + deployments.cloudflare = { + url: 'https://simple-package--simple-project.minivelos.workers.dev', + logger: builder.cfg._logger, + }; + deployments.fastly = { + url: `https://${fastlyTestDomain}.edgecompute.app`, + logger: builder.cfg._logger, + }; + + // eslint-disable-next-line no-console + console.log('--: Deployment completed'); + // eslint-disable-next-line no-console + console.log(`--: Cloudflare URL: ${deployments.cloudflare.url}`); + // eslint-disable-next-line no-console + console.log(`--: Fastly URL: ${deployments.fastly.url}`); + }); + + after(() => { + process.chdir(origPwd); + }); + + // Test suite that runs against both platforms + ['cloudflare', 'fastly'].forEach((platform) => { + describe(`${platform.charAt(0).toUpperCase() + platform.slice(1)} Platform Integration`, () => { + let baseUrl; + + before(() => { + baseUrl = deployments[platform].url; + }); + + it('should access environment variables correctly', async () => { + // eslint-disable-next-line no-console + console.log(`Testing ${platform}: ${baseUrl}/201`); + const response = await fetch(`${baseUrl}/201`); + const text = await response.text(); + + assert.ok(response.status === 200, `Response should be 200, got ${response.status}`); + assert.ok(text.includes('ok: ho bar'), `Response should include env vars: ${text}`); + // Only accept successful responses (200, 201) - never accept 503 or other errors + assert.ok(text.includes('– 200') || text.includes('– 201'), `Response should include successful backend status (200 or 201): ${text}`); + }); + + it('should handle logging functionality', async () => { + const response = await fetch(`${baseUrl}/?operation=verbose`); + const text = await response.text(); + + assert.ok(response.status === 200, `Logging endpoint should return 200, got ${response.status}`); + assert.ok(text.includes('"status":"ok"'), `Response should include status ok: ${text}`); + assert.ok(text.includes('"logging":"enabled"'), `Response should indicate logging is enabled: ${text}`); + assert.ok(text.includes('"timestamp"'), `Response should include timestamp: ${text}`); + }); + + it('should support TTL cache override', async () => { + const response = await fetch(`${baseUrl}/cache-override-ttl`); + const text = await response.text(); + + assert.ok(response.status === 200, `Cache override TTL should return 200, got ${response.status}`); + assert.ok(text.includes('cache-override-ttl'), `Response should include route name: ${text}`); + assert.ok(text.includes('ttl=3600'), `Response should include TTL parameter: ${text}`); + // Only accept successful responses (200, 201) - never accept 503 or other errors + assert.ok(text.includes('– 200') || text.includes('– 201'), `Response should include successful backend status (200 or 201): ${text}`); + }); + + it('should support pass mode cache override', async () => { + const response = await fetch(`${baseUrl}/cache-override-pass`); + const text = await response.text(); + + assert.ok(response.status === 200, `Cache override pass should return 200, got ${response.status}`); + assert.ok(text.includes('cache-override-pass'), `Response should include route name: ${text}`); + assert.ok(text.includes('mode=pass'), `Response should include pass mode: ${text}`); + // Only accept successful responses (200, 201) - never accept 503 or other errors + assert.ok(text.includes('– 200') || text.includes('– 201'), `Response should include successful backend status (200 or 201): ${text}`); + }); + + it('should support custom cache key override', async () => { + const response = await fetch(`${baseUrl}/cache-override-key`); + const text = await response.text(); + + assert.ok(response.status === 200, `Cache override key should return 200, got ${response.status}`); + assert.ok(text.includes('cache-override-key'), `Response should include route name: ${text}`); + assert.ok(text.includes('cacheKey=test-key'), `Response should include cache key: ${text}`); + // Only accept successful responses (200, 201) - never accept 503 or other errors + assert.ok(text.includes('– 200') || text.includes('– 201'), `Response should include successful backend status (200 or 201): ${text}`); + }); + + it('should handle package and action parameters correctly', async () => { + const response = await fetch(`${baseUrl}/201`); + const text = await response.text(); + + // Verify both package params (HEY=ho) and action params (FOO=bar) are accessible + assert.ok(text.includes('ho'), `Response should include package param HEY=ho: ${text}`); + assert.ok(text.includes('bar'), `Response should include action param FOO=bar: ${text}`); + + // Verify the service/function identifier is present + if (platform === 'fastly') { + assert.ok(text.includes('1yv1Wl7NQCFmNBkW4L8htc'), `Response should include Fastly service ID: ${text}`); + } else { + // Cloudflare now returns the function name extracted from hostname + assert.ok(text.includes('simple-package--simple-project'), `Response should include Cloudflare function name: ${text}`); + } + }); + }); + }); +}); diff --git a/test/fastly-adapter.test.js b/test/fastly-adapter.test.js index 0d45a34..8a13384 100644 --- a/test/fastly-adapter.test.js +++ b/test/fastly-adapter.test.js @@ -11,160 +11,298 @@ */ /* eslint-env mocha */ +/* eslint-disable max-classes-per-file */ import assert from 'assert'; -import adapter, { getEnvInfo, handleRequest } from '../src/template/fastly-adapter.js'; +import esmock from 'esmock'; -describe('Fastly Adapter Test', () => { - it('Captures the environment', () => { - const headers = new Map(); - const req = { headers }; - const env = (envvar) => { - switch (envvar) { - case 'FASTLY_CUSTOMER_ID': return 'cust1'; - case 'FASTLY_POP': return 'fpop'; - case 'FASTLY_SERVICE_ID': return 'sid999'; - case 'FASTLY_SERVICE_VERSION': return '1234'; - case 'FASTLY_TRACE_ID': return 'trace-id'; - default: return undefined; - } +// Mock SecretStore class +class MockSecretStore { + constructor(name) { + this.name = name; + } + + async get(key) { + // Return mock secrets based on store name and key + const secrets = { + action_secrets: { + FOO: { plaintext: () => 'bar' }, + ACTION_ONLY: { plaintext: () => 'action-value' }, + }, + package_secrets: { + HEY: { plaintext: () => 'ho' }, + PACKAGE_ONLY: { plaintext: () => 'package-value' }, + }, }; + return secrets[this.name]?.[key] || null; + } +} - const info = getEnvInfo(req, env); +// Mock fastly-runtime module +const mockFastlyRuntime = { + getFastlyEnv: async () => ({ + env: (envvar) => { + const envVars = { + FASTLY_CUSTOMER_ID: 'test-customer', + FASTLY_SERVICE_ID: 'test-service', + FASTLY_SERVICE_VERSION: '42', + FASTLY_TRACE_ID: 'trace-123', + FASTLY_POP: 'SFO', + }; + return envVars[envvar]; + }, + }), + getSecretStore: async () => MockSecretStore, + getLogger: async () => class MockLogger { + constructor() { + this.logs = []; + } - assert.equal(info.functionFQN, 'cust1-sid999-1234'); - assert.equal(info.functionName, 'sid999'); - assert.equal(info.region, 'fpop'); - assert.equal(info.requestId, 'trace-id'); - assert.equal(info.serviceVersion, '1234'); - assert.equal(info.txId, 'trace-id'); + log(msg) { + this.logs.push(msg); + } + }, +}; + +describe('Fastly Adapter Test', () => { + let getEnvInfo; + let handleRequest; + + before(async () => { + // Import with mocked fastly-runtime module + const adapter = await esmock('../src/template/fastly-adapter.js', { + '../src/template/fastly-runtime.js': mockFastlyRuntime, + }); + getEnvInfo = adapter.getEnvInfo; + handleRequest = adapter.handleRequest; }); - it('Takes the txid from the request headers', () => { - const headers = new Map(); - headers.set('foo', 'bar'); - headers.set('x-transaction-id', 'tx7'); - const req = { headers }; - const env = (_) => 'something'; + describe('getEnvInfo', () => { + it('captures the environment', () => { + const headers = new Map(); + const req = { headers }; + const env = (envvar) => { + switch (envvar) { + case 'FASTLY_CUSTOMER_ID': return 'cust1'; + case 'FASTLY_POP': return 'fpop'; + case 'FASTLY_SERVICE_ID': return 'sid999'; + case 'FASTLY_SERVICE_VERSION': return '1234'; + case 'FASTLY_TRACE_ID': return 'trace-id'; + default: return undefined; + } + }; - const info = getEnvInfo(req, env); + const info = getEnvInfo(req, env); - assert.equal(info.txId, 'tx7'); - }); + assert.equal(info.functionFQN, 'cust1-sid999-1234'); + assert.equal(info.functionName, 'sid999'); + assert.equal(info.region, 'fpop'); + assert.equal(info.requestId, 'trace-id'); + assert.equal(info.serviceVersion, '1234'); + assert.equal(info.txId, 'trace-id'); + }); - it('returns the request handler in a fastly environment', () => { - try { - global.CacheOverride = true; - assert.strictEqual(adapter(), handleRequest); - } finally { - delete global.CacheOverride; - } - }); + it('takes the txid from the request headers', () => { + const headers = new Map(); + headers.set('foo', 'bar'); + headers.set('x-transaction-id', 'tx7'); + const req = { headers }; + const env = () => 'something'; + + const info = getEnvInfo(req, env); - it('returns null in a non-fastly environment', () => { - assert.strictEqual(adapter(), null); + assert.equal(info.txId, 'tx7'); + }); }); - it('creates context with logger initialized', async () => { - const logs = []; - const errors = []; - const originalLog = console.log; - const originalError = console.error; - console.log = (msg) => logs.push(msg); - console.error = (msg) => errors.push(msg); + describe('handleRequest', () => { + it('creates context with correct structure', async () => { + const request = { + url: 'https://example.com/test/path', + headers: new Map(), + }; - // Mock Dictionary constructor - const mockDictionary = function MockDictionary(/* name */) { - this.get = function mockGet(/* prop */) { - return undefined; + let capturedContext; + const mockMain = (req, ctx) => { + capturedContext = ctx; + return new Response('ok'); }; - }; - try { + global.require = () => ({ main: mockMain }); + + try { + await handleRequest({ request }); + + // Verify context structure + assert.ok(capturedContext); + assert.equal(capturedContext.runtime.name, 'compute-at-edge'); + assert.equal(capturedContext.runtime.region, 'SFO'); + assert.equal(capturedContext.func.name, 'test-service'); + assert.equal(capturedContext.func.version, '42'); + assert.equal(capturedContext.func.fqn, 'test-customer-test-service-42'); + assert.deepStrictEqual(capturedContext.attributes, {}); + } finally { + delete global.require; + } + }); + + it('creates context with logger initialized', async () => { const request = { url: 'https://example.com/test', headers: new Map(), }; + let capturedContext; const mockMain = (req, ctx) => { - // Verify context has log property - assert.ok(ctx.log); - assert.ok(typeof ctx.log.fatal === 'function'); - assert.ok(typeof ctx.log.error === 'function'); - assert.ok(typeof ctx.log.warn === 'function'); - assert.ok(typeof ctx.log.info === 'function'); - assert.ok(typeof ctx.log.verbose === 'function'); - assert.ok(typeof ctx.log.debug === 'function'); - assert.ok(typeof ctx.log.silly === 'function'); - - // Verify context.attributes is initialized - assert.ok(ctx.attributes); - assert.ok(typeof ctx.attributes === 'object'); - - // Test logging - will fail to import fastly:logger but should not throw - ctx.log.info({ test: 'data' }); - + capturedContext = ctx; return new Response('ok'); }; - // Mock require for main module global.require = () => ({ main: mockMain }); - // Mock Dictionary - global.Dictionary = mockDictionary; + try { + await handleRequest({ request }); + + // Verify logger methods exist + assert.ok(capturedContext.log); + assert.ok(typeof capturedContext.log.fatal === 'function'); + assert.ok(typeof capturedContext.log.error === 'function'); + assert.ok(typeof capturedContext.log.warn === 'function'); + assert.ok(typeof capturedContext.log.info === 'function'); + assert.ok(typeof capturedContext.log.verbose === 'function'); + assert.ok(typeof capturedContext.log.debug === 'function'); + assert.ok(typeof capturedContext.log.silly === 'function'); + } finally { + delete global.require; + } + }); + + it('provides env proxy that accesses action secrets first', async () => { + const request = { + url: 'https://example.com/test', + headers: new Map(), + }; + + let capturedContext; + const mockMain = (req, ctx) => { + capturedContext = ctx; + return new Response('ok'); + }; - const event = { request }; + global.require = () => ({ main: mockMain }); - // This will fail to import fastly:env, so we expect an error try { - await handleRequest(event); - } catch (err) { - // Expected to fail due to missing fastly:env module - assert.ok(err.message.includes('fastly:env') || err.message.includes('Cannot find module')); + await handleRequest({ request }); + + // Access env through proxy - should get action secret + const fooValue = await capturedContext.env.FOO; + assert.equal(fooValue, 'bar'); + + // Action-only secret + const actionValue = await capturedContext.env.ACTION_ONLY; + assert.equal(actionValue, 'action-value'); + } finally { + delete global.require; } - } finally { - console.log = originalLog; - console.error = originalError; - delete global.require; - delete global.Dictionary; - } - }); + }); - it('initializes context.attributes as empty object', async () => { - // Mock Dictionary constructor - const mockDictionary = function MockDictionary2(/* name */) { - this.get = function mockGet2(/* prop */) { - return undefined; + it('provides env proxy that falls back to package secrets', async () => { + const request = { + url: 'https://example.com/test', + headers: new Map(), }; - }; - try { + let capturedContext; + const mockMain = (req, ctx) => { + capturedContext = ctx; + return new Response('ok'); + }; + + global.require = () => ({ main: mockMain }); + + try { + await handleRequest({ request }); + + // Package-only secret (not in action_secrets) + const packageValue = await capturedContext.env.PACKAGE_ONLY; + assert.equal(packageValue, 'package-value'); + + // HEY is only in package_secrets + const heyValue = await capturedContext.env.HEY; + assert.equal(heyValue, 'ho'); + } finally { + delete global.require; + } + }); + + it('returns undefined for non-existent secrets', async () => { const request = { url: 'https://example.com/test', headers: new Map(), }; + let capturedContext; const mockMain = (req, ctx) => { - // Verify context.attributes exists and is an object - assert.strictEqual(typeof ctx.attributes, 'object'); - assert.deepStrictEqual(ctx.attributes, {}); + capturedContext = ctx; return new Response('ok'); }; global.require = () => ({ main: mockMain }); - global.Dictionary = mockDictionary; - const event = { request }; + try { + await handleRequest({ request }); + + const nonExistent = await capturedContext.env.NON_EXISTENT; + assert.equal(nonExistent, undefined); + } finally { + delete global.require; + } + }); + + it('extracts path from request URL', async () => { + const request = { + url: 'https://example.com/my/custom/path', + headers: new Map(), + }; + + let capturedContext; + const mockMain = (req, ctx) => { + capturedContext = ctx; + return new Response('ok'); + }; + + global.require = () => ({ main: mockMain }); try { - await handleRequest(event); - } catch (err) { - // Expected to fail due to missing fastly:env - assert.ok(err.message.includes('fastly:env') || err.message.includes('Cannot find module')); + await handleRequest({ request }); + + assert.equal(capturedContext.pathInfo.suffix, '/my/custom/path'); + } finally { + delete global.require; } - } finally { - delete global.require; - delete global.Dictionary; - } + }); + + it('handles errors and returns 500 response', async () => { + const request = { + url: 'https://example.com/test', + headers: new Map(), + }; + + const mockMain = () => { + throw new Error('Test error'); + }; + + global.require = () => ({ main: mockMain }); + + try { + const response = await handleRequest({ request }); + + assert.equal(response.status, 500); + const text = await response.text(); + assert.ok(text.includes('Test error')); + } finally { + delete global.require; + } + }); }); }); diff --git a/test/fastly-gateway.test.js b/test/fastly-gateway.test.js index a47814f..ab4d4b8 100644 --- a/test/fastly-gateway.test.js +++ b/test/fastly-gateway.test.js @@ -71,9 +71,4 @@ describe.skip('Unit Tests for Fastly Gateway', () => { op: 'upsert', }); }); - - it('Generates correct package parameter JSON', () => { - const vcl = gateway.listPackageParamsVCL(); - console.log(vcl); - }); }); diff --git a/test/fixtures/edge-action/src/index.js b/test/fixtures/edge-action/src/index.js index 2d07f68..d422260 100644 --- a/test/fixtures/edge-action/src/index.js +++ b/test/fixtures/edge-action/src/index.js @@ -19,41 +19,80 @@ export async function main(req, context) { if (path.includes('/cache-override-ttl')) { // Test: TTL override const cacheOverride = new CacheOverride('override', { ttl: 3600 }); - const backendResponse = await fetch('https://httpbin.org/uuid', { - backend: 'httpbin.org', + const backendResponse = await fetch('https://www.aem.live/', { cacheOverride, }); - const data = await backendResponse.json(); - return new Response(`(${context?.func?.name}) ok: cache-override-ttl ttl=3600 uuid=${data.uuid} – ${backendResponse.status}`); + const contentLength = backendResponse.headers.get('content-length') || 'unknown'; + return new Response(`(${context?.func?.name}) ok: cache-override-ttl ttl=3600 size=${contentLength} – ${backendResponse.status}`); } if (path.includes('/cache-override-pass')) { // Test: Pass mode (no caching) const cacheOverride = new CacheOverride('pass'); - const backendResponse = await fetch('https://httpbin.org/uuid', { - backend: 'httpbin.org', + const backendResponse = await fetch('https://www.aem.live/', { cacheOverride, }); - const data = await backendResponse.json(); - return new Response(`(${context?.func?.name}) ok: cache-override-pass mode=pass uuid=${data.uuid} – ${backendResponse.status}`); + const contentLength = backendResponse.headers.get('content-length') || 'unknown'; + return new Response(`(${context?.func?.name}) ok: cache-override-pass mode=pass size=${contentLength} – ${backendResponse.status}`); } if (path.includes('/cache-override-key')) { // Test: Custom cache key const cacheOverride = new CacheOverride({ ttl: 300, cacheKey: 'test-key' }); - const backendResponse = await fetch('https://httpbin.org/uuid', { - backend: 'httpbin.org', + const backendResponse = await fetch('https://www.aem.live/', { cacheOverride, }); - const data = await backendResponse.json(); - return new Response(`(${context?.func?.name}) ok: cache-override-key cacheKey=test-key uuid=${data.uuid} – ${backendResponse.status}`); + const contentLength = backendResponse.headers.get('content-length') || 'unknown'; + return new Response(`(${context?.func?.name}) ok: cache-override-key cacheKey=test-key size=${contentLength} – ${backendResponse.status}`); } - // Original status code test - console.log(req.url, `https://httpbin.org/status/${req.url.split('/').pop()}`); - const backendresponse = await fetch(`https://httpbin.org/status/${req.url.split('/').pop()}`, { - backend: 'httpbin.org', - }); - console.log(await backendresponse.text()); + // Logging test route - only for requests with operation=verbose + if (url.searchParams.get('operation') === 'verbose') { + // Configure logger targets dynamically + const loggers = url.searchParams.get('loggers'); + if (loggers) { + context.attributes.loggers = loggers.split(','); + } + + // Example: Structured logging with different levels + context.log.info({ + action: 'request_started', + path: url.pathname, + method: req.method, + }); + + context.log.verbose({ + operation: 'data_processing', + records: 1000, + duration_ms: 123, + }); + + // Example: Plain string logging + context.log.info('Request processed successfully'); + + // Example: Silly level (most verbose) + context.log.silly('Extra verbose logging for development'); + + const response = { + status: 'ok', + logging: 'enabled', + loggers: context.attributes.loggers || [], + timestamp: new Date().toISOString(), + }; + + return new Response(JSON.stringify(response), { + headers: { + 'Content-Type': 'application/json', + }, + }); + } + + // Original status code test - use reliable endpoint (v2) + // eslint-disable-next-line no-console + console.log(req.url, 'https://www.aem.live/ (updated)'); + const backendresponse = await fetch('https://www.aem.live/'); + const contentLength = backendresponse.headers.get('content-length') || 'unknown'; + // eslint-disable-next-line no-console + console.log(`Response: ${backendresponse.status}, Content-Length: ${contentLength}`); return new Response(`(${context?.func?.name}) ok: ${await context.env.HEY} ${await context.env.FOO} – ${backendresponse.status}`); } diff --git a/test/fixtures/logging-example/index.js b/test/fixtures/logging-example/index.js deleted file mode 100644 index 1acd02e..0000000 --- a/test/fixtures/logging-example/index.js +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2025 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ -import { Response } from '@adobe/fetch'; - -/** - * Example demonstrating context.log usage with all log levels. - * This fixture shows how to use the unified logging API in edge workers. - */ -export function main(req, context) { - const url = new URL(req.url); - - // Configure logger targets dynamically - const loggers = url.searchParams.get('loggers'); - if (loggers) { - context.attributes.loggers = loggers.split(','); - } - - // Example: Structured logging with different levels - context.log.info({ - action: 'request_started', - path: url.pathname, - method: req.method, - }); - - try { - // Simulate some processing - const operation = url.searchParams.get('operation'); - - if (operation === 'verbose') { - context.log.verbose({ - operation: 'data_processing', - records: 1000, - duration_ms: 123, - }); - } - - if (operation === 'debug') { - context.log.debug({ - debug_info: 'detailed debugging information', - variables: { a: 1, b: 2 }, - }); - } - - if (operation === 'fail') { - context.log.error('Simulated error condition'); - throw new Error('Operation failed'); - } - - if (operation === 'fatal') { - context.log.fatal({ - error: 'Critical system error', - code: 'SYSTEM_FAILURE', - }); - return new Response('Fatal error', { status: 500 }); - } - - // Example: Plain string logging - context.log.info('Request processed successfully'); - - // Example: Warning logging - if (url.searchParams.has('deprecated')) { - context.log.warn({ - warning: 'Using deprecated parameter', - parameter: 'deprecated', - }); - } - - // Example: Silly level (most verbose) - context.log.silly('Extra verbose logging for development'); - - const response = { - status: 'ok', - logging: 'enabled', - loggers: context.attributes.loggers || [], - timestamp: new Date().toISOString(), - }; - - return new Response(JSON.stringify(response), { - headers: { - 'Content-Type': 'application/json', - }, - }); - } catch (error) { - context.log.error({ - error: error.message, - stack: error.stack, - }); - - return new Response(JSON.stringify({ - error: error.message, - }), { - status: 500, - headers: { - 'Content-Type': 'application/json', - }, - }); - } -} diff --git a/test/fixtures/logging-example/package.json b/test/fixtures/logging-example/package.json deleted file mode 100644 index 39ad92f..0000000 --- a/test/fixtures/logging-example/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "logging-example", - "version": "1.0.0", - "description": "Example demonstrating context.log usage", - "type": "module", - "main": "index.js", - "dependencies": { - "@adobe/fetch": "^4.1.8" - } -} diff --git a/test/fixtures/logging-example/test.env b/test/fixtures/logging-example/test.env deleted file mode 100644 index e69de29..0000000