diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 68b81569..6f0258b6 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -604,6 +604,79 @@ If detailed usage data is present (granular history, profiles, etc.), the comman --- +## b2c sandbox settings + +Show effective OCAPI and WebDAV settings for a specific sandbox. + +### Usage + +```bash +b2c sandbox settings +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | + +### Examples + +```bash +# Show settings summary for a sandbox +b2c sandbox settings zzzz-001 + +# Output full settings payload as JSON +b2c sandbox settings zzzz-001 --json +``` + +### Output + +When not using `--json`, the command prints: + +- Number of OCAPI client entries +- Number of WebDAV client entries +- A short per-client breakdown for each settings type + +--- + +## b2c sandbox storage + +Show filesystem storage usage for a specific sandbox. + +### Usage + +```bash +b2c sandbox storage +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `SANDBOXID` | Sandbox ID (UUID or realm-instance, e.g., `zzzv-123`) | Yes | + +### Examples + +```bash +# Show storage table for a sandbox +b2c sandbox storage zzzz-001 + +# Output raw storage response as JSON +b2c sandbox storage zzzz-001 --json +``` + +### Output + +When not using `--json`, the command prints a table with one row per filesystem: + +- Filesystem name +- Total space (MB) +- Used space (MB) +- Used percentage + +--- + ## Sandbox Aliases Sandbox aliases let you access a sandbox via a custom hostname instead of the default instance hostname. @@ -968,9 +1041,11 @@ For the complete response including all metadata, use the `--json` flag. Realm commands operate at the **realm** level rather than on an individual sandbox. They are available as both `realm` topic commands and as `sandbox realm` subcommands: - `b2c realm list` (`b2c sandbox realm list`) +- `b2c realm configuration` (`b2c sandbox realm configuration`) - `b2c realm get` (`b2c sandbox realm get`) - `b2c realm update` (`b2c sandbox realm update`) - `b2c realm usage` (`b2c sandbox realm usage`) +- `b2c realm usages` (`b2c sandbox realm usages`) ### Required Access for Realm Commands @@ -990,7 +1065,7 @@ b2c realm list [REALM] | Argument | Description | Required | |----------|-------------|----------| -| `REALM` | Specific realm ID (four-letter ID) to get details for | No | +| `REALM` | Optional realm ID filter (four-letter ID) | No | #### Examples @@ -1005,7 +1080,35 @@ b2c realm list zzzz b2c realm list --json ``` -When `REALM` is omitted, the command discovers realms from the `/me` endpoint and then fetches configuration for each. +When `REALM` is omitted, the command discovers realms from the `/me` endpoint. + +### b2c realm configuration + +Get sandbox configuration for a specific realm. + +#### Usage + +```bash +b2c realm configuration +``` + +#### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `REALM` | Realm ID (four-letter ID) | Yes | + +#### Examples + +```bash +# Get realm sandbox configuration +b2c realm configuration zzzz + +# JSON output +b2c realm configuration zzzz --json +``` + +When not using `--json`, the command prints configuration details such as emails, sandbox limits, TTL values, and start/stop schedulers. ### b2c realm get @@ -1143,3 +1246,46 @@ When not using `--json`, the command prints a summary including: If detailed usage is available, it prints a hint to re-run with `--json` for the full structure. If no usage data is returned for the requested period, it prints a friendly message instead of failing. +### b2c realm usages + +Show usage information for multiple realms in one request. + +#### Usage + +```bash +b2c realm usages [FLAGS] +``` + +#### Flags + +| Flag | Description | +|------|-------------| +| `--realm` | Realm IDs to include (repeat flag or provide comma-separated values) | +| `--from` | Earliest date to include in usage (ISO 8601) | +| `--to` | Latest date to include in usage (ISO 8601) | +| `--detailed-report` | Include detailed usage information in the response | + +If `--realm` is omitted, the command auto-discovers realms from `/me` and queries usage for all discovered realms. + +#### Examples + +```bash +# Usage for all realms available to the current user +b2c realm usages + +# Usage for two specific realms +b2c realm usages --realm zzzz --realm yyyy + +# Usage for comma-separated realms and date range +b2c realm usages --realm zzzz,yyyy --from 2024-01-01 --to 2024-01-31 + +# Detailed report in JSON +b2c realm usages --detailed-report --json +``` + +When not using `--json`, the command prints one row per realm with summary metrics such as: + +- Active / created / deleted sandbox counts +- Minutes up / minutes down +- Sandbox seconds + diff --git a/packages/b2c-cli/src/commands/sandbox/realm/configuration.ts b/packages/b2c-cli/src/commands/sandbox/realm/configuration.ts new file mode 100644 index 00000000..57c23041 --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/realm/configuration.ts @@ -0,0 +1,113 @@ +/* + * 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 {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../../i18n/index.js'; + +type RealmConfigurationModel = OdsComponents['schemas']['RealmConfigurationModel']; +type RealmConfigurationResponse = OdsComponents['schemas']['RealmConfigurationResponse']; + +/** + * Get realm sandbox configuration. + */ +export default class SandboxRealmConfiguration extends OdsCommand { + static aliases = ['ods:realm:configuration', 'realm:configuration']; + + static args = { + realm: Args.string({ + description: 'Realm ID (four-letter ID)', + required: true, + }), + }; + + static description = withDocs( + t('commands.realm.configuration.description', 'Get sandbox configuration for a realm'), + '/cli/realm.html#b2c-realm-configuration', + ); + + static enableJsonFlag = true; + + static examples = ['<%= config.bin %> <%= command.id %> zzzz', '<%= config.bin %> <%= command.id %> zzzz --json']; + + async run(): Promise { + const {args} = await this.parse(SandboxRealmConfiguration); + const realm = args.realm; + + this.log(t('commands.realm.configuration.fetching', 'Fetching configuration for realm {{realm}}...', {realm})); + + const result = await this.odsClient.GET('/realms/{realm}/configuration', { + params: {path: {realm}}, + }); + + if (result.error) { + this.error( + t('commands.realm.configuration.error', 'Failed to fetch configuration for realm {{realm}}: {{message}}', { + realm, + message: getApiErrorMessage(result.error, result.response), + }), + ); + } + + const data = (result.data as RealmConfigurationResponse | undefined)?.data; + if (!data) { + this.log(t('commands.realm.configuration.noData', 'No configuration data was returned for this realm.')); + return undefined; + } + + if (this.jsonEnabled()) { + return result.data as RealmConfigurationResponse; + } + + this.printConfiguration(data); + return data; + } + + private printConfiguration(configuration: RealmConfigurationModel): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cfg = configuration as any; + + console.log('Realm Configuration'); + console.log('───────────────────'); + + const maxTtlRaw = cfg.sandbox?.sandboxTTL?.maximum as number | undefined; + const maxTtlDisplay = maxTtlRaw === undefined ? undefined : maxTtlRaw >= 2_147_483_647 ? '0' : String(maxTtlRaw); + + const rows: Array<[string, string | undefined]> = [ + ['Enabled', cfg.enabled === undefined ? undefined : String(cfg.enabled)], + ['Emails', Array.isArray(cfg.emails) ? cfg.emails.join(', ') : undefined], + ['Limits Enabled', cfg.sandbox?.limitsEnabled === undefined ? undefined : String(cfg.sandbox.limitsEnabled)], + [ + 'Total Sandboxes', + cfg.sandbox?.totalNumberOfSandboxes === undefined ? undefined : String(cfg.sandbox.totalNumberOfSandboxes), + ], + ['Max Sandbox TTL', maxTtlDisplay], + [ + 'Default Sandbox TTL', + cfg.sandbox?.sandboxTTL?.defaultValue === undefined ? undefined : String(cfg.sandbox.sandboxTTL.defaultValue), + ], + [ + 'Local Users Allowed', + cfg.sandbox?.localUsersAllowed === undefined ? undefined : String(cfg.sandbox.localUsersAllowed), + ], + ]; + + for (const [label, value] of rows) { + if (value !== undefined) { + console.log(`${label}: ${value}`); + } + } + + if (cfg.sandbox?.startScheduler) { + console.log(`Start Scheduler: ${JSON.stringify(cfg.sandbox.startScheduler)}`); + } + + if (cfg.sandbox?.stopScheduler) { + console.log(`Stop Scheduler: ${JSON.stringify(cfg.sandbox.stopScheduler)}`); + } + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/realm/list.ts b/packages/b2c-cli/src/commands/sandbox/realm/list.ts index 5d9209ed..fbd91d4a 100644 --- a/packages/b2c-cli/src/commands/sandbox/realm/list.ts +++ b/packages/b2c-cli/src/commands/sandbox/realm/list.ts @@ -6,29 +6,26 @@ import {Args} from '@oclif/core'; import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; -import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk'; import {t, withDocs} from '../../../i18n/index.js'; -type RealmConfigurationModel = OdsComponents['schemas']['RealmConfigurationModel']; - -interface RealmWithUsage { +interface RealmItem { realmId: string; - configuration?: RealmConfigurationModel; } interface RealmListResponse { - realms: RealmWithUsage[]; + realms: RealmItem[]; } /** - * List realms eligible for sandbox management, optionally including usage. + * List realms eligible for sandbox management. */ export default class SandboxRealmList extends OdsCommand { static aliases = ['ods:realm:list', 'realm:list']; static args = { realm: Args.string({ - description: 'Specific realm ID (four-letter ID) to get details for', + description: 'Optional realm ID filter (four-letter ID)', required: false, }), }; @@ -70,32 +67,7 @@ export default class SandboxRealmList extends OdsCommand ({realmId})); const response: RealmListResponse = {realms}; @@ -110,16 +82,12 @@ export default class SandboxRealmList extends OdsCommand { + static aliases = ['ods:realm:usages', 'realm:usages']; + + static description = withDocs( + t('commands.realm.usages.description', 'Show usage information for multiple realms'), + '/cli/realm.html#b2c-realm-usages', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --realm zzzz --realm yyyy', + '<%= config.bin %> <%= command.id %> --realm zzzz,yyyy --from 2026-02-08 --to 2026-02-11', + '<%= config.bin %> <%= command.id %> --detailed-report --json', + ]; + + static flags = { + realm: Flags.string({ + description: 'Realm ID(s). Repeat flag or provide comma-separated values', + multiple: true, + multipleNonGreedy: true, + delimiter: ',', + }), + from: Flags.string({ + description: 'Earliest date to include in usage (ISO 8601)', + }), + to: Flags.string({ + description: 'Latest date to include in usage (ISO 8601)', + }), + 'detailed-report': Flags.boolean({ + description: 'Include detailed usage information in the response', + default: false, + }), + } as const; + + async run(): Promise { + const {flags} = await this.parse(SandboxRealmUsages); + + const realms = await this.resolveRealms(flags.realm); + if (realms.length === 0) { + this.log(t('commands.realm.usages.noRealms', 'No realms found for the current user.')); + return undefined; + } + + const result = await this.odsClient.POST('/realms/usages', { + body: { + from: flags.from, + to: flags.to, + realms, + detailedReport: flags['detailed-report'], + }, + }); + + if (result.error) { + this.error( + t('commands.realm.usages.error', 'Failed to fetch multi-realm usage: {{message}}', { + message: getApiErrorMessage(result.error, result.response), + }), + ); + } + + const data = (result.data as MultiRealmUsageResponse | undefined)?.data; + if (!data || data.length === 0) { + this.log(t('commands.realm.usages.noData', 'No usage data was returned for the requested realms.')); + return undefined; + } + + if (this.jsonEnabled()) { + return result.data as MultiRealmUsageResponse; + } + + this.printUsage(data); + return data; + } + + private printUsage(items: MultiRealmUsageModel[]): void { + console.log('Realm Active Created Deleted Minutes Up Minutes Down Sandbox Seconds'); + console.log('───── ────── ─────── ─────── ────────── ──────────── ───────────────'); + + for (const item of items) { + const usage = item.realmUsage; + const row = [ + String(item.realmName ?? '-').padEnd(5), + String(usage?.activeSandboxes ?? '-').padStart(6), + String(usage?.createdSandboxes ?? '-').padStart(7), + String(usage?.deletedSandboxes ?? '-').padStart(7), + String(usage?.minutesUp ?? '-').padStart(10), + String(usage?.minutesDown ?? '-').padStart(12), + String(usage?.sandboxSeconds ?? '-').padStart(15), + ]; + + console.log(row.join(' ')); + + if (item.error) { + console.log(` ! ${item.error}`); + } + } + } + + private async resolveRealms(inputRealms: string[] | undefined): Promise { + if (inputRealms && inputRealms.length > 0) { + return inputRealms; + } + + const meResult = await this.odsClient.GET('/me', {}); + if (meResult.error || !meResult.data?.data?.realms) { + this.error( + t('commands.realm.usages.realmsError', 'Failed to resolve realms from current user: {{message}}', { + message: getApiErrorMessage(meResult.error, meResult.response), + }), + ); + } + + return meResult.data.data.realms ?? []; + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/settings.ts b/packages/b2c-cli/src/commands/sandbox/settings.ts new file mode 100644 index 00000000..133e059c --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/settings.ts @@ -0,0 +1,99 @@ +/* + * 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 {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../i18n/index.js'; + +type SandboxSettings = OdsComponents['schemas']['SandboxSettings']; +type SandboxSettingsResponse = OdsComponents['schemas']['SandboxSettingsResponse']; + +/** + * Show sandbox settings. + */ +export default class SandboxSettingsCommand extends OdsCommand { + static aliases = ['ods:settings']; + + static args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or realm-instance, e.g., zzzz-001)', + required: true, + }), + }; + + static description = withDocs( + t('commands.sandbox.settings.description', 'Show settings for a specific sandbox'), + '/cli/sandbox.html#b2c-sandbox-settings', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> zzzz-001', + '<%= config.bin %> <%= command.id %> zzzz-001 --json', + ]; + + async run(): Promise { + const {args} = await this.parse(SandboxSettingsCommand); + const sandboxId = await this.resolveSandboxId(args.sandboxId); + + const result = await this.odsClient.GET('/sandboxes/{sandboxId}/settings', { + params: { + path: {sandboxId}, + }, + }); + + if (result.error) { + this.error( + t('commands.sandbox.settings.error', 'Failed to fetch sandbox settings: {{message}}', { + message: getApiErrorMessage(result.error, result.response), + }), + ); + } + + const data = (result.data as SandboxSettingsResponse | undefined)?.data; + if (!data) { + this.log(t('commands.sandbox.settings.noData', 'No settings were returned for this sandbox.')); + return undefined; + } + + if (this.jsonEnabled()) { + return result.data as SandboxSettingsResponse; + } + + this.printSettings(data); + return data; + } + + private printSettings(settings: SandboxSettings): void { + const ocapi = settings.ocapi ?? []; + const webdav = settings.webdav ?? []; + + console.log('Sandbox Settings'); + console.log('────────────────'); + console.log(`OCAPI client entries: ${ocapi.length}`); + console.log(`WebDAV client entries: ${webdav.length}`); + + if (ocapi.length > 0) { + console.log(); + console.log('OCAPI'); + for (const entry of ocapi) { + const resources = entry.resources?.length ?? 0; + console.log(` - ${entry.client_id ?? 'unknown-client'} (${resources} resource rules)`); + } + } + + if (webdav.length > 0) { + console.log(); + console.log('WebDAV'); + for (const entry of webdav) { + const permissions = entry.permissions?.length ?? 0; + console.log(` - ${entry.client_id ?? 'unknown-client'} (${permissions} permission rules)`); + } + } + } +} diff --git a/packages/b2c-cli/src/commands/sandbox/storage.ts b/packages/b2c-cli/src/commands/sandbox/storage.ts new file mode 100644 index 00000000..17fa077f --- /dev/null +++ b/packages/b2c-cli/src/commands/sandbox/storage.ts @@ -0,0 +1,88 @@ +/* + * 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 {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import {getApiErrorMessage, type OdsComponents} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../i18n/index.js'; + +type SandboxStorageModel = OdsComponents['schemas']['SandboxStorageModel']; +type SandboxStorageResponse = OdsComponents['schemas']['SandboxStorageResponse']; + +/** + * Show sandbox storage details. + */ +export default class SandboxStorage extends OdsCommand { + static aliases = ['ods:storage']; + + static args = { + sandboxId: Args.string({ + description: 'Sandbox ID (UUID or realm-instance, e.g., zzzz-001)', + required: true, + }), + }; + + static description = withDocs( + t('commands.sandbox.storage.description', 'Show storage details for a specific sandbox'), + '/cli/sandbox.html#b2c-sandbox-storage', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> zzzz-001', + '<%= config.bin %> <%= command.id %> zzzz-001 --json', + ]; + + async run(): Promise { + const {args} = await this.parse(SandboxStorage); + const sandboxId = await this.resolveSandboxId(args.sandboxId); + + const result = await this.odsClient.GET('/sandboxes/{sandboxId}/storage', { + params: { + path: {sandboxId}, + }, + }); + + if (result.error) { + this.error( + t('commands.sandbox.storage.error', 'Failed to fetch sandbox storage: {{message}}', { + message: getApiErrorMessage(result.error, result.response), + }), + ); + } + + const data = (result.data as SandboxStorageResponse | undefined)?.data; + if (!data || Object.keys(data).length === 0) { + this.log(t('commands.sandbox.storage.noData', 'No storage data was returned for this sandbox.')); + return undefined; + } + + if (this.jsonEnabled()) { + return result.data as SandboxStorageResponse; + } + + this.printStorage(data); + return data; + } + + private printStorage(storage: SandboxStorageModel): void { + console.log('Sandbox Storage'); + console.log('───────────────'); + console.log('Filesystem Total (MB) Used (MB) Used (%)'); + console.log('──────────────────────── ────────── ───────── ────────'); + + for (const [name, usage] of Object.entries(storage)) { + const total = usage?.spaceTotal ?? '-'; + const used = usage?.spaceUsed ?? '-'; + const percentage = usage?.percentageUsed ?? '-'; + + console.log( + `${name.padEnd(24)} ${String(total).padStart(10)} ${String(used).padStart(9)} ${String(percentage).padStart(8)}`, + ); + } + } +} diff --git a/packages/b2c-cli/test/commands/sandbox/realm/configuration.test.ts b/packages/b2c-cli/test/commands/sandbox/realm/configuration.test.ts new file mode 100644 index 00000000..7db836e6 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/realm/configuration.test.ts @@ -0,0 +1,80 @@ +/* + * 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 SandboxRealmConfiguration from '../../../../src/commands/sandbox/realm/configuration.js'; +import { + createIsolatedConfigHooks, + createTestCommand, + makeCommandThrowOnError, + runSilent, +} from '../../../helpers/test-setup.js'; + +function stubOdsClient(command: any, client: Partial<{GET: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +describe('sandbox realm configuration', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(async () => { + await hooks.beforeEach(); + }); + + afterEach(() => { + sinon.restore(); + hooks.afterEach(); + }); + + async function setupCommand(flags: Record, args: Record): Promise { + const config = hooks.getConfig(); + const command = await createTestCommand(SandboxRealmConfiguration as any, config, flags, args); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + return command; + } + + it('calls /realms/{realm}/configuration with provided realm', async () => { + const command = await setupCommand({}, {realm: 'zzzz'}); + sinon.stub(command as any, 'jsonEnabled').returns(false); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async GET(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return {data: {data: {enabled: true}}}; + }, + }); + + const result = await runSilent(() => command.run()); + expect(requestUrl).to.equal('/realms/{realm}/configuration'); + expect(requestOptions).to.have.nested.property('params.path.realm', 'zzzz'); + expect(result).to.deep.equal({enabled: true}); + }); + + it('returns full response in JSON mode', async () => { + const command = await setupCommand({json: true}, {realm: 'zzzz'}); + sinon.stub(command as any, 'jsonEnabled').returns(true); + + const response = {data: {data: {enabled: false}}}; + + stubOdsClient(command, { + async GET() { + return response; + }, + }); + + const result = await runSilent(() => command.run()); + expect(result).to.deep.equal(response.data); + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/realm/list.test.ts b/packages/b2c-cli/test/commands/sandbox/realm/list.test.ts index 7ccbf166..5a3e09d9 100644 --- a/packages/b2c-cli/test/commands/sandbox/realm/list.test.ts +++ b/packages/b2c-cli/test/commands/sandbox/realm/list.test.ts @@ -54,31 +54,12 @@ describe('sandbox realm list', () => { it('discovers realms from /me when no realm argument is provided', async () => { const command = await setupCommand({json: true}, {}); - const getStub = sinon.stub(); - - getStub.callsFake(async (url: string, options: any) => { - if (url === '/me') { - return { - data: { - data: { - realms: ['zzza', 'zzzb'], - }, - }, - }; - } - - if (url === '/realms/{realm}/configuration') { - const realmId = options.params.path.realm; - return { - data: { - data: { - enabled: realmId === 'zzza', - }, - }, - }; - } - - throw new Error(`Unexpected URL: ${url}`); + const getStub = sinon.stub().resolves({ + data: { + data: { + realms: ['zzza', 'zzzb'], + }, + }, }); stubOdsClient(command, {GET: getStub}); @@ -87,28 +68,14 @@ describe('sandbox realm list', () => { expect(result.realms).to.have.lengthOf(2); expect(result.realms[0].realmId).to.equal('zzza'); - expect(result.realms[0].configuration?.enabled).to.equal(true); expect(result.realms[1].realmId).to.equal('zzzb'); - expect(result.realms[1].configuration?.enabled).to.equal(false); + expect(getStub.calledOnceWithExactly('/me', {})).to.be.true; }); - it('fetches configuration for a specific realm when argument is provided', async () => { + it('returns specific realm when argument is provided', async () => { const command = await setupCommand({json: true}, {realm: 'zzzz'}); - const getStub = sinon.stub().callsFake(async (url: string, options: any) => { - if (url === '/realms/{realm}/configuration') { - expect(options).to.have.nested.property('params.path.realm', 'zzzz'); - return { - data: { - data: { - enabled: true, - }, - }, - }; - } - - throw new Error(`Unexpected URL: ${url}`); - }); + const getStub = sinon.stub(); stubOdsClient(command, {GET: getStub}); @@ -116,6 +83,6 @@ describe('sandbox realm list', () => { expect(result.realms).to.have.lengthOf(1); expect(result.realms[0].realmId).to.equal('zzzz'); - expect(result.realms[0].configuration?.enabled).to.equal(true); + expect(getStub.called).to.be.false; }); }); diff --git a/packages/b2c-cli/test/commands/sandbox/realm/usages.test.ts b/packages/b2c-cli/test/commands/sandbox/realm/usages.test.ts new file mode 100644 index 00000000..56ff6c1f --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/realm/usages.test.ts @@ -0,0 +1,92 @@ +/* + * 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 SandboxRealmUsages from '../../../../src/commands/sandbox/realm/usages.js'; +import { + createIsolatedConfigHooks, + createTestCommand, + makeCommandThrowOnError, + runSilent, +} from '../../../helpers/test-setup.js'; + +function stubOdsClient(command: any, client: Partial<{GET: any; POST: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +describe('sandbox realm usages', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(async () => { + await hooks.beforeEach(); + }); + + afterEach(() => { + sinon.restore(); + hooks.afterEach(); + }); + + async function setupCommand(flags: Record): Promise { + const config = hooks.getConfig(); + const command = await createTestCommand(SandboxRealmUsages as any, config, flags, {}); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + return command; + } + + it('calls /realms/usages with provided realms', async () => { + const command = await setupCommand({ + realm: ['zzzz', 'yyyy'], + from: '2026-01-01', + to: '2026-01-31', + 'detailed-report': true, + }); + + sinon.stub(command as any, 'jsonEnabled').returns(false); + + let requestBody: any; + stubOdsClient(command, { + async POST(url: string, options: any) { + expect(url).to.equal('/realms/usages'); + requestBody = options.body; + return {data: {data: [{realmName: 'zzzz', realmUsage: {activeSandboxes: 1}}]}}; + }, + }); + + const result = await runSilent(() => command.run()); + + expect(requestBody).to.deep.equal({ + from: '2026-01-01', + to: '2026-01-31', + realms: ['zzzz', 'yyyy'], + detailedReport: true, + }); + expect(result).to.deep.equal([{realmName: 'zzzz', realmUsage: {activeSandboxes: 1}}]); + }); + + it('discovers realms from /me when no realm flag is provided', async () => { + const command = await setupCommand({}); + sinon.stub(command as any, 'jsonEnabled').returns(false); + + const getStub = sinon.stub().resolves({data: {data: {realms: ['zzzz', 'yyyy']}}}); + const postStub = sinon.stub().resolves({data: {data: [{realmName: 'zzzz'}, {realmName: 'yyyy'}]}}); + + stubOdsClient(command, { + GET: getStub, + POST: postStub, + }); + + await runSilent(() => command.run()); + + expect(getStub.calledOnceWithExactly('/me', {})).to.be.true; + expect(postStub.calledOnce).to.be.true; + expect(postStub.firstCall.args[1].body.realms).to.deep.equal(['zzzz', 'yyyy']); + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/settings.test.ts b/packages/b2c-cli/test/commands/sandbox/settings.test.ts new file mode 100644 index 00000000..a726b34b --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/settings.test.ts @@ -0,0 +1,99 @@ +/* + * 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 SandboxSettingsCommand from '../../../src/commands/sandbox/settings.js'; +import { + createIsolatedConfigHooks, + createTestCommand, + makeCommandThrowOnError, + runSilent, +} from '../../helpers/test-setup.js'; + +function stubOdsClient(command: any, client: Partial<{GET: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +describe('sandbox settings', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(async () => { + await hooks.beforeEach(); + }); + + afterEach(() => { + sinon.restore(); + hooks.afterEach(); + }); + + async function setupCommand(flags: Record, args: Record): Promise { + const config = hooks.getConfig(); + const command = await createTestCommand(SandboxSettingsCommand as any, config, flags, args); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + return command; + } + + it('calls /sandboxes/{sandboxId}/settings with resolved sandbox id', async () => { + const command = await setupCommand({}, {sandboxId: 'zzzz-001'}); + sinon.stub(command as any, 'jsonEnabled').returns(false); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async GET(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return { + data: { + data: { + ocapi: [{client_id: 'client-a', resources: []}], + webdav: [{client_id: 'client-a', permissions: []}], + }, + }, + }; + }, + }); + + const result = await runSilent(() => command.run()); + expect(requestUrl).to.equal('/sandboxes/{sandboxId}/settings'); + expect(requestOptions).to.have.nested.property('params.path.sandboxId', 'sb-uuid-123'); + expect(result).to.deep.equal({ + ocapi: [{client_id: 'client-a', resources: []}], + webdav: [{client_id: 'client-a', permissions: []}], + }); + }); + + it('returns full response in JSON mode', async () => { + const command = await setupCommand({json: true}, {sandboxId: 'zzzz-001'}); + sinon.stub(command as any, 'jsonEnabled').returns(true); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + const response = { + data: { + data: { + ocapi: [], + webdav: [], + }, + }, + }; + + stubOdsClient(command, { + async GET() { + return response; + }, + }); + + const result = await runSilent(() => command.run()); + expect(result).to.deep.equal(response.data); + }); +}); diff --git a/packages/b2c-cli/test/commands/sandbox/storage.test.ts b/packages/b2c-cli/test/commands/sandbox/storage.test.ts new file mode 100644 index 00000000..1ffd5a64 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/storage.test.ts @@ -0,0 +1,96 @@ +/* + * 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 SandboxStorage from '../../../src/commands/sandbox/storage.js'; +import { + createIsolatedConfigHooks, + createTestCommand, + makeCommandThrowOnError, + runSilent, +} from '../../helpers/test-setup.js'; + +function stubOdsClient(command: any, client: Partial<{GET: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +describe('sandbox storage', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(async () => { + await hooks.beforeEach(); + }); + + afterEach(() => { + sinon.restore(); + hooks.afterEach(); + }); + + async function setupCommand(flags: Record, args: Record): Promise { + const config = hooks.getConfig(); + const command = await createTestCommand(SandboxStorage as any, config, flags, args); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + return command; + } + + it('calls /sandboxes/{sandboxId}/storage with resolved sandbox id', async () => { + const command = await setupCommand({}, {sandboxId: 'zzzz-001'}); + sinon.stub(command as any, 'jsonEnabled').returns(false); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async GET(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return { + data: { + data: { + impex: {spaceTotal: 1000, spaceUsed: 500, percentageUsed: 50}, + }, + }, + }; + }, + }); + + const result = await runSilent(() => command.run()); + expect(requestUrl).to.equal('/sandboxes/{sandboxId}/storage'); + expect(requestOptions).to.have.nested.property('params.path.sandboxId', 'sb-uuid-123'); + expect(result).to.deep.equal({ + impex: {spaceTotal: 1000, spaceUsed: 500, percentageUsed: 50}, + }); + }); + + it('returns full response in JSON mode', async () => { + const command = await setupCommand({json: true}, {sandboxId: 'zzzz-001'}); + sinon.stub(command as any, 'jsonEnabled').returns(true); + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + + const response = { + data: { + data: { + logs: {spaceTotal: 200, spaceUsed: 20, percentageUsed: 10}, + }, + }, + }; + + stubOdsClient(command, { + async GET() { + return response; + }, + }); + + const result = await runSilent(() => command.run()); + expect(result).to.deep.equal(response.data); + }); +});