diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 8e393e1..94ea5e0 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -48,6 +48,8 @@ jobs: run: npm run deploy:ci - name: Post-Deployment Integration Test run: npm run test-postdeploy + env: + SUPERUSER_KEY: ${{ secrets.SUPERUSER_KEY }} - name: Semantic Release (Dry Run) run: npm run semantic-release-dry env: diff --git a/src/index.js b/src/index.js index 2bfc2bc..9fdef02 100644 --- a/src/index.js +++ b/src/index.js @@ -23,13 +23,20 @@ async function parseData(req) { } if (['POST', 'PUT', 'PATCH'].includes(req.method)) { const text = await req.text(); - try { - return JSON.parse(text); - } catch { - return text; + if (text.trim().length) { + try { + return JSON.parse(text); + } catch { + return text; + } + } + + const params = new URL(req.url).searchParams; + if (params.size) { + return Object.fromEntries(params.entries()); } } - return null; + return {}; } /** diff --git a/src/routes/auth/fetch.js b/src/routes/auth/fetch.js new file mode 100644 index 0000000..df6e2c1 --- /dev/null +++ b/src/routes/auth/fetch.js @@ -0,0 +1,34 @@ +/* + * 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 { assertAuthorization } from '../../utils/auth.js'; +import { errorResponse } from '../../utils/http.js'; + +/** + * @param {Context} ctx + */ +export default async function fetch(ctx) { + const { config } = ctx; + + await assertAuthorization(ctx); + + const token = await ctx.env.KEYS.get(config.siteKey); + if (!token) { + return errorResponse(404); + } + + return new Response(JSON.stringify({ token }), { + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/src/routes/auth/handler.js b/src/routes/auth/handler.js new file mode 100644 index 0000000..f196724 --- /dev/null +++ b/src/routes/auth/handler.js @@ -0,0 +1,46 @@ +/* + * 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 { errorResponse } from '../../utils/http.js'; +import fetch from './fetch.js'; +import update from './update.js'; +import rotate from './rotate.js'; + +/** + * @type {Record Promise>>} + */ +const handlers = { + token: { + GET: fetch, + PUT: update, + POST: rotate, + }, +}; + +/** + * @param {Context} ctx + * @param {Request} req + * @returns {Promise} + */ +export default async function handler(ctx, req) { + const { + info: { method }, + url: { pathname }, + } = ctx; + const [subRoute] = pathname.split('/').filter(Boolean).slice(['org', 'site', 'route'].length); + + const fn = handlers[subRoute]?.[method]; + if (!fn) { + return errorResponse(404); + } + return fn(ctx, req); +} diff --git a/src/routes/auth/rotate.js b/src/routes/auth/rotate.js new file mode 100644 index 0000000..def7c4e --- /dev/null +++ b/src/routes/auth/rotate.js @@ -0,0 +1,34 @@ +/* + * 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 { assertAuthorization } from '../../utils/auth.js'; +import { errorResponse } from '../../utils/http.js'; +import { updateToken } from './update.js'; + +/** + * @param {Context} ctx + */ +export default async function rotate(ctx) { + const { data } = ctx; + if (data.token) { + return errorResponse(400, 'token can not be provided on rotate'); + } + + await assertAuthorization(ctx); + + const token = await updateToken(ctx); + return new Response(JSON.stringify({ token }), { + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/src/routes/auth/update.js b/src/routes/auth/update.js new file mode 100644 index 0000000..7fb99f1 --- /dev/null +++ b/src/routes/auth/update.js @@ -0,0 +1,52 @@ +/* + * 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 { assertAuthorization } from '../../utils/auth.js'; +import { errorResponse, errorWithResponse } from '../../utils/http.js'; + +const generateToken = () => crypto.randomUUID().toUpperCase(); + +/** + * + * @param {Context} ctx + * @param {string} [token] + * @returns {Promise} + */ +export async function updateToken(ctx, token = generateToken()) { + const { config } = ctx; + try { + await ctx.env.KEYS.put(config.siteKey, token); + } catch (e) { + ctx.log.error('failed to update token', e); + throw errorWithResponse(503, 'failed to update token'); + } + return token; +} + +/** + * @param {Context} ctx + */ +export default async function update(ctx) { + const { data } = ctx; + if (!data.token || typeof data.token !== 'string') { + return errorResponse(400, 'missing or invalid token'); + } + + await assertAuthorization(ctx); + + const token = await updateToken(ctx, data.token); + return new Response(JSON.stringify({ token }), { + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/src/routes/catalog/remove.js b/src/routes/catalog/remove.js index 699b5a8..2cc68f4 100644 --- a/src/routes/catalog/remove.js +++ b/src/routes/catalog/remove.js @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import { assertAuthorization } from '../../utils/auth.js'; import { errorResponse, errorWithResponse } from '../../utils/http.js'; import StorageClient from './StorageClient.js'; @@ -30,6 +31,8 @@ export default async function remove(ctx) { throw errorWithResponse(400, 'Helix API key is required to delete or unpublish products.'); } + await assertAuthorization(ctx); + const storage = StorageClient.fromContext(ctx); const deleteResults = await storage.deleteProducts([sku]); diff --git a/src/routes/catalog/update.js b/src/routes/catalog/update.js index eec5276..70061f1 100644 --- a/src/routes/catalog/update.js +++ b/src/routes/catalog/update.js @@ -13,6 +13,7 @@ import { assertValidProduct, hasUppercase } from '../../utils/product.js'; import { errorResponse } from '../../utils/http.js'; import StorageClient from './StorageClient.js'; +import { assertAuthorization } from '../../utils/auth.js'; /** * Handles a PUT request to update a product. @@ -40,6 +41,8 @@ export default async function update(ctx) { assertValidProduct(product); + await assertAuthorization(ctx); + const storage = StorageClient.fromContext(ctx); const saveResults = await storage.saveProducts([product]); diff --git a/src/routes/config/handler.js b/src/routes/config/handler.js index 6d9ef9b..d7d861d 100644 --- a/src/routes/config/handler.js +++ b/src/routes/config/handler.js @@ -13,6 +13,7 @@ import { errorResponse } from '../../utils/http.js'; import { assertAuthorization } from '../../utils/auth.js'; import { validate } from '../../utils/validation.js'; +import { updateToken } from '../auth/update.js'; import ConfigSchema from '../../schemas/Config.js'; /** @@ -46,7 +47,14 @@ export default async function configHandler(ctx) { } // valid, persist it + const exists = (await ctx.env.CONFIGS.list({ prefix: ctx.config.siteKey })).keys.length > 0; await ctx.env.CONFIGS.put(ctx.config.siteKey, JSON.stringify(json)); + + // add key + if (!exists) { + await updateToken(ctx); + } + return new Response(JSON.stringify(json), { status: 200, headers: { diff --git a/src/routes/index.js b/src/routes/index.js index bbf0269..d3b3c69 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -13,7 +13,7 @@ import content from './content/handler.js'; import catalog from './catalog/handler.js'; import config from './config/handler.js'; -import { errorResponse } from '../utils/http.js'; +import auth from './auth/handler.js'; /** * @type {Record Promise>} @@ -22,6 +22,5 @@ export default { content, catalog, config, - // eslint-disable-next-line no-unused-vars - graphql: async (ctx) => errorResponse(501, 'not implemented'), + auth, }; diff --git a/src/schemas/Config.js b/src/schemas/Config.js index 0ac5b37..9b16648 100644 --- a/src/schemas/Config.js +++ b/src/schemas/Config.js @@ -44,6 +44,7 @@ const ConfigEntry = { offerVariantURLTemplate: { type: 'string' }, liveSearchEnabled: { type: 'boolean' }, attributeOverrides: AttributeOverrides, + catalogSource: { type: 'string', enum: ['helix', 'magento'] }, imageRoleOrder: { type: 'array', items: { type: 'string' }, diff --git a/src/types.d.ts b/src/types.d.ts index 223450d..791167f 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -34,7 +34,7 @@ declare global { /** * Override "escape hatch" for json-ld */ - jsonld?: any; + jsonld?: string; /** * Additional data that can be retrieved via .json API @@ -204,6 +204,7 @@ declare global { export interface Env { VERSION: string; ENVIRONMENT: string; + SUPERUSER_KEY: string; // KV namespaces CONFIGS: KVNamespace; diff --git a/src/utils/auth.js b/src/utils/auth.js index bcd40d0..c80a9be 100644 --- a/src/utils/auth.js +++ b/src/utils/auth.js @@ -16,20 +16,20 @@ import { errorWithResponse } from './http.js'; * @param {Context} ctx */ export async function assertAuthorization(ctx) { - let actual; + let actual = ctx.attributes.key; if (typeof ctx.attributes.key === 'undefined') { ctx.attributes.key = ctx.info.headers.authorization?.slice('Bearer '.length); actual = ctx.attributes.key; } - if (!actual) { - throw errorWithResponse(403, 'invalid key'); - } - if (actual === ctx.env.SUPERUSER_KEY) { - ctx.log.info('acting as superuser'); + ctx.log.debug('acting as superuser'); return; } + if (!actual) { + throw errorWithResponse(403, 'invalid key'); + } + const expected = await ctx.env.KEYS.get(ctx.config.siteKey); if (!expected) { throw errorWithResponse(403, 'no key found for site'); diff --git a/src/utils/http.js b/src/utils/http.js index 89001c8..c15420f 100644 --- a/src/utils/http.js +++ b/src/utils/http.js @@ -48,7 +48,7 @@ export class ResponseError extends Error { /** * @param {number} status - The HTTP status code. - * @param {string} xError - The error message. + * @param {string} [xError] - The error message. * @param {string|Record} [body=''] - The response body. * @returns {Response} - A response object. */ diff --git a/test/fixtures/context.js b/test/fixtures/context.js index 966b799..8b2ebdb 100644 --- a/test/fixtures/context.js +++ b/test/fixtures/context.js @@ -29,11 +29,21 @@ export const DEFAULT_CONTEXT = ( ) => ({ url: new URL(`${baseUrl}${path}`), log: console, + // @ts-ignore + config: { + siteKey: 'org--site', + }, ...overrides, attributes: { + key: 'test-key', ...(overrides.attributes ?? {}), }, env: { + SUPERUSER_KEY: 'su-test-key', + KEYS: { + // @ts-ignore + get: async () => 'test-key', + }, CONFIGS: { // @ts-ignore get: async (id) => configMap[id], @@ -45,6 +55,17 @@ export const DEFAULT_CONTEXT = ( headers: {}, ...(overrides.info ?? {}), }, + data: typeof overrides.data === 'string' ? overrides.data : { + ...(overrides.data ?? {}), + }, +}); + +export const SUPERUSER_CONTEXT = (overrides = {}) => DEFAULT_CONTEXT({ + ...overrides, + attributes: { + key: 'su-test-key', + ...(overrides.attributes ?? {}), + }, }); /** diff --git a/test/post-deploy.test.js b/test/post-deploy.test.js index 2ff0b38..2c2c730 100644 --- a/test/post-deploy.test.js +++ b/test/post-deploy.test.js @@ -24,13 +24,19 @@ config(); /** * @param {string} path + * @param {RequestInit} [init] * @returns {{url: URL} & RequestInit} */ -function getFetchOptions(path) { +function getFetchOptions(path, init = {}) { return { url: new URL(`https://adobe-commerce-api-ci.adobeaem.workers.dev${path}`), cache: 'no-store', redirect: 'manual', + ...init, + headers: { + authorization: `bearer ${process.env.SUPERUSER_KEY}`, + ...(init.headers ?? {}), + }, }; } @@ -92,14 +98,16 @@ describe('Post-Deploy Tests', () => { }); it('can PUT, GET, lookup, and DELETE a product', async () => { - const putOpts = { - ...getFetchOptions(`/dylandepass/commerce-boilerplate/catalog/main_website_store/default/products/${testProduct.sku}`), - method: 'PUT', - headers: { - 'Content-Type': 'application/json', + const putOpts = getFetchOptions( + `/dylandepass/commerce-boilerplate/catalog/main_website_store/default/products/${testProduct.sku}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(testProduct), }, - body: JSON.stringify(testProduct), - }; + ); const putRes = await fetch(putOpts.url, putOpts); assert.strictEqual(putRes.status, 201, 'PUT request should succeed'); diff --git a/test/routes/auth/fetch.test.js b/test/routes/auth/fetch.test.js new file mode 100644 index 0000000..4a2a20c --- /dev/null +++ b/test/routes/auth/fetch.test.js @@ -0,0 +1,48 @@ +/* + * 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. + */ + +// @ts-nocheck + +import assert from 'node:assert'; +import { SUPERUSER_CONTEXT } from '../../fixtures/context.js'; +import handler from '../../../src/routes/auth/fetch.js'; + +describe('routes/auth fetch tests', () => { + it('no token in storage, responds 404', async () => { + const ctx = SUPERUSER_CONTEXT({ + env: { + KEYS: { + get: async () => undefined, + }, + }, + url: { pathname: '/org/site/auth/token' }, + }); + const resp = await handler(ctx); + assert.equal(resp.status, 404); + }); + + it('retrieves token from storage', async () => { + const ctx = SUPERUSER_CONTEXT({ + env: { + KEYS: { + get: async () => 'foo', + }, + }, + url: { pathname: '/org/site/auth/token' }, + }); + const resp = await handler(ctx); + assert.equal(resp.status, 200); + assert.equal(resp.headers.get('Content-Type'), 'application/json'); + const { token } = await resp.json(); + assert.equal(token, 'foo'); + }); +}); diff --git a/test/routes/auth/handler.test.js b/test/routes/auth/handler.test.js new file mode 100644 index 0000000..d1140c1 --- /dev/null +++ b/test/routes/auth/handler.test.js @@ -0,0 +1,35 @@ +/* + * 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. + */ + +// @ts-nocheck + +import assert from 'node:assert'; +import esmock from 'esmock'; +import { DEFAULT_CONTEXT } from '../../fixtures/context.js'; +import handler from '../../../src/routes/auth/handler.js'; + +describe('routes/auth handler tests', () => { + it('should 404 on invalid route', async () => { + const ctx = DEFAULT_CONTEXT({ url: { pathname: '/org/site/auth/invalid' } }); + const resp = await handler(ctx); + assert.equal(resp.status, 404); + }); + + it('should respond on valid route', async () => { + const mocked = await esmock('../../../src/routes/auth/handler.js', { + '../../../src/routes/auth/fetch.js': async () => ({ status: 200 }), + }); + const ctx = DEFAULT_CONTEXT({ url: { pathname: '/org/site/auth/token' } }); + const resp = await mocked.default(ctx); + assert.equal(resp.status, 200); + }); +}); diff --git a/test/routes/auth/rotate.test.js b/test/routes/auth/rotate.test.js new file mode 100644 index 0000000..b3f8cea --- /dev/null +++ b/test/routes/auth/rotate.test.js @@ -0,0 +1,57 @@ +/* + * 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. + */ + +// @ts-nocheck + +import assert from 'node:assert'; +import esmock from 'esmock'; +import { DEFAULT_CONTEXT, SUPERUSER_CONTEXT } from '../../fixtures/context.js'; +import handler from '../../../src/routes/auth/rotate.js'; + +describe('routes/auth rotate tests', () => { + let ogUUID; + beforeEach(() => { + ogUUID = crypto.randomUUID; + crypto.randomUUID = () => 'foo-uuid'; + }); + afterEach(() => { + crypto.randomUUID = ogUUID; + }); + + it('should reject body with token', async () => { + const ctx = DEFAULT_CONTEXT({ + data: { token: '123' }, + }); + const resp = await handler(ctx); + assert.equal(resp.status, 400); + assert.equal(resp.headers.get('x-error'), 'token can not be provided on rotate'); + }); + + it('rotates token', async () => { + const mocked = await esmock('../../../src/routes/auth/rotate.js', { + '../../../src/routes/auth/update.js': { updateToken: async () => 'foo' }, + }); + const ctx = SUPERUSER_CONTEXT({ + env: { + KEYS: { + get: async () => 'test-key', + put: async () => undefined, + }, + }, + }); + const resp = await mocked.default(ctx); + assert.equal(resp.status, 200); + assert.equal(resp.headers.get('Content-Type'), 'application/json'); + const { token } = await resp.json(); + assert.equal(token, 'foo'); + }); +}); diff --git a/test/routes/auth/update.test.js b/test/routes/auth/update.test.js new file mode 100644 index 0000000..b1c9923 --- /dev/null +++ b/test/routes/auth/update.test.js @@ -0,0 +1,72 @@ +/* + * 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. + */ + +// @ts-nocheck + +import assert from 'node:assert'; +import { DEFAULT_CONTEXT } from '../../fixtures/context.js'; +import handler from '../../../src/routes/auth/update.js'; + +describe('routes/auth update tests', () => { + it('should reject invalid token', async () => { + const ctx = DEFAULT_CONTEXT({ + data: { token: null }, + env: { + KEYS: { + // @ts-ignore + get: async () => 'test-key', + }, + }, + }); + const resp = await handler(ctx); + assert.equal(resp.status, 400); + assert.equal(resp.headers.get('x-error'), 'missing or invalid token'); + }); + + it('should update token', async () => { + const ctx = DEFAULT_CONTEXT({ + data: { token: 'new-key' }, + env: { + KEYS: { + get: async () => 'test-key', + put: async () => undefined, + }, + }, + }); + const resp = await handler(ctx); + assert.equal(resp.status, 200); + assert.equal(resp.headers.get('Content-Type'), 'application/json'); + const { token } = await resp.json(); + assert.equal(token, 'new-key'); + }); + + it('should handle errors during token put', async () => { + const ctx = DEFAULT_CONTEXT({ + data: { token: 'new-key' }, + env: { + KEYS: { + get: async () => 'test-key', + put: async () => { throw Error('bad'); }, + }, + }, + }); + + let e; + try { + await handler(ctx); + } catch (ee) { + e = ee; + } + assert.equal(e.response.status, 503); + assert.equal(e.response.headers.get('x-error'), 'failed to update token'); + }); +});