diff --git a/docs/README.md b/docs/README.md index 3b312014..0f5b345b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -81,9 +81,11 @@ for more information, find the documentation at https://saleor.io * [environment](#environment) * [environment auth](#environment-auth) * [environment clear](#environment-clear) + * [environment cors](#environment-cors) * [environment create](#environment-create) * [environment list](#environment-list) * [environment maintenance](#environment-maintenance) + * [environment origins](#environment-origins) * [environment populate](#environment-populate) * [environment promote](#environment-promote) * [environment remove](#environment-remove) @@ -455,9 +457,11 @@ saleor environment [command] Commands: saleor environment auth [key|environment] Manage basic auth for a specific environment saleor environment clear Clear database for environment + saleor environment cors [key|environment] Manage environment's CORS saleor environment create [name] Create a new environment saleor environment list List environments saleor environment maintenance [key|environment] Enable or disable maintenance mode in a specific environment + saleor environment origins [key|environment] Manage environment's trusted client origins saleor environment populate [key|environment] Populate database for environment saleor environment promote [key|environment] Promote environment to production saleor environment remove [key|environment] Delete an environment @@ -531,6 +535,39 @@ Options: -h, --help Show help [boolean] ``` +#### environment cors + +```sh +$ saleor environment cors --help +``` + +Help output: + +``` +saleor environment cors [key|environment] + +Manage environment's CORS + +Positionals: + key, environment key of the environment [string] + +Options: + --json Output the data as JSON [boolean] [default: false] + --short Output data as text [boolean] [default: false] + -u, --instance, --url Saleor instance to work with [string] + --all All origins are allowed [boolean] + --dashboard Only dashboard is allowed [boolean] + --selected Only specified origins are allowed [array] + -V, --version Show version number [boolean] + -h, --help Show help [boolean] + +Examples: + saleor env cors + saleor env cors my-environment --all + saleor env cors my-environment --dashboard + saleor env cors my-environment --selected="https://example.com" +``` + #### environment create ```sh @@ -622,6 +659,35 @@ Examples: saleor env maintenance my-environment --disable ``` +#### environment origins + +```sh +$ saleor environment origins --help +``` + +Help output: + +``` +saleor environment origins [key|environment] + +Manage environment's trusted client origins + +Positionals: + key, environment key of the environment [string] + +Options: + --json Output the data as JSON [boolean] [default: false] + --short Output data as text [boolean] [default: false] + -u, --instance, --url Saleor instance to work with [string] + --origin Allowed domains [array] + -V, --version Show version number [boolean] + -h, --help Show help [boolean] + +Examples: + saleor env origins + saleor env origins my-environment --origin=https://trusted-origin.com +``` + #### environment populate ```sh diff --git a/src/cli/env/cors.ts b/src/cli/env/cors.ts new file mode 100644 index 00000000..bd174c0e --- /dev/null +++ b/src/cli/env/cors.ts @@ -0,0 +1,206 @@ +import Debug from 'debug'; +import type { Arguments, CommandBuilder } from 'yargs'; + +import Enquirer from 'enquirer'; +import { ar } from 'date-fns/locale'; +import { error } from 'console'; +import chalk from 'chalk'; +import { API, PATCH } from '../../lib/index.js'; +import { obfuscateArgv, println, printlnSuccess } from '../../lib/util.js'; +import { + useBlockingTasksChecker, + useEnvironment, +} from '../../middleware/index.js'; +import { Options } from '../../types.js'; +import { getEnvironment, promptOrigin } from '../../lib/environment.js'; + +const debug = Debug('saleor-cli:env:auth'); + +export const command = 'cors [key|environment]'; +export const desc = 'Manage environment\'s CORS'; + +export const builder: CommandBuilder = (_) => + _.positional('key', { + type: 'string', + demandOption: false, + desc: 'key of the environment', + }) + .option('all', { + type: 'boolean', + demandOption: false, + desc: 'All origins are allowed', + }) + .option('dashboard', { + type: 'boolean', + demandOption: false, + desc: 'Only dashboard is allowed', + }) + .option('selected', { + type: 'array', + demandOption: false, + desc: 'Only specified origins are allowed', + }) + .example('saleor env cors', '') + .example('saleor env cors my-environment --all', '') + .example('saleor env cors my-environment --dashboard', '') + .example( + 'saleor env cors my-environment --selected="https://example.com"', + '', + ); + +export const handler = async (argv: Arguments) => { + debug('command arguments: %O', obfuscateArgv(argv)); + + const { allowed_cors_origins: allowedCorsOrigins } = + await getEnvironment(argv); + + if (argv.all) { + if (allowedCorsOrigins === '*') { + println('All origins are already allowed'); + return; + } + + await PATCH(API.Environment, argv, { + json: { + allowed_cors_origins: '*', + }, + }); + + printlnSuccess('All origins are allowed'); + return; + } + + if (argv.dashboard) { + if (!allowedCorsOrigins) { + println('Only dashboard is already allowed'); + return; + } + + await PATCH(API.Environment, argv, { + json: { + allowed_cors_origins: null, + }, + }); + + printlnSuccess('Only dashboard is allowed'); + return; + } + + if (((argv.selected as undefined | string[]) || []).length > 0) { + await PATCH(API.Environment, argv, { + json: { + allowed_cors_origins: argv.selected, + }, + }); + + printlnSuccess('Selected Origins are allowed'); + return; + } + + // First form to check current + const { kind } = await Enquirer.prompt<{ + kind: string; + }>([ + { + type: 'select', + name: 'kind', + message: 'Choose allowed API origins ', + choices: [ + { + name: 'Allow all origins', + value: 'all', + }, + { + name: 'Selected Origins', + value: 'selected', + }, + { + name: 'Dashboard only', + value: 'dashboard', + }, + ], + initial: () => { + if (allowedCorsOrigins === '*') { + return 1; + } + if (allowedCorsOrigins == null) { + return 2; + } + if (Array.isArray(allowedCorsOrigins)) { + return 3; + } + + return 1; + }, + }, + ]); + + // Trigger an update for all and dashboard + if (['all', 'dashboard'].includes(kind)) { + await PATCH(API.Environment, argv, { + json: { + allowed_cors_origins: kind === 'all' ? '*' : null, + }, + }); + + printlnSuccess( + kind === 'all' ? 'All origins are allowed' : 'Only dashboard is allowed', + ); + return; + } + + // Handle selected origins + const selected: string[] = + (argv.selected as string[]) || (allowedCorsOrigins as string[]) || []; + let addMore = true; + + const { origins } = await Enquirer.prompt<{ + origins: string; + }>([ + { + type: 'multiselect', + name: 'origins', + message: + 'Define Selected Origins\n (use the arrows to navigate and the space bar to select)', + choices: [...selected, 'Add a new origin'], + initial: selected, + }, + ]); + + do { + if (origins.length === 0) { + return; + } + if (origins.includes('Add a new CORS origin')) { + const form = await promptOrigin(); + selected.push(form.origin); + addMore = form.addMore; + } else { + addMore = false; + } + } while (addMore); + + const { proceed } = await Enquirer.prompt<{ + proceed: boolean; + }>([ + { + type: 'confirm', + name: 'proceed', + message: `Do you want to set the following CORS origins?\n ${selected.join( + '\n ', + )}`, + }, + ]); + + if (proceed) { + await PATCH(API.Environment, argv, { + json: { + allowed_cors_origins: selected, + }, + }); + + printlnSuccess('Specified CORS origins are allowed'); + } +}; + +export const middlewares = [useEnvironment, useBlockingTasksChecker]; diff --git a/src/cli/env/index.ts b/src/cli/env/index.ts index ba443cda..2a17dda9 100644 --- a/src/cli/env/index.ts +++ b/src/cli/env/index.ts @@ -1,9 +1,11 @@ import { useOrganization, useToken } from '../../middleware/index.js'; import * as auth from './auth.js'; import * as cleardb from './clear.js'; +import * as cors from './cors.js'; import * as create from './create.js'; import * as list from './list.js'; import * as maintenance from './maintenance.js'; +import * as origins from './origins.js'; import * as populatedb from './populate.js'; import * as promote from './promote.js'; import * as remove from './remove.js'; @@ -16,9 +18,11 @@ export default function (_: any) { _.command([ auth, cleardb, + cors, create, list, maintenance, + origins, populatedb, promote, remove, diff --git a/src/cli/env/origins.ts b/src/cli/env/origins.ts new file mode 100644 index 00000000..e4018850 --- /dev/null +++ b/src/cli/env/origins.ts @@ -0,0 +1,105 @@ +import Debug from 'debug'; +import type { Arguments, CommandBuilder } from 'yargs'; + +import Enquirer from 'enquirer'; +import { API, PATCH } from '../../lib/index.js'; +import { obfuscateArgv, printlnSuccess } from '../../lib/util.js'; +import { + useBlockingTasksChecker, + useEnvironment, +} from '../../middleware/index.js'; +import { Options } from '../../types.js'; +import { getEnvironment, promptOrigin } from '../../lib/environment.js'; + +const debug = Debug('saleor-cli:env:auth'); + +export const command = 'origins [key|environment]'; +export const desc = 'Manage environment\'s trusted client origins'; + +export const builder: CommandBuilder = (_) => + _.positional('key', { + type: 'string', + demandOption: false, + desc: 'key of the environment', + }) + .option('origin', { + type: 'array', + demandOption: false, + desc: 'Allowed domains', + }) + .example('saleor env origins', '') + .example( + 'saleor env origins my-environment --origin=https://trusted-origin.com', + '', + ); + +export const handler = async (argv: Arguments) => { + debug('command arguments: %O', obfuscateArgv(argv)); + + const { allowed_client_origins: allowedClientOrigins } = + await getEnvironment(argv); + + if (((argv.origin as undefined | string[]) || [])?.length > 0) { + await PATCH(API.Environment, argv, { + json: { + allowed_client_origins: argv.origin, + }, + }); + + printlnSuccess('Specified trusted origins are allowed'); + return; + } + + const selected: string[] = (allowedClientOrigins as string[]) || []; + let addMore = true; + + const { origins } = await Enquirer.prompt<{ + origins: string; + }>([ + { + type: 'multiselect', + name: 'origins', + message: + 'Define Trusted Origins\n (use the arrows to navigate and the space bar to select)', + choices: [...selected, 'Add a new trusted origin'], + initial: selected, + }, + ]); + + do { + if (origins.length === 0) { + return; + } + if (origins.includes('Add a new trusted origin')) { + const form = await promptOrigin(); + selected.push(form.origin); + addMore = form.addMore; + } else { + addMore = false; + } + } while (addMore); + + const { proceed } = await Enquirer.prompt<{ + proceed: boolean; + }>([ + { + type: 'confirm', + name: 'proceed', + message: `Do you want to set the following trusted origins?\n ${selected.join( + '\n ', + )}`, + }, + ]); + + if (proceed) { + await PATCH(API.Environment, argv, { + json: { + allowed_client_origins: selected, + }, + }); + + printlnSuccess('Specified trusted origins are allowed'); + } +}; + +export const middlewares = [useEnvironment, useBlockingTasksChecker]; diff --git a/src/cli/webhook/create.ts b/src/cli/webhook/create.ts index 1bf69e5d..ed985625 100644 --- a/src/cli/webhook/create.ts +++ b/src/cli/webhook/create.ts @@ -17,6 +17,7 @@ import { formatConfirm, obfuscateArgv, println, + validateURL, without, } from '../../lib/util.js'; import { interactiveSaleorApp } from '../../middleware/index.js'; @@ -64,14 +65,7 @@ export const handler = async (argv: Arguments) => { message: 'Target URL', initial: argv.targetUrl, required: true, - validate: (value) => { - try { - const _ = new URL(value); - return true; - } catch { - return false; - } - }, + validate: (value) => validateURL(value), skip: !!argv.targetUrl, }, { diff --git a/src/lib/environment.ts b/src/lib/environment.ts index 26a247c6..959c0549 100644 --- a/src/lib/environment.ts +++ b/src/lib/environment.ts @@ -1,8 +1,33 @@ import { Arguments } from 'yargs'; +import Enquirer from 'enquirer'; import { API, GET } from '../lib/index.js'; import { Environment, Options } from '../types.js'; +import { validateURL } from './util.js'; -// eslint-disable-next-line import/prefer-default-export export const getEnvironment = async (argv: Arguments) => GET(API.Environment, argv) as Promise; + +export const promptOrigin = async () => { + const form = await Enquirer.prompt<{ + origin: string; + addMore: boolean; + }>([ + { + type: 'input', + name: 'origin', + message: 'Origin', + validate: (value) => validateURL(value), + }, + { + type: 'confirm', + name: 'addMore', + message: 'Add another one?', + }, + ]); + + return { + origin: new URL(form.origin).origin, + addMore: form.addMore, + }; +}; diff --git a/src/lib/util.ts b/src/lib/util.ts index a027446e..e5730293 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -556,6 +556,15 @@ export const validatePresence = (value: string): boolean => { return true; }; +export const validateURL = (value: string): boolean => { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + export const checkIfJobSucceeded = async (taskId: string): Promise => { const result = (await GET(API.TaskStatus, { task: taskId })) as any; return result.status === 'SUCCEEDED'; diff --git a/test/functional/environment/auth.test.ts b/test/functional/environment/auth.test.ts index b26dc308..5fb7ee49 100644 --- a/test/functional/environment/auth.test.ts +++ b/test/functional/environment/auth.test.ts @@ -9,13 +9,10 @@ import { testOrganization, trigger, waitForBlockingTasks, - removeEnvironment, } from '../../helper'; const envKey = await prepareEnvironment(); -afterAll(async () => removeEnvironment(envKey)); - describe('update auth in environment', async () => { await waitForBlockingTasks(envKey); diff --git a/test/functional/environment/cors.test.ts b/test/functional/environment/cors.test.ts new file mode 100644 index 00000000..13a777bb --- /dev/null +++ b/test/functional/environment/cors.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, afterAll } from 'vitest'; + +import { + cleanEnvAfterUpdate, + command, + DefaultTriggerResponse, + getEnvironment, + prepareEnvironment, + testOrganization, + trigger, + waitForBlockingTasks, +} from '../../helper'; + +const envKey = await prepareEnvironment(); + +describe('update CORS origins in environment', async () => { + await waitForBlockingTasks(envKey); + + it('`env cors` updates the environments CORS origins', async () => { + const params = [ + 'env', + 'cors', + envKey, + '--selected="https://example.com"', + '--selected="https://test.com"', + '--password=saleor', + `--organization=${testOrganization}`, + ]; + + const { exitCode } = await trigger( + command, + params, + {}, + { + ...DefaultTriggerResponse, + }, + ); + expect(exitCode).toBe(0); + + const environment = await getEnvironment(envKey); + expect(environment.allowed_cors_origins).toHaveLength(2); + expect(environment.allowed_cors_origins).toContain('https://example.com'); + }); + + await cleanEnvAfterUpdate(envKey); +}); diff --git a/test/functional/environment/create.test.ts b/test/functional/environment/create.test.ts index b2ec5695..1f53b7ff 100644 --- a/test/functional/environment/create.test.ts +++ b/test/functional/environment/create.test.ts @@ -1,9 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; import { + clearProjects, command, currentDate, DefaultTriggerResponse, + prepareEnvironment, testOrganization, testProjectName, trigger, @@ -11,6 +13,14 @@ import { const envName = `test-env-${currentDate()}`; +beforeAll( + async () => { + await clearProjects(true); + await prepareEnvironment(); + }, + 1000 * 60 * 5, +); + describe('create new environment', async () => { it( 'creates a new environment', @@ -80,4 +90,43 @@ describe('create new environment', async () => { expect(output.join()).toContain(`domain: ${envName}`); expect(output.join()).toContain('database_population: sample'); }); + + it('`environment remove` removes environment', async () => { + const params = [ + 'environment', + 'remove', + envName, + `--organization=${testOrganization}`, + '--force', + ]; + + const { exitCode } = await trigger( + command, + params, + {}, + { + ...DefaultTriggerResponse, + }, + ); + expect(exitCode).toBe(0); + }); + + it('`env list` does not contain newly created env after removal', async () => { + const params = [ + 'environment', + 'list', + `--organization=${testOrganization}`, + ]; + + const { exitCode, output } = await trigger( + command, + params, + {}, + { + ...DefaultTriggerResponse, + }, + ); + expect(exitCode).toBe(0); + expect(output.join()).not.toContain(envName); + }); }); diff --git a/test/functional/environment/maintenance.test.ts b/test/functional/environment/maintenance.test.ts index 1b71b75b..b5cf0ec6 100644 --- a/test/functional/environment/maintenance.test.ts +++ b/test/functional/environment/maintenance.test.ts @@ -9,13 +9,10 @@ import { testOrganization, trigger, waitForBlockingTasks, - removeEnvironment, } from '../../helper'; const envKey = await prepareEnvironment(); -afterAll(async () => removeEnvironment(envKey)); - describe('update maintenance mode in the environment', async () => { await waitForBlockingTasks(envKey); diff --git a/test/functional/environment/origins.test.ts b/test/functional/environment/origins.test.ts new file mode 100644 index 00000000..e3dd85e5 --- /dev/null +++ b/test/functional/environment/origins.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { + cleanEnvAfterUpdate, + command, + DefaultTriggerResponse, + getEnvironment, + prepareEnvironment, + testOrganization, + trigger, + waitForBlockingTasks, +} from '../../helper'; + +const envKey = await prepareEnvironment(); + +describe('update trusted origins in environment', async () => { + await waitForBlockingTasks(envKey); + + it('`env origins` update trusted origins in the environment', async () => { + const params = [ + 'env', + 'origins', + envKey, + '--origin="https://example.com"', + '--origin="https://test.com"', + `--organization=${testOrganization}`, + ]; + + const { exitCode } = await trigger( + command, + params, + {}, + { + ...DefaultTriggerResponse, + }, + ); + expect(exitCode).toBe(0); + + const environment = await getEnvironment(envKey); + expect(environment.allowed_cors_origins).toHaveLength(2); + expect(environment.allowed_cors_origins).toContain('https://example.com'); + }); + + await cleanEnvAfterUpdate(envKey); +}); diff --git a/test/functional/environment/update.test.ts b/test/functional/environment/update.test.ts index 2eb091ad..d1be465b 100644 --- a/test/functional/environment/update.test.ts +++ b/test/functional/environment/update.test.ts @@ -7,7 +7,6 @@ import { getEnvironment, newTestEnvironmentName, prepareEnvironment, - removeEnvironment, testOrganization, trigger, waitForBlockingTasks, @@ -15,8 +14,6 @@ import { const envKey = await prepareEnvironment(); -afterAll(async () => removeEnvironment(envKey)); - describe('update environment', async () => { await waitForBlockingTasks(envKey); diff --git a/test/helper.ts b/test/helper.ts index e213768f..915326fb 100644 --- a/test/helper.ts +++ b/test/helper.ts @@ -301,6 +301,8 @@ export const getEnvironment = async (envKey: string) => { maintenance_mode: true, protected: true, blocking_tasks_in_progress: false, + allowed_cors_origins: ['https://example.com', 'https://test.com'], + allowed_client_origins: ['https://example.com', 'https://test.com'], }; } @@ -336,6 +338,8 @@ export const cleanEnvAfterUpdate = async (envKey: string) => { name: testEnvironmentName, maintenance_mode: false, protected: false, + allowed_cors_origins: '*', + allowed_client_origins: [], }; }