diff --git a/src/api/classic/SiteApi.ts b/src/api/classic/SiteApi.ts new file mode 100644 index 00000000..9e0f9271 --- /dev/null +++ b/src/api/classic/SiteApi.ts @@ -0,0 +1,93 @@ +import util from 'util'; + +import { State } from '../../shared/State'; +import { IdObjectSkeletonInterface, PagedResult } from '../ApiTypes'; +import { generateAmApi } from '../BaseApi'; + +const siteURLTemplate = '%s/json/global-config/sites/%s'; +const sitesURLTemplate = '%s/json/global-config/sites?_queryFilter=true'; + +const apiVersion = 'protocol=2.0,resource=1.0'; + +function getApiConfig() { + return { + apiVersion, + }; +} + +export type SiteSkeleton = IdObjectSkeletonInterface & { + id: string; + url: string; + secondaryURLs: string[]; + servers: { + id: string; + url: string; + }[]; +}; + +/** + * Get site + * @param {string} siteId Site id + * @returns {Promise} a promise that resolves to a site object + */ +export async function getSite({ + siteId, + state, +}: { + siteId: string; + state: State; +}): Promise { + const urlString = util.format(siteURLTemplate, state.getHost(), siteId); + const { data } = await generateAmApi({ resource: getApiConfig(), state }).get( + urlString, + { + withCredentials: true, + } + ); + return data; +} + +/** + * Get all sites + * @returns {Promise>} a promise that resolves to an array of site objects + */ +export async function getSites({ + state, +}: { + state: State; +}): Promise> { + const urlString = util.format(sitesURLTemplate, state.getHost()); + const { data } = await generateAmApi({ + resource: getApiConfig(), + state, + }).get(urlString, { + withCredentials: true, + }); + return data; +} + +/** + * Put site + * @param {string} siteId site id + * @param {SiteSkeleton} siteData site config object + * @returns {Promise} a promise that resolves to a site object + */ +export async function putSite({ + siteId, + siteData, + state, +}: { + siteId: string; + siteData: SiteSkeleton; + state: State; +}): Promise { + const urlString = util.format(siteURLTemplate, state.getHost(), siteId); + const { data } = await generateAmApi({ resource: getApiConfig(), state }).put( + urlString, + siteData, + { + withCredentials: true, + } + ); + return data; +} diff --git a/src/lib/FrodoLib.ts b/src/lib/FrodoLib.ts index e07632d0..d688e4e7 100644 --- a/src/lib/FrodoLib.ts +++ b/src/lib/FrodoLib.ts @@ -10,6 +10,7 @@ import AuthenticationSettingsOps, { } from '../ops/AuthenticationSettingsOps'; import CirclesOfTrustOps, { CirclesOfTrust } from '../ops/CirclesOfTrustOps'; import ServerOps, { Server } from '../ops/classic/ServerOps'; +import SiteOps, { Site } from '../ops/classic/SiteOps'; import AdminFederationOps, { AdminFederation, } from '../ops/cloud/AdminFederationOps'; @@ -188,6 +189,7 @@ export type Frodo = { server: Server; service: Service; session: Session; + site: Site; theme: Theme; @@ -356,6 +358,7 @@ const FrodoLib = (config: StateInterface = {}): Frodo => { server: ServerOps(state), service: ServiceOps(state), session: SessionOps(state), + site: SiteOps(state), theme: ThemeOps(state), diff --git a/src/ops/classic/SiteOps.test.ts b/src/ops/classic/SiteOps.test.ts new file mode 100644 index 00000000..f3df20bd --- /dev/null +++ b/src/ops/classic/SiteOps.test.ts @@ -0,0 +1,123 @@ +/** + * To record and update snapshots, you must perform 3 steps in order: + * + * 1. Record API responses + * + * Recording requires an available classic deployment, since sites + * can only be accessed in classic. Set FRODO_HOST and FRODO_REALM + * environment variables or alternatively FRODO_DEPLOY=classic + * in order to appropriately record requests to the classic deployment. + * + * To record API responses, you must call the test:record script and + * override all the connection state required to connect to the + * env to record from: + * + * ATTENTION: For the recording to succeed, you MUST make sure to use a + * user account, not a service account. + * + * FRODO_DEBUG=1 FRODO_HOST=frodo-dev npm run test:record SiteOps + * + * The above command assumes that you have a connection profile for + * 'frodo-dev' on your development machine. + * + * 2. Update snapshots + * + * After recording API responses, you must manually update/create snapshots + * by running: + * + * FRODO_DEBUG=1 npm run test:update SiteOps + * + * 3. Test your changes + * + * If 1 and 2 didn't produce any errors, you are ready to run the tests in + * replay mode and make sure they all succeed as well: + * + * FRODO_DEBUG=1 npm run test:only SiteOps + * + * Note: FRODO_DEBUG=1 is optional and enables debug logging for some output + * in case things don't function as expected + */ +import { autoSetupPolly, setDefaultState } from "../../utils/AutoSetupPolly"; +import { filterRecording } from "../../utils/PollyUtils"; +import * as SiteOps from "./SiteOps"; +import { state } from "../../lib/FrodoLib"; +import Constants from "../../shared/Constants"; + +const ctx = autoSetupPolly(); + +describe('SiteOps', () => { + beforeEach(async () => { + if (process.env.FRODO_POLLY_MODE === 'record') { + ctx.polly.server.any().on('beforePersist', (_req, recording) => { + filterRecording(recording); + }); + } + setDefaultState(Constants.CLASSIC_DEPLOYMENT_TYPE_KEY); + }); + + describe('createSiteExportTemplate()', () => { + test('0: Method is implemented', async () => { + expect(SiteOps.createSiteExportTemplate).toBeDefined(); + }); + + test('1: Create Site Export Template', async () => { + const response = SiteOps.createSiteExportTemplate({ state }); + expect(response).toMatchSnapshot({ + meta: expect.any(Object), + }); + }); + }); + + describe('readSite()', () => { + test('0: Method is implemented', async () => { + expect(SiteOps.readSite).toBeDefined(); + }); + //TODO: create tests + }); + + describe('readSites()', () => { + test('0: Method is implemented', async () => { + expect(SiteOps.readSites).toBeDefined(); + }); + + test('1: Read Sites', async () => { + const response = await SiteOps.readSites({ state }); + expect(response).toMatchSnapshot(); + }); + }); + + describe('exportSite()', () => { + test('0: Method is implemented', async () => { + expect(SiteOps.exportSite).toBeDefined(); + }); + //TODO: create tests + }); + + describe('exportSites()', () => { + test('0: Method is implemented', async () => { + expect(SiteOps.exportSites).toBeDefined(); + }); + + test('1: Export Sites', async () => { + const response = await SiteOps.exportSites({ state }); + expect(response).toMatchSnapshot({ + meta: expect.any(Object), + }); + }); + }); + + describe('updateSite()', () => { + test('0: Method is implemented', async () => { + expect(SiteOps.updateSite).toBeDefined(); + }); + //TODO: create tests + }); + + describe('importSites()', () => { + test('0: Method is implemented', async () => { + expect(SiteOps.importSites).toBeDefined(); + }); + //TODO: create tests + }); + +}); diff --git a/src/ops/classic/SiteOps.ts b/src/ops/classic/SiteOps.ts new file mode 100644 index 00000000..2004d81b --- /dev/null +++ b/src/ops/classic/SiteOps.ts @@ -0,0 +1,310 @@ +import { + getSite, + getSites, + putSite, + SiteSkeleton, +} from '../../api/classic/SiteApi'; +import { State } from '../../shared/State'; +import { + createProgressIndicator, + debugMessage, + stopProgressIndicator, + updateProgressIndicator, +} from '../../utils/Console'; +import { getMetadata } from '../../utils/ExportImportUtils'; +import { FrodoError } from '../FrodoError'; +import { ExportMetaData } from '../OpsTypes'; + +export type Site = { + /** + * Create an empty site export template + * @returns {SiteExportInterface} an empty site export template + */ + createSiteExportTemplate(): SiteExportInterface; + /** + * Read site by id + * @param {string} siteId Site id + * @returns {Promise} a promise that resolves to a site object + */ + readSite(siteId: string): Promise; + /** + * Read all sites. + * @returns {Promise} a promise that resolves to an array of site objects + */ + readSites(): Promise; + /** + * Export a single site by id. The response can be saved to file as is. + * @param {string} siteId Site id + * @returns {Promise} Promise resolving to a SiteExportInterface object. + */ + exportSite(siteId: string): Promise; + /** + * Export all sites. The response can be saved to file as is. + * @returns {Promise} Promise resolving to a SiteExportInterface object. + */ + exportSites(): Promise; + /** + * Update site + * @param {string} siteId site id + * @param {SiteSkeleton} siteData site data + * @returns {Promise} a promise resolving to a site object + */ + updateSite(siteId: string, siteData: SiteSkeleton): Promise; + /** + * Import sites + * @param {SiteExportInterface} importData site import data + * @param {string} siteId Optional site id. If supplied, only the site of that id is imported. Takes priority over siteUrl if both are provided. + * @param {string} siteUrl Optional site url. If supplied, only the site of that url is imported. + * @returns {Promise} the imported sites + */ + importSites( + importData: SiteExportInterface, + siteId?: string, + siteUrl?: string + ): Promise; +}; + +export default (state: State): Site => { + return { + createSiteExportTemplate(): SiteExportInterface { + return createSiteExportTemplate({ state }); + }, + async readSite(siteId: string): Promise { + return readSite({ siteId, state }); + }, + async readSites(): Promise { + return readSites({ state }); + }, + async exportSite(siteId: string): Promise { + return exportSite({ siteId, state }); + }, + async exportSites(): Promise { + return exportSites({ state }); + }, + async updateSite( + siteId: string, + siteData: SiteSkeleton + ): Promise { + return updateSite({ siteId, siteData, state }); + }, + async importSites( + importData: SiteExportInterface, + siteId?: string, + siteUrl?: string + ): Promise { + return importSites({ + siteId, + siteUrl, + importData, + state, + }); + }, + }; +}; + +export interface SiteExportInterface { + meta?: ExportMetaData; + site: Record; +} + +/** + * Create an empty site export template + * @returns {SiteExportInterface} an empty site export template + */ +export function createSiteExportTemplate({ + state, +}: { + state: State; +}): SiteExportInterface { + return { + meta: getMetadata({ state }), + site: {}, + }; +} + +/** + * Read site by id + * @param {string} siteId Site id + * @returns {Promise} a promise that resolves to a site object + */ +export async function readSite({ + siteId, + state, +}: { + siteId: string; + state: State; +}): Promise { + try { + return getSite({ siteId, state }); + } catch (error) { + throw new FrodoError(`Error reading site ${siteId}`, error); + } +} + +/** + * Read all sites. + * @returns {Promise} a promise that resolves to an array of site objects + */ +export async function readSites({ + state, +}: { + state: State; +}): Promise { + try { + debugMessage({ + message: `SiteOps.readSites: start`, + state, + }); + const { result } = await getSites({ state }); + debugMessage({ message: `SiteOps.readSites: end`, state }); + return result; + } catch (error) { + throw new FrodoError(`Error reading sites`, error); + } +} + +/** + * Export a single site by id. The response can be saved to file as is. + * @param {string} siteId Site id + * @returns {Promise} Promise resolving to a SiteExportInterface object. + */ +export async function exportSite({ + siteId, + state, +}: { + siteId: string; + state: State; +}): Promise { + try { + const site = await readSite({ + siteId, + state, + }); + // Don't include id in export (no need to since it's not the actual id) + delete site.id; + const exportData = createSiteExportTemplate({ state }); + exportData.site[siteId] = site; + return exportData; + } catch (error) { + throw new FrodoError(`Error exporting site ${siteId}`, error); + } +} + +/** + * Export all sites. The response can be saved to file as is. + * @returns {Promise} Promise resolving to a SiteExportInterface object. + */ +export async function exportSites({ + state, +}: { + state: State; +}): Promise { + let indicatorId: string; + try { + debugMessage({ message: `SiteOps.exportSites: start`, state }); + const exportData = createSiteExportTemplate({ state }); + const sites = await readSites({ state }); + indicatorId = createProgressIndicator({ + total: sites.length, + message: 'Exporting sites...', + state, + }); + for (const site of sites) { + updateProgressIndicator({ + id: indicatorId, + message: `Exporting site ${site.url}`, + state, + }); + // Don't include id in export (no need to since it's not the actual id) + delete site.id; + exportData.site[site._id] = site; + } + stopProgressIndicator({ + id: indicatorId, + message: `Exported ${sites.length} sites.`, + state, + }); + debugMessage({ message: `SiteOps.exportSites: end`, state }); + return exportData; + } catch (error) { + stopProgressIndicator({ + id: indicatorId, + message: `Error exporting sites.`, + status: 'fail', + state, + }); + throw new FrodoError(`Error reading sites`, error); + } +} + +/** + * Update site + * @param {string} siteId site id + * @param {SiteSkeleton} siteData site config object + * @returns {Promise} a promise that resolves to a site object + */ +export async function updateSite({ + siteId, + siteData, + state, +}: { + siteId: string; + siteData: SiteSkeleton; + state: State; +}): Promise { + return putSite({ siteId, siteData, state }); +} + +/** + * Import sites + * @param {string} siteId Optional site id. If supplied, only the site of that id is imported. Takes priority over siteUrl if both are provided. + * @param {string} siteUrl Optional site url. If supplied, only the site of that url is imported. + * @param {SiteExportInterface} importData site import data + * @returns {Promise} the imported sites + */ +export async function importSites({ + siteId, + siteUrl, + importData, + state, +}: { + siteId?: string; + siteUrl?: string; + importData: SiteExportInterface; + state: State; +}): Promise { + const errors = []; + try { + debugMessage({ message: `SiteOps.importSites: start`, state }); + const response = []; + for (const site of Object.values(importData.site)) { + try { + if ( + (siteId && site._id !== siteId) || + (siteUrl && site.url !== siteUrl) + ) { + continue; + } + const result = await updateSite({ + siteId: site._id, + siteData: site, + state, + }); + response.push(result); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + throw new FrodoError(`Error importing sites`, errors); + } + debugMessage({ message: `SiteOps.importSites: end`, state }); + return response; + } catch (error) { + // re-throw previously caught errors + if (errors.length > 0) { + throw error; + } + throw new FrodoError(`Error importing sites`, error); + } +} diff --git a/src/test/mock-recordings/SiteOps_641231486/exportSites_2764305430/1-Export-Sites_1501836084/recording.har b/src/test/mock-recordings/SiteOps_641231486/exportSites_2764305430/1-Export-Sites_1501836084/recording.har new file mode 100644 index 00000000..3013d7b2 --- /dev/null +++ b/src/test/mock-recordings/SiteOps_641231486/exportSites_2764305430/1-Export-Sites_1501836084/recording.har @@ -0,0 +1,150 @@ +{ + "log": { + "_recordingName": "SiteOps/exportSites()/1: Export Sites", + "creator": { + "comment": "persister:fs", + "name": "Polly.JS", + "version": "6.0.6" + }, + "entries": [ + { + "_id": "b07b722406a6f6bb8728cd0350a0aa27", + "_order": 0, + "cache": {}, + "request": { + "bodySize": 0, + "cookies": [], + "headers": [ + { + "name": "accept", + "value": "application/json, text/plain, */*" + }, + { + "name": "content-type", + "value": "application/json" + }, + { + "name": "user-agent", + "value": "@rockcarver/frodo-lib/2.1.2-0" + }, + { + "name": "x-forgerock-transactionid", + "value": "frodo-13f26bd5-e630-4513-bed2-c951e6417089" + }, + { + "name": "accept-api-version", + "value": "protocol=2.0,resource=1.0" + }, + { + "name": "cookie", + "value": "iPlanetDirectoryPro=" + }, + { + "name": "accept-encoding", + "value": "gzip, compress, deflate, br" + }, + { + "name": "host", + "value": "openam-frodo-dev.classic.com:8080" + } + ], + "headersSize": 566, + "httpVersion": "HTTP/1.1", + "method": "GET", + "queryString": [ + { + "name": "_queryFilter", + "value": "true" + } + ], + "url": "http://openam-frodo-dev.classic.com:8080/am/json/global-config/sites?_queryFilter=true" + }, + "response": { + "bodySize": 249, + "content": { + "mimeType": "application/json;charset=UTF-8", + "size": 249, + "text": "{\"result\":[{\"_id\":\"testsite\",\"_rev\":\"868179817\",\"id\":\"02\",\"url\":\"http://testurl.com:8080\",\"secondaryURLs\":[],\"servers\":[]}],\"resultCount\":1,\"pagedResultsCookie\":null,\"totalPagedResultsPolicy\":\"NONE\",\"totalPagedResults\":-1,\"remainingPagedResults\":-1}" + }, + "cookies": [], + "headers": [ + { + "name": "x-frame-options", + "value": "SAMEORIGIN" + }, + { + "name": "x-content-type-options", + "value": "nosniff" + }, + { + "name": "cache-control", + "value": "private" + }, + { + "name": "content-api-version", + "value": "protocol=2.0,resource=1.0, resource=1.0" + }, + { + "name": "content-security-policy", + "value": "default-src 'none';frame-ancestors 'none';sandbox" + }, + { + "name": "cross-origin-opener-policy", + "value": "same-origin" + }, + { + "name": "cross-origin-resource-policy", + "value": "same-origin" + }, + { + "name": "expires", + "value": "0" + }, + { + "name": "pragma", + "value": "no-cache" + }, + { + "name": "content-type", + "value": "application/json;charset=UTF-8" + }, + { + "name": "content-length", + "value": "249" + }, + { + "name": "date", + "value": "Mon, 09 Sep 2024 22:20:18 GMT" + }, + { + "name": "keep-alive", + "value": "timeout=20" + }, + { + "name": "connection", + "value": "keep-alive" + } + ], + "headersSize": 492, + "httpVersion": "HTTP/1.1", + "redirectURL": "", + "status": 200, + "statusText": "OK" + }, + "startedDateTime": "2024-09-09T22:20:18.479Z", + "time": 3, + "timings": { + "blocked": -1, + "connect": -1, + "dns": -1, + "receive": 0, + "send": 0, + "ssl": -1, + "wait": 3 + } + } + ], + "pages": [], + "version": "1.2" + } +} diff --git a/src/test/mock-recordings/SiteOps_641231486/readSites_2480315612/1-Read-Sites_2991476030/recording.har b/src/test/mock-recordings/SiteOps_641231486/readSites_2480315612/1-Read-Sites_2991476030/recording.har new file mode 100644 index 00000000..36131ba0 --- /dev/null +++ b/src/test/mock-recordings/SiteOps_641231486/readSites_2480315612/1-Read-Sites_2991476030/recording.har @@ -0,0 +1,150 @@ +{ + "log": { + "_recordingName": "SiteOps/readSites()/1: Read Sites", + "creator": { + "comment": "persister:fs", + "name": "Polly.JS", + "version": "6.0.6" + }, + "entries": [ + { + "_id": "b07b722406a6f6bb8728cd0350a0aa27", + "_order": 0, + "cache": {}, + "request": { + "bodySize": 0, + "cookies": [], + "headers": [ + { + "name": "accept", + "value": "application/json, text/plain, */*" + }, + { + "name": "content-type", + "value": "application/json" + }, + { + "name": "user-agent", + "value": "@rockcarver/frodo-lib/2.1.2-0" + }, + { + "name": "x-forgerock-transactionid", + "value": "frodo-13f26bd5-e630-4513-bed2-c951e6417089" + }, + { + "name": "accept-api-version", + "value": "protocol=2.0,resource=1.0" + }, + { + "name": "cookie", + "value": "iPlanetDirectoryPro=" + }, + { + "name": "accept-encoding", + "value": "gzip, compress, deflate, br" + }, + { + "name": "host", + "value": "openam-frodo-dev.classic.com:8080" + } + ], + "headersSize": 566, + "httpVersion": "HTTP/1.1", + "method": "GET", + "queryString": [ + { + "name": "_queryFilter", + "value": "true" + } + ], + "url": "http://openam-frodo-dev.classic.com:8080/am/json/global-config/sites?_queryFilter=true" + }, + "response": { + "bodySize": 249, + "content": { + "mimeType": "application/json;charset=UTF-8", + "size": 249, + "text": "{\"result\":[{\"_id\":\"testsite\",\"_rev\":\"868179817\",\"id\":\"02\",\"url\":\"http://testurl.com:8080\",\"secondaryURLs\":[],\"servers\":[]}],\"resultCount\":1,\"pagedResultsCookie\":null,\"totalPagedResultsPolicy\":\"NONE\",\"totalPagedResults\":-1,\"remainingPagedResults\":-1}" + }, + "cookies": [], + "headers": [ + { + "name": "x-frame-options", + "value": "SAMEORIGIN" + }, + { + "name": "x-content-type-options", + "value": "nosniff" + }, + { + "name": "cache-control", + "value": "private" + }, + { + "name": "content-api-version", + "value": "protocol=2.0,resource=1.0, resource=1.0" + }, + { + "name": "content-security-policy", + "value": "default-src 'none';frame-ancestors 'none';sandbox" + }, + { + "name": "cross-origin-opener-policy", + "value": "same-origin" + }, + { + "name": "cross-origin-resource-policy", + "value": "same-origin" + }, + { + "name": "expires", + "value": "0" + }, + { + "name": "pragma", + "value": "no-cache" + }, + { + "name": "content-type", + "value": "application/json;charset=UTF-8" + }, + { + "name": "content-length", + "value": "249" + }, + { + "name": "date", + "value": "Mon, 09 Sep 2024 22:20:18 GMT" + }, + { + "name": "keep-alive", + "value": "timeout=20" + }, + { + "name": "connection", + "value": "keep-alive" + } + ], + "headersSize": 492, + "httpVersion": "HTTP/1.1", + "redirectURL": "", + "status": 200, + "statusText": "OK" + }, + "startedDateTime": "2024-09-09T22:20:18.458Z", + "time": 8, + "timings": { + "blocked": -1, + "connect": -1, + "dns": -1, + "receive": 0, + "send": 0, + "ssl": -1, + "wait": 8 + } + } + ], + "pages": [], + "version": "1.2" + } +} diff --git a/src/test/snapshots/ops/classic/SiteOps.test.js.snap b/src/test/snapshots/ops/classic/SiteOps.test.js.snap new file mode 100644 index 00000000..634da3a1 --- /dev/null +++ b/src/test/snapshots/ops/classic/SiteOps.test.js.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SiteOps createSiteExportTemplate() 1: Create Site Export Template 1`] = ` +{ + "meta": Any, + "site": {}, +} +`; + +exports[`SiteOps exportSites() 1: Export Sites 1`] = ` +{ + "meta": Any, + "site": { + "testsite": { + "_id": "testsite", + "_rev": "868179817", + "secondaryURLs": [], + "servers": [], + "url": "http://testurl.com:8080", + }, + }, +} +`; + +exports[`SiteOps readSites() 1: Read Sites 1`] = ` +[ + { + "_id": "testsite", + "_rev": "868179817", + "id": "02", + "secondaryURLs": [], + "servers": [], + "url": "http://testurl.com:8080", + }, +] +`;