Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions messages/delete_sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,24 @@ Ensure the CLI has authenticated with the sandbox's production org.
# error.missingUsername

Unable to determine the username of the org to delete. Specify the username with the --target-org | -o flag.

# error.insufficientPermissions

You don't have the required "DeleteSandbox" Permission Set assigned in the production org to delete sandbox "%s".

# error.insufficientPermissions.actions

- Ask your administrator to create a Permission Set named "DeleteSandbox" in the production org and assign it to your user.
- Re-authenticate with the production org and try again.

# error.missingProdOrg

Unable to verify delete permissions for sandbox "%s" because the production org could not be resolved from the sandbox configuration.

# error.missingProdOrg.actions

- Ensure the production org that owns this sandbox is authenticated with the CLI using `sf org login web`.

# error.insufficientAccess

You don't have permission to delete this sandbox. Ask your Salesforce admin to grant you the "Manage Sandboxes" system permission on your profile or a permission set in the production org.
50 changes: 47 additions & 3 deletions src/commands/org/delete/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,25 +61,69 @@ export default class DeleteSandbox extends SfCommand<SandboxDeleteResponse> {
throw messages.createError('error.unknownSandbox', [username]);
}

await this.verifyDeletePermission(stateAggregator, orgId, username);

const org = await Org.create({ aliasOrUsername: username });

if (flags['no-prompt'] || (await this.confirm({ message: messages.getMessage('prompt.confirm', [username]) }))) {
try {
const org = await Org.create({ aliasOrUsername: username });
await org.delete();
this.logSuccess(messages.getMessage('success', [username]));
} catch (e) {
if (e instanceof Error && e.name === 'DomainNotFoundError') {
// the org has expired, so remote operations won't work
// let's clean up the files locally
const authRemover = await AuthRemover.create();
await authRemover.removeAuth(username);
this.logSuccess(messages.getMessage('success.Idempotent', [username]));
} else if (e instanceof Error && e.name === 'SandboxNotFound') {
this.logSuccess(messages.getMessage('success.Idempotent', [username]));
} else if (
e instanceof Error &&
'errorCode' in e &&
(e as { errorCode: string }).errorCode === 'INSUFFICIENT_ACCESS_OR_READONLY'
) {
throw messages.createError('error.insufficientAccess');
} else {
throw e;
}
}
}
return { username, orgId };
}

/**
* Verifies the current user has the 'DeleteSandbox' PermissionSet assigned
* in the production org that owns the sandbox.
*/
// eslint-disable-next-line class-methods-use-this
private async verifyDeletePermission(
stateAggregator: StateAggregator,
orgId: string,
sandboxUsername: string
): Promise<void> {
const sandboxConfig = await stateAggregator.sandboxes.read(orgId);
const prodOrgUsername = sandboxConfig?.prodOrgUsername;

if (!prodOrgUsername) {
throw messages.createError('error.missingProdOrg', [sandboxUsername]);
}

const prodOrg = await Org.create({ aliasOrUsername: prodOrgUsername });
const connection = prodOrg.getConnection();
await prodOrg.refreshAuth();

const identity = await connection.identity();
const userId = identity.user_id;

if (!userId) {
throw messages.createError('error.insufficientPermissions', [sandboxUsername]);
}

const result = await connection.query(
`SELECT Id FROM PermissionSetAssignment WHERE AssigneeId = '${userId}' AND PermissionSet.Name = 'DeleteSandbox'`
);

if (result.totalSize === 0) {
throw messages.createError('error.insufficientPermissions', [sandboxUsername]);
}
}
}
72 changes: 71 additions & 1 deletion test/unit/org/delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import { AuthInfo, Messages, Org, SfError } from '@salesforce/core';
import { AuthInfo, Connection, Messages, Org, SfError } from '@salesforce/core';
import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup';
import { SinonStub } from 'sinon';
import { config, expect } from 'chai';
Expand Down Expand Up @@ -38,6 +38,20 @@ describe('org delete', () => {
});

describe('sandbox', () => {
let sandboxReadStub: SinonStub;

beforeEach(() => {
sandboxReadStub = $$.SANDBOX.stub(SandboxAccessor.prototype, 'read').resolves({
sandboxOrgId: testOrg.orgId,
prodOrgUsername: testHub.username,
});
$$.SANDBOX.stub(Org.prototype, 'refreshAuth').resolves();
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, camelcase
$$.SANDBOX.stub(Connection.prototype, 'identity').resolves({ user_id: '005xx000001X' } as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
$$.SANDBOX.stub(Connection.prototype, 'query').resolves({ totalSize: 1, records: [{ Id: '0Pa000000000001' }] } as any);
});

it('will throw an error when no org provided', async () => {
await $$.stubConfig({});
try {
Expand Down Expand Up @@ -128,6 +142,62 @@ describe('org delete', () => {
JSON.stringify(sfCommandUxStubs.logSuccess.getCalls().flatMap((call) => call.args))
).to.deep.include(sbxOrgMessages.getMessage('success.Idempotent', [testOrg.username]));
});

it('will throw a clean SfError when the user lacks Manage Sandboxes permission', async () => {
$$.SANDBOX.stub(SandboxAccessor.prototype, 'hasFile').resolves(true);
orgDeleteStub.restore();
const insufficientAccessError = Object.assign(new Error('INSUFFICIENT_ACCESS_OR_READONLY'), {
errorCode: 'INSUFFICIENT_ACCESS_OR_READONLY',
});
$$.SANDBOX.stub(Org.prototype, 'delete').throws(insufficientAccessError);
try {
await DeleteSandbox.run(['--no-prompt', '--target-org', testOrg.username]);
expect.fail('should have thrown InsufficientAccessError');
} catch (e) {
const err = e as SfError;
expect(err.name).to.equal('InsufficientAccessError');
expect(err.message).to.equal(sbxOrgMessages.getMessage('error.insufficientAccess'));
}
});

describe('DeleteSandbox permission check', () => {
beforeEach(() => {
$$.SANDBOX.stub(SandboxAccessor.prototype, 'hasFile').resolves(true);
});

it('will allow deletion when user has DeleteSandbox permission set', async () => {
const res = await DeleteSandbox.run(['--no-prompt', '--target-org', testOrg.username]);
expect(sfCommandUxStubs.logSuccess.callCount).to.equal(1);
expect(res).to.deep.equal({ orgId: testOrg.orgId, username: testOrg.username });
});

it('will block deletion when user lacks DeleteSandbox permission set', async () => {
(Connection.prototype.query as SinonStub).restore();
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
$$.SANDBOX.stub(Connection.prototype, 'query').resolves({ totalSize: 0, records: [] } as any);
try {
await DeleteSandbox.run(['--no-prompt', '--target-org', testOrg.username]);
expect.fail('should have thrown InsufficientPermissionsError');
} catch (e) {
const err = e as SfError;
expect(err.name).to.equal('InsufficientPermissionsError');
expect(err.message).to.include(testOrg.username);
expect(err.message).to.include('DeleteSandbox');
}
});

it('will throw when prodOrgUsername is missing from sandbox config', async () => {
sandboxReadStub.resolves({ sandboxOrgId: testOrg.orgId });
try {
await DeleteSandbox.run(['--no-prompt', '--target-org', testOrg.username]);
expect.fail('should have thrown MissingProdOrgError');
} catch (e) {
const err = e as SfError;
expect(err.name).to.equal('MissingProdOrgError');
expect(err.message).to.include(testOrg.username);
}
});
});
});

describe('scratch', () => {
Expand Down