Skip to content
Merged
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
16 changes: 10 additions & 6 deletions messages/license.provision.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ Path to a JSON file that contains the PSL provisioning request information.

<%= config.bin %> <%= command.id %> --target-org myScratchOrg --definition-file test/config/provisionPSLs.json

# success.provisioned
# success.traceId

Provisioned %s licenses for the license definition '%s'
Trace ID: %s

# error.missingLicenseFlag

Expand All @@ -60,14 +60,18 @@ The --definition-file flag cannot be used with --namespace, --license, --quantit

The definition file must contain at least one license entry.

# error.invalidDateFormat

Invalid date format '%s' for --%s. Expected YYYY-MM-DD.

# error.provisionFailed

Failed to provision licenses. %s

# success

Success:

# success.column.licenseDefinition

License Definition

# success.column.provisionedQuantity

Provisioned Quantity
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
],
"topics": {
"license": {
"description": "description for license"
"description": "Commands to provision and manage Permission Set Licenses in a scratch org."
}
},
"flexibleTaxonomy": true
Expand Down
17 changes: 2 additions & 15 deletions schemas/license-provision.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,8 @@
"status": {
"type": "string"
},
"messages": {
"type": "array",
"items": {
"type": "object",
"properties": {
"errorCode": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["errorCode", "message"],
"additionalProperties": false
}
"traceId": {
"type": "string"
}
},
"required": ["status"],
Expand Down
50 changes: 19 additions & 31 deletions src/commands/license/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ import { Messages, SfError } from '@salesforce/core';
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-license-management', 'license.provision');

const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;

type ProvisionLicenseSpec = {
namespacePrefix?: string;
permissionSetLicense?: string;
Expand All @@ -35,34 +33,23 @@ type ProvisionPslRequest = {
licenses: ProvisionLicenseSpec[];
};

type ProvisionErrorMessage = {
errorCode: string;
message: string;
};

type ProvisionPslResponse = {
status: string;
licensesProvisioned?: number;
message?: string;
messages?: ProvisionErrorMessage[];
traceId?: string;
};

export type LicenseProvisionResult = {
status: string;
messages?: ProvisionErrorMessage[];
traceId?: string;
};

function getLicenseDefinitionName(spec: ProvisionLicenseSpec): string {
const psl = spec.permissionSetLicense ?? '';
return spec.namespacePrefix ? `${spec.namespacePrefix}__${psl}` : psl;
}

function validateDate(dateStr: string, flagName: string): void {
if (!DATE_REGEX.test(dateStr)) {
throw messages.createError('error.invalidDateFormat', [dateStr, flagName]);
}
}

export default class LicenseProvision extends SfCommand<LicenseProvisionResult> {
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
Expand Down Expand Up @@ -153,11 +140,6 @@ export default class LicenseProvision extends SfCommand<LicenseProvisionResult>
? await LicenseProvision.loadSpecsFromFile(flags['definition-file'], flags)
: LicenseProvision.buildSpecsFromFlags(flags);

for (const spec of licenseSpecs) {
if (spec.startDate) validateDate(spec.startDate, 'start-date');
if (spec.endDate) validateDate(spec.endDate, 'end-date');
}

const endpoint = `/services/data/v${connection.getApiVersion()}/partnerdevelopment/permissionsetlicenses`;
const requestBody: ProvisionPslRequest = { licenses: licenseSpecs };

Expand All @@ -174,22 +156,28 @@ export default class LicenseProvision extends SfCommand<LicenseProvisionResult>
}

if (response.status !== 'SUCCESS') {
const errorMessages: ProvisionErrorMessage[] =
response.messages ?? (response.message ? [{ errorCode: 'PROVISION_ERROR', message: response.message }] : []);

const errorDetail = errorMessages.map((m) => m.message).join(' ');
throw SfError.create({
message: messages.getMessage('error.provisionFailed', [errorDetail]),
message: messages.getMessage('error.provisionFailed', [response.message ?? 'Unknown error']),
name: 'PROVISION_FAILED',
data: { status: 'error', messages: errorMessages },
data: { traceId: response.traceId },
});
}

this.log(messages.getMessage('success'));
for (const spec of licenseSpecs) {
this.log(messages.getMessage('success.provisioned', [spec.quantity ?? 0, getLicenseDefinitionName(spec)]));
}
this.display(licenseSpecs, response);

return { status: 'success' };
return { status: 'success', traceId: response.traceId };
}

private display(licenseSpecs: ProvisionLicenseSpec[], response: ProvisionPslResponse): void {
this.table({
data: licenseSpecs.map((spec) => ({
[messages.getMessage('success.column.licenseDefinition')]: getLicenseDefinitionName(spec),
[messages.getMessage('success.column.provisionedQuantity')]: String(spec.quantity ?? 0),
})),
title: messages.getMessage('success'),
});
if (response.traceId) {
this.log(messages.getMessage('success.traceId', [response.traceId]));
}
}
}
133 changes: 44 additions & 89 deletions test/commands/license/provision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { stubSfCommandUx } from '@salesforce/sf-plugins-core';
import { Connection, Org } from '@salesforce/core';
import LicenseProvision from '../../../src/commands/license/provision.js';

const SUCCESS_RESPONSE = { status: 'SUCCESS', licensesProvisioned: 5, message: 'OK' };
const SUCCESS_RESPONSE = { status: 'SUCCESS', licensesProvisioned: 5, message: 'OK', traceId: 'tm_test123' };

describe('license provision', () => {
const $$ = new TestContext();
Expand Down Expand Up @@ -71,21 +71,22 @@ describe('license provision', () => {
'2027-03-30',
]);

const output = sfCommandStubs.log
.getCalls()
.flatMap((c) => c.args)
.join('\n');
expect(output).to.equal("Success:\nProvisioned 5 licenses for the license definition 'demo__newLicense'");
const tableCall = sfCommandStubs.table.firstCall.args[0] as { data: Array<Record<string, string>>; title: string };
expect(tableCall.title).to.equal('Success:');
expect(tableCall.data).to.deep.include({ 'License Definition': 'demo__newLicense', 'Provisioned Quantity': '5' });
expect(
sfCommandStubs.log
.getCalls()
.flatMap((c) => c.args)
.join('\n')
).to.include('Trace ID: tm_test123');
});

it('provisions a PSL without a namespace', async () => {
await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'myLicense', '--quantity', '3']);

const output = sfCommandStubs.log
.getCalls()
.flatMap((c) => c.args)
.join('\n');
expect(output).to.include("Provisioned 3 licenses for the license definition 'myLicense'");
const tableCall = sfCommandStubs.table.firstCall.args[0] as { data: Array<Record<string, string>> };
expect(tableCall.data).to.deep.include({ 'License Definition': 'myLicense', 'Provisioned Quantity': '3' });
});

it('sends the correct POST request payload', async () => {
Expand Down Expand Up @@ -130,9 +131,9 @@ describe('license provision', () => {
expect(body.licenses[0].startDate).to.equal(today);
});

it('returns status:success result', async () => {
it('returns status:success result with traceId', async () => {
const result = await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'myLicense']);
expect(result).to.deep.equal({ status: 'success' });
expect(result).to.deep.equal({ status: 'success', traceId: 'tm_test123' });
});

// ─── Success: definition file ────────────────────────────────────────────────
Expand Down Expand Up @@ -161,12 +162,22 @@ describe('license provision', () => {

await LicenseProvision.run(['--target-org', testOrg.username, '--definition-file', tmpFilePath]);

const output = sfCommandStubs.log
.getCalls()
.flatMap((c) => c.args)
.join('\n');
expect(output).to.include("Provisioned 5 licenses for the license definition 'demo__newLicense'");
expect(output).to.include("Provisioned 8 licenses for the license definition 'demo__premiumLicense'");
const tableCall = sfCommandStubs.table.firstCall.args[0] as {
data: Array<Record<string, string>>;
title: string;
};
expect(tableCall.title).to.equal('Success:');
expect(tableCall.data).to.deep.include({ 'License Definition': 'demo__newLicense', 'Provisioned Quantity': '5' });
expect(tableCall.data).to.deep.include({
'License Definition': 'demo__premiumLicense',
'Provisioned Quantity': '8',
});
expect(
sfCommandStubs.log
.getCalls()
.flatMap((c) => c.args)
.join('\n')
).to.include('Trace ID: tm_test123');
});

it('sends all PSLs from the definition file in a single request', async () => {
Expand Down Expand Up @@ -240,40 +251,6 @@ describe('license provision', () => {
}
});

it('throws for an invalid start-date format', async () => {
try {
await LicenseProvision.run([
'--target-org',
testOrg.username,
'--license',
'myLicense',
'--start-date',
'30-03-2026',
]);
expect.fail('Expected an error to be thrown');
} catch (error: unknown) {
expect((error as Error).message).to.include('30-03-2026');
expect((error as Error).message).to.include('start-date');
}
});

it('throws for an invalid end-date format', async () => {
try {
await LicenseProvision.run([
'--target-org',
testOrg.username,
'--license',
'myLicense',
'--end-date',
'March 30 2027',
]);
expect.fail('Expected an error to be thrown');
} catch (error: unknown) {
expect((error as Error).message).to.include('March 30 2027');
expect((error as Error).message).to.include('end-date');
}
});

it('throws when definition file contains no license entries', async () => {
const tmpFilePath = join(tmpdir(), `provision-empty-${Date.now()}.json`);
await writeFile(tmpFilePath, JSON.stringify({ licenses: [] }));
Expand All @@ -289,56 +266,34 @@ describe('license provision', () => {

// ─── API error responses ─────────────────────────────────────────────────────

it('throws with the server error message when status is error', async () => {
requestStub.resolves({
status: 'error',
messages: [
{ errorCode: 'INVALID_LICENSE_DEFINITION', message: "License definition not found for 'demo__badLicense'" },
],
});
it('wraps a HTTP 400 provisioning error as SfError', async () => {
requestStub.rejects(
Object.assign(new Error("Invalid endDate format for permissionSetLicense 'premium'"), {
name: 'INVALID_END_DATE',
})
);

try {
await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'badLicense', '--namespace', 'demo']);
await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'premium']);
expect.fail('Expected an error to be thrown');
} catch (error: unknown) {
expect((error as Error).message).to.include("License definition not found for 'demo__badLicense'");
expect((error as Error).message).to.include('Invalid endDate format');
}
});

it('includes all error messages when multiple PSLs fail', async () => {
requestStub.resolves({
status: 'error',
messages: [
{ errorCode: 'INVALID_LICENSE_DEFINITION', message: "License definition not found for 'demo__badLicense'" },
{ errorCode: 'INVALID_QUANTITY', message: "Quantity cannot be negative for 'demo__negativeLicense'" },
],
});

const tmpFilePath = join(tmpdir(), `provision-multi-err-${Date.now()}.json`);
await writeFile(
tmpFilePath,
JSON.stringify({
licenses: [
{ namespacePrefix: 'demo', permissionSetLicense: 'badLicense', quantity: 5 },
{ namespacePrefix: 'demo', permissionSetLicense: 'negativeLicense', quantity: -1 },
],
})
);
it('throws with a generic message when non-SUCCESS response has no message', async () => {
requestStub.resolves({ status: 'ERROR' });

try {
await LicenseProvision.run(['--target-org', testOrg.username, '--definition-file', tmpFilePath]);
await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'anyLicense']);
expect.fail('Expected an error to be thrown');
} catch (error: unknown) {
const msg = (error as Error).message;
expect(msg).to.include("License definition not found for 'demo__badLicense'");
expect(msg).to.include("Quantity cannot be negative for 'demo__negativeLicense'");
} finally {
await unlink(tmpFilePath).catch(() => {});
expect((error as Error).message).to.include('Unknown error');
}
});

it('falls back to the message field when messages array is absent', async () => {
requestStub.resolves({ status: 'error', message: 'An unexpected error occurred' });
it('falls back gracefully when non-200 response has no structured error body', async () => {
requestStub.resolves({ status: 'ERROR', message: 'An unexpected error occurred' });

try {
await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'anyLicense']);
Expand Down
Loading