Skip to content

Commit aed14cc

Browse files
authored
Prevent sandbox clone profile downgrades and remove duplicate source option in vs extension (#482)
1 parent 8acafaf commit aed14cc

3 files changed

Lines changed: 124 additions & 4 deletions

File tree

packages/b2c-vs-extension/src/sandbox-tree/sandbox-clone-helpers.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,21 @@ export interface SandboxLike {
1010
realm?: string;
1111
instance?: string;
1212
state?: string;
13+
profile?: string;
14+
resourceProfile?: string;
1315
clonedFrom?: string;
1416
}
1517

18+
export const CLONE_PROFILES = ['medium', 'large', 'xlarge', 'xxlarge'] as const;
19+
export type CloneProfile = (typeof CLONE_PROFILES)[number];
20+
21+
const CLONE_PROFILE_RANK: Record<CloneProfile, number> = {
22+
medium: 0,
23+
large: 1,
24+
xlarge: 2,
25+
xxlarge: 3,
26+
};
27+
1628
/** States of a cloned sandbox that indicate the clone is still being set up from its source. */
1729
export const CLONE_IN_PROGRESS_STATES = new Set(['cloning', 'creating', 'failed']);
1830

@@ -23,6 +35,37 @@ export function getRealmInstanceId(s: SandboxLike): string | undefined {
2335
return s.realm && s.instance ? `${s.realm}-${s.instance}` : undefined;
2436
}
2537

38+
export function normalizeCloneProfile(profile: string | undefined): CloneProfile | undefined {
39+
const normalized = profile?.trim().toLowerCase();
40+
if (!normalized) return undefined;
41+
return CLONE_PROFILES.find((p) => p === normalized);
42+
}
43+
44+
export function getSandboxSourceProfile(sandbox: Pick<SandboxLike, 'profile' | 'resourceProfile'>): string | undefined {
45+
return sandbox.profile ?? sandbox.resourceProfile;
46+
}
47+
48+
export function getAllowedCloneTargetProfiles(sourceProfile: string | undefined): CloneProfile[] {
49+
const normalizedSource = normalizeCloneProfile(sourceProfile);
50+
if (!normalizedSource) return [...CLONE_PROFILES];
51+
const sourceRank = CLONE_PROFILE_RANK[normalizedSource];
52+
return CLONE_PROFILES.filter((p) => CLONE_PROFILE_RANK[p] >= sourceRank);
53+
}
54+
55+
export function getExplicitCloneTargetProfiles(sourceProfile: string | undefined): CloneProfile[] {
56+
const allowedProfiles = getAllowedCloneTargetProfiles(sourceProfile);
57+
const normalizedSource = normalizeCloneProfile(sourceProfile);
58+
if (!normalizedSource) return allowedProfiles;
59+
return allowedProfiles.filter((p) => p !== normalizedSource);
60+
}
61+
62+
export function isCloneProfileDowngrade(sourceProfile: string | undefined, targetProfile: string | undefined): boolean {
63+
const normalizedSource = normalizeCloneProfile(sourceProfile);
64+
const normalizedTarget = normalizeCloneProfile(targetProfile);
65+
if (!normalizedSource || !normalizedTarget) return false;
66+
return CLONE_PROFILE_RANK[normalizedTarget] < CLONE_PROFILE_RANK[normalizedSource];
67+
}
68+
2669
/** Return the set of realm-instance identifiers that are currently a source of an in-progress clone. */
2770
export function getActiveCloneSourceIds(sandboxes: SandboxLike[]): Set<string> {
2871
const sources = new Set<string>();

packages/b2c-vs-extension/src/sandbox-tree/sandbox-commands.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import {getApiErrorMessage} from '@salesforce/b2c-tooling-sdk';
77
import {createOdsClient} from '@salesforce/b2c-tooling-sdk/clients';
88
import * as vscode from 'vscode';
99
import {registerSafeCommand, runWithSafety} from '../safety.js';
10+
import {
11+
CLONE_PROFILES,
12+
getExplicitCloneTargetProfiles,
13+
getSandboxSourceProfile,
14+
isCloneProfileDowngrade,
15+
type CloneProfile,
16+
} from './sandbox-clone-helpers.js';
1017
import type {SandboxConfigProvider} from './sandbox-config.js';
1118
import type {RealmTreeItem, SandboxTreeDataProvider, SandboxTreeItem} from './sandbox-tree-provider.js';
1219

@@ -292,8 +299,6 @@ export function registerSandboxCommands(
292299
);
293300
});
294301

295-
const CLONE_PROFILES = ['medium', 'large', 'xlarge', 'xxlarge'] as const;
296-
type CloneProfile = (typeof CLONE_PROFILES)[number];
297302
const CLONE_POLL_INTERVAL_MS = 10_000;
298303
const CLONE_POLL_TIMEOUT_MS = 60 * 60_000;
299304

@@ -314,13 +319,28 @@ export function registerSandboxCommands(
314319
if (ttlStr === undefined) return;
315320
const ttl = Number(ttlStr);
316321

322+
const sourceProfile = getSandboxSourceProfile(node.sandbox);
323+
const explicitTargetProfiles = getExplicitCloneTargetProfiles(sourceProfile);
317324
const profilePick = await vscode.window.showQuickPick(
318-
[{label: 'Same as source', value: undefined}, ...CLONE_PROFILES.map((p) => ({label: p, value: p}))],
319-
{title: 'Clone Sandbox — Resource Profile', placeHolder: 'Select profile for the clone'},
325+
[{label: 'Same as source', value: undefined}, ...explicitTargetProfiles.map((p) => ({label: p, value: p}))],
326+
{
327+
title: 'Clone Sandbox — Resource Profile',
328+
placeHolder:
329+
explicitTargetProfiles.length < CLONE_PROFILES.length
330+
? `Select profile for the clone (downgrades from ${sourceProfile ?? 'source profile'} are blocked)`
331+
: 'Select profile for the clone',
332+
},
320333
);
321334
if (!profilePick) return;
322335
const targetProfile = profilePick.value as CloneProfile | undefined;
323336

337+
if (isCloneProfileDowngrade(sourceProfile, targetProfile)) {
338+
vscode.window.showErrorMessage(
339+
`Profile downgrade not allowed: source profile is ${sourceProfile}. Choose same or higher profile.`,
340+
);
341+
return;
342+
}
343+
324344
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
325345
const emailsStr = await vscode.window.showInputBox({
326346
title: `Clone Sandbox — Notification Emails`,

packages/b2c-vs-extension/src/test/sandbox-clone-helpers.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@
55
*/
66
import * as assert from 'assert';
77
import {
8+
CLONE_PROFILES,
89
CLONE_IN_PROGRESS_STATES,
910
TRANSITIONAL_STATES,
1011
computeSandboxDisplay,
1112
getActiveCloneSourceIds,
13+
getAllowedCloneTargetProfiles,
14+
getExplicitCloneTargetProfiles,
1215
getRealmInstanceId,
16+
getSandboxSourceProfile,
17+
isCloneProfileDowngrade,
18+
normalizeCloneProfile,
1319
type SandboxLike,
1420
} from '../sandbox-tree/sandbox-clone-helpers.js';
1521

@@ -18,6 +24,57 @@ function sandbox(partial: Partial<SandboxLike> & {id: string}): SandboxLike {
1824
}
1925

2026
suite('sandbox-clone-helpers', () => {
27+
suite('clone profile helpers', () => {
28+
test('normalizeCloneProfile handles case and whitespace', () => {
29+
assert.strictEqual(normalizeCloneProfile(' LARGE '), 'large');
30+
assert.strictEqual(normalizeCloneProfile('xlarge'), 'xlarge');
31+
assert.strictEqual(normalizeCloneProfile(undefined), undefined);
32+
assert.strictEqual(normalizeCloneProfile(''), undefined);
33+
assert.strictEqual(normalizeCloneProfile('tiny'), undefined);
34+
});
35+
36+
test('getAllowedCloneTargetProfiles returns all profiles when source profile is unknown', () => {
37+
assert.deepStrictEqual(getAllowedCloneTargetProfiles(undefined), [...CLONE_PROFILES]);
38+
assert.deepStrictEqual(getAllowedCloneTargetProfiles('tiny'), [...CLONE_PROFILES]);
39+
});
40+
41+
test('getAllowedCloneTargetProfiles blocks downgrades for large source', () => {
42+
assert.deepStrictEqual(getAllowedCloneTargetProfiles('large'), ['large', 'xlarge', 'xxlarge']);
43+
});
44+
45+
test('getSandboxSourceProfile falls back to resourceProfile when profile is missing', () => {
46+
assert.strictEqual(getSandboxSourceProfile({resourceProfile: 'large'}), 'large');
47+
});
48+
49+
test('getSandboxSourceProfile prefers profile when both are present', () => {
50+
assert.strictEqual(getSandboxSourceProfile({profile: 'xlarge', resourceProfile: 'large'}), 'xlarge');
51+
});
52+
53+
test('getAllowedCloneTargetProfiles allows only xxlarge for xxlarge source', () => {
54+
assert.deepStrictEqual(getAllowedCloneTargetProfiles('xxlarge'), ['xxlarge']);
55+
});
56+
57+
test('getExplicitCloneTargetProfiles excludes source profile for large source', () => {
58+
assert.deepStrictEqual(getExplicitCloneTargetProfiles('large'), ['xlarge', 'xxlarge']);
59+
});
60+
61+
test('getExplicitCloneTargetProfiles returns empty list for xxlarge source', () => {
62+
assert.deepStrictEqual(getExplicitCloneTargetProfiles('xxlarge'), []);
63+
});
64+
65+
test('getExplicitCloneTargetProfiles keeps all options when source profile is unknown', () => {
66+
assert.deepStrictEqual(getExplicitCloneTargetProfiles(undefined), [...CLONE_PROFILES]);
67+
});
68+
69+
test('isCloneProfileDowngrade detects downgrade and allows same/upgrade', () => {
70+
assert.strictEqual(isCloneProfileDowngrade('large', 'medium'), true);
71+
assert.strictEqual(isCloneProfileDowngrade('large', 'large'), false);
72+
assert.strictEqual(isCloneProfileDowngrade('large', 'xlarge'), false);
73+
assert.strictEqual(isCloneProfileDowngrade('large', undefined), false);
74+
assert.strictEqual(isCloneProfileDowngrade(undefined, 'medium'), false);
75+
});
76+
});
77+
2178
suite('getRealmInstanceId', () => {
2279
test('joins realm and instance', () => {
2380
assert.strictEqual(getRealmInstanceId(sandbox({id: 'x', realm: 'zzzz', instance: '004'})), 'zzzz-004');

0 commit comments

Comments
 (0)