diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b4800dfa9..2a8957ced2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,8 @@ Our versioning strategy is as follows: * `[create-sitecore-jss]` Rework Angular initializer to support XMCloud and SXP journeys ([#1845](https://github.com/Sitecore/jss/pull/1845))([#1858](https://github.com/Sitecore/jss/pull/1858))([#1868](https://github.com/Sitecore/jss/pull/1868))([#1881](https://github.com/Sitecore/jss/pull/1881))([#1882](https://github.com/Sitecore/jss/pull/1882)) * `[create-sitecore-jss]` Allow node-xmcloud-proxy app to be installed alongside Angular SPA application * `proxyAppDestination` arg can be passed into `create-sitecore-jss` command to define path for proxy to be installed in -* `[create-sitecore-jss]` `[template/angular]` `[template/angular-xmcloud]` `[template/node-xmcloud-proxy]` Introduced /api/editing/config endpoint ([#1903](https://github.com/Sitecore/jss/pull/1903)) +* `[templates/angular]` `[templates/angular-xmcloud]` `[template/node-xmcloud-proxy]` `[sitecore-jss-proxy]` Introduced /api/editing/config endpoint ([#1903](https://github.com/Sitecore/jss/pull/1903)) +* `[templates/angular]` `[templates/angular-xmcloud]` `[template/node-xmcloud-proxy]` `[sitecore-jss-proxy]` Introduced /api/editing/render endpoint ([#1908](https://github.com/Sitecore/jss/pull/1908)) * `[create-sitecore-jss]``[sitecore-jss-angular]``[template/angular-xmcloud]` Angular SXA components * Angular placeholder now supports SXA components ([#1870](https://github.com/Sitecore/jss/pull/1870)) * Title component ([#1904](https://github.com/Sitecore/jss/pull/1904)) diff --git a/packages/create-sitecore-jss/src/templates/angular-xmcloud/server.exports.ts b/packages/create-sitecore-jss/src/templates/angular-xmcloud/server.exports.ts index 2dcc766c2e..5d2c0eb237 100644 --- a/packages/create-sitecore-jss/src/templates/angular-xmcloud/server.exports.ts +++ b/packages/create-sitecore-jss/src/templates/angular-xmcloud/server.exports.ts @@ -10,14 +10,10 @@ import metadata from './src/environments/metadata.json'; * Define the required configuration values to be exported from the server.bundle.ts. */ -const apiKey = environment.sitecoreApiKey; -const siteName = environment.sitecoreSiteName; const defaultLanguage = environment.defaultLanguage; const getClientFactoryConfig = getGraphQLClientFactoryConfig; export { - apiKey, - siteName, clientFactory, getClientFactoryConfig, dictionaryServiceFactory, diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts index f7fb90e43b..6431f400f8 100644 --- a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/index.ts @@ -23,7 +23,6 @@ const requiredProperties = [ 'parseRouteUrl', 'clientFactory', 'getClientFactoryConfig', - 'siteName', 'defaultLanguage', 'layoutServiceFactory', 'dictionaryServiceFactory', @@ -41,12 +40,16 @@ if (missingProperties.length > 0) { ); } +const layoutService = layoutServiceFactory.create(); + +const dictionaryService = dictionaryServiceFactory.create(); + +const clientFactoryConfig = config.serverBundle.getClientFactoryConfig(); + /** * GraphQL endpoint resolution to meet the requirements of the http-proxy-middleware */ const graphQLEndpoint = (() => { - const clientFactoryConfig = config.serverBundle.getClientFactoryConfig(); - try { const graphQLEndpoint = new URL(clientFactoryConfig.endpoint); // Browser request path to the proxy. Includes only the pathname. @@ -66,10 +69,6 @@ const graphQLEndpoint = (() => { } })(); -const layoutService = layoutServiceFactory.create(); - -const dictionaryService = dictionaryServiceFactory.create(); - /** * Parse requested url in order to detect current route and language * @param {string} reqRoute requested route @@ -138,6 +137,10 @@ server.use( components: config.serverBundle.components, metadata: config.serverBundle.metadata, }, + render: { + clientFactory: config.serverBundle.clientFactory, + renderView, + }, }) ); diff --git a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts index bd9746d3fe..43d34f4ec9 100644 --- a/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts +++ b/packages/create-sitecore-jss/src/templates/node-xmcloud-proxy/src/types.ts @@ -13,7 +13,6 @@ export interface ServerBundle { parseRouteUrl: RouteUrlParser; clientFactory: GraphQLRequestClientFactory; getClientFactoryConfig: () => GraphQLRequestClientFactoryConfig; - siteName: string; defaultLanguage: string; layoutServiceFactory: { create: () => LayoutService }; dictionaryServiceFactory: { create: () => DictionaryService }; diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.test.ts b/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.test.ts index 71c6c06b3b..93730f5ad7 100644 --- a/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.test.ts +++ b/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.test.ts @@ -8,6 +8,7 @@ import { EditingDataService, EditingPreviewData } from './editing-data-service'; import { EDITING_ALLOWED_ORIGINS, QUERY_PARAM_EDITING_SECRET, + RenderMetadataQueryParams, } from '@sitecore-jss/sitecore-jss/editing'; import { QUERY_PARAM_VERCEL_PROTECTION_BYPASS, @@ -17,7 +18,6 @@ import { EE_PATH, EE_LANGUAGE, EE_LAYOUT, EE_DICTIONARY, EE_BODY } from '../test import { ChromesHandler, EditingRenderMiddleware, - MetadataQueryParams, isEditingMetadataPreviewData, } from './editing-render-middleware'; import { spy, match } from 'sinon'; @@ -38,7 +38,7 @@ const allowedOrigin = 'https://allowed.com'; const mockRequest = ( body?: any, - query?: Query | MetadataQueryParams, + query?: Query | RenderMetadataQueryParams, method?: string, headers?: { [key: string]: string } ) => { @@ -182,7 +182,7 @@ describe('EditingRenderMiddleware', () => { sc_version: 'latest', secret: secret, sc_layoutKind: 'shared', - } as MetadataQueryParams; + } as RenderMetadataQueryParams; it('should handle request', async () => { const req = mockRequest(EE_BODY, query, 'GET'); @@ -221,7 +221,7 @@ describe('EditingRenderMiddleware', () => { sc_site: 'website', secret: secret, sc_variant: 'id-1,id-2,id-3', - } as MetadataQueryParams; + } as RenderMetadataQueryParams; const req = mockRequest(EE_BODY, query, 'GET'); const res = mockResponse(); @@ -251,7 +251,7 @@ describe('EditingRenderMiddleware', () => { sc_lang: 'en', sc_site: 'website', secret: secret, - } as MetadataQueryParams; + } as RenderMetadataQueryParams; const req = mockRequest(EE_BODY, queryWithoutOptionalParams, 'GET'); const res = mockResponse(); diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts b/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts index 49dcf8f6c4..7125582b66 100644 --- a/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts +++ b/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts @@ -2,10 +2,11 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { STATIC_PROPS_ID, SERVER_PROPS_ID } from 'next/constants'; import { AxiosDataFetcher, debug } from '@sitecore-jss/sitecore-jss'; import { EditMode, LayoutServicePageState } from '@sitecore-jss/sitecore-jss/layout'; -import { LayoutKind } from '@sitecore-jss/sitecore-jss/editing'; import { QUERY_PARAM_EDITING_SECRET, EDITING_ALLOWED_ORIGINS, + RenderMetadataQueryParams, + LayoutKind, } from '@sitecore-jss/sitecore-jss/editing'; import { EditingData } from './editing-data'; import { EditingDataService, editingDataService } from './editing-data-service'; @@ -265,27 +266,11 @@ export type EditingRenderMiddlewareMetadataConfig = Pick< 'resolvePageUrl' >; -/** - * Query parameters appended to the page route URL - * Appended when XMCloud Pages preview (editing) Metadata Edit Mode is used - */ -export type MetadataQueryParams = { - secret: string; - sc_lang: string; - sc_itemid: string; - sc_site: string; - route: string; - mode: Exclude; - sc_variant?: string; - sc_version?: string; - sc_layoutKind?: LayoutKind; -}; - /** * Next.js API request with Metadata query parameters. */ type MetadataNextApiRequest = NextApiRequest & { - query: MetadataQueryParams; + query: RenderMetadataQueryParams; }; /** @@ -330,7 +315,7 @@ export class MetadataHandler { const startTimestamp = Date.now(); - const requiredQueryParams: (keyof MetadataQueryParams)[] = [ + const requiredQueryParams: (keyof RenderMetadataQueryParams)[] = [ 'sc_site', 'sc_itemid', 'sc_lang', diff --git a/packages/sitecore-jss-proxy/package.json b/packages/sitecore-jss-proxy/package.json index 7589906bd9..ded4309933 100644 --- a/packages/sitecore-jss-proxy/package.json +++ b/packages/sitecore-jss-proxy/package.json @@ -9,7 +9,7 @@ "build": "npm run clean && tsc -p tsconfig.json && tsc -p tsconfig-esm.json", "clean": "del-cli dist types", "lint": "eslint \"./src/**/*.ts\"", - "test": "mocha --require ts-node/register \"./src/**/*.test.ts\"", + "test": "mocha --require ts-node/register \"./src/**/*.test.ts\" --exit", "prepublishOnly": "npm run build", "coverage": "nyc npm test", "generate-docs": "npx typedoc --plugin typedoc-plugin-markdown --readme none --out ../../ref-docs/sitecore-jss-proxy src/index.ts --githubPages false" @@ -38,6 +38,7 @@ "@types/mocha": "^10.0.1", "@types/node": "^20.14.2", "@types/set-cookie-parser": "^2.4.2", + "@types/sinon": "^17.0.3", "@types/supertest": "^6.0.2", "chai": "^4.3.7", "del-cli": "^5.0.0", @@ -45,6 +46,7 @@ "express": "^4.19.2", "mocha": "^10.2.0", "nyc": "^15.1.0", + "sinon": "^17.0.1", "supertest": "^7.0.0", "ts-node": "^10.9.1", "typescript": "~4.9.5" diff --git a/packages/sitecore-jss-proxy/src/middleware/editing/config.test.ts b/packages/sitecore-jss-proxy/src/middleware/editing/config.test.ts new file mode 100644 index 0000000000..8116b1e9a5 --- /dev/null +++ b/packages/sitecore-jss-proxy/src/middleware/editing/config.test.ts @@ -0,0 +1,135 @@ +/* eslint-disable no-unused-expressions */ +import sinon from 'sinon'; +import express from 'express'; +import request from 'supertest'; +import { editingRouter, EditingRouterConfig } from './index'; +import { GraphQLRequestClient } from '@sitecore-jss/sitecore-jss'; +import { EditingRenderEndpointOptions } from './render'; + +describe('editingRouter - /editing/config', () => { + const clientFactory = GraphQLRequestClient.createClientFactory({ + endpoint: 'http://site/?sitecoreContextId=context-id', + }); + + const renderView: sinon.SinonStub = sinon.stub(); + + const renderConfig: EditingRenderEndpointOptions = { + clientFactory, + renderView, + }; + + const defaultOptions: EditingRouterConfig = { + config: { + components: ['component1', 'component2'], + metadata: { + packages: { + foo: '1.0.0', + bar: '2.0.0', + }, + }, + }, + render: renderConfig, + }; + + let app: express.Express; + + beforeEach(() => { + app = express(); + + process.env.JSS_EDITING_SECRET = 'correct'; + }); + + afterEach(() => { + delete process.env.JSS_EDITING_SECRET; + }); + + it('should response 200 status code and return editing config', (done) => { + app.use('/api/editing', editingRouter(defaultOptions)); + + request(app) + .get('/api/editing/config') + .query({ secret: 'correct' }) + .expect( + 200, + { + components: ['component1', 'component2'], + packages: { + foo: '1.0.0', + bar: '2.0.0', + }, + editMode: 'metadata', + }, + done + ); + }); + + it('should response 200 status code and return editing config when components are a map', (done) => { + app.use( + '/api/editing', + editingRouter({ + config: { + components: new Map([ + ['component1', true], + ['component2', true], + ]), + metadata: { + packages: { + foo: '1.0.0', + bar: '2.0.0', + }, + }, + }, + render: renderConfig, + }) + ); + + request(app) + .get('/api/editing/config') + .query({ secret: 'correct' }) + .expect( + 200, + { + components: ['component1', 'component2'], + packages: { + foo: '1.0.0', + bar: '2.0.0', + }, + editMode: 'metadata', + }, + done + ); + }); + + it('should response 200 status code and return editing config when custom request path is set', (done) => { + app.use( + '/api/editing', + editingRouter({ + config: { + components: ['component1'], + metadata: { + packages: { + foo: '1.0.0', + }, + }, + path: '/foo/config', + }, + render: renderConfig, + }) + ); + + request(app) + .get('/api/editing/foo/config') + .query({ secret: 'correct' }) + .expect( + 200, + { + components: ['component1'], + packages: { + foo: '1.0.0', + }, + editMode: 'metadata', + }, + done + ); + }); +}); diff --git a/packages/sitecore-jss-proxy/src/middleware/editing/config.ts b/packages/sitecore-jss-proxy/src/middleware/editing/config.ts index d0b515ca35..c6c805423e 100644 --- a/packages/sitecore-jss-proxy/src/middleware/editing/config.ts +++ b/packages/sitecore-jss-proxy/src/middleware/editing/config.ts @@ -1,4 +1,4 @@ -import { Request, RequestHandler, Response } from 'express'; +import { Request, Response } from 'express'; import { debug } from '@sitecore-jss/sitecore-jss'; import { EditMode } from '@sitecore-jss/sitecore-jss/layout'; import { Metadata } from '@sitecore-jss/sitecore-jss/utils'; @@ -28,7 +28,7 @@ export type EditingConfigEndpointOptions = { * @param {EditingConfigEndpointOptions} config Configuration for the endpoint * @returns {RequestHandler} Middleware function */ -export const editingConfigMiddleware = (config: EditingConfigEndpointOptions): RequestHandler => ( +export const editingConfigMiddleware = (config: EditingConfigEndpointOptions) => ( _req: Request, res: Response ): void => { diff --git a/packages/sitecore-jss-proxy/src/middleware/editing/index.test.ts b/packages/sitecore-jss-proxy/src/middleware/editing/index.test.ts index 761e791c1a..e3877971e8 100644 --- a/packages/sitecore-jss-proxy/src/middleware/editing/index.test.ts +++ b/packages/sitecore-jss-proxy/src/middleware/editing/index.test.ts @@ -1,10 +1,24 @@ -import { expect } from 'chai'; +/* eslint-disable no-unused-expressions */ +import sinon from 'sinon'; import express from 'express'; import request from 'supertest'; -import { editingRouter } from './index'; +import { editingRouter, EditingRouterConfig } from './index'; +import { GraphQLRequestClient } from '@sitecore-jss/sitecore-jss'; +import { EditingRenderEndpointOptions } from './render'; describe('editingRouter', () => { - const defaultOptions = { + const clientFactory = GraphQLRequestClient.createClientFactory({ + endpoint: 'http://site/?sitecoreContextId=context-id', + }); + + const renderView: sinon.SinonStub = sinon.stub(); + + const renderConfig: EditingRenderEndpointOptions = { + clientFactory, + renderView, + }; + + const defaultOptions: EditingRouterConfig = { config: { components: ['component1', 'component2'], metadata: { @@ -14,211 +28,68 @@ describe('editingRouter', () => { }, }, }, + render: renderConfig, }; let app: express.Express; beforeEach(() => { app = express(); + + process.env.JSS_EDITING_SECRET = 'correct'; }); - it('should throw 401 CORS error when requested origin is not allowed', (done) => { + afterEach(() => { + delete process.env.JSS_EDITING_SECRET; + }); + + it('should response 401 CORS error when requested origin is not allowed', (done) => { app.use(editingRouter(defaultOptions)); request(app) .get('/config') .set('origin', 'http://not-allowed.com') - .expect(401) - .end((err, res) => { - if (err) return done(err); - expect(res.body.html).to.equal( - 'Requests from origin http://not-allowed.com not allowed' - ); - done(); - }); + .expect(401, 'Requests from origin http://not-allowed.com are not allowed', done); }); - it('should throw 401 error when editing secret is not set', (done) => { + it('should response 401 error when editing secret is not set', (done) => { + process.env.JSS_EDITING_SECRET = ''; + app.use(editingRouter(defaultOptions)); request(app) .get('/config') - .expect(401) - .end((err, res) => { - if (err) return done(err); - expect(res.body.html).to.equal( - 'Missing editing secret - set JSS_EDITING_SECRET environment variable' - ); - done(); - }); + .expect(401, 'Missing editing secret - set JSS_EDITING_SECRET environment variable', done); }); - it('should throw 401 error when editing secret is incorrect', (done) => { - process.env.JSS_EDITING_SECRET = 'correct'; - + it('should response 401 error when editing secret is incorrect', (done) => { app.use(editingRouter(defaultOptions)); request(app) .get('/config') .query({ secret: 'incorrect' }) - .expect(401) - .end((err, res) => { - if (err) return done(err); - expect(res.body.html).to.equal('Missing or invalid secret'); - - delete process.env.JSS_EDITING_SECRET; - - done(); - }); + .expect(401, 'Missing or invalid secret', done); }); - it('should throw 405 error when not allowed method is used', (done) => { - process.env.JSS_EDITING_SECRET = 'correct'; - + it('should response 405 error when not allowed method is used', (done) => { app.use('/api/editing', editingRouter(defaultOptions)); request(app) .post('/api/editing/config') .query({ secret: 'correct' }) - .expect(405) - .end((err, res) => { - if (err) return done(err); - expect(res.body.html).to.equal( - 'Invalid request method or path POST /api/editing/config?secret=correct' - ); - - delete process.env.JSS_EDITING_SECRET; - - done(); - }); + .expect(405, 'Invalid request method or path POST /api/editing/config?secret=correct', done); }); - it('should throw 405 error when not allowed path is used', (done) => { - process.env.JSS_EDITING_SECRET = 'correct'; - + it('should response 405 error when not allowed path is used', (done) => { app.use('/api/editing', editingRouter(defaultOptions)); request(app) .get('/api/editing/fake-config') .query({ secret: 'correct' }) - .expect(405) - .end((err, res) => { - if (err) return done(err); - expect(res.body.html).to.equal( - 'Invalid request method or path GET /api/editing/fake-config?secret=correct' - ); - - delete process.env.JSS_EDITING_SECRET; - - done(); - }); - }); - - describe('/config', () => { - it('should return editing config', (done) => { - process.env.JSS_EDITING_SECRET = 'correct'; - - app.use('/api/editing', editingRouter(defaultOptions)); - - request(app) - .get('/api/editing/config') - .query({ secret: 'correct' }) - .expect(200) - .end((err, res) => { - if (err) return done(err); - expect(res.body).to.deep.equal({ - components: ['component1', 'component2'], - packages: { - foo: '1.0.0', - bar: '2.0.0', - }, - editMode: 'metadata', - }); - - delete process.env.JSS_EDITING_SECRET; - - done(); - }); - }); - - it('should return editing config when components are a map', (done) => { - process.env.JSS_EDITING_SECRET = 'correct'; - - app.use( - '/api/editing', - editingRouter({ - config: { - components: new Map([ - ['component1', true], - ['component2', true], - ]), - metadata: { - packages: { - foo: '1.0.0', - bar: '2.0.0', - }, - }, - }, - }) + .expect( + 405, + 'Invalid request method or path GET /api/editing/fake-config?secret=correct', + done ); - - request(app) - .get('/api/editing/config') - .query({ secret: 'correct' }) - .expect(200) - .end((err, res) => { - if (err) return done(err); - expect(res.body).to.deep.equal({ - components: ['component1', 'component2'], - packages: { - foo: '1.0.0', - bar: '2.0.0', - }, - editMode: 'metadata', - }); - - delete process.env.JSS_EDITING_SECRET; - - done(); - }); - }); - - it('should return editing config when custom request path is set', (done) => { - process.env.JSS_EDITING_SECRET = 'correct'; - - app.use( - '/api/editing', - editingRouter({ - config: { - components: ['component1'], - metadata: { - packages: { - foo: '1.0.0', - }, - }, - path: '/foo/config', - }, - }) - ); - - request(app) - .get('/api/editing/foo/config') - .query({ secret: 'correct' }) - .expect(200) - .end((err, res) => { - if (err) return done(err); - expect(res.body).to.deep.equal({ - components: ['component1'], - packages: { - foo: '1.0.0', - }, - editMode: 'metadata', - }); - - delete process.env.JSS_EDITING_SECRET; - - done(); - }); - }); }); }); diff --git a/packages/sitecore-jss-proxy/src/middleware/editing/index.ts b/packages/sitecore-jss-proxy/src/middleware/editing/index.ts index f602ec6582..8bd03fc345 100644 --- a/packages/sitecore-jss-proxy/src/middleware/editing/index.ts +++ b/packages/sitecore-jss-proxy/src/middleware/editing/index.ts @@ -6,6 +6,7 @@ import { } from '@sitecore-jss/sitecore-jss/editing'; import { enforceCors } from '@sitecore-jss/sitecore-jss/utils'; import { EditingConfigEndpointOptions, editingConfigMiddleware } from './config'; +import { EditingRenderEndpointOptions, editingRenderMiddleware } from './render'; /** * Default endpoints for editing requests @@ -26,12 +27,7 @@ export type EditingRouterConfig = { /** * Configuration for the /render endpoint */ - render?: { - /** - * Custom path for the editing render endpoint - */ - path?: string; - }; + render: EditingRenderEndpointOptions; }; /** @@ -59,24 +55,21 @@ export const editingMiddleware = async ( debug.editing( 'invalid origin host - set allowed origins in JSS_ALLOWED_ORIGINS environment variable' ); - return res.status(401).json({ - html: `Requests from origin ${req.headers?.origin} not allowed`, - }); + return res.status(401).send(`Requests from origin ${req.headers?.origin} are not allowed`); } if (!secret) { debug.editing('missing editing secret - set JSS_EDITING_SECRET environment variable'); - return res.status(401).json({ - html: - 'Missing editing secret - set JSS_EDITING_SECRET environment variable', - }); + return res + .status(401) + .send('Missing editing secret - set JSS_EDITING_SECRET environment variable'); } if (secret !== providedSecret) { - debug.editing('invalid editing secret - sent "%s" expected "%s"', secret, providedSecret); + debug.editing('invalid editing secret - sent "%s" expected "%s"', providedSecret, secret); - return res.status(401).json({ html: 'Missing or invalid secret' }); + return res.status(401).send('Missing or invalid secret'); } return next(); @@ -90,9 +83,7 @@ export const editingMiddleware = async ( const editingNotFoundMiddleware = (req: Request, res: Response) => { debug.editing('invalid method or path - sent %s %s', req.method, req.originalUrl); - return res.status(405).json({ - html: `Invalid request method or path ${req.method} ${req.originalUrl}`, - }); + return res.status(405).send(`Invalid request method or path ${req.method} ${req.originalUrl}`); }; /** @@ -109,9 +100,7 @@ export const editingRouter = (options: EditingRouterConfig) => { router.use(editingMiddleware); router.get(options.config.path || ENDPOINTS.CONFIG, editingConfigMiddleware(options.config)); - router.get(options.render?.path || ENDPOINTS.RENDER, () => { - return null; - }); + router.get(options.render.path || ENDPOINTS.RENDER, editingRenderMiddleware(options.render)); router.use(editingNotFoundMiddleware); diff --git a/packages/sitecore-jss-proxy/src/middleware/editing/render.test.ts b/packages/sitecore-jss-proxy/src/middleware/editing/render.test.ts new file mode 100644 index 0000000000..e19a7a4a75 --- /dev/null +++ b/packages/sitecore-jss-proxy/src/middleware/editing/render.test.ts @@ -0,0 +1,417 @@ +/* eslint-disable no-unused-expressions */ +import { expect } from 'chai'; +import sinon from 'sinon'; +import express from 'express'; +import request from 'supertest'; +import { editingRouter, EditingRouterConfig } from './index'; +import { debug, GraphQLRequestClient } from '@sitecore-jss/sitecore-jss'; +import { + GraphQLEditingService, + LayoutKind, + RenderMetadataQueryParams, +} from '@sitecore-jss/sitecore-jss/editing'; +import { EditingRenderEndpointOptions, getSCPHeader } from './render'; +import { LayoutServiceData, LayoutServicePageState } from '@sitecore-jss/sitecore-jss/layout'; +import { DictionaryPhrases } from '@sitecore-jss/sitecore-jss/types/i18n'; + +describe('editingRouter - /editing/render', () => { + const clientFactory = GraphQLRequestClient.createClientFactory({ + endpoint: 'http://site/?sitecoreContextId=context-id', + }); + + const renderView: sinon.SinonStub = sinon.stub(); + + const renderConfig: EditingRenderEndpointOptions = { + clientFactory, + renderView, + }; + + const defaultOptions: EditingRouterConfig = { + config: { + components: ['component1', 'component2'], + metadata: { + packages: { + foo: '1.0.0', + bar: '2.0.0', + }, + }, + }, + render: renderConfig, + }; + + let app: express.Express; + + beforeEach(() => { + app = express(); + + process.env.JSS_EDITING_SECRET = 'correct'; + }); + + afterEach(() => { + delete process.env.JSS_EDITING_SECRET; + }); + + const requiredParams = ['sc_site', 'sc_itemid', 'sc_lang', 'route', 'mode']; + + const validQS: RenderMetadataQueryParams = { + secret: 'correct', + sc_site: 'site', + sc_itemid: '{Guid}', + sc_lang: 'en', + sc_layoutKind: LayoutKind.Shared, + sc_version: '1', + route: '/', + mode: LayoutServicePageState.Edit, + }; + + const fetchEditingDataArgs = { + siteName: validQS.sc_site, + itemId: validQS.sc_itemid, + language: validQS.sc_lang, + version: validQS.sc_version, + layoutKind: validQS.sc_layoutKind, + }; + + let fetchEditingDataStub: sinon.SinonStub; + + before(() => { + fetchEditingDataStub = sinon.stub(GraphQLEditingService.prototype, 'fetchEditingData'); + }); + + afterEach(() => { + fetchEditingDataStub.resetHistory(); + renderView.resetHistory(); + }); + + it('should response 400 error when missing required query params', (done) => { + app.use('/api/editing', editingRouter(defaultOptions)); + + request(app) + .get('/api/editing/render') + .query({ secret: 'correct' }) + .expect(400, `Missing required query parameters: ${requiredParams.join(', ')}`, done); + }); + + it('should response 500 error when unable to fetch editing data', (done) => { + const debugStub = sinon.stub(debug, 'editing'); + + fetchEditingDataStub.rejects(new Error('Unable to fetch editing data')); + + app.use('/api/editing', editingRouter(defaultOptions)); + + request(app) + .get('/api/editing/render') + .query(validQS) + .expect(500, 'Internal Server Error') + .expect(() => { + expect(fetchEditingDataStub.calledOnceWith(fetchEditingDataArgs)).to.be.true; + + const debugErrorMessage = debugStub.getCall(debugStub.callCount - 1).args; + + expect(debugErrorMessage[0]).to.equal('response error %o'); + expect(debugErrorMessage[1]).to.deep.equal(new Error('Unable to fetch editing data')); + + debugStub.restore(); + }) + .end(done); + }); + + it('should response 500 error when editing data is empty', (done) => { + const debugStub = sinon.stub(debug, 'editing'); + + fetchEditingDataStub.resolves(null as any); + + app.use('/api/editing', editingRouter(defaultOptions)); + + request(app) + .get('/api/editing/render') + .query(validQS) + .expect(500, 'Internal Server Error') + .expect(() => { + expect(fetchEditingDataStub.calledOnceWith(fetchEditingDataArgs)).to.be.true; + + const debugErrorMessage = debugStub.getCall(debugStub.callCount - 1).args; + + expect(debugErrorMessage[0]).to.equal('response error %o'); + expect(debugErrorMessage[1]).to.deep.equal( + new Error(`Unable to fetch editing data for ${JSON.stringify(validQS)}`) + ); + + debugStub.restore(); + }) + .end(done); + }); + + it('should response 500 error when editing layout data is empty', (done) => { + const debugStub = sinon.stub(debug, 'editing'); + + fetchEditingDataStub.resolves({ + layoutData: (null as unknown) as LayoutServiceData, + dictionary: {}, + }); + + app.use('/api/editing', editingRouter(defaultOptions)); + + request(app) + .get('/api/editing/render') + .query(validQS) + .expect(500, 'Internal Server Error') + .expect(() => { + expect(fetchEditingDataStub.calledOnceWith(fetchEditingDataArgs)).to.be.true; + + const debugErrorMessage = debugStub.getCall(debugStub.callCount - 1).args; + + expect(debugErrorMessage[0]).to.equal('response error %o'); + expect(debugErrorMessage[1]).to.deep.equal( + new Error(`Unable to fetch editing data for ${JSON.stringify(validQS)}`) + ); + + debugStub.restore(); + }) + .end(done); + }); + + it('should response 500 error when editing dictionary data is empty', (done) => { + const debugStub = sinon.stub(debug, 'editing'); + + fetchEditingDataStub.resolves({ + layoutData: { + sitecore: { + context: { pageEditing: true }, + route: null, + }, + }, + dictionary: (null as unknown) as DictionaryPhrases, + }); + + app.use('/api/editing', editingRouter(defaultOptions)); + + request(app) + .get('/api/editing/render') + .query(validQS) + .expect(500, 'Internal Server Error') + .expect(() => { + expect(fetchEditingDataStub.calledOnceWith(fetchEditingDataArgs)).to.be.true; + + const debugErrorMessage = debugStub.getCall(debugStub.callCount - 1).args; + + expect(debugErrorMessage[0]).to.equal('response error %o'); + expect(debugErrorMessage[1]).to.deep.equal( + new Error(`Unable to fetch editing data for ${JSON.stringify(validQS)}`) + ); + + debugStub.restore(); + }) + .end(done); + }); + + it('should response 500 error when renderView returns error', (done) => { + const debugStub = sinon.stub(debug, 'editing'); + + const layoutData = { sitecore: { context: { pageEditing: true }, route: null } }; + const dictionary = {}; + + fetchEditingDataStub.resolves({ + layoutData, + dictionary, + }); + + renderView.callsFake((callback) => callback(new Error('Unable to render view'), null)); + + app.use( + '/api/editing', + editingRouter({ + ...defaultOptions, + render: { + ...defaultOptions.render, + renderView, + }, + }) + ); + + request(app) + .get('/api/editing/render') + .query(validQS) + .expect(500, 'Internal Server Error') + .expect(() => { + expect(fetchEditingDataStub.calledOnceWith(fetchEditingDataArgs)).to.be.true; + expect( + renderView.calledOnceWith(sinon.match.func, '/', layoutData, { + dictionary, + }) + ).to.be.true; + + const debugErrorMessage = debugStub.getCall(debugStub.callCount - 1).args; + + expect(debugErrorMessage[0]).to.equal('response error %o'); + expect(debugErrorMessage[1]).to.deep.equal(new Error('Unable to render view')); + + debugStub.restore(); + }) + .end(done); + }); + + it('should response 204 status code when renderView returns empty result', (done) => { + const layoutData = { sitecore: { context: { pageEditing: true }, route: null } }; + const dictionary = {}; + + fetchEditingDataStub.resolves({ + layoutData, + dictionary, + }); + + renderView.callsFake((callback) => callback(null, null)); + + app.use( + '/api/editing', + editingRouter({ + ...defaultOptions, + render: { + ...defaultOptions.render, + renderView, + }, + }) + ); + + request(app) + .get('/api/editing/render') + .query(validQS) + .expect(204, '') + .expect(() => { + expect(fetchEditingDataStub.calledOnceWith(fetchEditingDataArgs)).to.be.true; + expect( + renderView.calledOnceWith(sinon.match.func, '/', layoutData, { + dictionary, + }) + ).to.be.true; + }) + .end(done); + }); + + it('should response 200 status code when renderView returns result', (done) => { + const layoutData = { + sitecore: { context: { pageEditing: true }, route: { name: '/', placeholders: {} } }, + }; + const dictionary = {}; + + fetchEditingDataStub.resolves({ + layoutData, + dictionary, + }); + + renderView.callsFake((callback) => callback(null, { html: '
Hello World
' })); + + app.use( + '/api/editing', + editingRouter({ + ...defaultOptions, + render: { + ...defaultOptions.render, + renderView, + }, + }) + ); + + request(app) + .get('/api/editing/render') + .query(validQS) + .expect(200, '
Hello World
') + .expect('Content-Security-Policy', `${getSCPHeader()}`) + .expect(() => { + expect(fetchEditingDataStub.calledOnceWith(fetchEditingDataArgs)).to.be.true; + expect( + renderView.calledOnceWith(sinon.match.func, '/', layoutData, { + dictionary, + }) + ).to.be.true; + }) + .end(done); + }); + + it('should response 200 status code when renderView return result and custom endpoint path is used', (done) => { + const layoutData = { + sitecore: { + context: { pageEditing: true }, + route: { name: '/', placeholders: {} }, + }, + }; + const dictionary = {}; + + fetchEditingDataStub.resolves({ + layoutData, + dictionary, + }); + + renderView.callsFake((callback) => callback(null, { html: '
Hello World
' })); + + app.use( + '/api/editing', + editingRouter({ + ...defaultOptions, + render: { + ...defaultOptions.render, + path: '/foo/render', + renderView, + }, + }) + ); + + request(app) + .get('/api/editing/foo/render') + .query(validQS) + .expect(200, '
Hello World
') + .expect('Content-Security-Policy', `${getSCPHeader()}`) + .expect(() => { + expect(fetchEditingDataStub.calledOnceWith(fetchEditingDataArgs)).to.be.true; + expect( + renderView.calledOnceWith(sinon.match.func, '/', layoutData, { + dictionary, + }) + ).to.be.true; + }) + .end(done); + }); + + it('should response 404 status code when renderView returns not found route', (done) => { + const layoutData = { + sitecore: { + context: { pageEditing: true }, + route: null, + }, + }; + const dictionary = {}; + + fetchEditingDataStub.resolves({ + layoutData, + dictionary, + }); + + renderView.callsFake((callback) => callback(null, { html: '
Not Found
' })); + + app.use( + '/api/editing', + editingRouter({ + ...defaultOptions, + render: { + ...defaultOptions.render, + renderView, + }, + }) + ); + + request(app) + .get('/api/editing/render') + .query(validQS) + .expect(404, '
Not Found
') + .expect('Content-Security-Policy', `${getSCPHeader()}`) + .expect(() => { + expect(fetchEditingDataStub.calledOnceWith(fetchEditingDataArgs)).to.be.true; + expect( + renderView.calledOnceWith(sinon.match.func, '/', layoutData, { + dictionary, + }) + ).to.be.true; + }) + .end(done); + }); +}); diff --git a/packages/sitecore-jss-proxy/src/middleware/editing/render.ts b/packages/sitecore-jss-proxy/src/middleware/editing/render.ts new file mode 100644 index 0000000000..2e7aabf0fe --- /dev/null +++ b/packages/sitecore-jss-proxy/src/middleware/editing/render.ts @@ -0,0 +1,143 @@ +import { GraphQLRequestClientFactory, debug } from '@sitecore-jss/sitecore-jss'; +import { AppRenderer, RenderResponse } from '../../types/AppRenderer'; +import { Request, Response } from 'express'; +import { getAllowedOriginsFromEnv } from '@sitecore-jss/sitecore-jss/utils'; +import { + GraphQLEditingService, + EDITING_ALLOWED_ORIGINS, + RenderMetadataQueryParams, +} from '@sitecore-jss/sitecore-jss/editing'; + +/** + * Configuration for the editing render endpoint + */ +export type EditingRenderEndpointOptions = { + /** + * Custom path for the endpoint. Default is `/render` + * @example + * { path: '/foo/render' } -> /foo/render + */ + path?: string; + /** + * GraphQl Request Client Factory provided by the server bundle + */ + clientFactory: GraphQLRequestClientFactory; + /** + * The appRenderer will produce the requested route's html + */ + renderView: AppRenderer; +}; + +type MetadataRequest = Request & { query: RenderMetadataQueryParams }; + +/** + * Middleware to handle editing render requests + * @param {EditingRenderEndpointOptions} config for the endpoint + */ +export const editingRenderMiddleware = (config: EditingRenderEndpointOptions) => async ( + req: MetadataRequest, + res: Response +): Promise => { + try { + debug.editing('editing render middleware start'); + + const startTimestamp = Date.now(); + + const query = req.query; + + const requiredQueryParams: (keyof RenderMetadataQueryParams)[] = [ + 'sc_site', + 'sc_itemid', + 'sc_lang', + 'route', + 'mode', + ]; + + const missingQueryParams = requiredQueryParams.filter((param) => !query[param]); + + // Validate query parameters + if (missingQueryParams.length) { + debug.editing('missing required query parameters: %o', missingQueryParams); + + res.status(400).send(`Missing required query parameters: ${missingQueryParams.join(', ')}`); + + return; + } + + const graphQLEditingService = new GraphQLEditingService({ + clientFactory: config.clientFactory, + }); + + const data = await graphQLEditingService.fetchEditingData({ + siteName: query.sc_site, + itemId: query.sc_itemid, + language: query.sc_lang, + version: query.sc_version, + layoutKind: query.sc_layoutKind, + }); + + if (!data || !data.layoutData || !data.dictionary) { + throw new Error(`Unable to fetch editing data for ${JSON.stringify(query)}`); + } + + const viewBag = { dictionary: data.dictionary }; + + config.renderView( + (err: Error | null, result: RenderResponse | null) => { + if (err) { + handleError(res, err); + return; + } + + if (!result) { + debug.editing('editing render middleware end in %dms: %o', Date.now() - startTimestamp, { + status: 204, + route: query.route, + }); + + res.status(204).send(); + return; + } + + const statusCode = data.layoutData.sitecore.route ? 200 : 404; + + // Restrict the page to be rendered only within the allowed origins + res.setHeader('Content-Security-Policy', getSCPHeader()); + + debug.editing('editing render middleware end in %dms: %o', Date.now() - startTimestamp, { + status: statusCode, + route: query.route, + }); + + res.status(statusCode).send(result.html); + }, + query.route, + data.layoutData, + viewBag + ); + } catch (err) { + handleError(res, err); + return; + } +}; + +/** + * Gets the Content-Security-Policy header value + * @returns {string} Content-Security-Policy header value + */ +export const getSCPHeader = () => { + return `frame-ancestors 'self' ${[getAllowedOriginsFromEnv(), ...EDITING_ALLOWED_ORIGINS].join( + ' ' + )}`; +}; + +/** + * Handle unexpected error + * @param {Response} res server response + * @param {Error} err error + */ +const handleError = (res: Response, err: unknown) => { + debug.editing('response error %o', err); + + res.status(500).send('Internal Server Error'); +}; diff --git a/packages/sitecore-jss/src/editing/index.ts b/packages/sitecore-jss/src/editing/index.ts index e3febbe38a..02b2c6d48d 100644 --- a/packages/sitecore-jss/src/editing/index.ts +++ b/packages/sitecore-jss/src/editing/index.ts @@ -20,4 +20,5 @@ export { EditButtonTypes, mapButtonToCommand, } from './edit-frame'; +export { RenderMetadataQueryParams } from './models'; export { LayoutKind } from './models'; diff --git a/packages/sitecore-jss/src/editing/models.ts b/packages/sitecore-jss/src/editing/models.ts index 2abad5dc3f..f796152907 100644 --- a/packages/sitecore-jss/src/editing/models.ts +++ b/packages/sitecore-jss/src/editing/models.ts @@ -1,4 +1,23 @@ -/** +import { LayoutServicePageState } from '../layout'; + +/** + * Query parameters appended to the page route URL + * Appended when XMCloud Pages preview (editing) Metadata Edit Mode is used + */ +export interface RenderMetadataQueryParams { + [key: string]: unknown; + secret: string; + sc_lang: string; + sc_itemid: string; + sc_site: string; + route: string; + mode: Exclude; + sc_layoutKind?: LayoutKind; + sc_variant?: string; + sc_version?: string; +} + +/** * Represents the Editing Layout variant. * - shared - shared layout * - final - final layout diff --git a/yarn.lock b/yarn.lock index e1d745c1cd..5f9c7dae19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6273,6 +6273,7 @@ __metadata: "@types/mocha": ^10.0.1 "@types/node": ^20.14.2 "@types/set-cookie-parser": ^2.4.2 + "@types/sinon": ^17.0.3 "@types/supertest": ^6.0.2 chai: ^4.3.7 del-cli: ^5.0.0 @@ -6283,6 +6284,7 @@ __metadata: mocha: ^10.2.0 nyc: ^15.1.0 set-cookie-parser: ^2.5.1 + sinon: ^17.0.1 supertest: ^7.0.0 ts-node: ^10.9.1 typescript: ~4.9.5