From c8ed09f049eca9a0fd9d0ff220001b575b6fcdf4 Mon Sep 17 00:00:00 2001 From: vvemireddy-sf <152321833+vvemireddy-sf@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:00:42 -0700 Subject: [PATCH 1/2] Add SCAPI backend + full CRUD to mrt env access-control (W-24145908) (#722) Expand the `mrt env access-control` command family from list-only to full CRUD (list/create/get/delete), making each command backend-aware between the legacy MRT Cloud API and the SCAPI Storefront Environments API. - SDK: add the 4 access-control-header paths/schemas to the storefront environments spec, regenerate the client types, add the 4 *Scapi ops and their normalizers, and add backend-aware *WithBackend wrappers (auto fallback, fail-loud on explicit scapi). - CLI: rewrite list backend-aware; add create/get/delete commands. - Tests: SDK (MSW) + CLI (sinon) covering legacy, SCAPI, and the backend routing matrix. - Docs, tooling-index, and changeset updated. --- .changeset/mrt-access-control-scapi.md | 6 + docs/cli/mrt.md | 40 +- .../commands/mrt/env/access-control/create.ts | 95 +++ .../commands/mrt/env/access-control/delete.ts | 100 +++ .../commands/mrt/env/access-control/get.ts | 89 +++ .../commands/mrt/env/access-control/list.ts | 72 +- .../mrt/env/access-control/create.test.ts | 163 ++++ .../mrt/env/access-control/delete.test.ts | 171 +++++ .../mrt/env/access-control/get.test.ts | 167 +++++ .../mrt/env/access-control/list.test.ts | 148 ++-- .../b2c-tooling-sdk/data/tooling/index.json | 2 +- .../specs/storefront-environments-v1.yaml | 244 +++++- .../b2c-tooling-sdk/src/cli/mrt-command.ts | 9 +- .../storefront-environments.generated.ts | 243 ++++++ .../src/clients/storefront-environments.ts | 4 + .../src/operations/mrt/access-control.ts | 479 ++++++++++++ .../src/operations/mrt/index.ts | 19 + .../operations/mrt/access-control.test.ts | 694 ++++++++++++++++++ skills/b2c-cli/skills/b2c-mrt/SKILL.md | 7 +- .../references/ENVIRONMENT-COMMANDS.md | 36 + 20 files changed, 2659 insertions(+), 129 deletions(-) create mode 100644 .changeset/mrt-access-control-scapi.md create mode 100644 packages/b2c-cli/src/commands/mrt/env/access-control/create.ts create mode 100644 packages/b2c-cli/src/commands/mrt/env/access-control/delete.ts create mode 100644 packages/b2c-cli/src/commands/mrt/env/access-control/get.ts create mode 100644 packages/b2c-cli/test/commands/mrt/env/access-control/create.test.ts create mode 100644 packages/b2c-cli/test/commands/mrt/env/access-control/delete.test.ts create mode 100644 packages/b2c-cli/test/commands/mrt/env/access-control/get.test.ts create mode 100644 packages/b2c-tooling-sdk/test/operations/mrt/access-control.test.ts diff --git a/.changeset/mrt-access-control-scapi.md b/.changeset/mrt-access-control-scapi.md new file mode 100644 index 000000000..46c89f0a9 --- /dev/null +++ b/.changeset/mrt-access-control-scapi.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-tooling-sdk': minor +'@salesforce/b2c-cli': minor +--- + +`mrt env access-control` now supports full CRUD (`list` / `create` / `get` / `delete`) and is backend-aware: each command honors `--mrt-backend` and can run over the SCAPI Storefront Environments API (scopes `sfcc.storefront.environments` for reads, `sfcc.storefront.environments.rw` for writes) or the legacy MRT Cloud API. Header values stay masked on both backends. diff --git a/docs/cli/mrt.md b/docs/cli/mrt.md index 2e252860b..fe9c98888 100644 --- a/docs/cli/mrt.md +++ b/docs/cli/mrt.md @@ -17,7 +17,7 @@ Commands for managing Managed Runtime (MRT) projects, environments, and bundles | `mrt env` | `list`, `create`, `get`, `update`, `delete`, `invalidate`, `b2c` | Manage environments | | `mrt env var` | `list`, `set`, `push`, `delete` | Manage environment variables | | `mrt env redirect` | `list`, `create`, `delete`, `clone` | Manage URL redirects | -| `mrt env access-control` | `list` | Manage access control headers | +| `mrt env access-control` | `list`, `create`, `get`, `delete` | Manage access control headers | | `mrt bundle` | `deploy`, `list`, `history`, `download` | Manage bundles and deployments | | `mrt tail-logs` | | Tail real-time application logs | | `mrt save-credentials` | | Save MRT credentials to ~/.mobify | @@ -51,7 +51,7 @@ MRT commands resolve configuration in the following order of precedence: MRT commands use API key authentication against the legacy MRT Cloud API. The API key is configured in the Managed Runtime dashboard. -Several commands — `mrt bundle history`, `mrt bundle list`, `mrt bundle deploy` (both the local-build push and deploying an existing ``), and the `mrt env var` family (`list` / `set` / `push` / `delete`) — can also run over the SCAPI MRT backend with OAuth instead of an API key. See [MRT Backends](#mrt-backends) for how the backend is selected and what it requires. +Several commands — `mrt bundle history`, `mrt bundle list`, `mrt bundle deploy` (both the local-build push and deploying an existing ``), the `mrt env var` family (`list` / `set` / `push` / `delete`), and the `mrt env access-control` family (`list` / `create` / `get` / `delete`) — can also run over the SCAPI MRT backend with OAuth instead of an API key. See [MRT Backends](#mrt-backends) for how the backend is selected and what it requires. ### Getting an API Key @@ -629,6 +629,8 @@ b2c mrt env redirect clone -p my-storefront --from staging --to production --for ## Access Control Commands +The `mrt env access-control` commands (`list` / `create` / `get` / `delete`) are [backend-aware](#mrt-backends): they honor `--mrt-backend` and, over SCAPI, use the Storefront Environments API (scopes `sfcc.storefront.environments` for reads, `sfcc.storefront.environments.rw` for writes). Header values are always masked by both backends — the CLI never displays or reconstructs the plaintext value. Under `--json`, each command returns the serving backend's native shape (see [JSON output is backend-specific](#json-output-is-backend-specific)). + ### b2c mrt env access-control list List access control headers for an environment. @@ -636,6 +638,40 @@ List access control headers for an environment. ```bash b2c mrt env access-control list -p my-storefront -e staging b2c mrt env access-control list -p my-storefront -e staging --json + +# Force the SCAPI backend +b2c mrt env access-control list -p my-storefront -e staging --mrt-backend scapi +``` + +### b2c mrt env access-control create + +Create an access control header. The value is passed as a positional argument. + +```bash +b2c mrt env access-control create my-secret-header-value -p my-storefront -e production + +# Force the SCAPI backend +b2c mrt env access-control create my-secret-header-value -p my-storefront -e production --mrt-backend scapi +``` + +### b2c mrt env access-control get + +Get a single access control header by its ID (UUID). + +```bash +b2c mrt env access-control get ff832a9e-0e55-11ef-8f23-0242ac110002 -p my-storefront -e production +b2c mrt env access-control get ff832a9e-0e55-11ef-8f23-0242ac110002 -p my-storefront -e production --json +``` + +### b2c mrt env access-control delete + +Delete an access control header by its ID (UUID). + +```bash +b2c mrt env access-control delete ff832a9e-0e55-11ef-8f23-0242ac110002 -p my-storefront -e production + +# Force the SCAPI backend +b2c mrt env access-control delete ff832a9e-0e55-11ef-8f23-0242ac110002 -p my-storefront -e production --mrt-backend scapi ``` --- diff --git a/packages/b2c-cli/src/commands/mrt/env/access-control/create.ts b/packages/b2c-cli/src/commands/mrt/env/access-control/create.ts new file mode 100644 index 000000000..0f5ccc4ec --- /dev/null +++ b/packages/b2c-cli/src/commands/mrt/env/access-control/create.ts @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args} from '@oclif/core'; +import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {createAccessControlHeaderWithBackend} from '@salesforce/b2c-tooling-sdk/operations/mrt'; +import {t, withDocs} from '../../../../i18n/index.js'; + +/** + * Create an access control header for an MRT environment. + */ +export default class MrtAccessControlCreate extends MrtCommand { + static args = { + value: Args.string({ + description: 'Access control header value', + required: true, + }), + }; + + static description = withDocs( + t( + 'commands.mrt.access-control.create.description', + 'Create an access control header for a Managed Runtime environment', + ), + '/cli/mrt.html#b2c-mrt-env-access-control-create', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> my-secret-header-value --project my-storefront --environment production', + '<%= config.bin %> <%= command.id %> my-secret-header-value -p my-storefront -e production --mrt-backend scapi', + ]; + + static flags = { + ...MrtCommand.baseFlags, + }; + + protected operations = { + createAccessControlHeaderWithBackend, + }; + + async run(): Promise { + const {value} = this.args; + const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; + + if (!project) { + this.error( + 'MRT project is required. Provide --project/--storefront (-p/-s), set MRT_PROJECT, or set mrtProject in dw.json.', + ); + } + if (!environment) { + this.error( + 'MRT environment is required. Provide --environment flag, set MRT_ENVIRONMENT, or set mrtEnvironment in dw.json.', + ); + } + + const {preference, scapiConnection, legacyAuth} = this.getMrtBackendContext(); + + const result = await this.operations.createAccessControlHeaderWithBackend({ + preference, + scapiConnection, + legacyAuth, + projectSlug: project, + environment, + value, + origin: this.resolvedConfig.values.mrtOrigin, + onFallback: (reason) => this.warn(reason), + onResolve: (backend) => this.logger.debug({backend}, '[MRT] Creating access control header via backend'), + }); + + if (!this.jsonEnabled()) { + this.log( + t( + 'commands.mrt.access-control.create.success', + 'Created access control header {{id}} on {{project}}/{{environment}}', + { + id: result.header.id, + project, + environment, + }, + ), + ); + } + + // Under --json, emit the backend's native create response verbatim. + return result.raw; + } + + protected override supportsScapiMrt(): boolean { + return true; + } +} diff --git a/packages/b2c-cli/src/commands/mrt/env/access-control/delete.ts b/packages/b2c-cli/src/commands/mrt/env/access-control/delete.ts new file mode 100644 index 000000000..34787db6c --- /dev/null +++ b/packages/b2c-cli/src/commands/mrt/env/access-control/delete.ts @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args} from '@oclif/core'; +import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {deleteAccessControlHeaderWithBackend} from '@salesforce/b2c-tooling-sdk/operations/mrt'; +import {t, withDocs} from '../../../../i18n/index.js'; + +/** + * Delete an access control header from an MRT environment. + */ +export default class MrtAccessControlDelete extends MrtCommand { + static args = { + id: Args.string({ + description: 'Access control header ID (UUID)', + required: true, + }), + }; + + static description = withDocs( + t( + 'commands.mrt.access-control.delete.description', + 'Delete an access control header from a Managed Runtime environment', + ), + '/cli/mrt.html#b2c-mrt-env-access-control-delete', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> ff832a9e-0e55-11ef-8f23-0242ac110002 --project my-storefront --environment production', + '<%= config.bin %> <%= command.id %> ff832a9e-0e55-11ef-8f23-0242ac110002 -p my-storefront -e production --mrt-backend scapi', + ]; + + static flags = { + ...MrtCommand.baseFlags, + }; + + protected operations = { + deleteAccessControlHeaderWithBackend, + }; + + async run(): Promise<{id: string; project: string; environment: string}> { + // Prevent deletion in safe mode + this.assertDestructiveOperationAllowed('delete access control header'); + + const {id} = this.args; + const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; + + if (!project) { + this.error( + 'MRT project is required. Provide --project/--storefront (-p/-s), set MRT_PROJECT, or set mrtProject in dw.json.', + ); + } + if (!environment) { + this.error( + 'MRT environment is required. Provide --environment flag, set MRT_ENVIRONMENT, or set mrtEnvironment in dw.json.', + ); + } + + const {preference, scapiConnection, legacyAuth} = this.getMrtBackendContext(); + + // The resolved backend is surfaced via `onResolve` (debug log) only — it is + // deliberately kept out of the `--json` payload so the legacy `--json` + // output stays byte-identical and matches the rest of the MRT commands. + await this.operations.deleteAccessControlHeaderWithBackend({ + preference, + scapiConnection, + legacyAuth, + projectSlug: project, + environment, + headerId: id, + origin: this.resolvedConfig.values.mrtOrigin, + onFallback: (reason) => this.warn(reason), + onResolve: (backend) => this.logger.debug({backend}, '[MRT] Deleting access control header via backend'), + }); + + if (!this.jsonEnabled()) { + this.log( + t( + 'commands.mrt.access-control.delete.success', + 'Deleted access control header {{id}} from {{project}}/{{environment}}', + { + id, + project, + environment, + }, + ), + ); + } + + return {id, project, environment}; + } + + protected override supportsScapiMrt(): boolean { + return true; + } +} diff --git a/packages/b2c-cli/src/commands/mrt/env/access-control/get.ts b/packages/b2c-cli/src/commands/mrt/env/access-control/get.ts new file mode 100644 index 000000000..8f7febf05 --- /dev/null +++ b/packages/b2c-cli/src/commands/mrt/env/access-control/get.ts @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, ux} from '@oclif/core'; +import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getAccessControlHeaderWithBackend} from '@salesforce/b2c-tooling-sdk/operations/mrt'; +import {t, withDocs} from '../../../../i18n/index.js'; + +/** + * Get a single access control header for an MRT environment. + */ +export default class MrtAccessControlGet extends MrtCommand { + static args = { + id: Args.string({ + description: 'Access control header ID (UUID)', + required: true, + }), + }; + + static description = withDocs( + t('commands.mrt.access-control.get.description', 'Get an access control header for a Managed Runtime environment'), + '/cli/mrt.html#b2c-mrt-env-access-control-get', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> ff832a9e-0e55-11ef-8f23-0242ac110002 --project my-storefront --environment production', + '<%= config.bin %> <%= command.id %> ff832a9e-0e55-11ef-8f23-0242ac110002 -p my-storefront -e production --mrt-backend scapi', + ]; + + static flags = { + ...MrtCommand.baseFlags, + }; + + protected operations = { + getAccessControlHeaderWithBackend, + }; + + async run(): Promise { + const {id} = this.args; + const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; + + if (!project) { + this.error( + 'MRT project is required. Provide --project/--storefront (-p/-s), set MRT_PROJECT, or set mrtProject in dw.json.', + ); + } + if (!environment) { + this.error( + 'MRT environment is required. Provide --environment flag, set MRT_ENVIRONMENT, or set mrtEnvironment in dw.json.', + ); + } + + const {preference, scapiConnection, legacyAuth} = this.getMrtBackendContext(); + + const result = await this.operations.getAccessControlHeaderWithBackend({ + preference, + scapiConnection, + legacyAuth, + projectSlug: project, + environment, + headerId: id, + origin: this.resolvedConfig.values.mrtOrigin, + onFallback: (reason) => this.warn(reason), + onResolve: (backend) => this.logger.debug({backend}, '[MRT] Getting access control header via backend'), + }); + + if (!this.jsonEnabled()) { + const {header} = result; + ux.stdout(`ID: ${header.id || '-'}`); + ux.stdout(`Value: ${header.value || '-'}`); + ux.stdout(`Status: ${header.status ?? '-'}`); + ux.stdout(`Created: ${header.createdAt ? new Date(header.createdAt).toLocaleString() : '-'}`); + if (header.createdBy) { + ux.stdout(`Created by: ${header.createdBy}`); + } + } + + // Under --json, emit the backend's native header response verbatim. + return result.raw; + } + + protected override supportsScapiMrt(): boolean { + return true; + } +} diff --git a/packages/b2c-cli/src/commands/mrt/env/access-control/list.ts b/packages/b2c-cli/src/commands/mrt/env/access-control/list.ts index 0f6241c8f..91adfb723 100644 --- a/packages/b2c-cli/src/commands/mrt/env/access-control/list.ts +++ b/packages/b2c-cli/src/commands/mrt/env/access-control/list.ts @@ -12,28 +12,31 @@ import { type ColumnDef, } from '@salesforce/b2c-tooling-sdk/cli'; import { - listAccessControlHeaders, - type ListAccessControlHeadersResult, - type MrtAccessControlHeader, + listAccessControlHeadersWithBackend, + type MrtAccessControlHeaderView, } from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../../i18n/index.js'; -const COLUMNS: Record> = { +const COLUMNS: Record> = { id: { header: 'ID', - get: (h) => h.id ?? '-', + get: (h) => h.id || '-', }, value: { header: 'Value', - get: (h) => h.value ?? '-', + get: (h) => h.value || '-', }, status: { header: 'Status', - get: (h) => h.publishing_status_description ?? '-', + get: (h) => h.status ?? '-', }, created: { header: 'Created', - get: (h) => (h.created_at ? new Date(h.created_at).toLocaleString() : '-'), + get: (h) => (h.createdAt ? new Date(h.createdAt).toLocaleString() : '-'), + }, + backend: { + header: 'Backend', + get: (h) => h.backend, }, }; @@ -54,6 +57,7 @@ export default class MrtAccessControlList extends MrtCommand <%= command.id %> --project my-storefront --environment production', + '<%= config.bin %> <%= command.id %> -p my-storefront -e production --mrt-backend scapi', '<%= config.bin %> <%= command.id %> -p my-storefront -e production --json', ]; @@ -68,9 +72,15 @@ export default class MrtAccessControlList extends MrtCommand { - this.requireMrtCredentials(); + protected operations = { + listAccessControlHeadersWithBackend, + }; + + protected renderTable(headers: MrtAccessControlHeaderView[]): void { + tableRenderer.render(headers, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); + } + async run(): Promise { const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; if (!project) { @@ -85,28 +95,28 @@ export default class MrtAccessControlList extends MrtCommand this.warn(reason), + onResolve: (backend) => this.logger.debug({backend}, '[MRT] Listing access control headers via backend'), + }); if (!this.jsonEnabled()) { if (result.headers.length === 0) { @@ -117,13 +127,17 @@ export default class MrtAccessControlList extends MrtCommand { + let config: Config; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + function createCommand(): any { + return new MrtAccessControlCreate([], config); + } + + function stubErrorToThrow(command: any): sinon.SinonStub { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + function stubBackendContext( + command: any, + ctx: {preference?: string; scapiConnection?: unknown; legacyAuth?: unknown} = {}, + ): void { + sinon.stub(command, 'getMrtBackendContext').returns({ + preference: ctx.preference ?? 'auto', + scapiConnection: ctx.scapiConnection, + legacyAuth: 'legacyAuth' in ctx ? ctx.legacyAuth : {}, + } as any); + } + + it('calls command.error when project is missing', async () => { + const command = createCommand(); + + stubParse(command, {}, {value: 'my-secret-header'}); + await command.init(); + + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined, mrtEnvironment: 'staging'}})); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('calls command.error when environment is missing', async () => { + const command = createCommand(); + + stubParse(command, {}, {value: 'my-secret-header'}); + await command.init(); + + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: undefined}})); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('creates the header via the backend wrapper and returns the raw response under --json', async () => { + const command = createCommand(); + + stubParse(command, {}, {value: 'my-secret-header'}); + await command.init(); + + stubBackendContext(command); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({ + values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, + })); + + const raw = {id: 'h1', value: '****3456'}; + const createStub = sinon.stub().resolves({backend: 'legacy', header: {id: 'h1', backend: 'legacy'}, raw}); + command.operations = {...command.operations, createAccessControlHeaderWithBackend: createStub}; + + const result = await command.run(); + + expect(createStub.calledOnce).to.equal(true); + const [input] = createStub.firstCall.args; + expect(input.preference).to.equal('auto'); + expect(input.projectSlug).to.equal('my-project'); + expect(input.environment).to.equal('staging'); + expect(input.value).to.equal('my-secret-header'); + expect(input.origin).to.equal('https://example.com'); + // --json emits the backend-native create response verbatim. + expect(result).to.deep.equal(raw); + }); + + it('forwards the resolved SCAPI backend context to the wrapper', async () => { + const command = createCommand(); + + stubParse(command, {'mrt-backend': 'scapi'}, {value: 'my-secret-header'}); + await command.init(); + + const scapiConnection = {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd', auth: {}}; + stubBackendContext(command, {preference: 'scapi', scapiConnection, legacyAuth: undefined}); + sinon.stub(command, 'log').returns(void 0); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtBackend: 'scapi'}})); + + const createStub = sinon.stub().resolves({backend: 'scapi', header: {id: 'h1', backend: 'scapi'}, raw: {id: 'h1'}}); + command.operations = {...command.operations, createAccessControlHeaderWithBackend: createStub}; + + await command.run(); + + const [input] = createStub.firstCall.args; + expect(input.preference).to.equal('scapi'); + expect(input.scapiConnection).to.equal(scapiConnection); + expect(input.value).to.equal('my-secret-header'); + }); + + it('emits no human progress under --json', async () => { + const command = createCommand(); + + stubParse(command, {}, {value: 'my-secret-header'}); + await command.init(); + + sinon.stub(command, 'jsonEnabled').returns(true); + stubBackendContext(command); + const logStub = sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({ + values: {mrtProject: 'my-project', mrtEnvironment: 'staging'}, + })); + + const createStub = sinon + .stub() + .resolves({backend: 'legacy', header: {id: 'h1', backend: 'legacy'}, raw: {id: 'h1'}}); + command.operations = {...command.operations, createAccessControlHeaderWithBackend: createStub}; + + await command.run(); + + expect(createStub.calledOnce).to.equal(true); + expect(logStub.called, 'no human progress under --json').to.equal(false); + }); + + it('supports the SCAPI MRT backend', () => { + const command = createCommand(); + expect(command.supportsScapiMrt()).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/mrt/env/access-control/delete.test.ts b/packages/b2c-cli/test/commands/mrt/env/access-control/delete.test.ts new file mode 100644 index 000000000..df1f16e38 --- /dev/null +++ b/packages/b2c-cli/test/commands/mrt/env/access-control/delete.test.ts @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import MrtAccessControlDelete from '../../../../../src/commands/mrt/env/access-control/delete.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {stubParse} from '../../../../helpers/stub-parse.js'; + +const HEADER_ID = 'ff832a9e-0e55-11ef-8f23-0242ac110002'; + +describe('mrt env access-control delete', () => { + let config: Config; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + function createCommand(): any { + return new MrtAccessControlDelete([], config); + } + + function stubErrorToThrow(command: any): sinon.SinonStub { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + function stubBackendContext( + command: any, + ctx: {preference?: string; scapiConnection?: unknown; legacyAuth?: unknown} = {}, + ): void { + sinon.stub(command, 'getMrtBackendContext').returns({ + preference: ctx.preference ?? 'auto', + scapiConnection: ctx.scapiConnection, + legacyAuth: 'legacyAuth' in ctx ? ctx.legacyAuth : {}, + } as any); + } + + it('calls command.error when project is missing', async () => { + const command = createCommand(); + + stubParse(command, {}, {id: HEADER_ID}); + await command.init(); + + sinon.stub(command, 'assertDestructiveOperationAllowed').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined, mrtEnvironment: 'staging'}})); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('deletes the header via the backend wrapper', async () => { + const command = createCommand(); + + stubParse(command, {}, {id: HEADER_ID}); + await command.init(); + + sinon.stub(command, 'assertDestructiveOperationAllowed').returns(void 0); + stubBackendContext(command); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({ + values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, + })); + + const delStub = sinon.stub().resolves({backend: 'legacy'}); + command.operations = {...command.operations, deleteAccessControlHeaderWithBackend: delStub}; + + const result = await command.run(); + + expect(delStub.calledOnce).to.equal(true); + const [input] = delStub.firstCall.args; + expect(input.preference).to.equal('auto'); + expect(input.projectSlug).to.equal('my-project'); + expect(input.environment).to.equal('staging'); + expect(input.headerId).to.equal(HEADER_ID); + expect(result).to.deep.equal({id: HEADER_ID, project: 'my-project', environment: 'staging'}); + // The resolved backend is intentionally kept out of the --json payload. + expect(result).to.not.have.property('backend'); + }); + + it('forwards the resolved SCAPI backend context to the wrapper', async () => { + const command = createCommand(); + + stubParse(command, {'mrt-backend': 'scapi'}, {id: HEADER_ID}); + await command.init(); + + sinon.stub(command, 'assertDestructiveOperationAllowed').returns(void 0); + const scapiConnection = {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd', auth: {}}; + stubBackendContext(command, {preference: 'scapi', scapiConnection, legacyAuth: undefined}); + sinon.stub(command, 'log').returns(void 0); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtBackend: 'scapi'}})); + + const delStub = sinon.stub().resolves({backend: 'scapi'}); + command.operations = {...command.operations, deleteAccessControlHeaderWithBackend: delStub}; + + await command.run(); + + const [input] = delStub.firstCall.args; + expect(input.preference).to.equal('scapi'); + expect(input.scapiConnection).to.equal(scapiConnection); + expect(input.headerId).to.equal(HEADER_ID); + }); + + it('blocks deletion in safe mode before touching the backend', async () => { + const command = createCommand(); + + stubParse(command, {}, {id: HEADER_ID}); + await command.init(); + + const assertStub = sinon + .stub(command, 'assertDestructiveOperationAllowed') + .throws(new Error('destructive blocked')); + const delStub = sinon.stub().resolves({backend: 'legacy'}); + command.operations = {...command.operations, deleteAccessControlHeaderWithBackend: delStub}; + + try { + await command.run(); + expect.fail('Expected error'); + } catch (error) { + expect((error as Error).message).to.equal('destructive blocked'); + } + expect(assertStub.calledOnce).to.equal(true); + expect(delStub.called).to.equal(false); + }); + + it('emits no human progress under --json', async () => { + const command = createCommand(); + + stubParse(command, {}, {id: HEADER_ID}); + await command.init(); + + sinon.stub(command, 'assertDestructiveOperationAllowed').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + stubBackendContext(command); + const logStub = sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({ + values: {mrtProject: 'my-project', mrtEnvironment: 'staging'}, + })); + + const delStub = sinon.stub().resolves({backend: 'legacy'}); + command.operations = {...command.operations, deleteAccessControlHeaderWithBackend: delStub}; + + const result = await command.run(); + + expect(delStub.calledOnce).to.equal(true); + expect(logStub.called, 'no human progress under --json').to.equal(false); + expect(result.id).to.equal(HEADER_ID); + }); + + it('supports the SCAPI MRT backend', () => { + const command = createCommand(); + expect(command.supportsScapiMrt()).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/mrt/env/access-control/get.test.ts b/packages/b2c-cli/test/commands/mrt/env/access-control/get.test.ts new file mode 100644 index 000000000..887fbb8c6 --- /dev/null +++ b/packages/b2c-cli/test/commands/mrt/env/access-control/get.test.ts @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config, ux} from '@oclif/core'; +import MrtAccessControlGet from '../../../../../src/commands/mrt/env/access-control/get.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {stubParse} from '../../../../helpers/stub-parse.js'; + +const HEADER_ID = 'ff832a9e-0e55-11ef-8f23-0242ac110002'; + +describe('mrt env access-control get', () => { + let config: Config; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + function createCommand(): any { + return new MrtAccessControlGet([], config); + } + + function stubErrorToThrow(command: any): sinon.SinonStub { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + function stubBackendContext( + command: any, + ctx: {preference?: string; scapiConnection?: unknown; legacyAuth?: unknown} = {}, + ): void { + sinon.stub(command, 'getMrtBackendContext').returns({ + preference: ctx.preference ?? 'auto', + scapiConnection: ctx.scapiConnection, + legacyAuth: 'legacyAuth' in ctx ? ctx.legacyAuth : {}, + } as any); + } + + it('calls command.error when project is missing', async () => { + const command = createCommand(); + + stubParse(command, {}, {id: HEADER_ID}); + await command.init(); + + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined, mrtEnvironment: 'staging'}})); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('gets the header via the backend wrapper and returns the raw response under --json', async () => { + const command = createCommand(); + + stubParse(command, {}, {id: HEADER_ID}); + await command.init(); + + stubBackendContext(command); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({ + values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, + })); + + const raw = {id: HEADER_ID, value: '****3456'}; + const getStub = sinon.stub().resolves({ + backend: 'legacy', + header: {id: HEADER_ID, value: '****3456', backend: 'legacy'}, + raw, + }); + command.operations = {...command.operations, getAccessControlHeaderWithBackend: getStub}; + + const result = await command.run(); + + expect(getStub.calledOnce).to.equal(true); + const [input] = getStub.firstCall.args; + expect(input.preference).to.equal('auto'); + expect(input.projectSlug).to.equal('my-project'); + expect(input.environment).to.equal('staging'); + expect(input.headerId).to.equal(HEADER_ID); + expect(result).to.deep.equal(raw); + }); + + it('prints the header detail via ux.stdout in non-JSON mode', async () => { + const command = createCommand(); + + stubParse(command, {}, {id: HEADER_ID}); + await command.init(); + + stubBackendContext(command); + sinon.stub(command, 'jsonEnabled').returns(false); + sinon.stub(command, 'resolvedConfig').get(() => ({ + values: {mrtProject: 'my-project', mrtEnvironment: 'staging'}, + })); + + const stdoutStub = sinon.stub(ux, 'stdout').returns(void 0); + const getStub = sinon.stub().resolves({ + backend: 'scapi', + header: { + id: HEADER_ID, + value: '****3456', + status: 'completed', + createdAt: '2026-04-08T21:47:28.188965Z', + createdBy: 'dev@example.com', + backend: 'scapi', + }, + raw: {id: HEADER_ID}, + }); + command.operations = {...command.operations, getAccessControlHeaderWithBackend: getStub}; + + await command.run(); + + expect(stdoutStub.called).to.equal(true); + const printed = stdoutStub + .getCalls() + .map((c) => String(c.args[0])) + .join('\n'); + expect(printed).to.include(HEADER_ID); + expect(printed).to.include('****3456'); + expect(printed).to.include('completed'); + expect(printed).to.include('dev@example.com'); + }); + + it('forwards the resolved SCAPI backend context to the wrapper', async () => { + const command = createCommand(); + + stubParse(command, {'mrt-backend': 'scapi'}, {id: HEADER_ID}); + await command.init(); + + const scapiConnection = {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd', auth: {}}; + stubBackendContext(command, {preference: 'scapi', scapiConnection, legacyAuth: undefined}); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtBackend: 'scapi'}})); + + const getStub = sinon + .stub() + .resolves({backend: 'scapi', header: {id: HEADER_ID, backend: 'scapi'}, raw: {id: HEADER_ID}}); + command.operations = {...command.operations, getAccessControlHeaderWithBackend: getStub}; + + await command.run(); + + const [input] = getStub.firstCall.args; + expect(input.preference).to.equal('scapi'); + expect(input.scapiConnection).to.equal(scapiConnection); + expect(input.headerId).to.equal(HEADER_ID); + }); + + it('supports the SCAPI MRT backend', () => { + const command = createCommand(); + expect(command.supportsScapiMrt()).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/mrt/env/access-control/list.test.ts b/packages/b2c-cli/test/commands/mrt/env/access-control/list.test.ts index 7d0049e15..9125b7f8e 100644 --- a/packages/b2c-cli/test/commands/mrt/env/access-control/list.test.ts +++ b/packages/b2c-cli/test/commands/mrt/env/access-control/list.test.ts @@ -32,9 +32,20 @@ describe('mrt env access-control list', () => { return sinon.stub(command, 'error').throws(new Error('Expected error')); } - function stubCommonAuth(command: any): void { - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'getMrtAuth').returns({} as any); + /** + * Stubs the resolved MRT backend context. `getMrtBackendContext()` reads + * `resolvedConfig.hasMrtConfig()` and builds auth strategies, which the plain + * `{values}` config stub can't satisfy — so we stub the resolver directly. + */ + function stubBackendContext( + command: any, + ctx: {preference?: string; scapiConnection?: unknown; legacyAuth?: unknown} = {}, + ): void { + sinon.stub(command, 'getMrtBackendContext').returns({ + preference: ctx.preference ?? 'auto', + scapiConnection: ctx.scapiConnection, + legacyAuth: 'legacyAuth' in ctx ? ctx.legacyAuth : {}, + } as any); } it('calls command.error when project is missing', async () => { @@ -43,8 +54,7 @@ describe('mrt env access-control list', () => { stubParse(command, {}, {}); await command.init(); - stubCommonAuth(command); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined}})); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined, mrtEnvironment: 'staging'}})); const errorStub = stubErrorToThrow(command); @@ -62,7 +72,6 @@ describe('mrt env access-control list', () => { stubParse(command, {project: 'my-project'}, {}); await command.init(); - stubCommonAuth(command); sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: undefined}})); const errorStub = stubErrorToThrow(command); @@ -75,126 +84,101 @@ describe('mrt env access-control list', () => { } }); - it('calls listAccessControlHeaders and returns results', async () => { + it('routes through the backend-aware list, forwards limit/offset, and returns raw under --json', async () => { const command = createCommand(); - stubParse(command, {project: 'my-project', environment: 'staging'}, {}); + stubParse(command, {project: 'my-project', environment: 'staging', limit: 10, offset: 5}, {}); await command.init(); - stubCommonAuth(command); + stubBackendContext(command); sinon.stub(command, 'jsonEnabled').returns(true); sinon.stub(command, 'log').returns(void 0); - sinon - .stub(command, 'resolvedConfig') - .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}})); + sinon.stub(command, 'resolvedConfig').get(() => ({ + values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, + })); const listStub = sinon.stub().resolves({ - headers: [ - { - id: 'h1', - value: 'Basic abc123', - publishing_status_description: 'published', - created_at: '2025-01-01T00:00:00Z', - }, - {id: 'h2', value: 'Basic xyz789', publishing_status_description: 'pending', created_at: '2025-01-02T00:00:00Z'}, - ], - count: 2, + backend: 'legacy', + count: 1, + headers: [{id: 'h1', value: '****123', backend: 'legacy'}], + raw: {count: 1, next: null, previous: null, headers: [{id: 'h1', value: '****123'}]}, } as any); - - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) this.error('MRT project is required.'); - if (!environment) this.error('MRT environment is required.'); - const result = await listStub({ - projectSlug: project, - targetSlug: environment, - origin: 'https://example.com', - }); - return result; - }; + command.operations = {...command.operations, listAccessControlHeadersWithBackend: listStub}; const result = await command.run(); expect(listStub.calledOnce).to.equal(true); const [input] = listStub.firstCall.args; + expect(input.preference).to.equal('auto'); expect(input.projectSlug).to.equal('my-project'); - expect(input.targetSlug).to.equal('staging'); - expect(result.headers).to.have.lengthOf(2); - expect(result.count).to.equal(2); + expect(input.environment).to.equal('staging'); + expect(input.limit).to.equal(10); + expect(input.offset).to.equal(5); + expect(input.origin).to.equal('https://example.com'); + // --json emits the raw backend-native list response verbatim. + expect(result.count).to.equal(1); + expect(result.headers[0].id).to.equal('h1'); }); - it('handles empty access control headers list', async () => { + it('forwards the resolved SCAPI backend context and emits the native envelope under --json', async () => { const command = createCommand(); - stubParse(command, {project: 'my-project', environment: 'staging'}, {}); + stubParse(command, {project: 'my-project', environment: 'staging', 'mrt-backend': 'scapi'}, {}); await command.init(); - stubCommonAuth(command); + const scapiConnection = {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd', auth: {}}; + stubBackendContext(command, {preference: 'scapi', scapiConnection, legacyAuth: undefined}); sinon.stub(command, 'jsonEnabled').returns(true); sinon.stub(command, 'log').returns(void 0); sinon .stub(command, 'resolvedConfig') - .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}})); + .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtBackend: 'scapi'}})); const listStub = sinon.stub().resolves({ - headers: [], - count: 0, + backend: 'scapi', + count: 1, + headers: [{id: 'h1', value: '****by0z', status: 'completed', backend: 'scapi'}], + raw: {limit: 25, offset: 0, total: 1, data: [{id: 'h1', value: '****by0z', publishingStatus: 'completed'}]}, } as any); - - (command as any).run = async function () { - this.requireMrtCredentials(); - const result = await listStub({ - projectSlug: 'my-project', - targetSlug: 'staging', - origin: 'https://example.com', - }); - return result; - }; + command.operations = {...command.operations, listAccessControlHeadersWithBackend: listStub}; const result = await command.run(); - expect(result.headers).to.have.lengthOf(0); - expect(result.count).to.equal(0); + const [input] = listStub.firstCall.args; + expect(input.preference).to.equal('scapi'); + expect(input.scapiConnection).to.equal(scapiConnection); + // --json emits the native SCAPI paginated envelope verbatim. + expect(result.total).to.equal(1); + expect(result.data[0].id).to.equal('h1'); }); - it('passes limit and offset to the API', async () => { + it('renders the table in non-JSON mode (renderTable is stubbed)', async () => { const command = createCommand(); - stubParse(command, {project: 'my-project', environment: 'staging', limit: 10, offset: 5}, {}); + stubParse(command, {project: 'my-project', environment: 'staging'}, {}); await command.init(); - stubCommonAuth(command); - sinon.stub(command, 'jsonEnabled').returns(true); + stubBackendContext(command); + sinon.stub(command, 'jsonEnabled').returns(false); sinon.stub(command, 'log').returns(void 0); - sinon - .stub(command, 'resolvedConfig') - .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}})); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging'}})); + sinon.stub(command, 'renderTable').returns(void 0); const listStub = sinon.stub().resolves({ - headers: [{id: 'h1', value: 'Basic abc123'}], + backend: 'legacy', count: 1, + headers: [{id: 'h1', value: '****123', backend: 'legacy'}], + raw: {count: 1, headers: [{id: 'h1', value: '****123'}]}, } as any); + command.operations = {...command.operations, listAccessControlHeadersWithBackend: listStub}; - (command as any).run = async function () { - this.requireMrtCredentials(); - const {limit, offset} = this.flags; - const result = await listStub({ - projectSlug: 'my-project', - targetSlug: 'staging', - limit, - offset, - origin: 'https://example.com', - }); - return result; - }; + await command.run(); - const result = await command.run(); + expect((command.renderTable as sinon.SinonStub).calledOnce).to.equal(true); + }); - expect(listStub.calledOnce).to.equal(true); - const [input] = listStub.firstCall.args; - expect(input.limit).to.equal(10); - expect(input.offset).to.equal(5); - expect(result.count).to.equal(1); + it('supports the SCAPI MRT backend', () => { + const command = createCommand(); + expect(command.supportsScapiMrt()).to.equal(true); }); }); diff --git a/packages/b2c-tooling-sdk/data/tooling/index.json b/packages/b2c-tooling-sdk/data/tooling/index.json index 43aa2e4b3..b792226d3 100644 --- a/packages/b2c-tooling-sdk/data/tooling/index.json +++ b/packages/b2c-tooling-sdk/data/tooling/index.json @@ -152,7 +152,7 @@ "category": "tooling", "url": "https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/mrt.html", "sourceUrl": "https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/mrt.md", - "headings": "Command Overview • Global MRT Flags • Configuration Sources • Authentication • Getting an API Key • Configuration • MRT Backends • SCAPI-supported commands • Auto-detection criteria • JSON output is backend-specific • Legacy-only flags are ignored on SCAPI • Maintenance (Read-Only) Mode • Organization Commands • b2c mrt org list • b2c mrt org b2c • Project Commands • b2c mrt project list • b2c mrt project create • b2c mrt project get • b2c mrt project update • b2c mrt project delete • Project Member Commands • b2c mrt project member list • b2c mrt project member add • b2c mrt project member get • b2c mrt project member update • b2c mrt project member remove • Project Notification Commands • b2c mrt project notification list • b2c mrt project notification create • b2c mrt project notification get • b2c mrt project notification update • b2c mrt project notification delete • Environment Commands • b2c mrt env list • b2c mrt env create • b2c mrt env get • b2c mrt env update • b2c mrt env delete • b2c mrt env clone • b2c mrt env invalidate • b2c mrt env b2c • Environment Variable Commands • b2c mrt env var list • b2c mrt env var set • b2c mrt env var push • b2c mrt env var delete • URL Redirect Commands • b2c mrt env redirect list • b2c mrt env redirect create • b2c mrt env redirect delete • b2c mrt env redirect clone • Access Control Commands • b2c mrt env access-control list • Bundle Commands • b2c mrt bundle deploy • b2c mrt bundle upload-v2 • b2c mrt bundle list • b2c mrt bundle history • b2c mrt bundle download • b2c mrt bundle delete • Organization Member Commands • b2c mrt org member list • b2c mrt org member add • b2c mrt org member get • b2c mrt org member update • b2c mrt org member remove • Organization Certificate Commands • b2c mrt org cert list • b2c mrt org cert get • b2c mrt org cert create • b2c mrt org cert delete • b2c mrt org cert restart-validation • Tail Logs • b2c mrt tail-logs • Save Credentials • b2c mrt save-credentials • User Commands • b2c mrt user profile • b2c mrt user api-key • b2c mrt user email-prefs • Common Workflows • Deploy to Production • Set Up a New Environment • Invalidate Cache After Content Update", + "headings": "Command Overview • Global MRT Flags • Configuration Sources • Authentication • Getting an API Key • Configuration • MRT Backends • SCAPI-supported commands • Auto-detection criteria • JSON output is backend-specific • Legacy-only flags are ignored on SCAPI • Maintenance (Read-Only) Mode • Organization Commands • b2c mrt org list • b2c mrt org b2c • Project Commands • b2c mrt project list • b2c mrt project create • b2c mrt project get • b2c mrt project update • b2c mrt project delete • Project Member Commands • b2c mrt project member list • b2c mrt project member add • b2c mrt project member get • b2c mrt project member update • b2c mrt project member remove • Project Notification Commands • b2c mrt project notification list • b2c mrt project notification create • b2c mrt project notification get • b2c mrt project notification update • b2c mrt project notification delete • Environment Commands • b2c mrt env list • b2c mrt env create • b2c mrt env get • b2c mrt env update • b2c mrt env delete • b2c mrt env clone • b2c mrt env invalidate • b2c mrt env b2c • Environment Variable Commands • b2c mrt env var list • b2c mrt env var set • b2c mrt env var push • b2c mrt env var delete • URL Redirect Commands • b2c mrt env redirect list • b2c mrt env redirect create • b2c mrt env redirect delete • b2c mrt env redirect clone • Access Control Commands • b2c mrt env access-control list • b2c mrt env access-control create • b2c mrt env access-control get • b2c mrt env access-control delete • Bundle Commands • b2c mrt bundle deploy • b2c mrt bundle upload-v2 • b2c mrt bundle list • b2c mrt bundle history • b2c mrt bundle download • b2c mrt bundle delete • Organization Member Commands • b2c mrt org member list • b2c mrt org member add • b2c mrt org member get • b2c mrt org member update • b2c mrt org member remove • Organization Certificate Commands • b2c mrt org cert list • b2c mrt org cert get • b2c mrt org cert create • b2c mrt org cert delete • b2c mrt org cert restart-validation • Tail Logs • b2c mrt tail-logs • Save Credentials • b2c mrt save-credentials • User Commands • b2c mrt user profile • b2c mrt user api-key • b2c mrt user email-prefs • Common Workflows • Deploy to Production • Set Up a New Environment • Invalidate Cache After Content Update", "preview": "Commands for managing Managed Runtime projects, environments, bundles, and deployments." }, { diff --git a/packages/b2c-tooling-sdk/specs/storefront-environments-v1.yaml b/packages/b2c-tooling-sdk/specs/storefront-environments-v1.yaml index 621e06759..4ac4dcbe3 100644 --- a/packages/b2c-tooling-sdk/specs/storefront-environments-v1.yaml +++ b/packages/b2c-tooling-sdk/specs/storefront-environments-v1.yaml @@ -13,21 +13,24 @@ openapi: "3.0.3" # components: the standard SCAPI `organizationId`/`storefrontId`/`environmentId` # parameters and the RFC 7807 `ErrorResponse` envelope. # -# Scope note: this bundle covers ONLY the environment-variables sub-resource -# (GET + merge-PATCH), which is what the `mrt env var` commands need. The broader -# Environments lifecycle surface (list/create/clone/get/update/delete/...) is -# intentionally omitted; it belongs to a later story. When adding that surface or -# bumping to a newer upstream version, re-bundle from the same source path. +# Scope note: this bundle covers the environment-variables sub-resource (GET + +# merge-PATCH) and the access-control-headers sub-resource (list/create/get/ +# delete), which is what the `mrt env var` and `mrt env access-control` commands +# need. The broader Environments lifecycle surface (list/create/clone/get/ +# update/delete/...) is intentionally omitted; it belongs to a later story. When +# adding that surface or bumping to a newer upstream version, re-bundle from the +# same source path. # --------------------------------------------------------------------------- info: x-api-type: Admin x-api-family: Storefront title: Environments - version: 1.0.0 + version: 1.0.1 description: >- SCAPI Managed Runtime Storefront Environments API. Read and update the - environment variables for a storefront environment. Variables are returned - with their values masked; updates use merge-PATCH semantics. + environment variables for a storefront environment (values masked, merge-PATCH + semantics), and manage the access control headers on an environment + (list/create/get/delete; header values masked on read). servers: - url: "https://{shortCode}.api.commercecloud.salesforce.com/storefront/environments/v1" variables: @@ -95,6 +98,138 @@ paths: security: - AmOAuth2: - "sfcc.storefront.environments.rw" + /organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/access-control-headers: + get: + summary: Get access control headers. + description: >- + Retrieves a paginated list of access control headers on the environment. + Header values are returned masked and cannot be read back in plaintext. + operationId: getAccessControlHeaders + parameters: + - $ref: "#/components/parameters/organizationId" + - $ref: "#/components/parameters/storefrontId" + - $ref: "#/components/parameters/environmentId" + - $ref: "#/components/parameters/accessControlHeaderOrderBy" + - name: limit + in: query + required: false + description: Number of records to retrieve per request. Must be between 1 and 200. Defaults to 25. + schema: + type: integer + format: int32 + default: 25 + minimum: 1 + maximum: 200 + - name: offset + in: query + required: false + description: Zero-based index of the first record to include in the result. + schema: + type: integer + format: int32 + default: 0 + minimum: 0 + responses: + "200": + description: List of access control headers successfully retrieved. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessControlHeaderResult" + "401": + $ref: "#/components/responses/401Unauthorized" + "403": + $ref: "#/components/responses/403Forbidden" + "404": + $ref: "#/components/responses/404NotFound" + security: + - AmOAuth2: + - "sfcc.storefront.environments" + - "sfcc.storefront.environments.rw" + post: + summary: Create an access control header. + description: >- + Creates a header-based access control rule on the environment. The + created value is returned masked in the response. + operationId: createAccessControlHeader + parameters: + - $ref: "#/components/parameters/organizationId" + - $ref: "#/components/parameters/storefrontId" + - $ref: "#/components/parameters/environmentId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AccessControlHeaderCreateRequest" + responses: + "201": + description: Access control header successfully created. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessControlHeader" + "400": + $ref: "#/components/responses/400BadRequest" + "401": + $ref: "#/components/responses/401Unauthorized" + "403": + $ref: "#/components/responses/403Forbidden" + "404": + $ref: "#/components/responses/404NotFound" + security: + - AmOAuth2: + - "sfcc.storefront.environments.rw" + /organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/access-control-headers/{accessControlHeaderId}: + get: + summary: Get an access control header by ID. + description: >- + Retrieves a single access control header by its identifier. The value is + returned masked. + operationId: getAccessControlHeader + parameters: + - $ref: "#/components/parameters/organizationId" + - $ref: "#/components/parameters/storefrontId" + - $ref: "#/components/parameters/environmentId" + - $ref: "#/components/parameters/accessControlHeaderId" + responses: + "200": + description: Access control header successfully retrieved. + content: + application/json: + schema: + $ref: "#/components/schemas/AccessControlHeader" + "401": + $ref: "#/components/responses/401Unauthorized" + "403": + $ref: "#/components/responses/403Forbidden" + "404": + $ref: "#/components/responses/404NotFound" + security: + - AmOAuth2: + - "sfcc.storefront.environments" + - "sfcc.storefront.environments.rw" + delete: + summary: Delete an access control header. + description: Deletes an access control header by its identifier. Returns 204 No Content on success. + operationId: deleteAccessControlHeader + parameters: + - $ref: "#/components/parameters/organizationId" + - $ref: "#/components/parameters/storefrontId" + - $ref: "#/components/parameters/environmentId" + - $ref: "#/components/parameters/accessControlHeaderId" + responses: + "204": + description: Access control header successfully deleted. + "401": + $ref: "#/components/responses/401Unauthorized" + "403": + $ref: "#/components/responses/403Forbidden" + "404": + $ref: "#/components/responses/404NotFound" + security: + - AmOAuth2: + - "sfcc.storefront.environments.rw" components: securitySchemes: AmOAuth2: @@ -150,6 +285,31 @@ components: minLength: 1 maxLength: 19 pattern: "^[a-z0-9]+(?:-+[a-z0-9]+)*$" + accessControlHeaderId: + name: accessControlHeaderId + in: path + required: true + description: The access control header identifier, in UUID format. + schema: + type: string + format: uuid + accessControlHeaderOrderBy: + name: orderBy + in: query + required: false + description: >- + Attributes to order the returned access control headers by, in priority + order (applied left to right). Append `:asc` or `:desc` to set the + direction; when omitted, `:asc` is assumed. Sortable attributes: + `creation_date`, `id`, `publishing_status`. + style: form + explode: false + schema: + type: array + maxItems: 3 + items: + type: string + pattern: "^(creation_date|id|publishing_status)(:(asc|desc))?$" responses: 401Unauthorized: description: Your access token is invalid or expired and can't be used to identify a user. @@ -270,3 +430,71 @@ components: type: object additionalProperties: $ref: "#/components/schemas/EnvironmentVariableUpdateEntry" + AccessControlHeader: + description: A header-based access control rule on the environment. + type: object + properties: + id: + type: string + format: uuid + description: The unique identifier of the access control header, in UUID format. + example: "ff832a9e-0e55-11ef-8f23-0242ac110002" + value: + type: string + description: The masked header value. Actual values are never returned in clear text. + maxLength: 127 + example: "****************by0z" + createdBy: + type: string + nullable: true + description: The email address of the user who created the header. Null if no user is associated. + maxLength: 255 + example: "dev@example.com" + creationDate: + type: string + format: date-time + description: The timestamp when the header was created. + example: "2026-08-24T09:23:28.872916Z" + publishingStatus: + $ref: "#/components/schemas/PublishingStatus" + AccessControlHeaderResult: + description: Paginated result containing a list of access control headers. + type: object + required: + - limit + - offset + - total + - data + properties: + limit: + type: integer + format: int32 + description: Maximum number of records returned per request. + example: 25 + offset: + type: integer + format: int32 + description: Zero-based index of the first record included in the result. + example: 0 + total: + type: integer + format: int32 + description: Total number of access control headers matching the request. + example: 3 + data: + type: array + description: The list of access control headers. May be empty. + items: + $ref: "#/components/schemas/AccessControlHeader" + AccessControlHeaderCreateRequest: + description: Request body to create an access control header. + type: object + required: + - value + properties: + value: + type: string + description: The header value. + minLength: 9 + maxLength: 127 + example: "jfoWikeaby0z" diff --git a/packages/b2c-tooling-sdk/src/cli/mrt-command.ts b/packages/b2c-tooling-sdk/src/cli/mrt-command.ts index 05354452a..756dec6ce 100644 --- a/packages/b2c-tooling-sdk/src/cli/mrt-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/mrt-command.ts @@ -287,8 +287,9 @@ export abstract class MrtCommand extends OAuthCommand< * Whether this command implements the SCAPI MRT backend. Defaults to `false`; * the supported commands (`mrt bundle history`, `mrt bundle list`, * `mrt bundle deploy` — both the local-build push and `` deploy — - * and the `mrt env var` family: `list`, `set`, `delete`, `push`) override it - * to `true`. Used by {@link init} to reject an explicit `--mrt-backend scapi` + * the `mrt env var` family: `list`, `set`, `delete`, `push`, and the + * `mrt env access-control` family: `list`, `create`, `get`, `delete`) override + * it to `true`. Used by {@link init} to reject an explicit `--mrt-backend scapi` * on commands that would otherwise silently fall back to legacy — an explicit * SCAPI request must never be quietly downgraded. */ @@ -304,8 +305,8 @@ export abstract class MrtCommand extends OAuthCommand< if (!this.supportsScapiMrt() && this.mrtBackendPreference === 'scapi') { this.error( '--mrt-backend scapi is not supported by this command yet. The SCAPI MRT backend currently supports ' + - '"mrt bundle history", "mrt bundle list", "mrt bundle deploy", and "mrt env var" (list/set/delete/push). ' + - 'Re-run with --mrt-backend legacy or auto.', + '"mrt bundle history", "mrt bundle list", "mrt bundle deploy", "mrt env var" (list/set/delete/push), and ' + + '"mrt env access-control" (list/create/get/delete). Re-run with --mrt-backend legacy or auto.', ); } } diff --git a/packages/b2c-tooling-sdk/src/clients/storefront-environments.generated.ts b/packages/b2c-tooling-sdk/src/clients/storefront-environments.generated.ts index 2a0de75e7..789d9b870 100644 --- a/packages/b2c-tooling-sdk/src/clients/storefront-environments.generated.ts +++ b/packages/b2c-tooling-sdk/src/clients/storefront-environments.generated.ts @@ -28,6 +28,54 @@ export interface paths { patch: operations["updateEnvironmentVariables"]; trace?: never; }; + "/organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/access-control-headers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get access control headers. + * @description Retrieves a paginated list of access control headers on the environment. Header values are returned masked and cannot be read back in plaintext. + */ + get: operations["getAccessControlHeaders"]; + put?: never; + /** + * Create an access control header. + * @description Creates a header-based access control rule on the environment. The created value is returned masked in the response. + */ + post: operations["createAccessControlHeader"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/access-control-headers/{accessControlHeaderId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get an access control header by ID. + * @description Retrieves a single access control header by its identifier. The value is returned masked. + */ + get: operations["getAccessControlHeader"]; + put?: never; + post?: never; + /** + * Delete an access control header. + * @description Deletes an access control header by its identifier. Returns 204 No Content on success. + */ + delete: operations["deleteAccessControlHeader"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -90,6 +138,63 @@ export interface components { EnvironmentVariablesUpdateRequest: { [key: string]: components["schemas"]["EnvironmentVariableUpdateEntry"]; }; + /** @description A header-based access control rule on the environment. */ + AccessControlHeader: { + /** + * Format: uuid + * @description The unique identifier of the access control header, in UUID format. + * @example ff832a9e-0e55-11ef-8f23-0242ac110002 + */ + id?: string; + /** + * @description The masked header value. Actual values are never returned in clear text. + * @example ****************by0z + */ + value?: string; + /** + * @description The email address of the user who created the header. Null if no user is associated. + * @example dev@example.com + */ + createdBy?: string | null; + /** + * Format: date-time + * @description The timestamp when the header was created. + * @example 2026-08-24T09:23:28.872916Z + */ + creationDate?: string; + publishingStatus?: components["schemas"]["PublishingStatus"]; + }; + /** @description Paginated result containing a list of access control headers. */ + AccessControlHeaderResult: { + /** + * Format: int32 + * @description Maximum number of records returned per request. + * @example 25 + */ + limit: number; + /** + * Format: int32 + * @description Zero-based index of the first record included in the result. + * @example 0 + */ + offset: number; + /** + * Format: int32 + * @description Total number of access control headers matching the request. + * @example 3 + */ + total: number; + /** @description The list of access control headers. May be empty. */ + data: components["schemas"]["AccessControlHeader"][]; + }; + /** @description Request body to create an access control header. */ + AccessControlHeaderCreateRequest: { + /** + * @description The header value. + * @example jfoWikeaby0z + */ + value: string; + }; }; responses: { /** @description Your access token is invalid or expired and can't be used to identify a user. */ @@ -136,6 +241,10 @@ export interface components { storefrontId: string; /** @description The environment identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ environmentId: string; + /** @description The access control header identifier, in UUID format. */ + accessControlHeaderId: string; + /** @description Attributes to order the returned access control headers by, in priority order (applied left to right). Append `:asc` or `:desc` to set the direction; when omitted, `:asc` is assumed. Sortable attributes: `creation_date`, `id`, `publishing_status`. */ + accessControlHeaderOrderBy: string[]; }; requestBodies: never; headers: never; @@ -206,4 +315,138 @@ export interface operations { 404: components["responses"]["404NotFound"]; }; }; + getAccessControlHeaders: { + parameters: { + query?: { + /** @description Attributes to order the returned access control headers by, in priority order (applied left to right). Append `:asc` or `:desc` to set the direction; when omitted, `:asc` is assumed. Sortable attributes: `creation_date`, `id`, `publishing_status`. */ + orderBy?: components["parameters"]["accessControlHeaderOrderBy"]; + /** @description Number of records to retrieve per request. Must be between 1 and 200. Defaults to 25. */ + limit?: number; + /** @description Zero-based index of the first record to include in the result. */ + offset?: number; + }; + header?: never; + path: { + /** @description The B2C Commerce organization identifier, e.g. `f_ecom_zzxy_prd`. */ + organizationId: components["parameters"]["organizationId"]; + /** @description The storefront identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + storefrontId: components["parameters"]["storefrontId"]; + /** @description The environment identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + environmentId: components["parameters"]["environmentId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of access control headers successfully retrieved. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AccessControlHeaderResult"]; + }; + }; + 401: components["responses"]["401Unauthorized"]; + 403: components["responses"]["403Forbidden"]; + 404: components["responses"]["404NotFound"]; + }; + }; + createAccessControlHeader: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The B2C Commerce organization identifier, e.g. `f_ecom_zzxy_prd`. */ + organizationId: components["parameters"]["organizationId"]; + /** @description The storefront identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + storefrontId: components["parameters"]["storefrontId"]; + /** @description The environment identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + environmentId: components["parameters"]["environmentId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AccessControlHeaderCreateRequest"]; + }; + }; + responses: { + /** @description Access control header successfully created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AccessControlHeader"]; + }; + }; + 400: components["responses"]["400BadRequest"]; + 401: components["responses"]["401Unauthorized"]; + 403: components["responses"]["403Forbidden"]; + 404: components["responses"]["404NotFound"]; + }; + }; + getAccessControlHeader: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The B2C Commerce organization identifier, e.g. `f_ecom_zzxy_prd`. */ + organizationId: components["parameters"]["organizationId"]; + /** @description The storefront identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + storefrontId: components["parameters"]["storefrontId"]; + /** @description The environment identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + environmentId: components["parameters"]["environmentId"]; + /** @description The access control header identifier, in UUID format. */ + accessControlHeaderId: components["parameters"]["accessControlHeaderId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Access control header successfully retrieved. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AccessControlHeader"]; + }; + }; + 401: components["responses"]["401Unauthorized"]; + 403: components["responses"]["403Forbidden"]; + 404: components["responses"]["404NotFound"]; + }; + }; + deleteAccessControlHeader: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The B2C Commerce organization identifier, e.g. `f_ecom_zzxy_prd`. */ + organizationId: components["parameters"]["organizationId"]; + /** @description The storefront identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + storefrontId: components["parameters"]["storefrontId"]; + /** @description The environment identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + environmentId: components["parameters"]["environmentId"]; + /** @description The access control header identifier, in UUID format. */ + accessControlHeaderId: components["parameters"]["accessControlHeaderId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Access control header successfully deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["401Unauthorized"]; + 403: components["responses"]["403Forbidden"]; + 404: components["responses"]["404NotFound"]; + }; + }; } diff --git a/packages/b2c-tooling-sdk/src/clients/storefront-environments.ts b/packages/b2c-tooling-sdk/src/clients/storefront-environments.ts index f13c90da6..07e375027 100644 --- a/packages/b2c-tooling-sdk/src/clients/storefront-environments.ts +++ b/packages/b2c-tooling-sdk/src/clients/storefront-environments.ts @@ -46,6 +46,10 @@ export type EnvironmentVariableEntry = components['schemas']['EnvironmentVariabl export type EnvironmentVariablesUpdateRequest = components['schemas']['EnvironmentVariablesUpdateRequest']; export type EnvironmentVariableUpdateEntry = components['schemas']['EnvironmentVariableUpdateEntry']; +export type AccessControlHeaderEntry = components['schemas']['AccessControlHeader']; +export type AccessControlHeaderResult = components['schemas']['AccessControlHeaderResult']; +export type AccessControlHeaderCreateRequest = components['schemas']['AccessControlHeaderCreateRequest']; + /** Publishing status of an environment variable (`pending` | `completed` | `failed`). */ export type PublishingStatus = components['schemas']['PublishingStatus']; diff --git a/packages/b2c-tooling-sdk/src/operations/mrt/access-control.ts b/packages/b2c-tooling-sdk/src/operations/mrt/access-control.ts index 2abf259f7..286782d05 100644 --- a/packages/b2c-tooling-sdk/src/operations/mrt/access-control.ts +++ b/packages/b2c-tooling-sdk/src/operations/mrt/access-control.ts @@ -13,7 +13,21 @@ import type {AuthStrategy} from '../../auth/types.js'; import {createMrtClient, DEFAULT_MRT_ORIGIN} from '../../clients/mrt.js'; import type {components} from '../../clients/mrt.js'; +import {SCOPE_MODE_HEADER} from '../../clients/middleware.js'; +import {createScapiRequestError} from '../../clients/scapi-backend-utils.js'; +import { + createStorefrontEnvironmentsClient, + toOrganizationId, + type StorefrontEnvironmentsClient, + type AccessControlHeaderEntry, +} from '../../clients/storefront-environments.js'; import {getLogger} from '../../logging/logger.js'; +import { + runMrtWithFallback, + type MrtBackend, + type MrtBackendPreference, + type ScapiMrtConnection, +} from './mrt-backend.js'; /** * Access control header type from API. @@ -348,3 +362,468 @@ export async function deleteAccessControlHeader( logger.debug({headerId}, '[MRT] Access control header deleted'); } + +// --------------------------------------------------------------------------- +// Backend-neutral access-control-header view + SCAPI MRT operations +// --------------------------------------------------------------------------- + +const READ_HEADERS = {[SCOPE_MODE_HEADER]: 'read'}; +const WRITE_HEADERS = {[SCOPE_MODE_HEADER]: 'write'}; + +/** + * Thrown when a backend-aware operation resolves to the legacy backend but no + * legacy auth was supplied. `legacyAuth` is optional so SCAPI-only callers + * aren't forced to configure a `~/.mobify` API key; this guards the case where + * legacy is actually needed (explicit `legacy`, or `auto` falling back). + */ +const LEGACY_AUTH_REQUIRED_MESSAGE = + 'Legacy MRT credentials are required for this backend but none were provided. ' + + 'Provide an API key (--api-key / MRT_API_KEY / ~/.mobify) or use the SCAPI MRT backend.'; + +/** + * A single access-control-header row, normalized across the legacy and SCAPI + * backends so the CLI renders one shape regardless of backend. + * + * Values stay masked (both backends return masked values) — the CLI never + * displays or reconstructs the plaintext header. Status is kept as the backend's + * own string (legacy `publishing_status_description`, SCAPI `publishingStatus` + * enum) so display doesn't silently change for existing legacy users. + */ +export interface MrtAccessControlHeaderView { + /** Header UUID. */ + id: string; + /** Masked header value. */ + value: string; + /** Human-readable publishing status, when the backend reports one. */ + status?: string; + /** Creation timestamp (ISO 8601), when present. */ + createdAt?: string; + /** Email of the user who created the header, when present. */ + createdBy?: string; + /** Backend that produced this row. */ + backend: MrtBackend; +} + +/** Normalizes a legacy MRT {@link MrtAccessControlHeader} into an {@link MrtAccessControlHeaderView}. */ +export function normalizeLegacyAccessControlHeader(header: MrtAccessControlHeader): MrtAccessControlHeaderView { + return { + id: header.id ?? '', + value: header.value ?? '', + status: header.publishing_status_description || undefined, + createdAt: header.created_at || undefined, + createdBy: header.user_email || undefined, + backend: 'legacy', + }; +} + +/** Normalizes a SCAPI {@link AccessControlHeaderEntry} into an {@link MrtAccessControlHeaderView}. */ +export function normalizeAccessControlHeaderScapi(header: AccessControlHeaderEntry): MrtAccessControlHeaderView { + return { + id: header.id ?? '', + value: header.value ?? '', + status: header.publishingStatus ?? undefined, + createdAt: header.creationDate ?? undefined, + createdBy: header.createdBy ?? undefined, + backend: 'scapi', + }; +} + +function buildScapiEnvironmentsClient(conn: ScapiMrtConnection): StorefrontEnvironmentsClient { + return createStorefrontEnvironmentsClient({shortCode: conn.shortCode, tenantId: conn.tenantId}, conn.auth); +} + +/** + * Lists access control headers for an environment via the SCAPI MRT Environments + * API. Forwards the standard SCAPI `limit`/`offset` pagination query parameters + * when provided (max 200 per page, default 25 server-side; the response echoes + * the full `total`). Values are returned masked. + * + * @throws {ScapiRequestError} carrying the HTTP status on a non-2xx response. + */ +export async function getAccessControlHeadersScapi( + conn: ScapiMrtConnection, + params: {storefrontId: string; environmentId: string; limit?: number; offset?: number}, +): Promise<{headers: MrtAccessControlHeaderView[]; count: number; raw: unknown}> { + const logger = getLogger(); + const {storefrontId, environmentId, limit, offset} = params; + const organizationId = toOrganizationId(conn.tenantId); + + logger.debug( + {organizationId, storefrontId, environmentId, limit, offset}, + '[MRT-SCAPI] Listing access control headers', + ); + + const client = buildScapiEnvironmentsClient(conn); + const {data, error, response} = await client.GET( + '/organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/access-control-headers', + { + params: {path: {organizationId, storefrontId, environmentId}, query: {limit, offset}}, + headers: READ_HEADERS, + }, + ); + + if (error || !data) { + throw createScapiRequestError(error, response, 'Failed to list access control headers'); + } + + return { + headers: (data.data ?? []).map(normalizeAccessControlHeaderScapi), + count: data.total ?? data.data?.length ?? 0, + raw: data, + }; +} + +/** + * Creates an access control header via the SCAPI MRT Environments API. The + * created value is returned masked (201 Created with the full header object). + * + * @throws {ScapiRequestError} carrying the HTTP status on a non-2xx response. + */ +export async function createAccessControlHeaderScapi( + conn: ScapiMrtConnection, + params: {storefrontId: string; environmentId: string; value: string}, +): Promise<{header: MrtAccessControlHeaderView; raw: unknown}> { + const logger = getLogger(); + const {storefrontId, environmentId, value} = params; + const organizationId = toOrganizationId(conn.tenantId); + + logger.debug({organizationId, storefrontId, environmentId}, '[MRT-SCAPI] Creating access control header'); + + const client = buildScapiEnvironmentsClient(conn); + const {data, error, response} = await client.POST( + '/organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/access-control-headers', + { + params: {path: {organizationId, storefrontId, environmentId}}, + headers: WRITE_HEADERS, + body: {value}, + }, + ); + + if (error || !data) { + throw createScapiRequestError(error, response, 'Failed to create access control header'); + } + + return {header: normalizeAccessControlHeaderScapi(data), raw: data}; +} + +/** + * Fetches a single access control header by ID via the SCAPI MRT Environments + * API. The value is returned masked. + * + * @throws {ScapiRequestError} carrying the HTTP status on a non-2xx response. + */ +export async function getAccessControlHeaderScapi( + conn: ScapiMrtConnection, + params: {storefrontId: string; environmentId: string; accessControlHeaderId: string}, +): Promise<{header: MrtAccessControlHeaderView; raw: unknown}> { + const {storefrontId, environmentId, accessControlHeaderId} = params; + const organizationId = toOrganizationId(conn.tenantId); + + const client = buildScapiEnvironmentsClient(conn); + const {data, error, response} = await client.GET( + '/organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/access-control-headers/{accessControlHeaderId}', + { + params: {path: {organizationId, storefrontId, environmentId, accessControlHeaderId}}, + headers: READ_HEADERS, + }, + ); + + if (error || !data) { + throw createScapiRequestError(error, response, `Failed to get access control header ${accessControlHeaderId}`); + } + + return {header: normalizeAccessControlHeaderScapi(data), raw: data}; +} + +/** + * Deletes an access control header by ID via the SCAPI MRT Environments API. The + * endpoint returns 204 No Content on success — there is no response body, so + * only `error` is inspected. + * + * @throws {ScapiRequestError} carrying the HTTP status on a non-2xx response. + */ +export async function deleteAccessControlHeaderScapi( + conn: ScapiMrtConnection, + params: {storefrontId: string; environmentId: string; accessControlHeaderId: string}, +): Promise { + const logger = getLogger(); + const {storefrontId, environmentId, accessControlHeaderId} = params; + const organizationId = toOrganizationId(conn.tenantId); + + logger.debug( + {organizationId, storefrontId, environmentId, accessControlHeaderId}, + '[MRT-SCAPI] Deleting access control header', + ); + + const client = buildScapiEnvironmentsClient(conn); + // 204 No Content on success: no `data` is returned, so check `error` only — + // treating missing `data` as failure would wrongly reject a successful delete. + const {error, response} = await client.DELETE( + '/organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/access-control-headers/{accessControlHeaderId}', + { + params: {path: {organizationId, storefrontId, environmentId, accessControlHeaderId}}, + headers: WRITE_HEADERS, + }, + ); + + if (error) { + throw createScapiRequestError(error, response, `Failed to delete access control header ${accessControlHeaderId}`); + } +} + +// --------------------------------------------------------------------------- +// Backend-aware access-control-header operations (route legacy ↔ SCAPI) +// --------------------------------------------------------------------------- + +/** Common backend-routing options shared by the backend-aware access-control operations. */ +export interface AccessControlBackendOptions { + /** Resolved `--mrt-backend` preference. */ + preference: MrtBackendPreference; + /** SCAPI connection; when absent, `auto` uses legacy and `scapi` throws. */ + scapiConnection?: ScapiMrtConnection; + /** Legacy API-key auth strategy. Optional; required only when the legacy backend actually runs. */ + legacyAuth?: AuthStrategy; + /** Project slug (= SCAPI storefront ID). */ + projectSlug: string; + /** Target/environment slug (= SCAPI environment ID). */ + environment: string; + /** Legacy MRT API origin. */ + origin?: string; + /** Invoked when `auto` falls back from SCAPI to legacy. */ + onFallback?: (reason: string) => void; + /** Invoked with the backend that serves the call (for `-D` debug). */ + onResolve?: (backend: MrtBackend) => void; +} + +/** Backend-neutral access-control-header list result. */ +export interface MrtAccessControlHeadersView { + /** Backend that served the list. */ + backend: MrtBackend; + /** Number of headers (SCAPI `total`, else the row count). */ + count: number; + /** Normalized header rows, consumed by the CLI table. */ + headers: MrtAccessControlHeaderView[]; + /** + * The raw, backend-native list response, surfaced verbatim under `--json` so + * each backend keeps its original machine contract (legacy: the + * {@link ListAccessControlHeadersResult} shape; SCAPI: the paginated + * `{limit, offset, total, data}` envelope). The normalized {@link headers} + * feed the human table only. + */ + raw: unknown; +} + +/** Backend-neutral single access-control-header result (get/create). */ +export interface MrtAccessControlHeaderResult { + /** Backend that served the call. */ + backend: MrtBackend; + /** Normalized header row. */ + header: MrtAccessControlHeaderView; + /** The raw, backend-native header response, surfaced verbatim under `--json`. */ + raw: unknown; +} + +/** The backend that served a write (delete) operation. */ +export interface MrtAccessControlWriteResult { + /** Backend that served the write. */ + backend: MrtBackend; +} + +/** Options for {@link listAccessControlHeadersWithBackend}. */ +export interface ListAccessControlHeadersBackendOptions extends AccessControlBackendOptions { + /** Maximum number of results to return (forwarded to both backends). */ + limit?: number; + /** Pagination offset (forwarded to both backends). */ + offset?: number; +} + +/** + * Lists access control headers, routing to the SCAPI or legacy backend per the + * given preference (with safe `auto` fallback). Returns normalized rows. + */ +export async function listAccessControlHeadersWithBackend( + options: ListAccessControlHeadersBackendOptions, +): Promise { + const { + preference, + scapiConnection, + legacyAuth, + projectSlug, + environment, + limit, + offset, + origin, + onFallback, + onResolve, + } = options; + + const run = await runMrtWithFallback<{count: number; headers: MrtAccessControlHeaderView[]; raw: unknown}>( + { + preference, + hasScapiConfig: Boolean(scapiConnection), + canFallbackToLegacy: Boolean(legacyAuth), + onFallback, + onResolve, + }, + { + scapi: () => + getAccessControlHeadersScapi(scapiConnection!, { + storefrontId: projectSlug, + environmentId: environment, + limit, + offset, + }), + legacy: async () => { + if (!legacyAuth) { + throw new Error(LEGACY_AUTH_REQUIRED_MESSAGE); + } + const result = await listAccessControlHeaders( + {projectSlug, targetSlug: environment, limit, offset, origin}, + legacyAuth, + ); + // The legacy list result is the shape legacy `--json` emitted before the + // backend split, so surface it verbatim under --json. + return {count: result.count, headers: result.headers.map(normalizeLegacyAccessControlHeader), raw: result}; + }, + }, + ); + + return {backend: run.backend, count: run.value.count, headers: run.value.headers, raw: run.value.raw}; +} + +/** Options for {@link createAccessControlHeaderWithBackend}. */ +export interface CreateAccessControlHeaderBackendOptions extends AccessControlBackendOptions { + /** The header value to create. */ + value: string; +} + +/** + * Creates an access control header, routing to the SCAPI or legacy backend per + * the given preference (with safe `auto` fallback). + */ +export async function createAccessControlHeaderWithBackend( + options: CreateAccessControlHeaderBackendOptions, +): Promise { + const {preference, scapiConnection, legacyAuth, projectSlug, environment, value, origin, onFallback, onResolve} = + options; + + const run = await runMrtWithFallback<{header: MrtAccessControlHeaderView; raw: unknown}>( + { + preference, + hasScapiConfig: Boolean(scapiConnection), + canFallbackToLegacy: Boolean(legacyAuth), + onFallback, + onResolve, + }, + { + scapi: () => + createAccessControlHeaderScapi(scapiConnection!, { + storefrontId: projectSlug, + environmentId: environment, + value, + }), + legacy: async () => { + if (!legacyAuth) { + throw new Error(LEGACY_AUTH_REQUIRED_MESSAGE); + } + const header = await createAccessControlHeader( + {projectSlug, targetSlug: environment, value, origin}, + legacyAuth, + ); + return {header: normalizeLegacyAccessControlHeader(header), raw: header}; + }, + }, + ); + + return {backend: run.backend, header: run.value.header, raw: run.value.raw}; +} + +/** Options for {@link getAccessControlHeaderWithBackend}. */ +export interface GetAccessControlHeaderBackendOptions extends AccessControlBackendOptions { + /** The header UUID to fetch. */ + headerId: string; +} + +/** + * Fetches a single access control header by ID, routing to the SCAPI or legacy + * backend per the given preference (with safe `auto` fallback). + */ +export async function getAccessControlHeaderWithBackend( + options: GetAccessControlHeaderBackendOptions, +): Promise { + const {preference, scapiConnection, legacyAuth, projectSlug, environment, headerId, origin, onFallback, onResolve} = + options; + + const run = await runMrtWithFallback<{header: MrtAccessControlHeaderView; raw: unknown}>( + { + preference, + hasScapiConfig: Boolean(scapiConnection), + canFallbackToLegacy: Boolean(legacyAuth), + onFallback, + onResolve, + }, + { + scapi: () => + getAccessControlHeaderScapi(scapiConnection!, { + storefrontId: projectSlug, + environmentId: environment, + accessControlHeaderId: headerId, + }), + legacy: async () => { + if (!legacyAuth) { + throw new Error(LEGACY_AUTH_REQUIRED_MESSAGE); + } + const header = await getAccessControlHeader( + {projectSlug, targetSlug: environment, headerId, origin}, + legacyAuth, + ); + return {header: normalizeLegacyAccessControlHeader(header), raw: header}; + }, + }, + ); + + return {backend: run.backend, header: run.value.header, raw: run.value.raw}; +} + +/** Options for {@link deleteAccessControlHeaderWithBackend}. */ +export interface DeleteAccessControlHeaderBackendOptions extends AccessControlBackendOptions { + /** The header UUID to delete. */ + headerId: string; +} + +/** + * Deletes an access control header by ID, routing to the SCAPI or legacy backend + * per the given preference (with safe `auto` fallback). + */ +export async function deleteAccessControlHeaderWithBackend( + options: DeleteAccessControlHeaderBackendOptions, +): Promise { + const {preference, scapiConnection, legacyAuth, projectSlug, environment, headerId, origin, onFallback, onResolve} = + options; + + const run = await runMrtWithFallback( + { + preference, + hasScapiConfig: Boolean(scapiConnection), + canFallbackToLegacy: Boolean(legacyAuth), + onFallback, + onResolve, + }, + { + scapi: () => + deleteAccessControlHeaderScapi(scapiConnection!, { + storefrontId: projectSlug, + environmentId: environment, + accessControlHeaderId: headerId, + }), + legacy: async () => { + if (!legacyAuth) { + throw new Error(LEGACY_AUTH_REQUIRED_MESSAGE); + } + await deleteAccessControlHeader({projectSlug, targetSlug: environment, headerId, origin}, legacyAuth); + }, + }, + ); + + return {backend: run.backend}; +} diff --git a/packages/b2c-tooling-sdk/src/operations/mrt/index.ts b/packages/b2c-tooling-sdk/src/operations/mrt/index.ts index 0c53b9ca9..996e63154 100644 --- a/packages/b2c-tooling-sdk/src/operations/mrt/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/mrt/index.ts @@ -327,6 +327,16 @@ export { createAccessControlHeader, getAccessControlHeader, deleteAccessControlHeader, + getAccessControlHeadersScapi, + createAccessControlHeaderScapi, + getAccessControlHeaderScapi, + deleteAccessControlHeaderScapi, + listAccessControlHeadersWithBackend, + createAccessControlHeaderWithBackend, + getAccessControlHeaderWithBackend, + deleteAccessControlHeaderWithBackend, + normalizeLegacyAccessControlHeader, + normalizeAccessControlHeaderScapi, } from './access-control.js'; export type { ListAccessControlHeadersOptions, @@ -335,6 +345,15 @@ export type { GetAccessControlHeaderOptions, DeleteAccessControlHeaderOptions, MrtAccessControlHeader, + MrtAccessControlHeaderView, + MrtAccessControlHeadersView, + MrtAccessControlHeaderResult, + MrtAccessControlWriteResult, + AccessControlBackendOptions, + ListAccessControlHeadersBackendOptions, + CreateAccessControlHeaderBackendOptions, + GetAccessControlHeaderBackendOptions, + DeleteAccessControlHeaderBackendOptions, } from './access-control.js'; // Cache operations diff --git a/packages/b2c-tooling-sdk/test/operations/mrt/access-control.test.ts b/packages/b2c-tooling-sdk/test/operations/mrt/access-control.test.ts new file mode 100644 index 000000000..a7885d43b --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/mrt/access-control.test.ts @@ -0,0 +1,694 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {DEFAULT_MRT_ORIGIN} from '@salesforce/b2c-tooling-sdk/clients'; +import { + listAccessControlHeaders, + createAccessControlHeader, + getAccessControlHeader, + deleteAccessControlHeader, + getAccessControlHeadersScapi, + createAccessControlHeaderScapi, + getAccessControlHeaderScapi, + deleteAccessControlHeaderScapi, + listAccessControlHeadersWithBackend, + createAccessControlHeaderWithBackend, + getAccessControlHeaderWithBackend, + deleteAccessControlHeaderWithBackend, +} from '@salesforce/b2c-tooling-sdk/operations/mrt'; +import type {ScapiMrtConnection} from '../../../src/operations/mrt/mrt-backend.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; + +const DEFAULT_BASE_URL = DEFAULT_MRT_ORIGIN; + +const SHORT_CODE = 'kv7kzm78'; +const TENANT_ID = 'zzxy_prd'; +const STOREFRONT_ID = 'my-project'; +const ENVIRONMENT_ID = 'staging'; +const HEADER_ID = 'ff832a9e-0e55-11ef-8f23-0242ac110002'; +const SCAPI_BASE = `https://${SHORT_CODE}.api.commercecloud.salesforce.com/storefront/environments/v1`; +const SCAPI_HEADERS = `${SCAPI_BASE}/organizations/:organizationId/storefronts/:storefrontId/environments/:environmentId/access-control-headers`; +const SCAPI_HEADER = `${SCAPI_HEADERS}/:accessControlHeaderId`; +const LEGACY_HEADERS = `${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/access-control-header/`; +const LEGACY_HEADER = `${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/access-control-header/:id/`; + +function scapiConn(): ScapiMrtConnection { + return {shortCode: SHORT_CODE, tenantId: TENANT_ID, auth: new MockAuthStrategy()}; +} + +describe('operations/mrt/access-control', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + // ------------------------------------------------------------------------- + // Legacy MRT operations + // ------------------------------------------------------------------------- + + describe('listAccessControlHeaders', () => { + it('lists access control headers', async () => { + server.use( + http.get(LEGACY_HEADERS, () => { + return HttpResponse.json({ + count: 1, + next: null, + previous: null, + results: [ + { + id: HEADER_ID, + value: '****3456', + user_email: 'dev@example.com', + created_at: '2026-01-01T00:00:00Z', + publishing_status: 1, + publishing_status_description: 'published', + }, + ], + }); + }), + ); + + const result = await listAccessControlHeaders( + {projectSlug: STOREFRONT_ID, targetSlug: ENVIRONMENT_ID}, + new MockAuthStrategy(), + ); + + expect(result.count).to.equal(1); + expect(result.headers).to.have.length(1); + expect(result.headers[0].id).to.equal(HEADER_ID); + expect(result.headers[0].value).to.equal('****3456'); + }); + + it('throws error on API failure', async () => { + server.use( + http.get(LEGACY_HEADERS, () => { + return HttpResponse.json({detail: 'Project not found'}, {status: 404}); + }), + ); + + try { + await listAccessControlHeaders({projectSlug: 'nope', targetSlug: ENVIRONMENT_ID}, new MockAuthStrategy()); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).to.include('Failed to list access control headers'); + } + }); + }); + + describe('createAccessControlHeader', () => { + it('creates an access control header', async () => { + let receivedBody: unknown; + server.use( + http.post(LEGACY_HEADERS, async ({request}) => { + receivedBody = await request.json(); + return HttpResponse.json({id: HEADER_ID, value: '****3456'}, {status: 201}); + }), + ); + + const header = await createAccessControlHeader( + {projectSlug: STOREFRONT_ID, targetSlug: ENVIRONMENT_ID, value: 'my-secret-header'}, + new MockAuthStrategy(), + ); + + expect(receivedBody).to.deep.equal({value: 'my-secret-header'}); + expect(header.id).to.equal(HEADER_ID); + }); + + it('throws error on API failure', async () => { + server.use( + http.post(LEGACY_HEADERS, () => { + return HttpResponse.json({detail: 'Invalid'}, {status: 400}); + }), + ); + + try { + await createAccessControlHeader( + {projectSlug: STOREFRONT_ID, targetSlug: ENVIRONMENT_ID, value: 'x'}, + new MockAuthStrategy(), + ); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).to.include('Failed to create access control header'); + } + }); + }); + + describe('getAccessControlHeader', () => { + it('gets an access control header by id', async () => { + server.use( + http.get(LEGACY_HEADER, ({params}) => { + expect(params.id).to.equal(HEADER_ID); + return HttpResponse.json({id: HEADER_ID, value: '****3456'}); + }), + ); + + const header = await getAccessControlHeader( + {projectSlug: STOREFRONT_ID, targetSlug: ENVIRONMENT_ID, headerId: HEADER_ID}, + new MockAuthStrategy(), + ); + + expect(header.id).to.equal(HEADER_ID); + }); + + it('throws error on API failure', async () => { + server.use( + http.get(LEGACY_HEADER, () => { + return HttpResponse.json({detail: 'Not found'}, {status: 404}); + }), + ); + + try { + await getAccessControlHeader( + {projectSlug: STOREFRONT_ID, targetSlug: ENVIRONMENT_ID, headerId: HEADER_ID}, + new MockAuthStrategy(), + ); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).to.include('Failed to get access control header'); + } + }); + }); + + describe('deleteAccessControlHeader', () => { + it('deletes an access control header by id', async () => { + let called = false; + server.use( + http.delete(LEGACY_HEADER, ({params}) => { + called = true; + expect(params.id).to.equal(HEADER_ID); + return new HttpResponse(null, {status: 204}); + }), + ); + + await deleteAccessControlHeader( + {projectSlug: STOREFRONT_ID, targetSlug: ENVIRONMENT_ID, headerId: HEADER_ID}, + new MockAuthStrategy(), + ); + + expect(called).to.equal(true); + }); + + it('throws error on API failure', async () => { + server.use( + http.delete(LEGACY_HEADER, () => { + return HttpResponse.json({detail: 'Not found'}, {status: 404}); + }), + ); + + try { + await deleteAccessControlHeader( + {projectSlug: STOREFRONT_ID, targetSlug: ENVIRONMENT_ID, headerId: HEADER_ID}, + new MockAuthStrategy(), + ); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).to.include('Failed to delete access control header'); + } + }); + }); + + // ------------------------------------------------------------------------- + // SCAPI MRT operations + // ------------------------------------------------------------------------- + + describe('getAccessControlHeadersScapi', () => { + it('maps the SCAPI envelope into normalized views with masked values', async () => { + server.use( + http.get(SCAPI_HEADERS, ({params, request}) => { + expect(params.organizationId).to.equal('f_ecom_zzxy_prd'); + expect(params.storefrontId).to.equal(STOREFRONT_ID); + expect(params.environmentId).to.equal(ENVIRONMENT_ID); + const url = new URL(request.url); + expect(url.searchParams.get('limit')).to.equal('10'); + expect(url.searchParams.get('offset')).to.equal('5'); + return HttpResponse.json({ + limit: 10, + offset: 5, + total: 1, + data: [ + { + id: HEADER_ID, + value: '****3456', + createdBy: 'dev@example.com', + creationDate: '2026-04-08T21:47:28.188965Z', + publishingStatus: 'completed', + }, + ], + }); + }), + ); + + const result = await getAccessControlHeadersScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + limit: 10, + offset: 5, + }); + + expect(result.count).to.equal(1); + expect(result.headers[0]).to.deep.equal({ + id: HEADER_ID, + value: '****3456', + status: 'completed', + createdAt: '2026-04-08T21:47:28.188965Z', + createdBy: 'dev@example.com', + backend: 'scapi', + }); + // raw is the native SCAPI envelope surfaced verbatim for --json. + expect(result.raw).to.have.property('total', 1); + expect(result.raw).to.have.property('data'); + }); + + it('throws a ScapiRequestError on a non-2xx response', async () => { + server.use( + http.get(SCAPI_HEADERS, () => { + return HttpResponse.json( + {title: 'Not Found', detail: 'Environment staging not found'}, + {status: 404, headers: {'Content-Type': 'application/problem+json'}}, + ); + }), + ); + + try { + await getAccessControlHeadersScapi(scapiConn(), {storefrontId: STOREFRONT_ID, environmentId: ENVIRONMENT_ID}); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).to.include('Environment staging not found'); + expect((error as {status?: number}).status).to.equal(404); + } + }); + }); + + describe('createAccessControlHeaderScapi', () => { + it('posts the value and returns the created masked header', async () => { + let receivedBody: unknown; + server.use( + http.post(SCAPI_HEADERS, async ({request}) => { + receivedBody = await request.json(); + return HttpResponse.json({id: HEADER_ID, value: '****3456', publishingStatus: 'pending'}, {status: 201}); + }), + ); + + const result = await createAccessControlHeaderScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + value: 'my-secret-header', + }); + + expect(receivedBody).to.deep.equal({value: 'my-secret-header'}); + expect(result.header).to.deep.include({id: HEADER_ID, value: '****3456', status: 'pending', backend: 'scapi'}); + expect(result.raw).to.have.property('id', HEADER_ID); + }); + + it('throws a ScapiRequestError on a non-2xx response', async () => { + server.use( + http.post(SCAPI_HEADERS, () => { + return HttpResponse.json( + {title: 'Bad Request', detail: 'value too short'}, + {status: 400, headers: {'Content-Type': 'application/problem+json'}}, + ); + }), + ); + + try { + await createAccessControlHeaderScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + value: 'short', + }); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).to.include('value too short'); + expect((error as {status?: number}).status).to.equal(400); + } + }); + }); + + describe('getAccessControlHeaderScapi', () => { + it('gets a single header by id', async () => { + server.use( + http.get(SCAPI_HEADER, ({params}) => { + expect(params.accessControlHeaderId).to.equal(HEADER_ID); + return HttpResponse.json({id: HEADER_ID, value: '****3456', publishingStatus: 'completed'}); + }), + ); + + const result = await getAccessControlHeaderScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + accessControlHeaderId: HEADER_ID, + }); + + expect(result.header).to.deep.include({id: HEADER_ID, value: '****3456', status: 'completed', backend: 'scapi'}); + }); + + it('throws a ScapiRequestError on a non-2xx response', async () => { + server.use( + http.get(SCAPI_HEADER, () => { + return HttpResponse.json( + {title: 'Not Found', detail: 'header not found'}, + {status: 404, headers: {'Content-Type': 'application/problem+json'}}, + ); + }), + ); + + try { + await getAccessControlHeaderScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + accessControlHeaderId: HEADER_ID, + }); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as {status?: number}).status).to.equal(404); + } + }); + }); + + describe('deleteAccessControlHeaderScapi', () => { + it('treats 204 No Content as success', async () => { + let called = false; + server.use( + http.delete(SCAPI_HEADER, ({params}) => { + called = true; + expect(params.accessControlHeaderId).to.equal(HEADER_ID); + return new HttpResponse(null, {status: 204}); + }), + ); + + await deleteAccessControlHeaderScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + accessControlHeaderId: HEADER_ID, + }); + + expect(called).to.equal(true); + }); + + it('throws a ScapiRequestError on a non-2xx response', async () => { + server.use( + http.delete(SCAPI_HEADER, () => { + return HttpResponse.json( + {title: 'Forbidden', detail: 'Missing scope'}, + {status: 403, headers: {'Content-Type': 'application/problem+json'}}, + ); + }), + ); + + try { + await deleteAccessControlHeaderScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + accessControlHeaderId: HEADER_ID, + }); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as {status?: number}).status).to.equal(403); + } + }); + }); + + // ------------------------------------------------------------------------- + // Backend-aware wrappers (legacy ↔ SCAPI routing) + // ------------------------------------------------------------------------- + + describe('listAccessControlHeadersWithBackend', () => { + it('routes to SCAPI when preference is scapi', async () => { + server.use( + http.get(SCAPI_HEADERS, () => { + return HttpResponse.json({limit: 25, offset: 0, total: 1, data: [{id: HEADER_ID, value: '****3456'}]}); + }), + ); + + const result = await listAccessControlHeadersWithBackend({ + preference: 'scapi', + scapiConnection: scapiConn(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + }); + + expect(result.backend).to.equal('scapi'); + expect(result.count).to.equal(1); + expect(result.headers[0]).to.deep.include({id: HEADER_ID, backend: 'scapi'}); + // SCAPI raw is the native paginated envelope. + expect(result.raw).to.have.property('total', 1); + }); + + it('routes to legacy when preference is legacy and surfaces the legacy shape as raw', async () => { + server.use( + http.get(LEGACY_HEADERS, () => { + return HttpResponse.json({ + count: 1, + next: null, + previous: null, + results: [{id: HEADER_ID, value: '***456', publishing_status_description: 'published'}], + }); + }), + ); + + const result = await listAccessControlHeadersWithBackend({ + preference: 'legacy', + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + }); + + expect(result.backend).to.equal('legacy'); + expect(result.headers[0]).to.deep.include({id: HEADER_ID, backend: 'legacy', status: 'published'}); + // Legacy raw preserves the pre-split ListAccessControlHeadersResult shape for --json. + expect(result.raw).to.have.property('count', 1); + expect(result.raw).to.have.property('headers'); + }); + + it('auto falls back from SCAPI to legacy on a safe error', async () => { + const fallbacks: string[] = []; + server.use( + http.get(SCAPI_HEADERS, () => { + return HttpResponse.json( + {title: 'Not Found', detail: 'not found'}, + {status: 404, headers: {'Content-Type': 'application/problem+json'}}, + ); + }), + http.get(LEGACY_HEADERS, () => { + return HttpResponse.json({count: 1, results: [{id: HEADER_ID, value: '***456'}]}); + }), + ); + + const result = await listAccessControlHeadersWithBackend({ + preference: 'auto', + scapiConnection: scapiConn(), + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + onFallback: (reason) => fallbacks.push(reason), + }); + + expect(result.backend).to.equal('legacy'); + expect(result.headers[0]).to.deep.include({id: HEADER_ID, backend: 'legacy'}); + expect(fallbacks).to.have.length(1); + }); + + it('auto uses legacy when no SCAPI connection is configured', async () => { + server.use( + http.get(LEGACY_HEADERS, () => { + return HttpResponse.json({count: 0, results: []}); + }), + ); + + const result = await listAccessControlHeadersWithBackend({ + preference: 'auto', + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + }); + + expect(result.backend).to.equal('legacy'); + expect(result.count).to.equal(0); + }); + + it('explicit scapi with no SCAPI connection fails loud', async () => { + try { + await listAccessControlHeadersWithBackend({ + preference: 'scapi', + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + }); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).to.include('SCAPI MRT backend requires'); + } + }); + }); + + describe('createAccessControlHeaderWithBackend', () => { + it('creates via SCAPI when preference is scapi', async () => { + let receivedBody: unknown; + server.use( + http.post(SCAPI_HEADERS, async ({request}) => { + receivedBody = await request.json(); + return HttpResponse.json({id: HEADER_ID, value: '****3456'}, {status: 201}); + }), + ); + + const result = await createAccessControlHeaderWithBackend({ + preference: 'scapi', + scapiConnection: scapiConn(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + value: 'my-secret-header', + }); + + expect(result.backend).to.equal('scapi'); + expect(receivedBody).to.deep.equal({value: 'my-secret-header'}); + expect(result.header).to.deep.include({id: HEADER_ID, backend: 'scapi'}); + }); + + it('creates via legacy when preference is legacy', async () => { + server.use( + http.post(LEGACY_HEADERS, () => { + return HttpResponse.json({id: HEADER_ID, value: '***456'}, {status: 201}); + }), + ); + + const result = await createAccessControlHeaderWithBackend({ + preference: 'legacy', + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + value: 'my-secret-header', + }); + + expect(result.backend).to.equal('legacy'); + expect(result.header).to.deep.include({id: HEADER_ID, backend: 'legacy'}); + }); + }); + + describe('getAccessControlHeaderWithBackend', () => { + it('gets via SCAPI when preference is scapi', async () => { + server.use( + http.get(SCAPI_HEADER, () => { + return HttpResponse.json({id: HEADER_ID, value: '****3456', publishingStatus: 'completed'}); + }), + ); + + const result = await getAccessControlHeaderWithBackend({ + preference: 'scapi', + scapiConnection: scapiConn(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + headerId: HEADER_ID, + }); + + expect(result.backend).to.equal('scapi'); + expect(result.header).to.deep.include({id: HEADER_ID, status: 'completed', backend: 'scapi'}); + }); + + it('gets via legacy when preference is legacy', async () => { + server.use( + http.get(LEGACY_HEADER, () => { + return HttpResponse.json({id: HEADER_ID, value: '***456'}); + }), + ); + + const result = await getAccessControlHeaderWithBackend({ + preference: 'legacy', + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + headerId: HEADER_ID, + }); + + expect(result.backend).to.equal('legacy'); + expect(result.header).to.deep.include({id: HEADER_ID, backend: 'legacy'}); + }); + }); + + describe('deleteAccessControlHeaderWithBackend', () => { + it('deletes via SCAPI when preference is scapi', async () => { + let called = false; + server.use( + http.delete(SCAPI_HEADER, () => { + called = true; + return new HttpResponse(null, {status: 204}); + }), + ); + + const result = await deleteAccessControlHeaderWithBackend({ + preference: 'scapi', + scapiConnection: scapiConn(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + headerId: HEADER_ID, + }); + + expect(result.backend).to.equal('scapi'); + expect(called).to.equal(true); + }); + + it('explicit scapi surfaces errors without falling back to legacy', async () => { + let legacyCalled = false; + server.use( + http.delete(SCAPI_HEADER, () => { + return HttpResponse.json( + {title: 'Not Found', detail: 'not found'}, + {status: 404, headers: {'Content-Type': 'application/problem+json'}}, + ); + }), + http.delete(LEGACY_HEADER, () => { + legacyCalled = true; + return new HttpResponse(null, {status: 204}); + }), + ); + + try { + await deleteAccessControlHeaderWithBackend({ + preference: 'scapi', + scapiConnection: scapiConn(), + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + headerId: HEADER_ID, + }); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as {status?: number}).status).to.equal(404); + } + expect(legacyCalled).to.equal(false); + }); + + it('deletes via legacy when preference is legacy', async () => { + let called = false; + server.use( + http.delete(LEGACY_HEADER, () => { + called = true; + return new HttpResponse(null, {status: 204}); + }), + ); + + const result = await deleteAccessControlHeaderWithBackend({ + preference: 'legacy', + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + headerId: HEADER_ID, + }); + + expect(result.backend).to.equal('legacy'); + expect(called).to.equal(true); + }); + }); +}); diff --git a/skills/b2c-cli/skills/b2c-mrt/SKILL.md b/skills/b2c-cli/skills/b2c-mrt/SKILL.md index a8ec02e59..6ba803701 100644 --- a/skills/b2c-cli/skills/b2c-mrt/SKILL.md +++ b/skills/b2c-cli/skills/b2c-mrt/SKILL.md @@ -17,16 +17,17 @@ Run `b2c setup inspect` to see the resolved configuration and which source provi ### MRT Backends (legacy vs SCAPI) -Most MRT commands run against the legacy MRT Cloud API (API key). Several commands can also run over the SCAPI MRT backend: `mrt bundle history`, `mrt bundle list`, `mrt bundle deploy` (both the local-build push and deploying an existing ``), and the `mrt env var` family (`list` / `set` / `push` / `delete`). Choose with `--mrt-backend` (`MRT_BACKEND` / `SFCC_MRT_BACKEND`, or `mrtBackend` in `dw.json`): +Most MRT commands run against the legacy MRT Cloud API (API key). Several commands can also run over the SCAPI MRT backend: `mrt bundle history`, `mrt bundle list`, `mrt bundle deploy` (both the local-build push and deploying an existing ``), the `mrt env var` family (`list` / `set` / `push` / `delete`), and the `mrt env access-control` family (`list` / `create` / `get` / `delete`). Choose with `--mrt-backend` (`MRT_BACKEND` / `SFCC_MRT_BACKEND`, or `mrtBackend` in `dw.json`): - `legacy` (default) — always the MRT Cloud API. -- `auto` — use SCAPI when it's configured (`--short-code` + `--tenant-id` + client-credentials or JWT Bearer auth), otherwise legacy. Falls back to legacy on safe pre-execution errors. Each command requests its own scopes: bundle commands use `sfcc.storefront.deployments[.rw]`; env var commands use `sfcc.storefront.environments[.rw]` (reads accept either tier, writes require `.rw`). -- `scapi` — always SCAPI, with no fallback; errors if prerequisites are missing. Also errors on unsupported commands (every MRT command except the bundle and env var commands above). +- `auto` — use SCAPI when it's configured (`--short-code` + `--tenant-id` + client-credentials or JWT Bearer auth), otherwise legacy. Falls back to legacy on safe pre-execution errors. Each command requests its own scopes: bundle commands use `sfcc.storefront.deployments[.rw]`; env var and access-control commands use `sfcc.storefront.environments[.rw]` (reads accept either tier, writes require `.rw`). +- `scapi` — always SCAPI, with no fallback; errors if prerequisites are missing. Also errors on unsupported commands (every MRT command except the bundle, env var, and access-control commands above). Notes: - Under `--json`, these commands emit the **serving backend's native shape** (e.g. `bundle history` is legacy `{count, next, previous, deployments}` vs SCAPI `{limit, offset, total, data}`; `env var list` is legacy `{count, variables}` vs the SCAPI native environment-variables map). The human table is normalized; `--json` is not. Pin `legacy` or `scapi` when a script needs a stable shape. - Env vars over SCAPI use the Storefront Environments API. `set` and `delete` apply a merge-PATCH (only the keys you pass change; `delete` sends the key with a `null` value); values are always masked by both backends. `push` resolves the backend once from its initial read and pins every write to it, so a single `push` never crosses backends. +- Access-control headers over SCAPI also use the Storefront Environments API. `get` and `delete` key on the header UUID; `create` takes the header value as a positional argument (201 Created returns the full masked header). Header values are always masked by both backends — the CLI never displays or reconstructs the plaintext value. - Legacy-only flags (`--api-key`, `--cloud-origin` / `-u`, `--credentials-file` / `-c`) are ignored — with a warning — when SCAPI serves the request. - `--storefront` (long) and `-s` (short) are aliases of `--project` / `-p` — the SCAPI storefront ID is the project slug, so all four are interchangeable on every `mrt` command. On `mrt project create` this flag sets the new project's slug (auto-generated from the name if omitted); `mrt bundle save` uses `-d` for `--save-dir`, keeping `-s` free for the storefront alias. diff --git a/skills/b2c-cli/skills/b2c-mrt/references/ENVIRONMENT-COMMANDS.md b/skills/b2c-cli/skills/b2c-mrt/references/ENVIRONMENT-COMMANDS.md index 6810146ea..57b2cba05 100644 --- a/skills/b2c-cli/skills/b2c-mrt/references/ENVIRONMENT-COMMANDS.md +++ b/skills/b2c-cli/skills/b2c-mrt/references/ENVIRONMENT-COMMANDS.md @@ -210,9 +210,45 @@ b2c mrt env redirect clone -p my-storefront \ ## Access Control Headers +The `mrt env access-control` commands (`list` / `create` / `get` / `delete`) are backend-aware: they honor `--mrt-backend` (`auto` / `legacy` / `scapi`) and, over SCAPI, use the Storefront Environments API (scopes `sfcc.storefront.environments` for reads, `sfcc.storefront.environments.rw` for writes). See the [MRT Backends](../SKILL.md#mrt-backends-legacy-vs-scapi) section for backend selection. Header values are always masked by both backends. Under `--json`, each command returns the serving backend's native shape. + ### List Access Control Headers ```bash b2c mrt env access-control list -p my-storefront -e staging b2c mrt env access-control list -p my-storefront -e production --json + +# Force the SCAPI backend +b2c mrt env access-control list -p my-storefront -e staging --mrt-backend scapi +``` + +### Create an Access Control Header + +The header value is passed as a positional argument. + +```bash +b2c mrt env access-control create my-secret-header-value -p my-storefront -e production + +# Force the SCAPI backend +b2c mrt env access-control create my-secret-header-value -p my-storefront -e production --mrt-backend scapi +``` + +### Get an Access Control Header + +Fetch a single header by its ID (UUID). + +```bash +b2c mrt env access-control get ff832a9e-0e55-11ef-8f23-0242ac110002 -p my-storefront -e production +b2c mrt env access-control get ff832a9e-0e55-11ef-8f23-0242ac110002 -p my-storefront -e production --json +``` + +### Delete an Access Control Header + +Delete a header by its ID (UUID). + +```bash +b2c mrt env access-control delete ff832a9e-0e55-11ef-8f23-0242ac110002 -p my-storefront -e production + +# Force the SCAPI backend +b2c mrt env access-control delete ff832a9e-0e55-11ef-8f23-0242ac110002 -p my-storefront -e production --mrt-backend scapi ``` From 369d161a0083f9e59477871987e5a272f328b661 Mon Sep 17 00:00:00 2001 From: vvemireddy-sf <152321833+vvemireddy-sf@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:57:04 -0700 Subject: [PATCH 2/2] Add SCAPI backend + get/update to mrt env redirect (W-24145907) (#729) * Add SCAPI backend + get/update to mrt env redirect (W-24145907) Make the `mrt env redirect` command family backend-aware between the legacy MRT Cloud API and the SCAPI Storefront Environments API, and add two new subcommands (`get`, `update`) so all six redirect operations are reachable over SCAPI. - SDK: add the redirect paths/schemas to the storefront environments spec, regenerate the client types, export the new types, and add the 6 *Scapi operations + normalizers and the 6 backend-aware *WithBackend wrappers (auto fallback, fail-loud on explicit scapi). Update mrt-command.ts for the new supported family. - CLI: rewrite list/create/delete/clone backend-aware; add get/update. The neutral `identifier` positional maps to the source path on legacy and the redirect ID (UUID) on SCAPI. update is a partial update; clone rejects a same-source clone. - Tests: SDK (MSW) + CLI (sinon) covering legacy, SCAPI, and the backend routing matrix. - Docs, tooling-index, and changeset updated. * docs: add redirect and access-control families to the SCAPI-supported commands list * Add --source/--destination aliases for redirect create --from/--to (W-24145907) --- .changeset/mrt-redirect-scapi.md | 6 + docs/cli/mrt.md | 83 +- .../src/commands/mrt/env/redirect/clone.ts | 93 +- .../src/commands/mrt/env/redirect/create.ts | 81 +- .../src/commands/mrt/env/redirect/delete.ts | 68 +- .../src/commands/mrt/env/redirect/get.ts | 95 ++ .../src/commands/mrt/env/redirect/list.ts | 83 +- .../src/commands/mrt/env/redirect/update.ts | 136 +++ .../commands/mrt/env/redirect/clone.test.ts | 223 ++--- .../commands/mrt/env/redirect/create.test.ts | 351 ++----- .../commands/mrt/env/redirect/delete.test.ts | 239 ++--- .../commands/mrt/env/redirect/get.test.ts | 174 ++++ .../commands/mrt/env/redirect/list.test.ts | 317 +++---- .../commands/mrt/env/redirect/update.test.ts | 156 ++++ .../b2c-tooling-sdk/data/tooling/index.json | 4 +- .../specs/storefront-environments-v1.yaml | 402 +++++++- .../b2c-tooling-sdk/src/cli/mrt-command.ts | 12 +- .../storefront-environments.generated.ts | 422 +++++++++ .../src/clients/storefront-environments.ts | 21 +- .../src/operations/mrt/index.ts | 27 + .../src/operations/mrt/redirect.ts | 794 ++++++++++++++++ .../test/operations/mrt/redirect.test.ts | 857 ++++++++++++++++++ skills/b2c-cli/skills/b2c-mrt/SKILL.md | 7 +- .../references/ENVIRONMENT-COMMANDS.md | 57 +- 24 files changed, 3784 insertions(+), 924 deletions(-) create mode 100644 .changeset/mrt-redirect-scapi.md create mode 100644 packages/b2c-cli/src/commands/mrt/env/redirect/get.ts create mode 100644 packages/b2c-cli/src/commands/mrt/env/redirect/update.ts create mode 100644 packages/b2c-cli/test/commands/mrt/env/redirect/get.test.ts create mode 100644 packages/b2c-cli/test/commands/mrt/env/redirect/update.test.ts create mode 100644 packages/b2c-tooling-sdk/test/operations/mrt/redirect.test.ts diff --git a/.changeset/mrt-redirect-scapi.md b/.changeset/mrt-redirect-scapi.md new file mode 100644 index 000000000..30d2150ac --- /dev/null +++ b/.changeset/mrt-redirect-scapi.md @@ -0,0 +1,6 @@ +--- +'@salesforce/b2c-tooling-sdk': minor +'@salesforce/b2c-cli': minor +--- + +`mrt env redirect` now supports `get` and `update` in addition to `list` / `create` / `delete` / `clone`, and every command is backend-aware: each honors `--mrt-backend` and can run over the SCAPI Storefront Environments API (scopes `sfcc.storefront.environments` for reads, `sfcc.storefront.environments.rw` for writes) or the legacy MRT Cloud API. The two backends identify a redirect differently (legacy source path vs SCAPI redirect ID), so `get` / `update` / `delete` take a neutral identifier; `update` is a partial update and `clone` rejects a same-source clone. diff --git a/docs/cli/mrt.md b/docs/cli/mrt.md index fe9c98888..91826e249 100644 --- a/docs/cli/mrt.md +++ b/docs/cli/mrt.md @@ -16,8 +16,8 @@ Commands for managing Managed Runtime (MRT) projects, environments, and bundles | `mrt project notification` | `list`, `create`, `get`, `update`, `delete` | Manage deployment notifications | | `mrt env` | `list`, `create`, `get`, `update`, `delete`, `invalidate`, `b2c` | Manage environments | | `mrt env var` | `list`, `set`, `push`, `delete` | Manage environment variables | -| `mrt env redirect` | `list`, `create`, `delete`, `clone` | Manage URL redirects | -| `mrt env access-control` | `list`, `create`, `get`, `delete` | Manage access control headers | +| `mrt env redirect` | `list`, `create`, `get`, `update`, `delete`, `clone` | Manage URL redirects | +| `mrt env access-control` | `list`, `create`, `get`, `delete` | Manage access control headers | | `mrt bundle` | `deploy`, `list`, `history`, `download` | Manage bundles and deployments | | `mrt tail-logs` | | Tail real-time application logs | | `mrt save-credentials` | | Save MRT credentials to ~/.mobify | @@ -51,7 +51,7 @@ MRT commands resolve configuration in the following order of precedence: MRT commands use API key authentication against the legacy MRT Cloud API. The API key is configured in the Managed Runtime dashboard. -Several commands — `mrt bundle history`, `mrt bundle list`, `mrt bundle deploy` (both the local-build push and deploying an existing ``), the `mrt env var` family (`list` / `set` / `push` / `delete`), and the `mrt env access-control` family (`list` / `create` / `get` / `delete`) — can also run over the SCAPI MRT backend with OAuth instead of an API key. See [MRT Backends](#mrt-backends) for how the backend is selected and what it requires. +Several commands — `mrt bundle history`, `mrt bundle list`, `mrt bundle deploy` (both the local-build push and deploying an existing ``), the `mrt env var` family (`list` / `set` / `push` / `delete`), the `mrt env redirect` family (`list` / `create` / `get` / `update` / `delete` / `clone`), and the `mrt env access-control` family (`list` / `create` / `get` / `delete`) — can also run over the SCAPI MRT backend with OAuth instead of an API key. See [MRT Backends](#mrt-backends) for how the backend is selected and what it requires. ### Getting an API Key @@ -98,6 +98,16 @@ These commands implement the SCAPI backend today: - `b2c mrt env var set` — set/update environment variables (merge) - `b2c mrt env var push` — sync a local `.env` file - `b2c mrt env var delete` — delete an environment variable +- `b2c mrt env redirect list` — list URL redirects +- `b2c mrt env redirect create` — create a URL redirect +- `b2c mrt env redirect get` — get a single URL redirect +- `b2c mrt env redirect update` — partially update a URL redirect +- `b2c mrt env redirect delete` — delete a URL redirect +- `b2c mrt env redirect clone` — clone redirects from a distinct source environment +- `b2c mrt env access-control list` — list access control headers +- `b2c mrt env access-control create` — create an access control header +- `b2c mrt env access-control get` — get a single access control header +- `b2c mrt env access-control delete` — delete an access control header Every other MRT command runs on the legacy MRT Cloud API. On those, `--mrt-backend scapi` errors with an actionable message, and `--mrt-backend auto` warns (only when SCAPI is actually configured) before using legacy. @@ -576,6 +586,8 @@ b2c mrt env var delete MY_VAR -p my-storefront -e production --mrt-backend scapi ## URL Redirect Commands +The `mrt env redirect` commands (`list` / `create` / `get` / `update` / `delete` / `clone`) are [backend-aware](#mrt-backends): they honor `--mrt-backend` and, over SCAPI, use the Storefront Environments API (scopes `sfcc.storefront.environments` for reads, `sfcc.storefront.environments.rw` for writes). The two backends identify a redirect differently — the legacy MRT Cloud API keys on the **source path** (`from_path`) while SCAPI keys on a **redirect ID** (UUID). The `get` / `update` / `delete` commands take a neutral `identifier` positional that is the source path on legacy and the redirect ID on SCAPI. Under `--json`, each command returns the serving backend's native shape (see [JSON output is backend-specific](#json-output-is-backend-specific)). + ### b2c mrt env redirect list List URL redirects for an environment. @@ -583,19 +595,22 @@ List URL redirects for an environment. ```bash b2c mrt env redirect list -p my-storefront -e production b2c mrt env redirect list -p my-storefront -e production --limit 50 + +# Force the SCAPI backend +b2c mrt env redirect list -p my-storefront -e production --mrt-backend scapi ``` ### b2c mrt env redirect create Create a URL redirect. -| Flag | Description | Default | -| ----------------------- | ---------------------------------------- | ------- | -| `--from` | Source path (required) | — | -| `--to` | Destination path (required) | — | -| `--status` | HTTP status code (`301` or `302`) | `301` | -| `--forward-querystring` | Forward query string parameters | `false` | -| `--forward-wildcard` | Forward the wildcard portion of the path | `false` | +| Flag | Description | Default | +| ----------------------- | -------------------------------------------------- | ------- | +| `--from` | Source path (required; alias `--source`) | — | +| `--to` | Destination path (required; alias `--destination`) | — | +| `--status` | HTTP status code (`301` or `302`) | `301` | +| `--forward-querystring` | Forward query string parameters | `false` | +| `--forward-wildcard` | Forward the wildcard portion of the path | `false` | ```bash # Permanent redirect (default — 301) @@ -605,24 +620,68 @@ b2c mrt env redirect create -p my-storefront -e production \ # Temporary redirect b2c mrt env redirect create -p my-storefront -e production \ --from "/legacy/*" --to "/modern/$1" --status 302 --forward-wildcard + +# Force the SCAPI backend +b2c mrt env redirect create -p my-storefront -e production \ + --from "/old-path" --to "/new-path" --mrt-backend scapi +``` + +### b2c mrt env redirect get + +Get a single URL redirect by its identifier — the source path on legacy, or the redirect ID (UUID) on SCAPI. + +```bash +# Legacy — identify by source path +b2c mrt env redirect get "/old-path" -p my-storefront -e production + +# SCAPI — identify by redirect ID +b2c mrt env redirect get 3f9b1c2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d \ + -p my-storefront -e production --mrt-backend scapi +``` + +### b2c mrt env redirect update + +Update a URL redirect by its identifier. Only the fields you supply change (partial update) — the source path cannot be changed. + +| Flag | Description | +| ----------------------- | ---------------------------------------- | +| `--to` | New destination path | +| `--status` | HTTP status code (`301` or `302`) | +| `--forward-querystring` | Forward query string parameters (`--no-forward-querystring` to disable) | +| `--forward-wildcard` | Forward the wildcard portion of the path (`--no-forward-wildcard` to disable) | + +```bash +# Change only the destination +b2c mrt env redirect update "/old-path" -p my-storefront -e production --to "/new-path" + +# Change the status code and disable query-string forwarding +b2c mrt env redirect update 3f9b1c2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d \ + -p my-storefront -e production --status 302 --no-forward-querystring --mrt-backend scapi ``` ### b2c mrt env redirect delete -Delete a URL redirect by its source path. +Delete a URL redirect by its identifier — the source path on legacy, or the redirect ID (UUID) on SCAPI. ```bash b2c mrt env redirect delete "/old-path" -p my-storefront -e production b2c mrt env redirect delete "/old-path" -p my-storefront -e production --force + +# SCAPI — identify by redirect ID +b2c mrt env redirect delete 3f9b1c2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d \ + -p my-storefront -e production --mrt-backend scapi ``` ### b2c mrt env redirect clone -Clone redirects from one environment to another within the same project. +Clone redirects from one environment to another within the same project. The source and target environments must differ. ```bash b2c mrt env redirect clone -p my-storefront --from staging --to production b2c mrt env redirect clone -p my-storefront --from staging --to production --force + +# Force the SCAPI backend +b2c mrt env redirect clone -p my-storefront --from staging --to production --mrt-backend scapi ``` --- diff --git a/packages/b2c-cli/src/commands/mrt/env/redirect/clone.ts b/packages/b2c-cli/src/commands/mrt/env/redirect/clone.ts index 6f91b3474..c4c8fc3d5 100644 --- a/packages/b2c-cli/src/commands/mrt/env/redirect/clone.ts +++ b/packages/b2c-cli/src/commands/mrt/env/redirect/clone.ts @@ -5,7 +5,7 @@ */ import {Flags} from '@oclif/core'; import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {cloneRedirects, type CloneRedirectsResult} from '@salesforce/b2c-tooling-sdk/operations/mrt'; +import {cloneRedirectsWithBackend} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../../i18n/index.js'; import {confirm} from '../../../../prompts.js'; @@ -23,6 +23,7 @@ export default class MrtRedirectClone extends MrtCommand <%= command.id %> --project my-storefront --from staging --to production', '<%= config.bin %> <%= command.id %> -p my-storefront --from staging --to production --force', + '<%= config.bin %> <%= command.id %> -p my-storefront --from staging --to production --mrt-backend scapi', ]; static flags = { @@ -42,9 +43,11 @@ export default class MrtRedirectClone extends MrtCommand { - this.requireMrtCredentials(); + protected operations = { + cloneRedirectsWithBackend, + }; + async run(): Promise { const {mrtProject: project} = this.resolvedConfig.values; if (!project) { @@ -53,7 +56,19 @@ export default class MrtRedirectClone extends MrtCommand this.warn(reason), + onResolve: (backend) => this.logger.debug({backend}, '[MRT] Cloning redirects via backend'), + }); - return result; - } catch (error) { - if (error instanceof Error) { - this.error( - t('commands.mrt.redirect.clone.failed', 'Failed to clone redirects: {{message}}', {message: error.message}), + if (!this.jsonEnabled()) { + // The legacy backend reports a cloned count; the SCAPI backend returns 201 + // with no body, so phrase the success without a count there. + if (result.count === null) { + this.log( + t('commands.mrt.redirect.clone.successNoCount', 'Cloned redirects from {{fromTarget}} to {{toTarget}}.', { + fromTarget: sourceEnvironment, + toTarget: targetEnvironment, + }), + ); + } else { + this.log( + t( + 'commands.mrt.redirect.clone.success', + 'Cloned {{count}} redirect(s) from {{fromTarget}} to {{toTarget}}.', + {count: result.count, fromTarget: sourceEnvironment, toTarget: targetEnvironment}, + ), ); } - throw error; } + + // Under --json, emit the backend's native clone response verbatim (the legacy + // clone result, or `null` for the SCAPI empty 201). + return result.raw; + } + + protected override supportsScapiMrt(): boolean { + return true; } } diff --git a/packages/b2c-cli/src/commands/mrt/env/redirect/create.ts b/packages/b2c-cli/src/commands/mrt/env/redirect/create.ts index bb036c408..7785f2fc5 100644 --- a/packages/b2c-cli/src/commands/mrt/env/redirect/create.ts +++ b/packages/b2c-cli/src/commands/mrt/env/redirect/create.ts @@ -5,11 +5,7 @@ */ import {Flags} from '@oclif/core'; import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import { - createRedirect, - type MrtRedirect, - type RedirectHttpStatusCode, -} from '@salesforce/b2c-tooling-sdk/operations/mrt'; +import {createRedirectWithBackend, type RedirectHttpStatusCode} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../../i18n/index.js'; /** @@ -27,16 +23,19 @@ export default class MrtRedirectCreate extends MrtCommand <%= command.id %> --project my-storefront --environment staging --from /old --to /new', '<%= config.bin %> <%= command.id %> -p my-storefront -e staging --from /sale --to /summer-sale --status 302', '<%= config.bin %> <%= command.id %> -p my-storefront -e staging --from "/a/*" --to /b --forward-wildcard', + '<%= config.bin %> <%= command.id %> -p my-storefront -e staging --from /old --to /new --mrt-backend scapi', ]; static flags = { ...MrtCommand.baseFlags, from: Flags.string({ - description: 'Source path to redirect from', + aliases: ['source'], + description: 'Source path to redirect from (alias: --source)', required: true, }), to: Flags.string({ - description: 'Destination URL to redirect to', + aliases: ['destination'], + description: 'Destination URL to redirect to (alias: --destination)', required: true, }), status: Flags.integer({ @@ -54,9 +53,11 @@ export default class MrtRedirectCreate extends MrtCommand { - this.requireMrtCredentials(); + protected operations = { + createRedirectWithBackend, + }; + async run(): Promise { const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; if (!project) { @@ -71,53 +72,55 @@ export default class MrtRedirectCreate extends MrtCommand {{to}}...', { - from: fromPath, - to: toUrl, + from: source, + to: destination, }), ); - try { - const result = await createRedirect( - { - projectSlug: project, - targetSlug: environment, - fromPath, - toUrl, - httpStatusCode: status as RedirectHttpStatusCode, - forwardQuerystring: forwardQs || undefined, - forwardWildcard: forwardWildcard || undefined, - origin: this.resolvedConfig.values.mrtOrigin, - }, - this.getMrtAuth(), - ); + const result = await this.operations.createRedirectWithBackend({ + preference, + scapiConnection, + legacyAuth, + projectSlug: project, + environment, + source, + destination, + httpStatusCode: status as RedirectHttpStatusCode, + forwardQuerystring, + forwardWildcard, + origin: this.resolvedConfig.values.mrtOrigin, + onFallback: (reason) => this.warn(reason), + onResolve: (backend) => this.logger.debug({backend}, '[MRT] Creating redirect via backend'), + }); + if (!this.jsonEnabled()) { this.log( t('commands.mrt.redirect.create.success', 'Redirect created: {{from}} -> {{to}}', { - from: fromPath, - to: toUrl, + from: source, + to: destination, }), ); this.log( t('commands.mrt.redirect.create.note', 'Note: Changes may take up to 20 minutes to take effect on your site.'), ); - - return result; - } catch (error) { - if (error instanceof Error) { - this.error( - t('commands.mrt.redirect.create.failed', 'Failed to create redirect: {{message}}', {message: error.message}), - ); - } - throw error; } + + // Under --json, emit the backend's native create response verbatim. + return result.raw; + } + + protected override supportsScapiMrt(): boolean { + return true; } } diff --git a/packages/b2c-cli/src/commands/mrt/env/redirect/delete.ts b/packages/b2c-cli/src/commands/mrt/env/redirect/delete.ts index b851d84b0..3dfbe0d43 100644 --- a/packages/b2c-cli/src/commands/mrt/env/redirect/delete.ts +++ b/packages/b2c-cli/src/commands/mrt/env/redirect/delete.ts @@ -5,7 +5,7 @@ */ import {Args, Flags} from '@oclif/core'; import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {deleteRedirect} from '@salesforce/b2c-tooling-sdk/operations/mrt'; +import {deleteRedirectWithBackend} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../../i18n/index.js'; import {confirm} from '../../../../prompts.js'; @@ -14,8 +14,10 @@ import {confirm} from '../../../../prompts.js'; */ export default class MrtRedirectDelete extends MrtCommand { static args = { - fromPath: Args.string({ - description: 'Source path of the redirect to delete', + identifier: Args.string({ + // Legacy keys a redirect by its source path (from_path); SCAPI keys it by + // its UUID. Accept whichever the resolved backend expects. + description: 'Redirect identifier: the source path (legacy) or the redirect UUID (SCAPI)', required: true, }), }; @@ -30,6 +32,7 @@ export default class MrtRedirectDelete extends MrtCommand <%= command.id %> /old-page --project my-storefront --environment staging', '<%= config.bin %> <%= command.id %> /old-page -p my-storefront -e staging --force', + '<%= config.bin %> <%= command.id %> 3f9b1c2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d -p my-storefront -e staging --mrt-backend scapi', ]; static flags = { @@ -41,13 +44,15 @@ export default class MrtRedirectDelete extends MrtCommand { + protected operations = { + deleteRedirectWithBackend, + }; + + async run(): Promise<{identifier: string; deleted: boolean}> { // Prevent deletion in safe mode this.assertDestructiveOperationAllowed('delete redirect'); - this.requireMrtCredentials(); - - const {fromPath} = this.args; + const {identifier} = this.args; const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; if (!project) { @@ -66,39 +71,40 @@ export default class MrtRedirectDelete extends MrtCommand this.warn(reason), + onResolve: (backend) => this.logger.debug({backend}, '[MRT] Deleting redirect via backend'), + }); + + if (!this.jsonEnabled()) { + this.log(t('commands.mrt.redirect.delete.success', 'Redirect {{identifier}} deleted.', {identifier})); } + + return {identifier, deleted: true}; + } + + protected override supportsScapiMrt(): boolean { + return true; } } diff --git a/packages/b2c-cli/src/commands/mrt/env/redirect/get.ts b/packages/b2c-cli/src/commands/mrt/env/redirect/get.ts new file mode 100644 index 000000000..9df7fba06 --- /dev/null +++ b/packages/b2c-cli/src/commands/mrt/env/redirect/get.ts @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, ux} from '@oclif/core'; +import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getRedirectWithBackend} from '@salesforce/b2c-tooling-sdk/operations/mrt'; +import {t, withDocs} from '../../../../i18n/index.js'; + +/** + * Get a single redirect for an MRT environment. + */ +export default class MrtRedirectGet extends MrtCommand { + static args = { + identifier: Args.string({ + // Legacy keys a redirect by its source path (from_path); SCAPI keys it by + // its UUID. Accept whichever the resolved backend expects. + description: 'Redirect identifier: the source path (legacy) or the redirect UUID (SCAPI)', + required: true, + }), + }; + + static description = withDocs( + t('commands.mrt.redirect.get.description', 'Get a redirect for a Managed Runtime environment'), + '/cli/mrt.html#b2c-mrt-env-redirect-get', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> /old-page --project my-storefront --environment staging', + '<%= config.bin %> <%= command.id %> 3f9b1c2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d -p my-storefront -e staging --mrt-backend scapi', + ]; + + static flags = { + ...MrtCommand.baseFlags, + }; + + protected operations = { + getRedirectWithBackend, + }; + + async run(): Promise { + const {identifier} = this.args; + const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; + + if (!project) { + this.error( + 'MRT project is required. Provide --project/--storefront (-p/-s), set MRT_PROJECT, or set mrtProject in dw.json.', + ); + } + if (!environment) { + this.error( + 'MRT environment is required. Provide --environment flag, set MRT_ENVIRONMENT, or set mrtEnvironment in dw.json.', + ); + } + + const {preference, scapiConnection, legacyAuth} = this.getMrtBackendContext(); + + const result = await this.operations.getRedirectWithBackend({ + preference, + scapiConnection, + legacyAuth, + projectSlug: project, + environment, + identifier, + origin: this.resolvedConfig.values.mrtOrigin, + onFallback: (reason) => this.warn(reason), + onResolve: (backend) => this.logger.debug({backend}, '[MRT] Getting redirect via backend'), + }); + + if (!this.jsonEnabled()) { + const {redirect} = result; + ux.stdout(`ID: ${redirect.id || '-'}`); + ux.stdout(`From: ${redirect.source || '-'}`); + ux.stdout(`To: ${redirect.destination || '-'}`); + ux.stdout(`HTTP: ${redirect.httpStatusCode ?? 301}`); + ux.stdout(`Forward QS: ${redirect.forwardQuerystring ? 'Yes' : 'No'}`); + ux.stdout(`Forward WC: ${redirect.forwardWildcard ? 'Yes' : 'No'}`); + ux.stdout(`Status: ${redirect.status ?? '-'}`); + ux.stdout(`Created: ${redirect.createdAt ? new Date(redirect.createdAt).toLocaleString() : '-'}`); + if (redirect.createdBy) { + ux.stdout(`Created by: ${redirect.createdBy}`); + } + } + + // Under --json, emit the backend's native redirect response verbatim. + return result.raw; + } + + protected override supportsScapiMrt(): boolean { + return true; + } +} diff --git a/packages/b2c-cli/src/commands/mrt/env/redirect/list.ts b/packages/b2c-cli/src/commands/mrt/env/redirect/list.ts index d1e1f0f53..57093aa15 100644 --- a/packages/b2c-cli/src/commands/mrt/env/redirect/list.ts +++ b/packages/b2c-cli/src/commands/mrt/env/redirect/list.ts @@ -11,33 +11,44 @@ import { selectColumns, type ColumnDef, } from '@salesforce/b2c-tooling-sdk/cli'; -import {listRedirects, type ListRedirectsResult, type MrtRedirect} from '@salesforce/b2c-tooling-sdk/operations/mrt'; +import {listRedirectsWithBackend, type MrtRedirectView} from '@salesforce/b2c-tooling-sdk/operations/mrt'; import {t, withDocs} from '../../../../i18n/index.js'; -const COLUMNS: Record> = { - fromPath: { +const COLUMNS: Record> = { + id: { + header: 'ID', + get: (r) => r.id || '-', + }, + source: { header: 'From', - get: (r) => r.from_path ?? '-', + get: (r) => r.source || '-', }, - toUrl: { + destination: { header: 'To', - get: (r) => r.to_url ?? '-', + get: (r) => r.destination || '-', }, status: { header: 'HTTP', - get: (r) => r.http_status_code?.toString() ?? '301', + get: (r) => r.httpStatusCode?.toString() ?? '301', }, publishingStatus: { header: 'Status', - get: (r) => r.publishing_status ?? '-', + get: (r) => r.status ?? '-', }, forwardQs: { header: 'Fwd QS', - get: (r) => (r.forward_querystring ? 'Yes' : 'No'), + get: (r) => (r.forwardQuerystring ? 'Yes' : 'No'), + }, + backend: { + header: 'Backend', + get: (r) => r.backend, }, }; -const DEFAULT_COLUMNS = ['fromPath', 'toUrl', 'status', 'publishingStatus']; +// The identifier differs by backend (from_path on legacy, UUID on SCAPI), so the +// default view leads with the human-readable source/destination; `id` and +// `backend` are opt-in columns. +const DEFAULT_COLUMNS = ['source', 'destination', 'status', 'publishingStatus']; const tableRenderer = new TableRenderer(COLUMNS); @@ -55,6 +66,7 @@ export default class MrtRedirectList extends MrtCommand static examples = [ '<%= config.bin %> <%= command.id %> --project my-storefront --environment staging', '<%= config.bin %> <%= command.id %> -p my-storefront -e staging --search "/old"', + '<%= config.bin %> <%= command.id %> -p my-storefront -e staging --mrt-backend scapi', '<%= config.bin %> <%= command.id %> -p my-storefront -e staging --json', ]; @@ -67,14 +79,20 @@ export default class MrtRedirectList extends MrtCommand description: 'Offset for pagination', }), search: Flags.string({ - description: 'Search term for filtering', + description: 'Search term for filtering (legacy backend only)', }), ...columnFlagsFor(COLUMNS), }; - async run(): Promise { - this.requireMrtCredentials(); + protected operations = { + listRedirectsWithBackend, + }; + + protected renderTable(redirects: MrtRedirectView[]): void { + tableRenderer.render(redirects, selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this))); + } + async run(): Promise { const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; if (!project) { @@ -89,6 +107,7 @@ export default class MrtRedirectList extends MrtCommand } const {limit, offset, search} = this.flags; + const {preference, scapiConnection, legacyAuth} = this.getMrtBackendContext(); this.log( t('commands.mrt.redirect.list.fetching', 'Fetching redirects for {{project}}/{{environment}}...', { @@ -97,30 +116,36 @@ export default class MrtRedirectList extends MrtCommand }), ); - const result = await listRedirects( - { - projectSlug: project, - targetSlug: environment, - limit, - offset, - search, - origin: this.resolvedConfig.values.mrtOrigin, - }, - this.getMrtAuth(), - ); + const result = await this.operations.listRedirectsWithBackend({ + preference, + scapiConnection, + legacyAuth, + projectSlug: project, + environment, + limit, + offset, + search, + origin: this.resolvedConfig.values.mrtOrigin, + onFallback: (reason) => this.warn(reason), + onResolve: (backend) => this.logger.debug({backend}, '[MRT] Listing redirects via backend'), + }); if (!this.jsonEnabled()) { if (result.redirects.length === 0) { this.log(t('commands.mrt.redirect.list.empty', 'No redirects found.')); } else { this.log(t('commands.mrt.redirect.list.count', 'Found {{count}} redirect(s):', {count: result.count})); - tableRenderer.render( - result.redirects, - selectColumns(this.flags, tableRenderer, DEFAULT_COLUMNS, this.warn.bind(this)), - ); + this.renderTable(result.redirects); } } - return result; + // Under --json, emit the backend's native list response verbatim (legacy MRT + // Cloud API list shape, or the SCAPI paginated envelope) so the machine + // contract stays backend-specific. The normalized rows feed the human table only. + return result.raw; + } + + protected override supportsScapiMrt(): boolean { + return true; } } diff --git a/packages/b2c-cli/src/commands/mrt/env/redirect/update.ts b/packages/b2c-cli/src/commands/mrt/env/redirect/update.ts new file mode 100644 index 000000000..26e84bb40 --- /dev/null +++ b/packages/b2c-cli/src/commands/mrt/env/redirect/update.ts @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {updateRedirectWithBackend, type RedirectHttpStatusCode} from '@salesforce/b2c-tooling-sdk/operations/mrt'; +import {t, withDocs} from '../../../../i18n/index.js'; + +/** + * Update a redirect for an MRT environment (partial — only supplied fields change). + */ +export default class MrtRedirectUpdate extends MrtCommand { + static args = { + identifier: Args.string({ + // Legacy keys a redirect by its source path (from_path); SCAPI keys it by + // its UUID. Accept whichever the resolved backend expects. + description: 'Redirect identifier: the source path (legacy) or the redirect UUID (SCAPI)', + required: true, + }), + }; + + static description = withDocs( + t( + 'commands.mrt.redirect.update.description', + 'Update a redirect for a Managed Runtime environment (only the supplied fields change)', + ), + '/cli/mrt.html#b2c-mrt-env-redirect-update', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> /old-page --project my-storefront --environment staging --to /new-page', + '<%= config.bin %> <%= command.id %> /old-page -p my-storefront -e staging --status 302', + '<%= config.bin %> <%= command.id %> /old-page -p my-storefront -e staging --no-forward-querystring', + '<%= config.bin %> <%= command.id %> 3f9b1c2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d -p my-storefront -e staging --to /new --mrt-backend scapi', + ]; + + static flags = { + ...MrtCommand.baseFlags, + to: Flags.string({ + description: 'New destination URL to redirect to', + }), + status: Flags.integer({ + description: 'HTTP status code (301 or 302)', + options: ['301', '302'], + }), + 'forward-querystring': Flags.boolean({ + description: 'Forward query string parameters (use --no-forward-querystring to disable)', + allowNo: true, + }), + 'forward-wildcard': Flags.boolean({ + description: 'Forward wildcard path portion (use --no-forward-wildcard to disable)', + allowNo: true, + }), + }; + + protected operations = { + updateRedirectWithBackend, + }; + + async run(): Promise { + const {identifier} = this.args; + const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; + + if (!project) { + this.error( + 'MRT project is required. Provide --project/--storefront (-p/-s), set MRT_PROJECT, or set mrtProject in dw.json.', + ); + } + if (!environment) { + this.error( + 'MRT environment is required. Provide --environment flag, set MRT_ENVIRONMENT, or set mrtEnvironment in dw.json.', + ); + } + + const { + to: destination, + status, + 'forward-querystring': forwardQuerystring, + 'forward-wildcard': forwardWildcard, + } = this.flags; + + // A partial update needs at least one field to change. oclif leaves an + // un-passed boolean (with allowNo) and un-passed string/integer as undefined. + if ( + destination === undefined && + status === undefined && + forwardQuerystring === undefined && + forwardWildcard === undefined + ) { + this.error( + t( + 'commands.mrt.redirect.update.noChanges', + 'Provide at least one field to update (--to, --status, --forward-querystring, or --forward-wildcard).', + ), + ); + } + + const {preference, scapiConnection, legacyAuth} = this.getMrtBackendContext(); + + this.log(t('commands.mrt.redirect.update.updating', 'Updating redirect {{identifier}}...', {identifier})); + + const result = await this.operations.updateRedirectWithBackend({ + preference, + scapiConnection, + legacyAuth, + projectSlug: project, + environment, + identifier, + destination, + httpStatusCode: status as RedirectHttpStatusCode | undefined, + forwardQuerystring, + forwardWildcard, + origin: this.resolvedConfig.values.mrtOrigin, + onFallback: (reason) => this.warn(reason), + onResolve: (backend) => this.logger.debug({backend}, '[MRT] Updating redirect via backend'), + }); + + if (!this.jsonEnabled()) { + this.log(t('commands.mrt.redirect.update.success', 'Redirect {{identifier}} updated.', {identifier})); + this.log( + t('commands.mrt.redirect.update.note', 'Note: Changes may take up to 20 minutes to take effect on your site.'), + ); + } + + // Under --json, emit the backend's native update response verbatim. + return result.raw; + } + + protected override supportsScapiMrt(): boolean { + return true; + } +} diff --git a/packages/b2c-cli/test/commands/mrt/env/redirect/clone.test.ts b/packages/b2c-cli/test/commands/mrt/env/redirect/clone.test.ts index 6649b619b..7e2517663 100644 --- a/packages/b2c-cli/test/commands/mrt/env/redirect/clone.test.ts +++ b/packages/b2c-cli/test/commands/mrt/env/redirect/clone.test.ts @@ -3,186 +3,139 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ + import {expect} from 'chai'; -import {afterEach, beforeEach} from 'mocha'; import sinon from 'sinon'; +import {Config} from '@oclif/core'; import MrtRedirectClone from '../../../../../src/commands/mrt/env/redirect/clone.js'; -import {createIsolatedConfigHooks, createTestCommand} from '../../../../helpers/test-setup.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {stubParse} from '../../../../helpers/stub-parse.js'; describe('mrt env redirect clone', () => { - const hooks = createIsolatedConfigHooks(); + let config: Config; beforeEach(async () => { - await hooks.beforeEach(); + isolateConfig(); + config = await Config.load(); }); afterEach(() => { - hooks.afterEach(); + sinon.restore(); + restoreConfig(); }); - async function createCommand(flags: Record): Promise { - return createTestCommand(MrtRedirectClone, hooks.getConfig(), flags, {}); + function createCommand(): any { + return new MrtRedirectClone([], config); + } + + function stubErrorToThrow(command: any): sinon.SinonStub { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + function stubBackendContext( + command: any, + ctx: {preference?: string; scapiConnection?: unknown; legacyAuth?: unknown} = {}, + ): void { + sinon.stub(command, 'getMrtBackendContext').returns({ + preference: ctx.preference ?? 'auto', + scapiConnection: ctx.scapiConnection, + legacyAuth: 'legacyAuth' in ctx ? ctx.legacyAuth : {}, + } as any); } - it('throws error when project is missing', async () => { - const command = await createCommand({from: 'staging', to: 'production', force: true}); + it('calls command.error when project is missing', async () => { + const command = createCommand(); + + stubParse(command, {from: 'staging', to: 'production', force: true}, {}); + await command.init(); - sinon.stub(command, 'requireMrtCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined}})); - const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + const errorStub = stubErrorToThrow(command); try { await command.run(); - expect.fail('Should have thrown'); + expect.fail('Expected error'); } catch { - expect(errorStub.called).to.be.true; - expect(errorStub.firstCall.args[0]).to.include('MRT project is required'); + expect(errorStub.calledOnce).to.equal(true); } }); - it('clones redirects with force flag', async () => { - const command = await createCommand({ - json: true, - project: 'my-storefront', - from: 'staging', - to: 'production', - force: true, - }); + it('rejects a same-source clone before touching the backend', async () => { + const command = createCommand(); - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'jsonEnabled').returns(true); - sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtOrigin: 'https://example.com'}, - })); - sinon.stub(command, 'getMrtAuth').returns({} as any); - - const mockResult = { - count: 5, - redirects: [{from: '/old-path', to: '/new-path', status: 301}], - }; - - const cloneStub = sinon.stub().resolves(mockResult); - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - this.log('Cloning redirects...'); - return cloneStub(); - }; + stubParse(command, {from: 'staging', to: 'staging', force: true}, {}); + await command.init(); - const result = await command.run(); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: 'my-project'}})); + + const errorStub = stubErrorToThrow(command); + const cloneStub = sinon.stub().resolves({backend: 'legacy', count: 1, raw: {count: 1}}); + command.operations = {...command.operations, cloneRedirectsWithBackend: cloneStub}; - expect(result.count).to.equal(5); - expect(result.redirects).to.have.lengthOf(1); + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + expect(errorStub.firstCall.args[0]).to.include('must differ'); + } + expect(cloneStub.called).to.equal(false); }); - it('clones redirects in non-JSON mode', async () => { - const command = await createCommand({ - project: 'my-storefront', - from: 'dev', - to: 'staging', - force: true, - }); + it('clones via the backend wrapper and returns raw under --json (legacy)', async () => { + const command = createCommand(); + + stubParse(command, {from: 'staging', to: 'production', force: true}, {}); + await command.init(); - sinon.stub(command, 'requireMrtCredentials').returns(void 0); + stubBackendContext(command); + sinon.stub(command, 'jsonEnabled').returns(true); sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'jsonEnabled').returns(false); sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtOrigin: 'https://example.com'}, + values: {mrtProject: 'my-project', mrtOrigin: 'https://example.com'}, })); - sinon.stub(command, 'getMrtAuth').returns({} as any); - - const mockResult = {count: 3, redirects: []}; - const cloneStub = sinon.stub().resolves(mockResult); - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - this.log('Cloning redirects...'); - return cloneStub(); - }; + const raw = {count: 5, results: []}; + const cloneStub = sinon.stub().resolves({backend: 'legacy', count: 5, raw}); + command.operations = {...command.operations, cloneRedirectsWithBackend: cloneStub}; const result = await command.run(); - expect(result.count).to.equal(3); + expect(cloneStub.calledOnce).to.equal(true); + const [input] = cloneStub.firstCall.args; + expect(input.preference).to.equal('auto'); + expect(input.projectSlug).to.equal('my-project'); + expect(input.sourceEnvironment).to.equal('staging'); + expect(input.targetEnvironment).to.equal('production'); + expect(result).to.deep.equal(raw); }); - it('handles API errors', async () => { - const command = await createCommand({ - project: 'my-storefront', - from: 'staging', - to: 'production', - force: true, - }); + it('forwards the resolved SCAPI backend context and returns the null raw (empty 201)', async () => { + const command = createCommand(); - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtOrigin: 'https://example.com'}, - })); - sinon.stub(command, 'getMrtAuth').returns({} as any); - - const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); - - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - this.log('Cloning redirects...'); - this.error('Failed to clone redirects: API error'); - }; + stubParse(command, {from: 'staging', to: 'production', force: true, 'mrt-backend': 'scapi'}, {}); + await command.init(); - try { - await command.run(); - expect.fail('Should have thrown'); - } catch { - expect(errorStub.called).to.be.true; - expect(errorStub.firstCall.args[0]).to.include('Failed to clone redirects'); - } - }); - - it('handles empty result', async () => { - const command = await createCommand({ - json: true, - project: 'my-storefront', - from: 'staging', - to: 'production', - force: true, - }); - - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); + const scapiConnection = {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd', auth: {}}; + stubBackendContext(command, {preference: 'scapi', scapiConnection, legacyAuth: undefined}); sinon.stub(command, 'jsonEnabled').returns(true); - sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtOrigin: 'https://example.com'}, - })); - sinon.stub(command, 'getMrtAuth').returns({} as any); - - const mockResult = {count: 0, redirects: []}; + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: 'my-project', mrtBackend: 'scapi'}})); - const cloneStub = sinon.stub().resolves(mockResult); - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - this.log('Cloning redirects...'); - return cloneStub(); - }; + const cloneStub = sinon.stub().resolves({backend: 'scapi', count: null, raw: null}); + command.operations = {...command.operations, cloneRedirectsWithBackend: cloneStub}; const result = await command.run(); - expect(result.count).to.equal(0); - expect(result.redirects).to.deep.equal([]); + const [input] = cloneStub.firstCall.args; + expect(input.preference).to.equal('scapi'); + expect(input.scapiConnection).to.equal(scapiConnection); + expect(result).to.equal(null); + }); + + it('supports the SCAPI MRT backend', () => { + const command = createCommand(); + expect(command.supportsScapiMrt()).to.equal(true); }); }); diff --git a/packages/b2c-cli/test/commands/mrt/env/redirect/create.test.ts b/packages/b2c-cli/test/commands/mrt/env/redirect/create.test.ts index 23d938a99..8fc04a073 100644 --- a/packages/b2c-cli/test/commands/mrt/env/redirect/create.test.ts +++ b/packages/b2c-cli/test/commands/mrt/env/redirect/create.test.ts @@ -3,315 +3,142 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ + import {expect} from 'chai'; -import {afterEach, beforeEach} from 'mocha'; import sinon from 'sinon'; +import {Config} from '@oclif/core'; import MrtRedirectCreate from '../../../../../src/commands/mrt/env/redirect/create.js'; -import {createIsolatedConfigHooks, createTestCommand} from '../../../../helpers/test-setup.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {stubParse} from '../../../../helpers/stub-parse.js'; describe('mrt env redirect create', () => { - const hooks = createIsolatedConfigHooks(); + let config: Config; beforeEach(async () => { - await hooks.beforeEach(); + isolateConfig(); + config = await Config.load(); }); afterEach(() => { - hooks.afterEach(); + sinon.restore(); + restoreConfig(); }); - async function createCommand(flags: Record): Promise { - return createTestCommand(MrtRedirectCreate, hooks.getConfig(), flags, {}); + function createCommand(): any { + return new MrtRedirectCreate([], config); } - it('throws error when project is missing', async () => { - const command = await createCommand({environment: 'staging', from: '/old', to: '/new'}); - - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined, mrtEnvironment: 'staging'}})); + function stubErrorToThrow(command: any): sinon.SinonStub { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } - const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + function stubBackendContext( + command: any, + ctx: {preference?: string; scapiConnection?: unknown; legacyAuth?: unknown} = {}, + ): void { + sinon.stub(command, 'getMrtBackendContext').returns({ + preference: ctx.preference ?? 'auto', + scapiConnection: ctx.scapiConnection, + legacyAuth: 'legacyAuth' in ctx ? ctx.legacyAuth : {}, + } as any); + } - try { - await command.run(); - expect.fail('Should have thrown'); - } catch { - expect(errorStub.called).to.be.true; - expect(errorStub.firstCall.args[0]).to.include('MRT project is required'); - } - }); + it('calls command.error when project is missing', async () => { + const command = createCommand(); - it('throws error when environment is missing', async () => { - const command = await createCommand({project: 'my-storefront', from: '/old', to: '/new'}); + stubParse(command, {from: '/old', to: '/new', status: 301}, {}); + await command.init(); - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon - .stub(command, 'resolvedConfig') - .get(() => ({values: {mrtProject: 'my-storefront', mrtEnvironment: undefined}})); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined, mrtEnvironment: 'staging'}})); - const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + const errorStub = stubErrorToThrow(command); try { await command.run(); - expect.fail('Should have thrown'); + expect.fail('Expected error'); } catch { - expect(errorStub.called).to.be.true; - expect(errorStub.firstCall.args[0]).to.include('MRT environment is required'); + expect(errorStub.calledOnce).to.equal(true); } }); - it('creates redirect with default status 301', async () => { - const command = await createCommand({ - json: true, - project: 'my-storefront', - environment: 'staging', - from: '/old-page', - to: '/new-page', - }); - - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'jsonEnabled').returns(true); - sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, - })); - sinon.stub(command, 'getMrtAuth').returns({} as any); - - const mockRedirect = { - from: '/old-page', - to: '/new-page', - status: 301, - }; + it('creates the redirect via the backend wrapper and returns raw under --json', async () => { + const command = createCommand(); - const createStub = sinon.stub().resolves(mockRedirect); - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - if (!environment) { - this.error('MRT environment is required'); - } - this.log('Creating redirect...'); - return createStub(); - }; + stubParse( + command, + {from: '/old', to: '/new', status: 302, 'forward-querystring': true, 'forward-wildcard': false}, + {}, + ); + await command.init(); - const result = await command.run(); - - expect(result.from).to.equal('/old-page'); - expect(result.to).to.equal('/new-page'); - expect(result.status).to.equal(301); - }); - - it('creates redirect with 302 status', async () => { - const command = await createCommand({ - json: true, - project: 'my-storefront', - environment: 'staging', - from: '/sale', - to: '/summer-sale', - status: 302, - }); - - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); + stubBackendContext(command); sinon.stub(command, 'jsonEnabled').returns(true); - sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, - })); - sinon.stub(command, 'getMrtAuth').returns({} as any); - - const mockRedirect = { - from: '/sale', - to: '/summer-sale', - status: 302, - }; - - const createStub = sinon.stub().resolves(mockRedirect); - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - if (!environment) { - this.error('MRT environment is required'); - } - this.log('Creating redirect...'); - return createStub(); - }; - - const result = await command.run(); - - expect(result.status).to.equal(302); - }); - - it('creates redirect with forward-querystring', async () => { - const command = await createCommand({ - json: true, - project: 'my-storefront', - environment: 'staging', - from: '/search', - to: '/new-search', - 'forward-querystring': true, - }); - - sinon.stub(command, 'requireMrtCredentials').returns(void 0); sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'jsonEnabled').returns(true); sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, + values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, })); - sinon.stub(command, 'getMrtAuth').returns({} as any); - - const mockRedirect = { - from: '/search', - to: '/new-search', - status: 301, - forwardQuerystring: true, - }; - - const createStub = sinon.stub().resolves(mockRedirect); - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - if (!environment) { - this.error('MRT environment is required'); - } - this.log('Creating redirect...'); - return createStub(); - }; - - const result = await command.run(); - - expect(result.forwardQuerystring).to.be.true; - }); - it('creates redirect with forward-wildcard', async () => { - const command = await createCommand({ - json: true, - project: 'my-storefront', - environment: 'staging', - from: '/a/*', - to: '/b', - 'forward-wildcard': true, + const raw = {from_path: '/old', to_url: '/new'}; + const createStub = sinon.stub().resolves({ + backend: 'legacy', + redirect: {id: '/old', source: '/old', destination: '/new', backend: 'legacy'}, + raw, }); - - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'jsonEnabled').returns(true); - sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, - })); - sinon.stub(command, 'getMrtAuth').returns({} as any); - - const mockRedirect = { - from: '/a/*', - to: '/b', - status: 301, - forwardWildcard: true, - }; - - const createStub = sinon.stub().resolves(mockRedirect); - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - if (!environment) { - this.error('MRT environment is required'); - } - this.log('Creating redirect...'); - return createStub(); - }; + command.operations = {...command.operations, createRedirectWithBackend: createStub}; const result = await command.run(); - expect(result.forwardWildcard).to.be.true; + expect(createStub.calledOnce).to.equal(true); + const [input] = createStub.firstCall.args; + expect(input.preference).to.equal('auto'); + expect(input.projectSlug).to.equal('my-project'); + expect(input.environment).to.equal('staging'); + expect(input.source).to.equal('/old'); + expect(input.destination).to.equal('/new'); + expect(input.httpStatusCode).to.equal(302); + expect(input.forwardQuerystring).to.equal(true); + expect(input.forwardWildcard).to.equal(false); + expect(result).to.deep.equal(raw); }); - it('creates redirect in non-JSON mode', async () => { - const command = await createCommand({ - project: 'my-storefront', - environment: 'staging', - from: '/old', - to: '/new', - }); + it('forwards the resolved SCAPI backend context to the wrapper', async () => { + const command = createCommand(); - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'jsonEnabled').returns(false); - sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, - })); - sinon.stub(command, 'getMrtAuth').returns({} as any); + stubParse(command, {from: '/old', to: '/new', status: 301, 'mrt-backend': 'scapi'}, {}); + await command.init(); - const mockRedirect = { - from: '/old', - to: '/new', - status: 301, - }; + const scapiConnection = {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd', auth: {}}; + stubBackendContext(command, {preference: 'scapi', scapiConnection, legacyAuth: undefined}); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'log').returns(void 0); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtBackend: 'scapi'}})); - const createStub = sinon.stub().resolves(mockRedirect); - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - if (!environment) { - this.error('MRT environment is required'); - } - this.log('Creating redirect...'); - return createStub(); - }; + const createStub = sinon + .stub() + .resolves({backend: 'scapi', redirect: {id: 'uuid-1', backend: 'scapi'}, raw: {redirectId: 'uuid-1'}}); + command.operations = {...command.operations, createRedirectWithBackend: createStub}; - const result = await command.run(); + await command.run(); - expect(result.from).to.equal('/old'); + const [input] = createStub.firstCall.args; + expect(input.preference).to.equal('scapi'); + expect(input.scapiConnection).to.equal(scapiConnection); + expect(input.source).to.equal('/old'); }); - it('handles API errors', async () => { - const command = await createCommand({ - project: 'my-storefront', - environment: 'staging', - from: '/old', - to: '/new', - }); - - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, - })); - sinon.stub(command, 'getMrtAuth').returns({} as any); + it('accepts --source/--destination as aliases for --from/--to', async () => { + // Run the real oclif parser (not stubParse) so the alias wiring is exercised. + const command: any = new MrtRedirectCreate(['--source', '/old', '--destination', '/new'], config); + const {flags} = await command.parse(MrtRedirectCreate, ['--source', '/old', '--destination', '/new']); - const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); - - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - if (!environment) { - this.error('MRT environment is required'); - } - this.log('Creating redirect...'); - this.error('Failed to create redirect: API error'); - }; + expect(flags.from).to.equal('/old'); + expect(flags.to).to.equal('/new'); + }); - try { - await command.run(); - expect.fail('Should have thrown'); - } catch { - expect(errorStub.called).to.be.true; - expect(errorStub.firstCall.args[0]).to.include('Failed to create redirect'); - } + it('supports the SCAPI MRT backend', () => { + const command = createCommand(); + expect(command.supportsScapiMrt()).to.equal(true); }); }); diff --git a/packages/b2c-cli/test/commands/mrt/env/redirect/delete.test.ts b/packages/b2c-cli/test/commands/mrt/env/redirect/delete.test.ts index 89bab110e..3923bf9d6 100644 --- a/packages/b2c-cli/test/commands/mrt/env/redirect/delete.test.ts +++ b/packages/b2c-cli/test/commands/mrt/env/redirect/delete.test.ts @@ -3,199 +3,146 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ + import {expect} from 'chai'; -import {afterEach, beforeEach} from 'mocha'; import sinon from 'sinon'; +import {Config} from '@oclif/core'; import MrtRedirectDelete from '../../../../../src/commands/mrt/env/redirect/delete.js'; -import {createIsolatedConfigHooks, createTestCommand} from '../../../../helpers/test-setup.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {stubParse} from '../../../../helpers/stub-parse.js'; + +const FROM_PATH = '/old-page'; +const REDIRECT_ID = '3f9b1c2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d'; describe('mrt env redirect delete', () => { - const hooks = createIsolatedConfigHooks(); + let config: Config; beforeEach(async () => { - await hooks.beforeEach(); + isolateConfig(); + config = await Config.load(); }); afterEach(() => { - hooks.afterEach(); + sinon.restore(); + restoreConfig(); }); - async function createCommand(flags: Record, args: Record): Promise { - return createTestCommand(MrtRedirectDelete, hooks.getConfig(), flags, args); + function createCommand(): any { + return new MrtRedirectDelete([], config); } - it('throws error when project is missing', async () => { - const command = await createCommand({force: true, environment: 'staging'}, {fromPath: '/old-page'}); - - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined, mrtEnvironment: 'staging'}})); + function stubErrorToThrow(command: any): sinon.SinonStub { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } - const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + function stubBackendContext( + command: any, + ctx: {preference?: string; scapiConnection?: unknown; legacyAuth?: unknown} = {}, + ): void { + sinon.stub(command, 'getMrtBackendContext').returns({ + preference: ctx.preference ?? 'auto', + scapiConnection: ctx.scapiConnection, + legacyAuth: 'legacyAuth' in ctx ? ctx.legacyAuth : {}, + } as any); + } - try { - await command.run(); - expect.fail('Should have thrown'); - } catch { - expect(errorStub.called).to.be.true; - expect(errorStub.firstCall.args[0]).to.include('MRT project is required'); - } - }); + it('calls command.error when project is missing', async () => { + const command = createCommand(); - it('throws error when environment is missing', async () => { - const command = await createCommand({force: true, project: 'my-storefront'}, {fromPath: '/old-page'}); + stubParse(command, {force: true}, {identifier: FROM_PATH}); + await command.init(); - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon - .stub(command, 'resolvedConfig') - .get(() => ({values: {mrtProject: 'my-storefront', mrtEnvironment: undefined}})); + sinon.stub(command, 'assertDestructiveOperationAllowed').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined, mrtEnvironment: 'staging'}})); - const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + const errorStub = stubErrorToThrow(command); try { await command.run(); - expect.fail('Should have thrown'); + expect.fail('Expected error'); } catch { - expect(errorStub.called).to.be.true; - expect(errorStub.firstCall.args[0]).to.include('MRT environment is required'); + expect(errorStub.calledOnce).to.equal(true); } }); - it('deletes redirect with force flag', async () => { - const command = await createCommand( - {json: true, project: 'my-storefront', environment: 'staging', force: true}, - {fromPath: '/old-page'}, - ); + it('deletes the redirect via the backend wrapper (legacy from_path identifier)', async () => { + const command = createCommand(); - sinon.stub(command, 'requireMrtCredentials').returns(void 0); + stubParse(command, {force: true}, {identifier: FROM_PATH}); + await command.init(); + + sinon.stub(command, 'assertDestructiveOperationAllowed').returns(void 0); + stubBackendContext(command); sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'jsonEnabled').returns(true); sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, + values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, })); - sinon.stub(command, 'getMrtAuth').returns({} as any); - - const deleteStub = sinon.stub().resolves(); - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - if (!environment) { - this.error('MRT environment is required'); - } - this.log('Deleting redirect...'); - await deleteStub(); - return {fromPath: '/old-page', deleted: true}; - }; + + const delStub = sinon.stub().resolves({backend: 'legacy'}); + command.operations = {...command.operations, deleteRedirectWithBackend: delStub}; const result = await command.run(); - expect(result.fromPath).to.equal('/old-page'); - expect(result.deleted).to.be.true; + expect(delStub.calledOnce).to.equal(true); + const [input] = delStub.firstCall.args; + expect(input.preference).to.equal('auto'); + expect(input.projectSlug).to.equal('my-project'); + expect(input.environment).to.equal('staging'); + expect(input.identifier).to.equal(FROM_PATH); + expect(result).to.deep.equal({identifier: FROM_PATH, deleted: true}); + // The resolved backend is intentionally kept out of the --json payload. + expect(result).to.not.have.property('backend'); }); - it('deletes redirect in non-JSON mode', async () => { - const command = await createCommand( - {project: 'my-storefront', environment: 'staging', force: true}, - {fromPath: '/old-url'}, - ); + it('forwards the resolved SCAPI backend context with the UUID identifier', async () => { + const command = createCommand(); - sinon.stub(command, 'requireMrtCredentials').returns(void 0); + stubParse(command, {force: true, 'mrt-backend': 'scapi'}, {identifier: REDIRECT_ID}); + await command.init(); + + sinon.stub(command, 'assertDestructiveOperationAllowed').returns(void 0); + const scapiConnection = {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd', auth: {}}; + stubBackendContext(command, {preference: 'scapi', scapiConnection, legacyAuth: undefined}); sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'jsonEnabled').returns(false); - sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, - })); - sinon.stub(command, 'getMrtAuth').returns({} as any); - - const deleteStub = sinon.stub().resolves(); - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - if (!environment) { - this.error('MRT environment is required'); - } - this.log('Deleting redirect...'); - await deleteStub(); - return {fromPath: '/old-url', deleted: true}; - }; + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtBackend: 'scapi'}})); - const result = await command.run(); + const delStub = sinon.stub().resolves({backend: 'scapi'}); + command.operations = {...command.operations, deleteRedirectWithBackend: delStub}; + + await command.run(); - expect(result.deleted).to.be.true; + const [input] = delStub.firstCall.args; + expect(input.preference).to.equal('scapi'); + expect(input.scapiConnection).to.equal(scapiConnection); + expect(input.identifier).to.equal(REDIRECT_ID); }); - it('handles API errors', async () => { - const command = await createCommand( - {project: 'my-storefront', environment: 'staging', force: true}, - {fromPath: '/old-page'}, - ); + it('blocks deletion in safe mode before touching the backend', async () => { + const command = createCommand(); - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, - })); - sinon.stub(command, 'getMrtAuth').returns({} as any); - - const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); - - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - if (!environment) { - this.error('MRT environment is required'); - } - this.log('Deleting redirect...'); - this.error('Failed to delete redirect: API error'); - }; + stubParse(command, {force: true}, {identifier: FROM_PATH}); + await command.init(); + + const assertStub = sinon + .stub(command, 'assertDestructiveOperationAllowed') + .throws(new Error('destructive blocked')); + const delStub = sinon.stub().resolves({backend: 'legacy'}); + command.operations = {...command.operations, deleteRedirectWithBackend: delStub}; try { await command.run(); - expect.fail('Should have thrown'); - } catch { - expect(errorStub.called).to.be.true; - expect(errorStub.firstCall.args[0]).to.include('Failed to delete redirect'); + expect.fail('Expected error'); + } catch (error) { + expect((error as Error).message).to.equal('destructive blocked'); } + expect(assertStub.calledOnce).to.equal(true); + expect(delStub.called).to.equal(false); }); - it('handles cancellation', async () => { - const command = await createCommand( - {json: true, project: 'my-storefront', environment: 'staging'}, - {fromPath: '/old-page'}, - ); - - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'jsonEnabled').returns(true); - sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, - })); - sinon.stub(command, 'getMrtAuth').returns({} as any); - - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - if (!environment) { - this.error('MRT environment is required'); - } - this.log('Deletion cancelled.'); - return {fromPath: '/old-page', deleted: false}; - }; - - const result = await command.run(); - - expect(result.fromPath).to.equal('/old-page'); - expect(result.deleted).to.be.false; + it('supports the SCAPI MRT backend', () => { + const command = createCommand(); + expect(command.supportsScapiMrt()).to.equal(true); }); }); diff --git a/packages/b2c-cli/test/commands/mrt/env/redirect/get.test.ts b/packages/b2c-cli/test/commands/mrt/env/redirect/get.test.ts new file mode 100644 index 000000000..6f02371b9 --- /dev/null +++ b/packages/b2c-cli/test/commands/mrt/env/redirect/get.test.ts @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config, ux} from '@oclif/core'; +import MrtRedirectGet from '../../../../../src/commands/mrt/env/redirect/get.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {stubParse} from '../../../../helpers/stub-parse.js'; + +const FROM_PATH = '/old-page'; +const REDIRECT_ID = '3f9b1c2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d'; + +describe('mrt env redirect get', () => { + let config: Config; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + function createCommand(): any { + return new MrtRedirectGet([], config); + } + + function stubErrorToThrow(command: any): sinon.SinonStub { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + function stubBackendContext( + command: any, + ctx: {preference?: string; scapiConnection?: unknown; legacyAuth?: unknown} = {}, + ): void { + sinon.stub(command, 'getMrtBackendContext').returns({ + preference: ctx.preference ?? 'auto', + scapiConnection: ctx.scapiConnection, + legacyAuth: 'legacyAuth' in ctx ? ctx.legacyAuth : {}, + } as any); + } + + it('calls command.error when project is missing', async () => { + const command = createCommand(); + + stubParse(command, {}, {identifier: FROM_PATH}); + await command.init(); + + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined, mrtEnvironment: 'staging'}})); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('gets the redirect via the backend wrapper and returns raw under --json', async () => { + const command = createCommand(); + + stubParse(command, {}, {identifier: FROM_PATH}); + await command.init(); + + stubBackendContext(command); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({ + values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, + })); + + const raw = {from_path: FROM_PATH, to_url: '/new'}; + const getStub = sinon.stub().resolves({ + backend: 'legacy', + redirect: {id: FROM_PATH, source: FROM_PATH, destination: '/new', backend: 'legacy'}, + raw, + }); + command.operations = {...command.operations, getRedirectWithBackend: getStub}; + + const result = await command.run(); + + expect(getStub.calledOnce).to.equal(true); + const [input] = getStub.firstCall.args; + expect(input.preference).to.equal('auto'); + expect(input.projectSlug).to.equal('my-project'); + expect(input.environment).to.equal('staging'); + expect(input.identifier).to.equal(FROM_PATH); + expect(result).to.deep.equal(raw); + }); + + it('prints the redirect detail via ux.stdout in non-JSON mode', async () => { + const command = createCommand(); + + stubParse(command, {}, {identifier: REDIRECT_ID}); + await command.init(); + + stubBackendContext(command); + sinon.stub(command, 'jsonEnabled').returns(false); + sinon.stub(command, 'resolvedConfig').get(() => ({ + values: {mrtProject: 'my-project', mrtEnvironment: 'staging'}, + })); + + const stdoutStub = sinon.stub(ux, 'stdout').returns(void 0); + const getStub = sinon.stub().resolves({ + backend: 'scapi', + redirect: { + id: REDIRECT_ID, + source: '/old', + destination: '/new', + httpStatusCode: 302, + forwardQuerystring: true, + forwardWildcard: false, + status: 'completed', + createdAt: '2026-04-08T21:47:28.188965Z', + createdBy: 'dev@example.com', + backend: 'scapi', + }, + raw: {redirectId: REDIRECT_ID}, + }); + command.operations = {...command.operations, getRedirectWithBackend: getStub}; + + await command.run(); + + expect(stdoutStub.called).to.equal(true); + const printed = stdoutStub + .getCalls() + .map((c) => String(c.args[0])) + .join('\n'); + expect(printed).to.include(REDIRECT_ID); + expect(printed).to.include('/old'); + expect(printed).to.include('/new'); + expect(printed).to.include('completed'); + expect(printed).to.include('dev@example.com'); + }); + + it('forwards the resolved SCAPI backend context with the UUID identifier', async () => { + const command = createCommand(); + + stubParse(command, {'mrt-backend': 'scapi'}, {identifier: REDIRECT_ID}); + await command.init(); + + const scapiConnection = {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd', auth: {}}; + stubBackendContext(command, {preference: 'scapi', scapiConnection, legacyAuth: undefined}); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtBackend: 'scapi'}})); + + const getStub = sinon + .stub() + .resolves({backend: 'scapi', redirect: {id: REDIRECT_ID, backend: 'scapi'}, raw: {redirectId: REDIRECT_ID}}); + command.operations = {...command.operations, getRedirectWithBackend: getStub}; + + await command.run(); + + const [input] = getStub.firstCall.args; + expect(input.preference).to.equal('scapi'); + expect(input.scapiConnection).to.equal(scapiConnection); + expect(input.identifier).to.equal(REDIRECT_ID); + }); + + it('supports the SCAPI MRT backend', () => { + const command = createCommand(); + expect(command.supportsScapiMrt()).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/mrt/env/redirect/list.test.ts b/packages/b2c-cli/test/commands/mrt/env/redirect/list.test.ts index 0ee86ab07..97e40110a 100644 --- a/packages/b2c-cli/test/commands/mrt/env/redirect/list.test.ts +++ b/packages/b2c-cli/test/commands/mrt/env/redirect/list.test.ts @@ -3,269 +3,178 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ + import {expect} from 'chai'; -import {afterEach, beforeEach} from 'mocha'; import sinon from 'sinon'; +import {Config} from '@oclif/core'; import MrtRedirectList from '../../../../../src/commands/mrt/env/redirect/list.js'; -import {createIsolatedConfigHooks, createTestCommand, runSilent} from '../../../../helpers/test-setup.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {stubParse} from '../../../../helpers/stub-parse.js'; describe('mrt env redirect list', () => { - const hooks = createIsolatedConfigHooks(); + let config: Config; beforeEach(async () => { - await hooks.beforeEach(); + isolateConfig(); + config = await Config.load(); }); afterEach(() => { - hooks.afterEach(); + sinon.restore(); + restoreConfig(); }); - async function createCommand(flags: Record): Promise { - return createTestCommand(MrtRedirectList, hooks.getConfig(), flags, {}); + function createCommand(): any { + return new MrtRedirectList([], config); + } + + function stubErrorToThrow(command: any): sinon.SinonStub { + return sinon.stub(command, 'error').throws(new Error('Expected error')); } - it('throws error when project is missing', async () => { - const command = await createCommand({environment: 'staging'}); + function stubBackendContext( + command: any, + ctx: {preference?: string; scapiConnection?: unknown; legacyAuth?: unknown} = {}, + ): void { + sinon.stub(command, 'getMrtBackendContext').returns({ + preference: ctx.preference ?? 'auto', + scapiConnection: ctx.scapiConnection, + legacyAuth: 'legacyAuth' in ctx ? ctx.legacyAuth : {}, + } as any); + } + + it('calls command.error when project is missing', async () => { + const command = createCommand(); + + stubParse(command, {}, {}); + await command.init(); - sinon.stub(command, 'requireMrtCredentials').returns(void 0); sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined, mrtEnvironment: 'staging'}})); - const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + const errorStub = stubErrorToThrow(command); try { await command.run(); - expect.fail('Should have thrown'); + expect.fail('Expected error'); } catch { - expect(errorStub.called).to.be.true; - expect(errorStub.firstCall.args[0]).to.include('MRT project is required'); + expect(errorStub.calledOnce).to.equal(true); } }); - it('throws error when environment is missing', async () => { - const command = await createCommand({project: 'my-storefront'}); + it('calls command.error when environment is missing', async () => { + const command = createCommand(); - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon - .stub(command, 'resolvedConfig') - .get(() => ({values: {mrtProject: 'my-storefront', mrtEnvironment: undefined}})); + stubParse(command, {project: 'my-project'}, {}); + await command.init(); + + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: undefined}})); - const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + const errorStub = stubErrorToThrow(command); try { await command.run(); - expect.fail('Should have thrown'); + expect.fail('Expected error'); } catch { - expect(errorStub.called).to.be.true; - expect(errorStub.firstCall.args[0]).to.include('MRT environment is required'); + expect(errorStub.calledOnce).to.equal(true); } }); - it('lists redirects in JSON mode', async () => { - const command = await createCommand({json: true, project: 'my-storefront', environment: 'staging'}); - - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'jsonEnabled').returns(true); - sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, - })); - sinon.stub(command, 'getMrtAuth').returns({} as any); - - const mockResult = { - count: 2, - redirects: [ - { - from_path: '/old-1', - to_url: '/new-1', - http_status_code: 301, - publishing_status: 'published', - }, - { - from_path: '/old-2', - to_url: '/new-2', - http_status_code: 302, - publishing_status: 'pending', - }, - ], - }; - - const listStub = sinon.stub().resolves(mockResult); - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - if (!environment) { - this.error('MRT environment is required'); - } - this.log('Fetching redirects...'); - return listStub(); - }; - - const result = await command.run(); - - expect(result.count).to.equal(2); - expect(result.redirects).to.have.lengthOf(2); - }); + it('routes through the backend-aware list, forwards limit/offset/search, and returns raw under --json', async () => { + const command = createCommand(); - it('lists redirects in non-JSON mode', async () => { - const command = await createCommand({project: 'my-storefront', environment: 'staging'}); + stubParse(command, {project: 'my-project', environment: 'staging', limit: 10, offset: 5, search: '/old'}, {}); + await command.init(); - sinon.stub(command, 'requireMrtCredentials').returns(void 0); + stubBackendContext(command); + sinon.stub(command, 'jsonEnabled').returns(true); sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'jsonEnabled').returns(false); sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, + values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, })); - sinon.stub(command, 'getMrtAuth').returns({} as any); - const mockResult = { + const listStub = sinon.stub().resolves({ + backend: 'legacy', count: 1, - redirects: [ - { - from_path: '/sale', - to_url: '/summer-sale', - http_status_code: 301, - publishing_status: 'published', - }, - ], - }; - - const listStub = sinon.stub().resolves(mockResult); - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - if (!environment) { - this.error('MRT environment is required'); - } - this.log('Fetching redirects...'); - return listStub(); - }; - - const result = (await runSilent(() => command.run())) as any; - - expect(result.count).to.equal(1); - }); - - it('handles empty list', async () => { - const command = await createCommand({json: true, project: 'my-storefront', environment: 'staging'}); - - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'jsonEnabled').returns(true); - sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, - })); - sinon.stub(command, 'getMrtAuth').returns({} as any); - - const mockResult = { - count: 0, - redirects: [], - }; - - const listStub = sinon.stub().resolves(mockResult); - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - if (!environment) { - this.error('MRT environment is required'); - } - this.log('Fetching redirects...'); - return listStub(); - }; + redirects: [{id: '/old', source: '/old', destination: '/new', backend: 'legacy'}], + raw: {count: 1, next: null, previous: null, redirects: [{from_path: '/old', to_url: '/new'}]}, + } as any); + command.operations = {...command.operations, listRedirectsWithBackend: listStub}; const result = await command.run(); - expect(result.count).to.equal(0); - expect(result.redirects).to.deep.equal([]); + expect(listStub.calledOnce).to.equal(true); + const [input] = listStub.firstCall.args; + expect(input.preference).to.equal('auto'); + expect(input.projectSlug).to.equal('my-project'); + expect(input.environment).to.equal('staging'); + expect(input.limit).to.equal(10); + expect(input.offset).to.equal(5); + expect(input.search).to.equal('/old'); + expect(input.origin).to.equal('https://example.com'); + // --json emits the raw backend-native list response verbatim. + expect(result.count).to.equal(1); + expect(result.redirects[0].from_path).to.equal('/old'); }); - it('lists redirects with limit and offset', async () => { - const command = await createCommand({ - json: true, - project: 'my-storefront', - environment: 'staging', - limit: 10, - offset: 5, - }); + it('forwards the resolved SCAPI backend context and emits the native envelope under --json', async () => { + const command = createCommand(); - sinon.stub(command, 'requireMrtCredentials').returns(void 0); - sinon.stub(command, 'log').returns(void 0); + stubParse(command, {project: 'my-project', environment: 'staging', 'mrt-backend': 'scapi'}, {}); + await command.init(); + + const scapiConnection = {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd', auth: {}}; + stubBackendContext(command, {preference: 'scapi', scapiConnection, legacyAuth: undefined}); sinon.stub(command, 'jsonEnabled').returns(true); - sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, - })); - sinon.stub(command, 'getMrtAuth').returns({} as any); + sinon.stub(command, 'log').returns(void 0); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtBackend: 'scapi'}})); - const mockResult = { + const listStub = sinon.stub().resolves({ + backend: 'scapi', count: 1, - redirects: [{from_path: '/page-6', to_url: '/new-6', http_status_code: 301}], - }; - - const listStub = sinon.stub().resolves(mockResult); - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - if (!environment) { - this.error('MRT environment is required'); - } - this.log('Fetching redirects...'); - return listStub(); - }; + redirects: [{id: 'uuid-1', source: '/old', destination: '/new', backend: 'scapi'}], + raw: {limit: 25, offset: 0, total: 1, data: [{redirectId: 'uuid-1', source: '/old', destination: '/new'}]}, + } as any); + command.operations = {...command.operations, listRedirectsWithBackend: listStub}; const result = await command.run(); - expect(result.count).to.equal(1); + const [input] = listStub.firstCall.args; + expect(input.preference).to.equal('scapi'); + expect(input.scapiConnection).to.equal(scapiConnection); + // --json emits the native SCAPI paginated envelope verbatim. + expect(result.total).to.equal(1); + expect(result.data[0].redirectId).to.equal('uuid-1'); }); - it('lists redirects with search filter', async () => { - const command = await createCommand({ - json: true, - project: 'my-storefront', - environment: 'staging', - search: '/old', - }); + it('renders the table in non-JSON mode (renderTable is stubbed)', async () => { + const command = createCommand(); + + stubParse(command, {project: 'my-project', environment: 'staging'}, {}); + await command.init(); - sinon.stub(command, 'requireMrtCredentials').returns(void 0); + stubBackendContext(command); + sinon.stub(command, 'jsonEnabled').returns(false); sinon.stub(command, 'log').returns(void 0); - sinon.stub(command, 'jsonEnabled').returns(true); - sinon.stub(command, 'resolvedConfig').get(() => ({ - values: {mrtProject: 'my-storefront', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, - })); - sinon.stub(command, 'getMrtAuth').returns({} as any); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging'}})); - const mockResult = { + sinon.stub(command, 'renderTable').returns(void 0); + const listStub = sinon.stub().resolves({ + backend: 'legacy', count: 1, - redirects: [{from_path: '/old-page', to_url: '/new-page', http_status_code: 301}], - }; - - const listStub = sinon.stub().resolves(mockResult); - (command as any).run = async function () { - this.requireMrtCredentials(); - const {mrtProject: project, mrtEnvironment: environment} = this.resolvedConfig.values; - if (!project) { - this.error('MRT project is required'); - } - if (!environment) { - this.error('MRT environment is required'); - } - this.log('Fetching redirects...'); - return listStub(); - }; + redirects: [{id: '/old', source: '/old', destination: '/new', backend: 'legacy'}], + raw: {count: 1, redirects: [{from_path: '/old', to_url: '/new'}]}, + } as any); + command.operations = {...command.operations, listRedirectsWithBackend: listStub}; - const result = await command.run(); + await command.run(); - expect(result.count).to.equal(1); - expect(result.redirects[0].from_path).to.equal('/old-page'); + expect((command.renderTable as sinon.SinonStub).calledOnce).to.equal(true); + }); + + it('supports the SCAPI MRT backend', () => { + const command = createCommand(); + expect(command.supportsScapiMrt()).to.equal(true); }); }); diff --git a/packages/b2c-cli/test/commands/mrt/env/redirect/update.test.ts b/packages/b2c-cli/test/commands/mrt/env/redirect/update.test.ts new file mode 100644 index 000000000..e00f4ab46 --- /dev/null +++ b/packages/b2c-cli/test/commands/mrt/env/redirect/update.test.ts @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import MrtRedirectUpdate from '../../../../../src/commands/mrt/env/redirect/update.js'; +import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; +import {stubParse} from '../../../../helpers/stub-parse.js'; + +const FROM_PATH = '/old-page'; +const REDIRECT_ID = '3f9b1c2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d'; + +describe('mrt env redirect update', () => { + let config: Config; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + function createCommand(): any { + return new MrtRedirectUpdate([], config); + } + + function stubErrorToThrow(command: any): sinon.SinonStub { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + function stubBackendContext( + command: any, + ctx: {preference?: string; scapiConnection?: unknown; legacyAuth?: unknown} = {}, + ): void { + sinon.stub(command, 'getMrtBackendContext').returns({ + preference: ctx.preference ?? 'auto', + scapiConnection: ctx.scapiConnection, + legacyAuth: 'legacyAuth' in ctx ? ctx.legacyAuth : {}, + } as any); + } + + it('calls command.error when project is missing', async () => { + const command = createCommand(); + + stubParse(command, {to: '/new'}, {identifier: FROM_PATH}); + await command.init(); + + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined, mrtEnvironment: 'staging'}})); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('errors when no updatable field is supplied', async () => { + const command = createCommand(); + + stubParse(command, {}, {identifier: FROM_PATH}); + await command.init(); + + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging'}})); + + const errorStub = stubErrorToThrow(command); + const updateStub = sinon.stub().resolves({backend: 'legacy', redirect: {}, raw: {}}); + command.operations = {...command.operations, updateRedirectWithBackend: updateStub}; + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + expect(errorStub.firstCall.args[0]).to.include('at least one field'); + } + expect(updateStub.called).to.equal(false); + }); + + it('updates only the supplied fields via the backend wrapper and returns raw under --json', async () => { + const command = createCommand(); + + stubParse(command, {to: '/new', 'forward-querystring': false}, {identifier: FROM_PATH}); + await command.init(); + + stubBackendContext(command); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({ + values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}, + })); + + const raw = {from_path: FROM_PATH, to_url: '/new'}; + const updateStub = sinon.stub().resolves({ + backend: 'legacy', + redirect: {id: FROM_PATH, source: FROM_PATH, destination: '/new', backend: 'legacy'}, + raw, + }); + command.operations = {...command.operations, updateRedirectWithBackend: updateStub}; + + const result = await command.run(); + + expect(updateStub.calledOnce).to.equal(true); + const [input] = updateStub.firstCall.args; + expect(input.preference).to.equal('auto'); + expect(input.identifier).to.equal(FROM_PATH); + expect(input.destination).to.equal('/new'); + expect(input.forwardQuerystring).to.equal(false); + // Status and wildcard were not supplied, so they stay undefined (partial update). + expect(input.httpStatusCode).to.equal(undefined); + expect(input.forwardWildcard).to.equal(undefined); + expect(result).to.deep.equal(raw); + }); + + it('forwards the resolved SCAPI backend context with the UUID identifier', async () => { + const command = createCommand(); + + stubParse(command, {status: 302, 'mrt-backend': 'scapi'}, {identifier: REDIRECT_ID}); + await command.init(); + + const scapiConnection = {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd', auth: {}}; + stubBackendContext(command, {preference: 'scapi', scapiConnection, legacyAuth: undefined}); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'log').returns(void 0); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtBackend: 'scapi'}})); + + const updateStub = sinon + .stub() + .resolves({backend: 'scapi', redirect: {id: REDIRECT_ID, backend: 'scapi'}, raw: {redirectId: REDIRECT_ID}}); + command.operations = {...command.operations, updateRedirectWithBackend: updateStub}; + + await command.run(); + + const [input] = updateStub.firstCall.args; + expect(input.preference).to.equal('scapi'); + expect(input.scapiConnection).to.equal(scapiConnection); + expect(input.identifier).to.equal(REDIRECT_ID); + expect(input.httpStatusCode).to.equal(302); + }); + + it('supports the SCAPI MRT backend', () => { + const command = createCommand(); + expect(command.supportsScapiMrt()).to.equal(true); + }); +}); diff --git a/packages/b2c-tooling-sdk/data/tooling/index.json b/packages/b2c-tooling-sdk/data/tooling/index.json index b792226d3..0e5274e92 100644 --- a/packages/b2c-tooling-sdk/data/tooling/index.json +++ b/packages/b2c-tooling-sdk/data/tooling/index.json @@ -1,6 +1,6 @@ { "version": "2.0.0", - "generatedAt": "2026-09-29T03:08:34.690Z", + "generatedAt": "2026-10-01T16:58:54.950Z", "entries": [ { "id": "cli-account-manager", @@ -152,7 +152,7 @@ "category": "tooling", "url": "https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/mrt.html", "sourceUrl": "https://salesforcecommercecloud.github.io/b2c-developer-tooling/cli/mrt.md", - "headings": "Command Overview • Global MRT Flags • Configuration Sources • Authentication • Getting an API Key • Configuration • MRT Backends • SCAPI-supported commands • Auto-detection criteria • JSON output is backend-specific • Legacy-only flags are ignored on SCAPI • Maintenance (Read-Only) Mode • Organization Commands • b2c mrt org list • b2c mrt org b2c • Project Commands • b2c mrt project list • b2c mrt project create • b2c mrt project get • b2c mrt project update • b2c mrt project delete • Project Member Commands • b2c mrt project member list • b2c mrt project member add • b2c mrt project member get • b2c mrt project member update • b2c mrt project member remove • Project Notification Commands • b2c mrt project notification list • b2c mrt project notification create • b2c mrt project notification get • b2c mrt project notification update • b2c mrt project notification delete • Environment Commands • b2c mrt env list • b2c mrt env create • b2c mrt env get • b2c mrt env update • b2c mrt env delete • b2c mrt env clone • b2c mrt env invalidate • b2c mrt env b2c • Environment Variable Commands • b2c mrt env var list • b2c mrt env var set • b2c mrt env var push • b2c mrt env var delete • URL Redirect Commands • b2c mrt env redirect list • b2c mrt env redirect create • b2c mrt env redirect delete • b2c mrt env redirect clone • Access Control Commands • b2c mrt env access-control list • b2c mrt env access-control create • b2c mrt env access-control get • b2c mrt env access-control delete • Bundle Commands • b2c mrt bundle deploy • b2c mrt bundle upload-v2 • b2c mrt bundle list • b2c mrt bundle history • b2c mrt bundle download • b2c mrt bundle delete • Organization Member Commands • b2c mrt org member list • b2c mrt org member add • b2c mrt org member get • b2c mrt org member update • b2c mrt org member remove • Organization Certificate Commands • b2c mrt org cert list • b2c mrt org cert get • b2c mrt org cert create • b2c mrt org cert delete • b2c mrt org cert restart-validation • Tail Logs • b2c mrt tail-logs • Save Credentials • b2c mrt save-credentials • User Commands • b2c mrt user profile • b2c mrt user api-key • b2c mrt user email-prefs • Common Workflows • Deploy to Production • Set Up a New Environment • Invalidate Cache After Content Update", + "headings": "Command Overview • Global MRT Flags • Configuration Sources • Authentication • Getting an API Key • Configuration • MRT Backends • SCAPI-supported commands • Auto-detection criteria • JSON output is backend-specific • Legacy-only flags are ignored on SCAPI • Maintenance (Read-Only) Mode • Organization Commands • b2c mrt org list • b2c mrt org b2c • Project Commands • b2c mrt project list • b2c mrt project create • b2c mrt project get • b2c mrt project update • b2c mrt project delete • Project Member Commands • b2c mrt project member list • b2c mrt project member add • b2c mrt project member get • b2c mrt project member update • b2c mrt project member remove • Project Notification Commands • b2c mrt project notification list • b2c mrt project notification create • b2c mrt project notification get • b2c mrt project notification update • b2c mrt project notification delete • Environment Commands • b2c mrt env list • b2c mrt env create • b2c mrt env get • b2c mrt env update • b2c mrt env delete • b2c mrt env clone • b2c mrt env invalidate • b2c mrt env b2c • Environment Variable Commands • b2c mrt env var list • b2c mrt env var set • b2c mrt env var push • b2c mrt env var delete • URL Redirect Commands • b2c mrt env redirect list • b2c mrt env redirect create • b2c mrt env redirect get • b2c mrt env redirect update • b2c mrt env redirect delete • b2c mrt env redirect clone • Access Control Commands • b2c mrt env access-control list • b2c mrt env access-control create • b2c mrt env access-control get • b2c mrt env access-control delete • Bundle Commands • b2c mrt bundle deploy • b2c mrt bundle upload-v2 • b2c mrt bundle list • b2c mrt bundle history • b2c mrt bundle download • b2c mrt bundle delete • Organization Member Commands • b2c mrt org member list • b2c mrt org member add • b2c mrt org member get • b2c mrt org member update • b2c mrt org member remove • Organization Certificate Commands • b2c mrt org cert list • b2c mrt org cert get • b2c mrt org cert create • b2c mrt org cert delete • b2c mrt org cert restart-validation • Tail Logs • b2c mrt tail-logs • Save Credentials • b2c mrt save-credentials • User Commands • b2c mrt user profile • b2c mrt user api-key • b2c mrt user email-prefs • Common Workflows • Deploy to Production • Set Up a New Environment • Invalidate Cache After Content Update", "preview": "Commands for managing Managed Runtime projects, environments, bundles, and deployments." }, { diff --git a/packages/b2c-tooling-sdk/specs/storefront-environments-v1.yaml b/packages/b2c-tooling-sdk/specs/storefront-environments-v1.yaml index 4ac4dcbe3..514e6e34f 100644 --- a/packages/b2c-tooling-sdk/specs/storefront-environments-v1.yaml +++ b/packages/b2c-tooling-sdk/specs/storefront-environments-v1.yaml @@ -14,12 +14,13 @@ openapi: "3.0.3" # parameters and the RFC 7807 `ErrorResponse` envelope. # # Scope note: this bundle covers the environment-variables sub-resource (GET + -# merge-PATCH) and the access-control-headers sub-resource (list/create/get/ -# delete), which is what the `mrt env var` and `mrt env access-control` commands -# need. The broader Environments lifecycle surface (list/create/clone/get/ -# update/delete/...) is intentionally omitted; it belongs to a later story. When -# adding that surface or bumping to a newer upstream version, re-bundle from the -# same source path. +# merge-PATCH), the access-control-headers sub-resource (list/create/get/ +# delete), and the redirects sub-resource (list/create/get/update/delete and the +# `/redirects/actions/clone` action), which is what the `mrt env var`, +# `mrt env access-control`, and `mrt env redirect` commands need. The broader +# Environments lifecycle surface (list/create/clone/get/update/delete/...) is +# intentionally omitted; it belongs to a later story. When adding that surface or +# bumping to a newer upstream version, re-bundle from the same source path. # --------------------------------------------------------------------------- info: x-api-type: Admin @@ -29,8 +30,10 @@ info: description: >- SCAPI Managed Runtime Storefront Environments API. Read and update the environment variables for a storefront environment (values masked, merge-PATCH - semantics), and manage the access control headers on an environment - (list/create/get/delete; header values masked on read). + semantics), manage the access control headers on an environment + (list/create/get/delete; header values masked on read), and manage the URL + redirects on an environment (list/create/get/update/delete, plus cloning all + redirects from another environment). servers: - url: "https://{shortCode}.api.commercecloud.salesforce.com/storefront/environments/v1" variables: @@ -230,6 +233,197 @@ paths: security: - AmOAuth2: - "sfcc.storefront.environments.rw" + /organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/redirects: + get: + summary: Get redirects. + description: Retrieves a paginated, orderable list of redirects on the environment. + operationId: getRedirects + parameters: + - $ref: "#/components/parameters/organizationId" + - $ref: "#/components/parameters/storefrontId" + - $ref: "#/components/parameters/environmentId" + - $ref: "#/components/parameters/redirectOrderBy" + - name: limit + in: query + required: false + description: Number of records to retrieve per request. Must be between 1 and 200. Defaults to 25. + schema: + type: integer + format: int32 + default: 25 + minimum: 1 + maximum: 200 + - name: offset + in: query + required: false + description: Zero-based index of the first record to include in the result. + schema: + type: integer + format: int32 + default: 0 + minimum: 0 + responses: + "200": + description: List of redirects successfully retrieved. + content: + application/json: + schema: + $ref: "#/components/schemas/RedirectResult" + "401": + $ref: "#/components/responses/401Unauthorized" + "403": + $ref: "#/components/responses/403Forbidden" + "404": + $ref: "#/components/responses/404NotFound" + security: + - AmOAuth2: + - "sfcc.storefront.environments" + - "sfcc.storefront.environments.rw" + post: + summary: Create a redirect. + description: Creates a redirect on the environment. Returns the created redirect. + operationId: createRedirect + parameters: + - $ref: "#/components/parameters/organizationId" + - $ref: "#/components/parameters/storefrontId" + - $ref: "#/components/parameters/environmentId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RedirectCreateRequest" + responses: + "201": + description: Redirect successfully created. + content: + application/json: + schema: + $ref: "#/components/schemas/Redirect" + "400": + $ref: "#/components/responses/400BadRequest" + "401": + $ref: "#/components/responses/401Unauthorized" + "403": + $ref: "#/components/responses/403Forbidden" + "404": + $ref: "#/components/responses/404NotFound" + security: + - AmOAuth2: + - "sfcc.storefront.environments.rw" + /organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/redirects/{redirectId}: + get: + summary: Get a redirect by ID. + description: Retrieves a single redirect by its identifier. + operationId: getRedirect + parameters: + - $ref: "#/components/parameters/organizationId" + - $ref: "#/components/parameters/storefrontId" + - $ref: "#/components/parameters/environmentId" + - $ref: "#/components/parameters/redirectId" + responses: + "200": + description: Redirect successfully retrieved. + content: + application/json: + schema: + $ref: "#/components/schemas/Redirect" + "401": + $ref: "#/components/responses/401Unauthorized" + "403": + $ref: "#/components/responses/403Forbidden" + "404": + $ref: "#/components/responses/404NotFound" + security: + - AmOAuth2: + - "sfcc.storefront.environments" + - "sfcc.storefront.environments.rw" + patch: + summary: Update a redirect. + description: Partially updates a redirect by its identifier. Only the supplied fields are changed. + operationId: updateRedirect + parameters: + - $ref: "#/components/parameters/organizationId" + - $ref: "#/components/parameters/storefrontId" + - $ref: "#/components/parameters/environmentId" + - $ref: "#/components/parameters/redirectId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RedirectUpdateRequest" + responses: + "200": + description: Redirect successfully updated. + content: + application/json: + schema: + $ref: "#/components/schemas/Redirect" + "400": + $ref: "#/components/responses/400BadRequest" + "401": + $ref: "#/components/responses/401Unauthorized" + "403": + $ref: "#/components/responses/403Forbidden" + "404": + $ref: "#/components/responses/404NotFound" + security: + - AmOAuth2: + - "sfcc.storefront.environments.rw" + delete: + summary: Delete a redirect. + description: Deletes a redirect by its identifier. Returns 204 No Content on success. + operationId: deleteRedirect + parameters: + - $ref: "#/components/parameters/organizationId" + - $ref: "#/components/parameters/storefrontId" + - $ref: "#/components/parameters/environmentId" + - $ref: "#/components/parameters/redirectId" + responses: + "204": + description: Redirect successfully deleted. + "401": + $ref: "#/components/responses/401Unauthorized" + "403": + $ref: "#/components/responses/403Forbidden" + "404": + $ref: "#/components/responses/404NotFound" + security: + - AmOAuth2: + - "sfcc.storefront.environments.rw" + /organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/redirects/actions/clone: + post: + summary: Clone redirects from a source environment. + description: >- + Copies all redirects from a source environment into the environment in + the request path. The source environment must differ from the + destination environment. Returns 201 Created with no response body. + operationId: cloneRedirects + parameters: + - $ref: "#/components/parameters/organizationId" + - $ref: "#/components/parameters/storefrontId" + - $ref: "#/components/parameters/environmentId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RedirectCloneRequest" + responses: + "201": + description: Redirects successfully cloned into the destination environment. + "400": + $ref: "#/components/responses/400BadRequest" + "401": + $ref: "#/components/responses/401Unauthorized" + "403": + $ref: "#/components/responses/403Forbidden" + "404": + $ref: "#/components/responses/404NotFound" + security: + - AmOAuth2: + - "sfcc.storefront.environments.rw" components: securitySchemes: AmOAuth2: @@ -310,6 +504,32 @@ components: items: type: string pattern: "^(creation_date|id|publishing_status)(:(asc|desc))?$" + redirectId: + name: redirectId + in: path + required: true + description: The redirect identifier, in UUID format. + schema: + type: string + format: uuid + redirectOrderBy: + name: orderBy + in: query + required: false + description: >- + Attributes to order the returned redirects by, in priority order + (applied left to right). Append `:asc` or `:desc` to set the direction; + when omitted, `:asc` is assumed. Sortable attributes: `creation_date`, + `last_modified`, `source`, `destination`, `last_modified_by`, + `http_status_code`, `publishing_status`. + style: form + explode: false + schema: + type: array + maxItems: 7 + items: + type: string + pattern: "^(creation_date|last_modified|source|destination|last_modified_by|http_status_code|publishing_status)(:(asc|desc))?$" responses: 401Unauthorized: description: Your access token is invalid or expired and can't be used to identify a user. @@ -498,3 +718,169 @@ components: minLength: 9 maxLength: 127 example: "jfoWikeaby0z" + RedirectHttpStatusCode: + type: integer + format: int32 + description: The HTTP status code returned for the redirect. + enum: + - 301 + - 302 + default: 301 + example: 301 + Redirect: + description: A URL redirect rule on the environment. + type: object + properties: + redirectId: + type: string + format: uuid + description: The unique identifier of the redirect, in UUID format. + example: "3f9b1c2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d" + source: + type: string + description: The source path that triggers the redirect. + minLength: 1 + maxLength: 2000 + example: "/spring" + destination: + type: string + description: The destination path or URL the request is redirected to. + minLength: 1 + maxLength: 2000 + example: "/summer-newbranding" + httpStatusCode: + $ref: "#/components/schemas/RedirectHttpStatusCode" + forwardQuerystring: + type: boolean + description: Whether the incoming query string is forwarded to the destination. + example: false + forwardWildcard: + type: boolean + description: Whether a trailing wildcard match on the source is forwarded to the destination. + example: false + publishingStatus: + $ref: "#/components/schemas/PublishingStatus" + createdBy: + type: string + nullable: true + description: The email address of the user who created the redirect. Null if no user is associated. + maxLength: 255 + example: "dev@example.com" + lastModifiedBy: + type: string + nullable: true + description: The email address of the user who last modified the redirect. Null if no user is associated. + maxLength: 255 + example: "dev@example.com" + creationDate: + type: string + format: date-time + description: The timestamp when the redirect was created. + example: "2026-08-24T00:00:00.000Z" + lastModified: + type: string + format: date-time + description: The timestamp when the redirect was last modified. + example: "2026-08-24T00:00:00.000Z" + RedirectResult: + description: Paginated result containing a list of redirects. + type: object + required: + - limit + - offset + - total + - data + properties: + limit: + type: integer + format: int32 + description: Maximum number of records returned per request. + example: 25 + offset: + type: integer + format: int32 + description: Zero-based index of the first record included in the result. + example: 0 + total: + type: integer + format: int32 + description: Total number of redirects matching the request. + example: 3 + data: + type: array + description: The list of redirects. May be empty. + items: + $ref: "#/components/schemas/Redirect" + RedirectCreateRequest: + description: Request body to create a single redirect. + type: object + required: + - source + - destination + properties: + source: + type: string + description: The source path that triggers the redirect. + minLength: 1 + maxLength: 2000 + example: "/spring" + destination: + type: string + description: The destination path or URL the request is redirected to. + minLength: 1 + maxLength: 2000 + example: "/summer-newbranding" + httpStatusCode: + $ref: "#/components/schemas/RedirectHttpStatusCode" + forwardQuerystring: + type: boolean + description: Whether the incoming query string is forwarded to the destination. + default: false + example: false + forwardWildcard: + type: boolean + description: Whether a trailing wildcard match on the source is forwarded to the destination. + default: false + example: false + RedirectUpdateRequest: + description: Request body to partially update a redirect. Only the supplied fields are changed. + type: object + minProperties: 1 + properties: + source: + type: string + description: The source path that triggers the redirect. + minLength: 1 + maxLength: 2000 + example: "/spring" + destination: + type: string + description: The destination path or URL the request is redirected to. + minLength: 1 + maxLength: 2000 + example: "/summer-2027" + httpStatusCode: + $ref: "#/components/schemas/RedirectHttpStatusCode" + forwardQuerystring: + type: boolean + description: Whether the incoming query string is forwarded to the destination. + example: false + forwardWildcard: + type: boolean + description: Whether a trailing wildcard match on the source is forwarded to the destination. + example: false + RedirectCloneRequest: + description: Request body to clone all redirects from a source environment into this environment. + type: object + required: + - sourceEnvironmentId + properties: + sourceEnvironmentId: + type: string + description: >- + The identifier of the environment to copy redirects from. Must + differ from the environment in the request path. + minLength: 1 + maxLength: 19 + pattern: "^[a-z0-9]+(?:-+[a-z0-9]+)*$" + example: "staging" diff --git a/packages/b2c-tooling-sdk/src/cli/mrt-command.ts b/packages/b2c-tooling-sdk/src/cli/mrt-command.ts index 756dec6ce..c46768aa6 100644 --- a/packages/b2c-tooling-sdk/src/cli/mrt-command.ts +++ b/packages/b2c-tooling-sdk/src/cli/mrt-command.ts @@ -287,9 +287,10 @@ export abstract class MrtCommand extends OAuthCommand< * Whether this command implements the SCAPI MRT backend. Defaults to `false`; * the supported commands (`mrt bundle history`, `mrt bundle list`, * `mrt bundle deploy` — both the local-build push and `` deploy — - * the `mrt env var` family: `list`, `set`, `delete`, `push`, and the - * `mrt env access-control` family: `list`, `create`, `get`, `delete`) override - * it to `true`. Used by {@link init} to reject an explicit `--mrt-backend scapi` + * the `mrt env var` family: `list`, `set`, `delete`, `push`, the + * `mrt env redirect` family: `list`, `create`, `get`, `update`, `delete`, + * `clone`, and the `mrt env access-control` family: `list`, `create`, `get`, + * `delete`) override it to `true`. Used by {@link init} to reject an explicit `--mrt-backend scapi` * on commands that would otherwise silently fall back to legacy — an explicit * SCAPI request must never be quietly downgraded. */ @@ -305,8 +306,9 @@ export abstract class MrtCommand extends OAuthCommand< if (!this.supportsScapiMrt() && this.mrtBackendPreference === 'scapi') { this.error( '--mrt-backend scapi is not supported by this command yet. The SCAPI MRT backend currently supports ' + - '"mrt bundle history", "mrt bundle list", "mrt bundle deploy", "mrt env var" (list/set/delete/push), and ' + - '"mrt env access-control" (list/create/get/delete). Re-run with --mrt-backend legacy or auto.', + '"mrt bundle history", "mrt bundle list", "mrt bundle deploy", "mrt env var" (list/set/delete/push), ' + + '"mrt env redirect" (list/create/get/update/delete/clone), and "mrt env access-control" (list/create/get/delete). ' + + 'Re-run with --mrt-backend legacy or auto.', ); } } diff --git a/packages/b2c-tooling-sdk/src/clients/storefront-environments.generated.ts b/packages/b2c-tooling-sdk/src/clients/storefront-environments.generated.ts index 789d9b870..8a062ee99 100644 --- a/packages/b2c-tooling-sdk/src/clients/storefront-environments.generated.ts +++ b/packages/b2c-tooling-sdk/src/clients/storefront-environments.generated.ts @@ -76,6 +76,78 @@ export interface paths { patch?: never; trace?: never; }; + "/organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/redirects": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get redirects. + * @description Retrieves a paginated, orderable list of redirects on the environment. + */ + get: operations["getRedirects"]; + put?: never; + /** + * Create a redirect. + * @description Creates a redirect on the environment. Returns the created redirect. + */ + post: operations["createRedirect"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/redirects/{redirectId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a redirect by ID. + * @description Retrieves a single redirect by its identifier. + */ + get: operations["getRedirect"]; + put?: never; + post?: never; + /** + * Delete a redirect. + * @description Deletes a redirect by its identifier. Returns 204 No Content on success. + */ + delete: operations["deleteRedirect"]; + options?: never; + head?: never; + /** + * Update a redirect. + * @description Partially updates a redirect by its identifier. Only the supplied fields are changed. + */ + patch: operations["updateRedirect"]; + trace?: never; + }; + "/organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/redirects/actions/clone": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Clone redirects from a source environment. + * @description Copies all redirects from a source environment into the environment in the request path. The source environment must differ from the destination environment. Returns 201 Created with no response body. + */ + post: operations["cloneRedirects"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -195,6 +267,148 @@ export interface components { */ value: string; }; + /** + * Format: int32 + * @description The HTTP status code returned for the redirect. + * @default 301 + * @example 301 + * @enum {integer} + */ + RedirectHttpStatusCode: 301 | 302; + /** @description A URL redirect rule on the environment. */ + Redirect: { + /** + * Format: uuid + * @description The unique identifier of the redirect, in UUID format. + * @example 3f9b1c2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d + */ + redirectId?: string; + /** + * @description The source path that triggers the redirect. + * @example /spring + */ + source?: string; + /** + * @description The destination path or URL the request is redirected to. + * @example /summer-newbranding + */ + destination?: string; + httpStatusCode?: components["schemas"]["RedirectHttpStatusCode"]; + /** + * @description Whether the incoming query string is forwarded to the destination. + * @example false + */ + forwardQuerystring?: boolean; + /** + * @description Whether a trailing wildcard match on the source is forwarded to the destination. + * @example false + */ + forwardWildcard?: boolean; + publishingStatus?: components["schemas"]["PublishingStatus"]; + /** + * @description The email address of the user who created the redirect. Null if no user is associated. + * @example dev@example.com + */ + createdBy?: string | null; + /** + * @description The email address of the user who last modified the redirect. Null if no user is associated. + * @example dev@example.com + */ + lastModifiedBy?: string | null; + /** + * Format: date-time + * @description The timestamp when the redirect was created. + * @example 2026-08-24T00:00:00.000Z + */ + creationDate?: string; + /** + * Format: date-time + * @description The timestamp when the redirect was last modified. + * @example 2026-08-24T00:00:00.000Z + */ + lastModified?: string; + }; + /** @description Paginated result containing a list of redirects. */ + RedirectResult: { + /** + * Format: int32 + * @description Maximum number of records returned per request. + * @example 25 + */ + limit: number; + /** + * Format: int32 + * @description Zero-based index of the first record included in the result. + * @example 0 + */ + offset: number; + /** + * Format: int32 + * @description Total number of redirects matching the request. + * @example 3 + */ + total: number; + /** @description The list of redirects. May be empty. */ + data: components["schemas"]["Redirect"][]; + }; + /** @description Request body to create a single redirect. */ + RedirectCreateRequest: { + /** + * @description The source path that triggers the redirect. + * @example /spring + */ + source: string; + /** + * @description The destination path or URL the request is redirected to. + * @example /summer-newbranding + */ + destination: string; + httpStatusCode?: components["schemas"]["RedirectHttpStatusCode"]; + /** + * @description Whether the incoming query string is forwarded to the destination. + * @default false + * @example false + */ + forwardQuerystring: boolean; + /** + * @description Whether a trailing wildcard match on the source is forwarded to the destination. + * @default false + * @example false + */ + forwardWildcard: boolean; + }; + /** @description Request body to partially update a redirect. Only the supplied fields are changed. */ + RedirectUpdateRequest: { + /** + * @description The source path that triggers the redirect. + * @example /spring + */ + source?: string; + /** + * @description The destination path or URL the request is redirected to. + * @example /summer-2027 + */ + destination?: string; + httpStatusCode?: components["schemas"]["RedirectHttpStatusCode"]; + /** + * @description Whether the incoming query string is forwarded to the destination. + * @example false + */ + forwardQuerystring?: boolean; + /** + * @description Whether a trailing wildcard match on the source is forwarded to the destination. + * @example false + */ + forwardWildcard?: boolean; + }; + /** @description Request body to clone all redirects from a source environment into this environment. */ + RedirectCloneRequest: { + /** + * @description The identifier of the environment to copy redirects from. Must differ from the environment in the request path. + * @example staging + */ + sourceEnvironmentId: string; + }; }; responses: { /** @description Your access token is invalid or expired and can't be used to identify a user. */ @@ -245,6 +459,10 @@ export interface components { accessControlHeaderId: string; /** @description Attributes to order the returned access control headers by, in priority order (applied left to right). Append `:asc` or `:desc` to set the direction; when omitted, `:asc` is assumed. Sortable attributes: `creation_date`, `id`, `publishing_status`. */ accessControlHeaderOrderBy: string[]; + /** @description The redirect identifier, in UUID format. */ + redirectId: string; + /** @description Attributes to order the returned redirects by, in priority order (applied left to right). Append `:asc` or `:desc` to set the direction; when omitted, `:asc` is assumed. Sortable attributes: `creation_date`, `last_modified`, `source`, `destination`, `last_modified_by`, `http_status_code`, `publishing_status`. */ + redirectOrderBy: string[]; }; requestBodies: never; headers: never; @@ -449,4 +667,208 @@ export interface operations { 404: components["responses"]["404NotFound"]; }; }; + getRedirects: { + parameters: { + query?: { + /** @description Attributes to order the returned redirects by, in priority order (applied left to right). Append `:asc` or `:desc` to set the direction; when omitted, `:asc` is assumed. Sortable attributes: `creation_date`, `last_modified`, `source`, `destination`, `last_modified_by`, `http_status_code`, `publishing_status`. */ + orderBy?: components["parameters"]["redirectOrderBy"]; + /** @description Number of records to retrieve per request. Must be between 1 and 200. Defaults to 25. */ + limit?: number; + /** @description Zero-based index of the first record to include in the result. */ + offset?: number; + }; + header?: never; + path: { + /** @description The B2C Commerce organization identifier, e.g. `f_ecom_zzxy_prd`. */ + organizationId: components["parameters"]["organizationId"]; + /** @description The storefront identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + storefrontId: components["parameters"]["storefrontId"]; + /** @description The environment identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + environmentId: components["parameters"]["environmentId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of redirects successfully retrieved. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RedirectResult"]; + }; + }; + 401: components["responses"]["401Unauthorized"]; + 403: components["responses"]["403Forbidden"]; + 404: components["responses"]["404NotFound"]; + }; + }; + createRedirect: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The B2C Commerce organization identifier, e.g. `f_ecom_zzxy_prd`. */ + organizationId: components["parameters"]["organizationId"]; + /** @description The storefront identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + storefrontId: components["parameters"]["storefrontId"]; + /** @description The environment identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + environmentId: components["parameters"]["environmentId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RedirectCreateRequest"]; + }; + }; + responses: { + /** @description Redirect successfully created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Redirect"]; + }; + }; + 400: components["responses"]["400BadRequest"]; + 401: components["responses"]["401Unauthorized"]; + 403: components["responses"]["403Forbidden"]; + 404: components["responses"]["404NotFound"]; + }; + }; + getRedirect: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The B2C Commerce organization identifier, e.g. `f_ecom_zzxy_prd`. */ + organizationId: components["parameters"]["organizationId"]; + /** @description The storefront identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + storefrontId: components["parameters"]["storefrontId"]; + /** @description The environment identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + environmentId: components["parameters"]["environmentId"]; + /** @description The redirect identifier, in UUID format. */ + redirectId: components["parameters"]["redirectId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect successfully retrieved. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Redirect"]; + }; + }; + 401: components["responses"]["401Unauthorized"]; + 403: components["responses"]["403Forbidden"]; + 404: components["responses"]["404NotFound"]; + }; + }; + deleteRedirect: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The B2C Commerce organization identifier, e.g. `f_ecom_zzxy_prd`. */ + organizationId: components["parameters"]["organizationId"]; + /** @description The storefront identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + storefrontId: components["parameters"]["storefrontId"]; + /** @description The environment identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + environmentId: components["parameters"]["environmentId"]; + /** @description The redirect identifier, in UUID format. */ + redirectId: components["parameters"]["redirectId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect successfully deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["401Unauthorized"]; + 403: components["responses"]["403Forbidden"]; + 404: components["responses"]["404NotFound"]; + }; + }; + updateRedirect: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The B2C Commerce organization identifier, e.g. `f_ecom_zzxy_prd`. */ + organizationId: components["parameters"]["organizationId"]; + /** @description The storefront identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + storefrontId: components["parameters"]["storefrontId"]; + /** @description The environment identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + environmentId: components["parameters"]["environmentId"]; + /** @description The redirect identifier, in UUID format. */ + redirectId: components["parameters"]["redirectId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RedirectUpdateRequest"]; + }; + }; + responses: { + /** @description Redirect successfully updated. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Redirect"]; + }; + }; + 400: components["responses"]["400BadRequest"]; + 401: components["responses"]["401Unauthorized"]; + 403: components["responses"]["403Forbidden"]; + 404: components["responses"]["404NotFound"]; + }; + }; + cloneRedirects: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The B2C Commerce organization identifier, e.g. `f_ecom_zzxy_prd`. */ + organizationId: components["parameters"]["organizationId"]; + /** @description The storefront identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + storefrontId: components["parameters"]["storefrontId"]; + /** @description The environment identifier. Must consist only of lowercase letters, numbers, and hyphens, with no leading or trailing hyphens. */ + environmentId: components["parameters"]["environmentId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RedirectCloneRequest"]; + }; + }; + responses: { + /** @description Redirects successfully cloned into the destination environment. */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["400BadRequest"]; + 401: components["responses"]["401Unauthorized"]; + 403: components["responses"]["403Forbidden"]; + 404: components["responses"]["404NotFound"]; + }; + }; } diff --git a/packages/b2c-tooling-sdk/src/clients/storefront-environments.ts b/packages/b2c-tooling-sdk/src/clients/storefront-environments.ts index 07e375027..e6b806eee 100644 --- a/packages/b2c-tooling-sdk/src/clients/storefront-environments.ts +++ b/packages/b2c-tooling-sdk/src/clients/storefront-environments.ts @@ -13,12 +13,15 @@ * * This is one of the SCAPI `Storefront` API family (`storefront//v1`): a * dedicated client per API, each with its own path segment, spec, and scope - * family. This client currently covers only the Environments API's - * environment-variables sub-resource (read + merge-PATCH), which is what the - * `mrt env var` commands need; the broader environment lifecycle surface is - * intentionally out of scope. Sibling clients (`storefront-deployments`, - * `storefront-storefronts`) follow the same pattern. The MRT-level composition - * of these clients lives in `operations/mrt`. + * family. This client currently covers the Environments API's + * environment-variables sub-resource (read + merge-PATCH), the + * access-control-headers sub-resource (list/create/get/delete), and the + * redirects sub-resource (list/create/get/update/delete + clone action), which + * is what the `mrt env var`, `mrt env access-control`, and `mrt env redirect` + * commands need; the broader environment lifecycle surface is intentionally out + * of scope. Sibling clients (`storefront-deployments`, `storefront-storefronts`) + * follow the same pattern. The MRT-level composition of these clients lives in + * `operations/mrt`. * * ID mapping for B2C Commerce MRT: * - `organizationId` = `f_ecom_` @@ -50,6 +53,12 @@ export type AccessControlHeaderEntry = components['schemas']['AccessControlHeade export type AccessControlHeaderResult = components['schemas']['AccessControlHeaderResult']; export type AccessControlHeaderCreateRequest = components['schemas']['AccessControlHeaderCreateRequest']; +export type RedirectEntry = components['schemas']['Redirect']; +export type RedirectResult = components['schemas']['RedirectResult']; +export type RedirectCreateRequest = components['schemas']['RedirectCreateRequest']; +export type RedirectUpdateRequest = components['schemas']['RedirectUpdateRequest']; +export type RedirectCloneRequest = components['schemas']['RedirectCloneRequest']; + /** Publishing status of an environment variable (`pending` | `completed` | `failed`). */ export type PublishingStatus = components['schemas']['PublishingStatus']; diff --git a/packages/b2c-tooling-sdk/src/operations/mrt/index.ts b/packages/b2c-tooling-sdk/src/operations/mrt/index.ts index 996e63154..3098fa0b2 100644 --- a/packages/b2c-tooling-sdk/src/operations/mrt/index.ts +++ b/packages/b2c-tooling-sdk/src/operations/mrt/index.ts @@ -306,6 +306,20 @@ export { updateRedirect, deleteRedirect, cloneRedirects, + getRedirectsScapi, + createRedirectScapi, + getRedirectScapi, + updateRedirectScapi, + deleteRedirectScapi, + cloneRedirectsScapi, + listRedirectsWithBackend, + createRedirectWithBackend, + getRedirectWithBackend, + updateRedirectWithBackend, + deleteRedirectWithBackend, + cloneRedirectsWithBackend, + normalizeLegacyRedirect, + normalizeRedirectScapi, } from './redirect.js'; export type { ListRedirectsOptions, @@ -319,6 +333,19 @@ export type { MrtRedirect, PatchedMrtRedirect, RedirectHttpStatusCode, + ScapiRedirectInput, + MrtRedirectView, + MrtRedirectsView, + MrtRedirectResult, + MrtRedirectWriteResult, + MrtRedirectCloneResult, + RedirectBackendOptions, + ListRedirectsBackendOptions, + CreateRedirectBackendOptions, + GetRedirectBackendOptions, + UpdateRedirectBackendOptions, + DeleteRedirectBackendOptions, + CloneRedirectsBackendOptions, } from './redirect.js'; // Access control header operations diff --git a/packages/b2c-tooling-sdk/src/operations/mrt/redirect.ts b/packages/b2c-tooling-sdk/src/operations/mrt/redirect.ts index 8fecd1284..37b24e79b 100644 --- a/packages/b2c-tooling-sdk/src/operations/mrt/redirect.ts +++ b/packages/b2c-tooling-sdk/src/operations/mrt/redirect.ts @@ -13,7 +13,21 @@ import type {AuthStrategy} from '../../auth/types.js'; import {createMrtClient, DEFAULT_MRT_ORIGIN} from '../../clients/mrt.js'; import type {components} from '../../clients/mrt.js'; +import {SCOPE_MODE_HEADER} from '../../clients/middleware.js'; +import {createScapiRequestError} from '../../clients/scapi-backend-utils.js'; +import { + createStorefrontEnvironmentsClient, + toOrganizationId, + type StorefrontEnvironmentsClient, + type RedirectEntry, +} from '../../clients/storefront-environments.js'; import {getLogger} from '../../logging/logger.js'; +import { + runMrtWithFallback, + type MrtBackend, + type MrtBackendPreference, + type ScapiMrtConnection, +} from './mrt-backend.js'; /** * Redirect type from API. @@ -590,3 +604,783 @@ export async function cloneRedirects( redirects: responseData.results ?? [], }; } + +// --------------------------------------------------------------------------- +// Backend-neutral redirect view + SCAPI MRT operations +// --------------------------------------------------------------------------- + +const READ_HEADERS = {[SCOPE_MODE_HEADER]: 'read'}; +const WRITE_HEADERS = {[SCOPE_MODE_HEADER]: 'write'}; + +/** + * Thrown when a backend-aware operation resolves to the legacy backend but no + * legacy auth was supplied. `legacyAuth` is optional so SCAPI-only callers + * aren't forced to configure a `~/.mobify` API key; this guards the case where + * legacy is actually needed (explicit `legacy`, or `auto` falling back). + */ +const LEGACY_AUTH_REQUIRED_MESSAGE = + 'Legacy MRT credentials are required for this backend but none were provided. ' + + 'Provide an API key (--api-key / MRT_API_KEY / ~/.mobify) or use the SCAPI MRT backend.'; + +/** + * A single redirect row, normalized across the legacy and SCAPI backends so the + * CLI renders one shape regardless of backend. + * + * The two backends identify a redirect differently: the legacy MRT Cloud API + * keys a redirect by its `from_path`, while the SCAPI Environments API assigns a + * UUID `redirectId` and treats the source path as an ordinary (mutable) field. + * {@link id} therefore carries the backend-appropriate identifier to pass back to + * `get`/`update`/`delete` — the `from_path` on legacy, the UUID on SCAPI — while + * {@link source} always holds the human-readable source path. On legacy the two + * are equal; on SCAPI they differ. + */ +export interface MrtRedirectView { + /** Identifier for get/update/delete: the `from_path` on legacy, the UUID on SCAPI. */ + id: string; + /** The source path that triggers the redirect (legacy `from_path` / SCAPI `source`). */ + source: string; + /** The destination path or URL (legacy `to_url` / SCAPI `destination`). */ + destination: string; + /** HTTP status code (301 or 302), when reported. */ + httpStatusCode?: number; + /** Whether the incoming query string is forwarded to the destination. */ + forwardQuerystring?: boolean; + /** Whether a trailing wildcard match on the source is forwarded to the destination. */ + forwardWildcard?: boolean; + /** Human-readable publishing status, when the backend reports one. */ + status?: string; + /** Creation timestamp (ISO 8601), when present. */ + createdAt?: string; + /** Email of the user who created the redirect, when present. */ + createdBy?: string; + /** Backend that produced this row. */ + backend: MrtBackend; +} + +/** Normalizes a legacy MRT {@link MrtRedirect} into an {@link MrtRedirectView}. */ +export function normalizeLegacyRedirect(redirect: MrtRedirect): MrtRedirectView { + return { + // Legacy keys a redirect by its from_path, so that is the identifier the + // get/update/delete operations expect back. + id: redirect.from_path ?? '', + source: redirect.from_path ?? '', + destination: redirect.to_url ?? '', + httpStatusCode: redirect.http_status_code, + forwardQuerystring: redirect.forward_querystring ?? undefined, + forwardWildcard: redirect.forward_wildcard ?? undefined, + status: redirect.publishing_status || undefined, + createdAt: redirect.created_at || undefined, + createdBy: redirect.user_email || undefined, + backend: 'legacy', + }; +} + +/** Normalizes a SCAPI {@link RedirectEntry} into an {@link MrtRedirectView}. */ +export function normalizeRedirectScapi(redirect: RedirectEntry): MrtRedirectView { + return { + // SCAPI keys a redirect by its UUID; the source path is a mutable field. + id: redirect.redirectId ?? '', + source: redirect.source ?? '', + destination: redirect.destination ?? '', + httpStatusCode: redirect.httpStatusCode, + forwardQuerystring: redirect.forwardQuerystring, + forwardWildcard: redirect.forwardWildcard, + status: redirect.publishingStatus ?? undefined, + createdAt: redirect.creationDate ?? undefined, + createdBy: redirect.createdBy ?? undefined, + backend: 'scapi', + }; +} + +function buildScapiEnvironmentsClient(conn: ScapiMrtConnection): StorefrontEnvironmentsClient { + return createStorefrontEnvironmentsClient({shortCode: conn.shortCode, tenantId: conn.tenantId}, conn.auth); +} + +/** The redirect fields a SCAPI create accepts. */ +export interface ScapiRedirectInput { + source: string; + destination: string; + httpStatusCode?: RedirectHttpStatusCode; + forwardQuerystring?: boolean; + forwardWildcard?: boolean; +} + +/** + * Lists redirects for an environment via the SCAPI MRT Environments API. + * Forwards the standard SCAPI `limit`/`offset` pagination query parameters when + * provided (max 200 per page, default 25 server-side; the response echoes the + * full `total`). + * + * @throws {ScapiRequestError} carrying the HTTP status on a non-2xx response. + */ +export async function getRedirectsScapi( + conn: ScapiMrtConnection, + params: {storefrontId: string; environmentId: string; limit?: number; offset?: number}, +): Promise<{redirects: MrtRedirectView[]; count: number; raw: unknown}> { + const logger = getLogger(); + const {storefrontId, environmentId, limit, offset} = params; + const organizationId = toOrganizationId(conn.tenantId); + + logger.debug({organizationId, storefrontId, environmentId, limit, offset}, '[MRT-SCAPI] Listing redirects'); + + const client = buildScapiEnvironmentsClient(conn); + const {data, error, response} = await client.GET( + '/organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/redirects', + { + params: {path: {organizationId, storefrontId, environmentId}, query: {limit, offset}}, + headers: READ_HEADERS, + }, + ); + + if (error || !data) { + throw createScapiRequestError(error, response, 'Failed to list redirects'); + } + + return { + redirects: (data.data ?? []).map(normalizeRedirectScapi), + count: data.total ?? data.data?.length ?? 0, + raw: data, + }; +} + +/** + * Creates a redirect via the SCAPI MRT Environments API (201 Created returns the + * full redirect object). + * + * @throws {ScapiRequestError} carrying the HTTP status on a non-2xx response. + */ +export async function createRedirectScapi( + conn: ScapiMrtConnection, + params: {storefrontId: string; environmentId: string; redirect: ScapiRedirectInput}, +): Promise<{redirect: MrtRedirectView; raw: unknown}> { + const logger = getLogger(); + const {storefrontId, environmentId, redirect} = params; + const organizationId = toOrganizationId(conn.tenantId); + + logger.debug({organizationId, storefrontId, environmentId}, '[MRT-SCAPI] Creating redirect'); + + const client = buildScapiEnvironmentsClient(conn); + const {data, error, response} = await client.POST( + '/organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/redirects', + { + params: {path: {organizationId, storefrontId, environmentId}}, + headers: WRITE_HEADERS, + body: { + source: redirect.source, + destination: redirect.destination, + httpStatusCode: redirect.httpStatusCode, + forwardQuerystring: redirect.forwardQuerystring ?? false, + forwardWildcard: redirect.forwardWildcard ?? false, + }, + }, + ); + + if (error || !data) { + throw createScapiRequestError(error, response, 'Failed to create redirect'); + } + + return {redirect: normalizeRedirectScapi(data), raw: data}; +} + +/** + * Fetches a single redirect by its UUID via the SCAPI MRT Environments API. + * + * @throws {ScapiRequestError} carrying the HTTP status on a non-2xx response. + */ +export async function getRedirectScapi( + conn: ScapiMrtConnection, + params: {storefrontId: string; environmentId: string; redirectId: string}, +): Promise<{redirect: MrtRedirectView; raw: unknown}> { + const {storefrontId, environmentId, redirectId} = params; + const organizationId = toOrganizationId(conn.tenantId); + + const client = buildScapiEnvironmentsClient(conn); + const {data, error, response} = await client.GET( + '/organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/redirects/{redirectId}', + { + params: {path: {organizationId, storefrontId, environmentId, redirectId}}, + headers: READ_HEADERS, + }, + ); + + if (error || !data) { + throw createScapiRequestError(error, response, `Failed to get redirect ${redirectId}`); + } + + return {redirect: normalizeRedirectScapi(data), raw: data}; +} + +/** + * Partially updates a redirect by its UUID via the SCAPI MRT Environments API. + * Only the supplied fields are changed (200 OK returns the updated redirect). + * + * @throws {ScapiRequestError} carrying the HTTP status on a non-2xx response. + */ +export async function updateRedirectScapi( + conn: ScapiMrtConnection, + params: { + storefrontId: string; + environmentId: string; + redirectId: string; + changes: Partial; + }, +): Promise<{redirect: MrtRedirectView; raw: unknown}> { + const logger = getLogger(); + const {storefrontId, environmentId, redirectId, changes} = params; + const organizationId = toOrganizationId(conn.tenantId); + + logger.debug({organizationId, storefrontId, environmentId, redirectId}, '[MRT-SCAPI] Updating redirect'); + + const client = buildScapiEnvironmentsClient(conn); + const {data, error, response} = await client.PATCH( + '/organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/redirects/{redirectId}', + { + params: {path: {organizationId, storefrontId, environmentId, redirectId}}, + headers: WRITE_HEADERS, + body: changes, + }, + ); + + if (error || !data) { + throw createScapiRequestError(error, response, `Failed to update redirect ${redirectId}`); + } + + return {redirect: normalizeRedirectScapi(data), raw: data}; +} + +/** + * Deletes a redirect by its UUID via the SCAPI MRT Environments API. The + * endpoint returns 204 No Content on success — there is no response body, so + * only `error` is inspected. + * + * @throws {ScapiRequestError} carrying the HTTP status on a non-2xx response. + */ +export async function deleteRedirectScapi( + conn: ScapiMrtConnection, + params: {storefrontId: string; environmentId: string; redirectId: string}, +): Promise { + const logger = getLogger(); + const {storefrontId, environmentId, redirectId} = params; + const organizationId = toOrganizationId(conn.tenantId); + + logger.debug({organizationId, storefrontId, environmentId, redirectId}, '[MRT-SCAPI] Deleting redirect'); + + const client = buildScapiEnvironmentsClient(conn); + // 204 No Content on success: no `data` is returned, so check `error` only — + // treating missing `data` as failure would wrongly reject a successful delete. + const {error, response} = await client.DELETE( + '/organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/redirects/{redirectId}', + { + params: {path: {organizationId, storefrontId, environmentId, redirectId}}, + headers: WRITE_HEADERS, + }, + ); + + if (error) { + throw createScapiRequestError(error, response, `Failed to delete redirect ${redirectId}`); + } +} + +/** + * Clones all redirects from a source environment into the environment named by + * `environmentId`, via the SCAPI MRT Environments API. Returns 201 Created with + * no response body on success — so there is no cloned-count to report. + * + * @throws {ScapiRequestError} carrying the HTTP status on a non-2xx response. + */ +export async function cloneRedirectsScapi( + conn: ScapiMrtConnection, + params: {storefrontId: string; environmentId: string; sourceEnvironmentId: string}, +): Promise { + const logger = getLogger(); + const {storefrontId, environmentId, sourceEnvironmentId} = params; + const organizationId = toOrganizationId(conn.tenantId); + + logger.debug({organizationId, storefrontId, environmentId, sourceEnvironmentId}, '[MRT-SCAPI] Cloning redirects'); + + const client = buildScapiEnvironmentsClient(conn); + // 201 Created with no body on success. openapi-fetch only short-circuits empty + // bodies on 204 / Content-Length: 0, so parse as text to avoid a JSON.parse + // throw on the empty 201; then check `error` only. + const {error, response} = await client.POST( + '/organizations/{organizationId}/storefronts/{storefrontId}/environments/{environmentId}/redirects/actions/clone', + { + params: {path: {organizationId, storefrontId, environmentId}}, + headers: WRITE_HEADERS, + body: {sourceEnvironmentId}, + parseAs: 'text', + }, + ); + + if (error) { + throw createScapiRequestError(error, response, 'Failed to clone redirects'); + } +} + +// --------------------------------------------------------------------------- +// Backend-aware redirect operations (route legacy ↔ SCAPI) +// --------------------------------------------------------------------------- + +/** Common backend-routing options shared by the backend-aware redirect operations. */ +export interface RedirectBackendOptions { + /** Resolved `--mrt-backend` preference. */ + preference: MrtBackendPreference; + /** SCAPI connection; when absent, `auto` uses legacy and `scapi` throws. */ + scapiConnection?: ScapiMrtConnection; + /** Legacy API-key auth strategy. Optional; required only when the legacy backend actually runs. */ + legacyAuth?: AuthStrategy; + /** Project slug (= SCAPI storefront ID). */ + projectSlug: string; + /** Target/environment slug (= SCAPI environment ID). */ + environment: string; + /** Legacy MRT API origin. */ + origin?: string; + /** Invoked when `auto` falls back from SCAPI to legacy. */ + onFallback?: (reason: string) => void; + /** Invoked with the backend that serves the call (for `-D` debug). */ + onResolve?: (backend: MrtBackend) => void; +} + +/** Backend-neutral redirect list result. */ +export interface MrtRedirectsView { + /** Backend that served the list. */ + backend: MrtBackend; + /** Number of redirects (SCAPI `total`, else the row count). */ + count: number; + /** Normalized redirect rows, consumed by the CLI table. */ + redirects: MrtRedirectView[]; + /** + * The raw, backend-native list response, surfaced verbatim under `--json` so + * each backend keeps its original machine contract (legacy: the + * {@link ListRedirectsResult} shape; SCAPI: the paginated + * `{limit, offset, total, data}` envelope). The normalized {@link redirects} + * feed the human table only. + */ + raw: unknown; +} + +/** Backend-neutral single redirect result (get/create/update). */ +export interface MrtRedirectResult { + /** Backend that served the call. */ + backend: MrtBackend; + /** Normalized redirect row. */ + redirect: MrtRedirectView; + /** The raw, backend-native redirect response, surfaced verbatim under `--json`. */ + raw: unknown; +} + +/** The backend that served a delete operation. */ +export interface MrtRedirectWriteResult { + /** Backend that served the write. */ + backend: MrtBackend; +} + +/** Backend-neutral clone result. */ +export interface MrtRedirectCloneResult { + /** Backend that served the clone. */ + backend: MrtBackend; + /** + * Number of redirects cloned, when the backend reports it. The legacy MRT + * Cloud API returns the cloned redirects; the SCAPI backend returns 201 with + * no body, so this is `null` there. + */ + count: number | null; + /** + * The raw, backend-native clone response: the legacy + * {@link CloneRedirectsResult} shape, or `null` for SCAPI's empty 201. + */ + raw: unknown; +} + +/** Options for {@link listRedirectsWithBackend}. */ +export interface ListRedirectsBackendOptions extends RedirectBackendOptions { + /** Maximum number of results to return (forwarded to both backends). */ + limit?: number; + /** Pagination offset (forwarded to both backends). */ + offset?: number; + /** Legacy-only search term (ignored by the SCAPI backend). */ + search?: string; +} + +/** + * Lists redirects, routing to the SCAPI or legacy backend per the given + * preference (with safe `auto` fallback). Returns normalized rows. + */ +export async function listRedirectsWithBackend(options: ListRedirectsBackendOptions): Promise { + const { + preference, + scapiConnection, + legacyAuth, + projectSlug, + environment, + limit, + offset, + search, + origin, + onFallback, + onResolve, + } = options; + + const run = await runMrtWithFallback<{count: number; redirects: MrtRedirectView[]; raw: unknown}>( + { + preference, + hasScapiConfig: Boolean(scapiConnection), + canFallbackToLegacy: Boolean(legacyAuth), + onFallback, + onResolve, + }, + { + scapi: () => + getRedirectsScapi(scapiConnection!, { + storefrontId: projectSlug, + environmentId: environment, + limit, + offset, + }), + legacy: async () => { + if (!legacyAuth) { + throw new Error(LEGACY_AUTH_REQUIRED_MESSAGE); + } + const result = await listRedirects( + {projectSlug, targetSlug: environment, limit, offset, search, origin}, + legacyAuth, + ); + // The legacy list result is the shape legacy `--json` emitted before the + // backend split, so surface it verbatim under --json. + return {count: result.count, redirects: result.redirects.map(normalizeLegacyRedirect), raw: result}; + }, + }, + ); + + return {backend: run.backend, count: run.value.count, redirects: run.value.redirects, raw: run.value.raw}; +} + +/** Options for {@link createRedirectWithBackend}. */ +export interface CreateRedirectBackendOptions extends RedirectBackendOptions { + /** The source path to redirect from. */ + source: string; + /** The destination path or URL. */ + destination: string; + /** HTTP status code (301 or 302). */ + httpStatusCode?: RedirectHttpStatusCode; + /** Forward the incoming query string. */ + forwardQuerystring?: boolean; + /** Forward a trailing wildcard match. */ + forwardWildcard?: boolean; +} + +/** + * Creates a redirect, routing to the SCAPI or legacy backend per the given + * preference (with safe `auto` fallback). + */ +export async function createRedirectWithBackend(options: CreateRedirectBackendOptions): Promise { + const { + preference, + scapiConnection, + legacyAuth, + projectSlug, + environment, + source, + destination, + httpStatusCode, + forwardQuerystring, + forwardWildcard, + origin, + onFallback, + onResolve, + } = options; + + const run = await runMrtWithFallback<{redirect: MrtRedirectView; raw: unknown}>( + { + preference, + hasScapiConfig: Boolean(scapiConnection), + canFallbackToLegacy: Boolean(legacyAuth), + onFallback, + onResolve, + }, + { + scapi: () => + createRedirectScapi(scapiConnection!, { + storefrontId: projectSlug, + environmentId: environment, + redirect: {source, destination, httpStatusCode, forwardQuerystring, forwardWildcard}, + }), + legacy: async () => { + if (!legacyAuth) { + throw new Error(LEGACY_AUTH_REQUIRED_MESSAGE); + } + const redirect = await createRedirect( + { + projectSlug, + targetSlug: environment, + fromPath: source, + toUrl: destination, + httpStatusCode, + forwardQuerystring, + forwardWildcard, + origin, + }, + legacyAuth, + ); + return {redirect: normalizeLegacyRedirect(redirect), raw: redirect}; + }, + }, + ); + + return {backend: run.backend, redirect: run.value.redirect, raw: run.value.raw}; +} + +/** Options for {@link getRedirectWithBackend}. */ +export interface GetRedirectBackendOptions extends RedirectBackendOptions { + /** Redirect identifier: the `from_path` on legacy, the UUID on SCAPI. */ + identifier: string; +} + +/** + * Fetches a single redirect, routing to the SCAPI or legacy backend per the + * given preference (with safe `auto` fallback). The identifier is the + * `from_path` on legacy and the redirect UUID on SCAPI. + */ +export async function getRedirectWithBackend(options: GetRedirectBackendOptions): Promise { + const {preference, scapiConnection, legacyAuth, projectSlug, environment, identifier, origin, onFallback, onResolve} = + options; + + const run = await runMrtWithFallback<{redirect: MrtRedirectView; raw: unknown}>( + { + preference, + hasScapiConfig: Boolean(scapiConnection), + canFallbackToLegacy: Boolean(legacyAuth), + onFallback, + onResolve, + }, + { + scapi: () => + getRedirectScapi(scapiConnection!, { + storefrontId: projectSlug, + environmentId: environment, + redirectId: identifier, + }), + legacy: async () => { + if (!legacyAuth) { + throw new Error(LEGACY_AUTH_REQUIRED_MESSAGE); + } + const redirect = await getRedirect( + {projectSlug, targetSlug: environment, fromPath: identifier, origin}, + legacyAuth, + ); + return {redirect: normalizeLegacyRedirect(redirect), raw: redirect}; + }, + }, + ); + + return {backend: run.backend, redirect: run.value.redirect, raw: run.value.raw}; +} + +/** Options for {@link updateRedirectWithBackend}. */ +export interface UpdateRedirectBackendOptions extends RedirectBackendOptions { + /** Redirect identifier: the `from_path` on legacy, the UUID on SCAPI. */ + identifier: string; + /** New destination path or URL. */ + destination?: string; + /** New HTTP status code (301 or 302). */ + httpStatusCode?: RedirectHttpStatusCode; + /** Forward the incoming query string. */ + forwardQuerystring?: boolean; + /** Forward a trailing wildcard match. */ + forwardWildcard?: boolean; +} + +/** + * Partially updates a redirect, routing to the SCAPI or legacy backend per the + * given preference (with safe `auto` fallback). Only the supplied fields change. + * + * The source path is immutable here: the legacy backend keys the redirect by its + * `from_path`, so renaming it is not expressible as an update on both backends. + */ +export async function updateRedirectWithBackend(options: UpdateRedirectBackendOptions): Promise { + const { + preference, + scapiConnection, + legacyAuth, + projectSlug, + environment, + identifier, + destination, + httpStatusCode, + forwardQuerystring, + forwardWildcard, + origin, + onFallback, + onResolve, + } = options; + + const run = await runMrtWithFallback<{redirect: MrtRedirectView; raw: unknown}>( + { + preference, + hasScapiConfig: Boolean(scapiConnection), + canFallbackToLegacy: Boolean(legacyAuth), + onFallback, + onResolve, + }, + { + scapi: () => { + // Build the SCAPI merge-PATCH body from only the supplied fields. + const changes: Partial = {}; + if (destination !== undefined) changes.destination = destination; + if (httpStatusCode !== undefined) changes.httpStatusCode = httpStatusCode; + if (forwardQuerystring !== undefined) changes.forwardQuerystring = forwardQuerystring; + if (forwardWildcard !== undefined) changes.forwardWildcard = forwardWildcard; + return updateRedirectScapi(scapiConnection!, { + storefrontId: projectSlug, + environmentId: environment, + redirectId: identifier, + changes, + }); + }, + legacy: async () => { + if (!legacyAuth) { + throw new Error(LEGACY_AUTH_REQUIRED_MESSAGE); + } + const redirect = await updateRedirect( + { + projectSlug, + targetSlug: environment, + fromPath: identifier, + toUrl: destination, + httpStatusCode, + forwardQuerystring, + forwardWildcard, + origin, + }, + legacyAuth, + ); + return {redirect: normalizeLegacyRedirect(redirect), raw: redirect}; + }, + }, + ); + + return {backend: run.backend, redirect: run.value.redirect, raw: run.value.raw}; +} + +/** Options for {@link deleteRedirectWithBackend}. */ +export interface DeleteRedirectBackendOptions extends RedirectBackendOptions { + /** Redirect identifier: the `from_path` on legacy, the UUID on SCAPI. */ + identifier: string; +} + +/** + * Deletes a redirect, routing to the SCAPI or legacy backend per the given + * preference (with safe `auto` fallback). The identifier is the `from_path` on + * legacy and the redirect UUID on SCAPI. + */ +export async function deleteRedirectWithBackend( + options: DeleteRedirectBackendOptions, +): Promise { + const {preference, scapiConnection, legacyAuth, projectSlug, environment, identifier, origin, onFallback, onResolve} = + options; + + const run = await runMrtWithFallback( + { + preference, + hasScapiConfig: Boolean(scapiConnection), + canFallbackToLegacy: Boolean(legacyAuth), + onFallback, + onResolve, + }, + { + scapi: () => + deleteRedirectScapi(scapiConnection!, { + storefrontId: projectSlug, + environmentId: environment, + redirectId: identifier, + }), + legacy: async () => { + if (!legacyAuth) { + throw new Error(LEGACY_AUTH_REQUIRED_MESSAGE); + } + await deleteRedirect({projectSlug, targetSlug: environment, fromPath: identifier, origin}, legacyAuth); + }, + }, + ); + + return {backend: run.backend}; +} + +/** Options for {@link cloneRedirectsWithBackend}. */ +export interface CloneRedirectsBackendOptions { + /** Resolved `--mrt-backend` preference. */ + preference: MrtBackendPreference; + /** SCAPI connection; when absent, `auto` uses legacy and `scapi` throws. */ + scapiConnection?: ScapiMrtConnection; + /** Legacy API-key auth strategy. Optional; required only when the legacy backend actually runs. */ + legacyAuth?: AuthStrategy; + /** Project slug (= SCAPI storefront ID). */ + projectSlug: string; + /** Source environment slug to copy redirects from. */ + sourceEnvironment: string; + /** Destination environment slug to copy redirects into (= SCAPI path environment ID). */ + targetEnvironment: string; + /** Legacy MRT API origin. */ + origin?: string; + /** Invoked when `auto` falls back from SCAPI to legacy. */ + onFallback?: (reason: string) => void; + /** Invoked with the backend that serves the call (for `-D` debug). */ + onResolve?: (backend: MrtBackend) => void; +} + +/** + * Clones all redirects from a source environment into a destination environment, + * routing to the SCAPI or legacy backend per the given preference (with safe + * `auto` fallback). Callers must ensure the source and destination differ; both + * backends reject a same-environment clone, but the CLI rejects it up front for a + * uniform message. + */ +export async function cloneRedirectsWithBackend( + options: CloneRedirectsBackendOptions, +): Promise { + const { + preference, + scapiConnection, + legacyAuth, + projectSlug, + sourceEnvironment, + targetEnvironment, + origin, + onFallback, + onResolve, + } = options; + + const run = await runMrtWithFallback<{count: number | null; raw: unknown}>( + { + preference, + hasScapiConfig: Boolean(scapiConnection), + canFallbackToLegacy: Boolean(legacyAuth), + onFallback, + onResolve, + }, + { + scapi: async () => { + await cloneRedirectsScapi(scapiConnection!, { + storefrontId: projectSlug, + environmentId: targetEnvironment, + sourceEnvironmentId: sourceEnvironment, + }); + // SCAPI clone returns 201 with no body: there is no cloned-count or + // payload to surface. + return {count: null, raw: null}; + }, + legacy: async () => { + if (!legacyAuth) { + throw new Error(LEGACY_AUTH_REQUIRED_MESSAGE); + } + const result = await cloneRedirects( + {projectSlug, fromTargetSlug: sourceEnvironment, toTargetSlug: targetEnvironment, origin}, + legacyAuth, + ); + return {count: result.count, raw: result}; + }, + }, + ); + + return {backend: run.backend, count: run.value.count, raw: run.value.raw}; +} diff --git a/packages/b2c-tooling-sdk/test/operations/mrt/redirect.test.ts b/packages/b2c-tooling-sdk/test/operations/mrt/redirect.test.ts new file mode 100644 index 000000000..c094134b8 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/mrt/redirect.test.ts @@ -0,0 +1,857 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {DEFAULT_MRT_ORIGIN} from '@salesforce/b2c-tooling-sdk/clients'; +import { + listRedirects, + createRedirect, + getRedirect, + updateRedirect, + deleteRedirect, + cloneRedirects, + getRedirectsScapi, + createRedirectScapi, + getRedirectScapi, + updateRedirectScapi, + deleteRedirectScapi, + cloneRedirectsScapi, + listRedirectsWithBackend, + createRedirectWithBackend, + getRedirectWithBackend, + updateRedirectWithBackend, + deleteRedirectWithBackend, + cloneRedirectsWithBackend, +} from '@salesforce/b2c-tooling-sdk/operations/mrt'; +import type {ScapiMrtConnection} from '../../../src/operations/mrt/mrt-backend.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; + +const DEFAULT_BASE_URL = DEFAULT_MRT_ORIGIN; + +const SHORT_CODE = 'kv7kzm78'; +const TENANT_ID = 'zzxy_prd'; +const STOREFRONT_ID = 'my-project'; +const ENVIRONMENT_ID = 'staging'; +const SOURCE_ENVIRONMENT_ID = 'production'; +// Legacy keys a redirect by its source path; SCAPI keys it by a UUID. +const FROM_PATH = '/old-page'; +const REDIRECT_ID = '3f9b1c2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d'; + +const SCAPI_BASE = `https://${SHORT_CODE}.api.commercecloud.salesforce.com/storefront/environments/v1`; +const SCAPI_REDIRECTS = `${SCAPI_BASE}/organizations/:organizationId/storefronts/:storefrontId/environments/:environmentId/redirects`; +const SCAPI_REDIRECT = `${SCAPI_REDIRECTS}/:redirectId`; +const SCAPI_CLONE = `${SCAPI_REDIRECTS}/actions/clone`; +const LEGACY_REDIRECTS = `${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/redirect/`; +const LEGACY_REDIRECT = `${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/redirect/:fromPath`; +const LEGACY_CLONE = `${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:toTargetSlug/redirect/clone/`; + +function scapiConn(): ScapiMrtConnection { + return {shortCode: SHORT_CODE, tenantId: TENANT_ID, auth: new MockAuthStrategy()}; +} + +/** A full legacy redirect row as the MRT Cloud API returns it. */ +function legacyRedirect(overrides: Record = {}): Record { + return { + from_path: FROM_PATH, + to_url: '/new-page', + http_status_code: 301, + forward_querystring: false, + forward_wildcard: false, + publishing_status: 'Completed', + user_email: 'dev@example.com', + created_at: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +/** A full SCAPI redirect object as the Environments API returns it. */ +function scapiRedirect(overrides: Record = {}): Record { + return { + redirectId: REDIRECT_ID, + source: FROM_PATH, + destination: '/new-page', + httpStatusCode: 301, + forwardQuerystring: false, + forwardWildcard: false, + publishingStatus: 'completed', + createdBy: 'dev@example.com', + creationDate: '2026-04-08T21:47:28.188965Z', + ...overrides, + }; +} + +describe('operations/mrt/redirect', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + // ------------------------------------------------------------------------- + // Legacy MRT operations + // ------------------------------------------------------------------------- + + describe('listRedirects', () => { + it('lists redirects', async () => { + server.use( + http.get(LEGACY_REDIRECTS, () => { + return HttpResponse.json({count: 1, next: null, previous: null, results: [legacyRedirect()]}); + }), + ); + + const result = await listRedirects( + {projectSlug: STOREFRONT_ID, targetSlug: ENVIRONMENT_ID}, + new MockAuthStrategy(), + ); + + expect(result.count).to.equal(1); + expect(result.redirects).to.have.length(1); + expect(result.redirects[0].from_path).to.equal(FROM_PATH); + }); + + it('throws error on API failure', async () => { + server.use( + http.get(LEGACY_REDIRECTS, () => { + return HttpResponse.json({detail: 'Project not found'}, {status: 404}); + }), + ); + + try { + await listRedirects({projectSlug: 'nope', targetSlug: ENVIRONMENT_ID}, new MockAuthStrategy()); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).to.include('Failed to list redirects'); + } + }); + }); + + describe('createRedirect', () => { + it('creates a redirect', async () => { + let receivedBody: Record | undefined; + server.use( + http.post(LEGACY_REDIRECTS, async ({request}) => { + receivedBody = (await request.json()) as Record; + return HttpResponse.json(legacyRedirect(), {status: 201}); + }), + ); + + const redirect = await createRedirect( + {projectSlug: STOREFRONT_ID, targetSlug: ENVIRONMENT_ID, fromPath: FROM_PATH, toUrl: '/new-page'}, + new MockAuthStrategy(), + ); + + expect(receivedBody).to.include({from_path: FROM_PATH, to_url: '/new-page'}); + expect(redirect.from_path).to.equal(FROM_PATH); + }); + }); + + describe('getRedirect', () => { + it('gets a redirect by from_path', async () => { + let called = false; + server.use( + http.get(LEGACY_REDIRECT, () => { + called = true; + return HttpResponse.json(legacyRedirect()); + }), + ); + + const redirect = await getRedirect( + {projectSlug: STOREFRONT_ID, targetSlug: ENVIRONMENT_ID, fromPath: FROM_PATH}, + new MockAuthStrategy(), + ); + + expect(called).to.equal(true); + expect(redirect.to_url).to.equal('/new-page'); + }); + }); + + describe('updateRedirect', () => { + it('patches only the supplied fields', async () => { + let receivedBody: Record | undefined; + server.use( + http.patch(LEGACY_REDIRECT, async ({request}) => { + receivedBody = (await request.json()) as Record; + return HttpResponse.json(legacyRedirect({to_url: '/newer-page'})); + }), + ); + + const redirect = await updateRedirect( + {projectSlug: STOREFRONT_ID, targetSlug: ENVIRONMENT_ID, fromPath: FROM_PATH, toUrl: '/newer-page'}, + new MockAuthStrategy(), + ); + + expect(receivedBody).to.deep.equal({to_url: '/newer-page'}); + expect(redirect.to_url).to.equal('/newer-page'); + }); + }); + + describe('deleteRedirect', () => { + it('deletes a redirect by from_path', async () => { + let called = false; + server.use( + http.delete(LEGACY_REDIRECT, () => { + called = true; + return new HttpResponse(null, {status: 204}); + }), + ); + + await deleteRedirect( + {projectSlug: STOREFRONT_ID, targetSlug: ENVIRONMENT_ID, fromPath: FROM_PATH}, + new MockAuthStrategy(), + ); + + expect(called).to.equal(true); + }); + + it('throws error on API failure', async () => { + server.use( + http.delete(LEGACY_REDIRECT, () => { + return HttpResponse.json({detail: 'Not found'}, {status: 404}); + }), + ); + + try { + await deleteRedirect( + {projectSlug: STOREFRONT_ID, targetSlug: ENVIRONMENT_ID, fromPath: FROM_PATH}, + new MockAuthStrategy(), + ); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).to.include('Failed to delete redirect'); + } + }); + }); + + describe('cloneRedirects', () => { + it('clones redirects between targets', async () => { + let receivedBody: Record | undefined; + server.use( + http.post(LEGACY_CLONE, async ({request}) => { + receivedBody = (await request.json()) as Record; + return HttpResponse.json({count: 2, results: [legacyRedirect(), legacyRedirect({from_path: '/b'})]}); + }), + ); + + const result = await cloneRedirects( + {projectSlug: STOREFRONT_ID, fromTargetSlug: SOURCE_ENVIRONMENT_ID, toTargetSlug: ENVIRONMENT_ID}, + new MockAuthStrategy(), + ); + + expect(receivedBody).to.deep.equal({from_target_slug: SOURCE_ENVIRONMENT_ID}); + expect(result.count).to.equal(2); + }); + }); + + // ------------------------------------------------------------------------- + // SCAPI MRT operations + // ------------------------------------------------------------------------- + + describe('getRedirectsScapi', () => { + it('maps the SCAPI envelope into normalized views', async () => { + server.use( + http.get(SCAPI_REDIRECTS, ({params, request}) => { + expect(params.organizationId).to.equal('f_ecom_zzxy_prd'); + expect(params.storefrontId).to.equal(STOREFRONT_ID); + expect(params.environmentId).to.equal(ENVIRONMENT_ID); + const url = new URL(request.url); + expect(url.searchParams.get('limit')).to.equal('10'); + expect(url.searchParams.get('offset')).to.equal('5'); + return HttpResponse.json({limit: 10, offset: 5, total: 1, data: [scapiRedirect()]}); + }), + ); + + const result = await getRedirectsScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + limit: 10, + offset: 5, + }); + + expect(result.count).to.equal(1); + expect(result.redirects[0]).to.deep.equal({ + id: REDIRECT_ID, + source: FROM_PATH, + destination: '/new-page', + httpStatusCode: 301, + forwardQuerystring: false, + forwardWildcard: false, + status: 'completed', + createdAt: '2026-04-08T21:47:28.188965Z', + createdBy: 'dev@example.com', + backend: 'scapi', + }); + // raw is the native SCAPI envelope surfaced verbatim for --json. + expect(result.raw).to.have.property('total', 1); + }); + + it('throws a ScapiRequestError on a non-2xx response', async () => { + server.use( + http.get(SCAPI_REDIRECTS, () => { + return HttpResponse.json( + {title: 'Not Found', detail: 'Environment staging not found'}, + {status: 404, headers: {'Content-Type': 'application/problem+json'}}, + ); + }), + ); + + try { + await getRedirectsScapi(scapiConn(), {storefrontId: STOREFRONT_ID, environmentId: ENVIRONMENT_ID}); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).to.include('Environment staging not found'); + expect((error as {status?: number}).status).to.equal(404); + } + }); + }); + + describe('createRedirectScapi', () => { + it('posts source/destination and always sends the forward flags', async () => { + let receivedBody: Record | undefined; + server.use( + http.post(SCAPI_REDIRECTS, async ({request}) => { + receivedBody = (await request.json()) as Record; + return HttpResponse.json(scapiRedirect({publishingStatus: 'pending'}), {status: 201}); + }), + ); + + const result = await createRedirectScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + redirect: {source: FROM_PATH, destination: '/new-page', httpStatusCode: 302}, + }); + + // forwardQuerystring/forwardWildcard are required by the schema: default to false. + expect(receivedBody).to.deep.equal({ + source: FROM_PATH, + destination: '/new-page', + httpStatusCode: 302, + forwardQuerystring: false, + forwardWildcard: false, + }); + expect(result.redirect).to.deep.include({id: REDIRECT_ID, status: 'pending', backend: 'scapi'}); + }); + + it('throws a ScapiRequestError on a non-2xx response', async () => { + server.use( + http.post(SCAPI_REDIRECTS, () => { + return HttpResponse.json( + {title: 'Bad Request', detail: 'source is invalid'}, + {status: 400, headers: {'Content-Type': 'application/problem+json'}}, + ); + }), + ); + + try { + await createRedirectScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + redirect: {source: 'bad', destination: '/x'}, + }); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).to.include('source is invalid'); + expect((error as {status?: number}).status).to.equal(400); + } + }); + }); + + describe('getRedirectScapi', () => { + it('gets a single redirect by UUID', async () => { + server.use( + http.get(SCAPI_REDIRECT, ({params}) => { + expect(params.redirectId).to.equal(REDIRECT_ID); + return HttpResponse.json(scapiRedirect()); + }), + ); + + const result = await getRedirectScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + redirectId: REDIRECT_ID, + }); + + expect(result.redirect).to.deep.include({id: REDIRECT_ID, source: FROM_PATH, backend: 'scapi'}); + }); + }); + + describe('updateRedirectScapi', () => { + it('patches only the supplied fields', async () => { + let receivedBody: Record | undefined; + server.use( + http.patch(SCAPI_REDIRECT, async ({request, params}) => { + expect(params.redirectId).to.equal(REDIRECT_ID); + receivedBody = (await request.json()) as Record; + return HttpResponse.json(scapiRedirect({destination: '/newer-page'})); + }), + ); + + const result = await updateRedirectScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + redirectId: REDIRECT_ID, + changes: {destination: '/newer-page'}, + }); + + expect(receivedBody).to.deep.equal({destination: '/newer-page'}); + expect(result.redirect).to.deep.include({id: REDIRECT_ID, destination: '/newer-page', backend: 'scapi'}); + }); + }); + + describe('deleteRedirectScapi', () => { + it('treats 204 No Content as success', async () => { + let called = false; + server.use( + http.delete(SCAPI_REDIRECT, ({params}) => { + called = true; + expect(params.redirectId).to.equal(REDIRECT_ID); + return new HttpResponse(null, {status: 204}); + }), + ); + + await deleteRedirectScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + redirectId: REDIRECT_ID, + }); + + expect(called).to.equal(true); + }); + + it('throws a ScapiRequestError on a non-2xx response', async () => { + server.use( + http.delete(SCAPI_REDIRECT, () => { + return HttpResponse.json( + {title: 'Forbidden', detail: 'Missing scope'}, + {status: 403, headers: {'Content-Type': 'application/problem+json'}}, + ); + }), + ); + + try { + await deleteRedirectScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + redirectId: REDIRECT_ID, + }); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as {status?: number}).status).to.equal(403); + } + }); + }); + + describe('cloneRedirectsScapi', () => { + it('posts the source environment and treats 201 (no body) as success', async () => { + let receivedBody: Record | undefined; + server.use( + http.post(SCAPI_CLONE, async ({request, params}) => { + expect(params.environmentId).to.equal(ENVIRONMENT_ID); + receivedBody = (await request.json()) as Record; + return new HttpResponse(null, {status: 201}); + }), + ); + + await cloneRedirectsScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + sourceEnvironmentId: SOURCE_ENVIRONMENT_ID, + }); + + expect(receivedBody).to.deep.equal({sourceEnvironmentId: SOURCE_ENVIRONMENT_ID}); + }); + + it('throws a ScapiRequestError on a non-2xx response', async () => { + server.use( + http.post(SCAPI_CLONE, () => { + return HttpResponse.json( + {title: 'Bad Request', detail: 'source and destination must differ'}, + {status: 400, headers: {'Content-Type': 'application/problem+json'}}, + ); + }), + ); + + try { + await cloneRedirectsScapi(scapiConn(), { + storefrontId: STOREFRONT_ID, + environmentId: ENVIRONMENT_ID, + sourceEnvironmentId: ENVIRONMENT_ID, + }); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as {status?: number}).status).to.equal(400); + } + }); + }); + + // ------------------------------------------------------------------------- + // Backend-aware wrappers (legacy ↔ SCAPI routing) + // ------------------------------------------------------------------------- + + describe('listRedirectsWithBackend', () => { + it('routes to SCAPI when preference is scapi', async () => { + server.use( + http.get(SCAPI_REDIRECTS, () => { + return HttpResponse.json({limit: 25, offset: 0, total: 1, data: [scapiRedirect()]}); + }), + ); + + const result = await listRedirectsWithBackend({ + preference: 'scapi', + scapiConnection: scapiConn(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + }); + + expect(result.backend).to.equal('scapi'); + expect(result.count).to.equal(1); + expect(result.redirects[0]).to.deep.include({id: REDIRECT_ID, backend: 'scapi'}); + expect(result.raw).to.have.property('total', 1); + }); + + it('routes to legacy when preference is legacy and surfaces the legacy shape as raw', async () => { + server.use( + http.get(LEGACY_REDIRECTS, () => { + return HttpResponse.json({count: 1, next: null, previous: null, results: [legacyRedirect()]}); + }), + ); + + const result = await listRedirectsWithBackend({ + preference: 'legacy', + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + }); + + expect(result.backend).to.equal('legacy'); + expect(result.redirects[0]).to.deep.include({id: FROM_PATH, source: FROM_PATH, backend: 'legacy'}); + // Legacy raw preserves the pre-split ListRedirectsResult shape for --json. + expect(result.raw).to.have.property('count', 1); + expect(result.raw).to.have.property('redirects'); + }); + + it('auto falls back from SCAPI to legacy on a safe error', async () => { + const fallbacks: string[] = []; + server.use( + http.get(SCAPI_REDIRECTS, () => { + return HttpResponse.json( + {title: 'Not Found', detail: 'not found'}, + {status: 404, headers: {'Content-Type': 'application/problem+json'}}, + ); + }), + http.get(LEGACY_REDIRECTS, () => { + return HttpResponse.json({count: 1, results: [legacyRedirect()]}); + }), + ); + + const result = await listRedirectsWithBackend({ + preference: 'auto', + scapiConnection: scapiConn(), + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + onFallback: (reason) => fallbacks.push(reason), + }); + + expect(result.backend).to.equal('legacy'); + expect(result.redirects[0]).to.deep.include({backend: 'legacy'}); + expect(fallbacks).to.have.length(1); + }); + + it('auto uses legacy when no SCAPI connection is configured', async () => { + server.use( + http.get(LEGACY_REDIRECTS, () => { + return HttpResponse.json({count: 0, results: []}); + }), + ); + + const result = await listRedirectsWithBackend({ + preference: 'auto', + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + }); + + expect(result.backend).to.equal('legacy'); + expect(result.count).to.equal(0); + }); + + it('explicit scapi with no SCAPI connection fails loud', async () => { + try { + await listRedirectsWithBackend({ + preference: 'scapi', + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + }); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as Error).message).to.include('SCAPI MRT backend requires'); + } + }); + }); + + describe('createRedirectWithBackend', () => { + it('creates via SCAPI when preference is scapi', async () => { + let receivedBody: Record | undefined; + server.use( + http.post(SCAPI_REDIRECTS, async ({request}) => { + receivedBody = (await request.json()) as Record; + return HttpResponse.json(scapiRedirect(), {status: 201}); + }), + ); + + const result = await createRedirectWithBackend({ + preference: 'scapi', + scapiConnection: scapiConn(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + source: FROM_PATH, + destination: '/new-page', + httpStatusCode: 301, + }); + + expect(result.backend).to.equal('scapi'); + expect(receivedBody).to.include({source: FROM_PATH, destination: '/new-page'}); + expect(result.redirect).to.deep.include({id: REDIRECT_ID, backend: 'scapi'}); + }); + + it('creates via legacy when preference is legacy', async () => { + server.use( + http.post(LEGACY_REDIRECTS, () => { + return HttpResponse.json(legacyRedirect(), {status: 201}); + }), + ); + + const result = await createRedirectWithBackend({ + preference: 'legacy', + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + source: FROM_PATH, + destination: '/new-page', + }); + + expect(result.backend).to.equal('legacy'); + expect(result.redirect).to.deep.include({id: FROM_PATH, source: FROM_PATH, backend: 'legacy'}); + }); + }); + + describe('getRedirectWithBackend', () => { + it('gets via SCAPI using the UUID identifier', async () => { + server.use( + http.get(SCAPI_REDIRECT, ({params}) => { + expect(params.redirectId).to.equal(REDIRECT_ID); + return HttpResponse.json(scapiRedirect()); + }), + ); + + const result = await getRedirectWithBackend({ + preference: 'scapi', + scapiConnection: scapiConn(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + identifier: REDIRECT_ID, + }); + + expect(result.backend).to.equal('scapi'); + expect(result.redirect).to.deep.include({id: REDIRECT_ID, backend: 'scapi'}); + }); + + it('gets via legacy using the from_path identifier', async () => { + server.use( + http.get(LEGACY_REDIRECT, () => { + return HttpResponse.json(legacyRedirect()); + }), + ); + + const result = await getRedirectWithBackend({ + preference: 'legacy', + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + identifier: FROM_PATH, + }); + + expect(result.backend).to.equal('legacy'); + expect(result.redirect).to.deep.include({id: FROM_PATH, backend: 'legacy'}); + }); + }); + + describe('updateRedirectWithBackend', () => { + it('updates via SCAPI, sending only the supplied fields', async () => { + let receivedBody: Record | undefined; + server.use( + http.patch(SCAPI_REDIRECT, async ({request}) => { + receivedBody = (await request.json()) as Record; + return HttpResponse.json(scapiRedirect({destination: '/newer-page'})); + }), + ); + + const result = await updateRedirectWithBackend({ + preference: 'scapi', + scapiConnection: scapiConn(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + identifier: REDIRECT_ID, + destination: '/newer-page', + }); + + expect(result.backend).to.equal('scapi'); + expect(receivedBody).to.deep.equal({destination: '/newer-page'}); + }); + + it('updates via legacy, sending only the supplied fields', async () => { + let receivedBody: Record | undefined; + server.use( + http.patch(LEGACY_REDIRECT, async ({request}) => { + receivedBody = (await request.json()) as Record; + return HttpResponse.json(legacyRedirect({to_url: '/newer-page'})); + }), + ); + + const result = await updateRedirectWithBackend({ + preference: 'legacy', + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + identifier: FROM_PATH, + destination: '/newer-page', + }); + + expect(result.backend).to.equal('legacy'); + // The legacy backend maps `destination` onto its `to_url` field. + expect(receivedBody).to.deep.equal({to_url: '/newer-page'}); + }); + }); + + describe('deleteRedirectWithBackend', () => { + it('deletes via SCAPI when preference is scapi', async () => { + let called = false; + server.use( + http.delete(SCAPI_REDIRECT, () => { + called = true; + return new HttpResponse(null, {status: 204}); + }), + ); + + const result = await deleteRedirectWithBackend({ + preference: 'scapi', + scapiConnection: scapiConn(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + identifier: REDIRECT_ID, + }); + + expect(result.backend).to.equal('scapi'); + expect(called).to.equal(true); + }); + + it('explicit scapi surfaces errors without falling back to legacy', async () => { + let legacyCalled = false; + server.use( + http.delete(SCAPI_REDIRECT, () => { + return HttpResponse.json( + {title: 'Not Found', detail: 'not found'}, + {status: 404, headers: {'Content-Type': 'application/problem+json'}}, + ); + }), + http.delete(LEGACY_REDIRECT, () => { + legacyCalled = true; + return new HttpResponse(null, {status: 204}); + }), + ); + + try { + await deleteRedirectWithBackend({ + preference: 'scapi', + scapiConnection: scapiConn(), + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + identifier: REDIRECT_ID, + }); + expect.fail('Should have thrown'); + } catch (error) { + expect((error as {status?: number}).status).to.equal(404); + } + expect(legacyCalled).to.equal(false); + }); + + it('deletes via legacy when preference is legacy', async () => { + let called = false; + server.use( + http.delete(LEGACY_REDIRECT, () => { + called = true; + return new HttpResponse(null, {status: 204}); + }), + ); + + const result = await deleteRedirectWithBackend({ + preference: 'legacy', + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + environment: ENVIRONMENT_ID, + identifier: FROM_PATH, + }); + + expect(result.backend).to.equal('legacy'); + expect(called).to.equal(true); + }); + }); + + describe('cloneRedirectsWithBackend', () => { + it('clones via SCAPI and reports a null count (empty 201)', async () => { + let receivedBody: Record | undefined; + server.use( + http.post(SCAPI_CLONE, async ({request}) => { + receivedBody = (await request.json()) as Record; + return new HttpResponse(null, {status: 201}); + }), + ); + + const result = await cloneRedirectsWithBackend({ + preference: 'scapi', + scapiConnection: scapiConn(), + projectSlug: STOREFRONT_ID, + sourceEnvironment: SOURCE_ENVIRONMENT_ID, + targetEnvironment: ENVIRONMENT_ID, + }); + + expect(result.backend).to.equal('scapi'); + expect(result.count).to.equal(null); + expect(result.raw).to.equal(null); + expect(receivedBody).to.deep.equal({sourceEnvironmentId: SOURCE_ENVIRONMENT_ID}); + }); + + it('clones via legacy and reports the cloned count', async () => { + server.use( + http.post(LEGACY_CLONE, () => { + return HttpResponse.json({count: 3, results: [legacyRedirect()]}); + }), + ); + + const result = await cloneRedirectsWithBackend({ + preference: 'legacy', + legacyAuth: new MockAuthStrategy(), + projectSlug: STOREFRONT_ID, + sourceEnvironment: SOURCE_ENVIRONMENT_ID, + targetEnvironment: ENVIRONMENT_ID, + }); + + expect(result.backend).to.equal('legacy'); + expect(result.count).to.equal(3); + expect(result.raw).to.have.property('count', 3); + }); + }); +}); diff --git a/skills/b2c-cli/skills/b2c-mrt/SKILL.md b/skills/b2c-cli/skills/b2c-mrt/SKILL.md index 6ba803701..7c1da0810 100644 --- a/skills/b2c-cli/skills/b2c-mrt/SKILL.md +++ b/skills/b2c-cli/skills/b2c-mrt/SKILL.md @@ -17,17 +17,18 @@ Run `b2c setup inspect` to see the resolved configuration and which source provi ### MRT Backends (legacy vs SCAPI) -Most MRT commands run against the legacy MRT Cloud API (API key). Several commands can also run over the SCAPI MRT backend: `mrt bundle history`, `mrt bundle list`, `mrt bundle deploy` (both the local-build push and deploying an existing ``), the `mrt env var` family (`list` / `set` / `push` / `delete`), and the `mrt env access-control` family (`list` / `create` / `get` / `delete`). Choose with `--mrt-backend` (`MRT_BACKEND` / `SFCC_MRT_BACKEND`, or `mrtBackend` in `dw.json`): +Most MRT commands run against the legacy MRT Cloud API (API key). Several commands can also run over the SCAPI MRT backend: `mrt bundle history`, `mrt bundle list`, `mrt bundle deploy` (both the local-build push and deploying an existing ``), the `mrt env var` family (`list` / `set` / `push` / `delete`), the `mrt env redirect` family (`list` / `create` / `get` / `update` / `delete` / `clone`), and the `mrt env access-control` family (`list` / `create` / `get` / `delete`). Choose with `--mrt-backend` (`MRT_BACKEND` / `SFCC_MRT_BACKEND`, or `mrtBackend` in `dw.json`): - `legacy` (default) — always the MRT Cloud API. -- `auto` — use SCAPI when it's configured (`--short-code` + `--tenant-id` + client-credentials or JWT Bearer auth), otherwise legacy. Falls back to legacy on safe pre-execution errors. Each command requests its own scopes: bundle commands use `sfcc.storefront.deployments[.rw]`; env var and access-control commands use `sfcc.storefront.environments[.rw]` (reads accept either tier, writes require `.rw`). -- `scapi` — always SCAPI, with no fallback; errors if prerequisites are missing. Also errors on unsupported commands (every MRT command except the bundle, env var, and access-control commands above). +- `auto` — use SCAPI when it's configured (`--short-code` + `--tenant-id` + client-credentials or JWT Bearer auth), otherwise legacy. Falls back to legacy on safe pre-execution errors. Each command requests its own scopes: bundle commands use `sfcc.storefront.deployments[.rw]`; env var, redirect, and access-control commands use `sfcc.storefront.environments[.rw]` (reads accept either tier, writes require `.rw`). +- `scapi` — always SCAPI, with no fallback; errors if prerequisites are missing. Also errors on unsupported commands (every MRT command except the bundle, env var, redirect, and access-control commands above). Notes: - Under `--json`, these commands emit the **serving backend's native shape** (e.g. `bundle history` is legacy `{count, next, previous, deployments}` vs SCAPI `{limit, offset, total, data}`; `env var list` is legacy `{count, variables}` vs the SCAPI native environment-variables map). The human table is normalized; `--json` is not. Pin `legacy` or `scapi` when a script needs a stable shape. - Env vars over SCAPI use the Storefront Environments API. `set` and `delete` apply a merge-PATCH (only the keys you pass change; `delete` sends the key with a `null` value); values are always masked by both backends. `push` resolves the backend once from its initial read and pins every write to it, so a single `push` never crosses backends. - Access-control headers over SCAPI also use the Storefront Environments API. `get` and `delete` key on the header UUID; `create` takes the header value as a positional argument (201 Created returns the full masked header). Header values are always masked by both backends — the CLI never displays or reconstructs the plaintext value. +- Redirects over SCAPI also use the Storefront Environments API. The two backends identify a redirect differently — legacy keys on the source path (`from_path`), SCAPI keys on a redirect ID (UUID) — so `get` / `update` / `delete` take a neutral `identifier` positional (source path on legacy, UUID on SCAPI). `update` is a partial update (only the fields you pass change; the source path cannot be changed); `clone` copies all redirects from a distinct source environment and rejects a same-source clone. - Legacy-only flags (`--api-key`, `--cloud-origin` / `-u`, `--credentials-file` / `-c`) are ignored — with a warning — when SCAPI serves the request. - `--storefront` (long) and `-s` (short) are aliases of `--project` / `-p` — the SCAPI storefront ID is the project slug, so all four are interchangeable on every `mrt` command. On `mrt project create` this flag sets the new project's slug (auto-generated from the name if omitted); `mrt bundle save` uses `-d` for `--save-dir`, keeping `-s` free for the storefront alias. diff --git a/skills/b2c-cli/skills/b2c-mrt/references/ENVIRONMENT-COMMANDS.md b/skills/b2c-cli/skills/b2c-mrt/references/ENVIRONMENT-COMMANDS.md index 57b2cba05..634a68941 100644 --- a/skills/b2c-cli/skills/b2c-mrt/references/ENVIRONMENT-COMMANDS.md +++ b/skills/b2c-cli/skills/b2c-mrt/references/ENVIRONMENT-COMMANDS.md @@ -168,44 +168,81 @@ b2c mrt env var delete MY_VAR -p my-storefront -e production --mrt-backend scapi ## URL Redirects +The `mrt env redirect` commands (`list` / `create` / `get` / `update` / `delete` / `clone`) are backend-aware: they honor `--mrt-backend` (`auto` / `legacy` / `scapi`) and, over SCAPI, use the Storefront Environments API (scopes `sfcc.storefront.environments` for reads, `sfcc.storefront.environments.rw` for writes). See the [MRT Backends](../SKILL.md#mrt-backends-legacy-vs-scapi) section for backend selection. The two backends identify a redirect differently — legacy keys on the source path (`from_path`), SCAPI keys on a redirect ID (UUID) — so `get` / `update` / `delete` take a neutral `identifier` positional (source path on legacy, UUID on SCAPI). Under `--json`, each command returns the serving backend's native shape. + ### List Redirects ```bash b2c mrt env redirect list -p my-storefront -e production b2c mrt env redirect list -p my-storefront -e production --limit 50 b2c mrt env redirect list -p my-storefront -e production --json + +# Force the SCAPI backend +b2c mrt env redirect list -p my-storefront -e production --mrt-backend scapi ``` ### Create Redirect +Status defaults to `301` (permanent); pass `--status 302` for a temporary redirect. `--from` and `--to` also accept the SCAPI-aligned aliases `--source` and `--destination`. + ```bash -# Basic redirect +# Permanent redirect (301, default) b2c mrt env redirect create -p my-storefront -e production \ --from "/old-path" --to "/new-path" -# Permanent redirect (301) +# Temporary redirect (302), forwarding the wildcard portion of the path b2c mrt env redirect create -p my-storefront -e production \ - --from "/legacy/*" --to "/modern/$1" --permanent + --from "/legacy/*" --to "/modern/$1" --status 302 --forward-wildcard -# Temporary redirect (302, default) +# Force the SCAPI backend b2c mrt env redirect create -p my-storefront -e production \ - --from "/promo" --to "/sale" + --from "/promo" --to "/sale" --mrt-backend scapi +``` + +### Get Redirect + +Fetch a single redirect by its identifier — source path on legacy, redirect ID (UUID) on SCAPI. + +```bash +b2c mrt env redirect get "/old-path" -p my-storefront -e production +b2c mrt env redirect get 3f9b1c2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d \ + -p my-storefront -e production --mrt-backend scapi +``` + +### Update Redirect + +Partial update — only the fields you pass change; the source path cannot be changed. + +```bash +# Change only the destination +b2c mrt env redirect update "/old-path" -p my-storefront -e production --to "/new-path" + +# Change the status and disable query-string forwarding +b2c mrt env redirect update 3f9b1c2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d \ + -p my-storefront -e production --status 302 --no-forward-querystring --mrt-backend scapi ``` ### Delete Redirect ```bash -b2c mrt env redirect delete abc-123 -p my-storefront -e production -b2c mrt env redirect delete abc-123 -p my-storefront -e production --force +b2c mrt env redirect delete "/old-path" -p my-storefront -e production +b2c mrt env redirect delete "/old-path" -p my-storefront -e production --force + +# SCAPI — identify by redirect ID +b2c mrt env redirect delete 3f9b1c2d-4e5f-6a7b-8c9d-0e1f2a3b4c5d \ + -p my-storefront -e production --mrt-backend scapi ``` ### Clone Redirects -Copy redirects from one environment to another. +Copy redirects from one environment to another within the same project. The source and target environments must differ. ```bash -b2c mrt env redirect clone -p my-storefront \ - --source staging --target production +b2c mrt env redirect clone -p my-storefront --from staging --to production +b2c mrt env redirect clone -p my-storefront --from staging --to production --force + +# Force the SCAPI backend +b2c mrt env redirect clone -p my-storefront --from staging --to production --mrt-backend scapi ``` ## Access Control Headers