diff --git a/README.md b/README.md index baf345e..d755bbe 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ To use your plugin, run using the local `./bin/dev` or `./bin/dev.cmd` file. ```bash # Run using local run file. -./bin/dev hello world +./bin/dev license provision ``` There should be no differences when running via the Salesforce CLI or using the local run file. However, it can be useful to link the plugin to do some additional testing or run your commands from anywhere on your machine. @@ -107,43 +107,84 @@ sf plugins -- [`sf hello world`](#sf-hello-world) +- [`sf license provision`](#sf-license-provision) -## `sf hello world` +## `sf license provision` -Say hello. +Provision Permission Set Licenses (PSL) into a target org. ``` USAGE - $ sf hello world [--json] [--flags-dir ] [-n ] + $ sf license provision -o [-n ] [-l ] [-q ] [-s ] [-e ] [-f ] [--api-version ] [--json] [--flags-dir ] FLAGS - -n, --name= [default: World] The name of the person you'd like to say hello to. + -e, --end-date= License end date in YYYY-MM-DD format. Default is no expiration. + -f, --definition-file= Path to a JSON file that contains the PSL provisioning request information. + -l, --license= Permission Set License name. + -n, --namespace= License package namespace. + -o, --target-org= (required) Username or alias of the target org. + -q, --quantity= Number of licenses to provision. + -s, --start-date= License start date in YYYY-MM-DD format. Defaults to today. + --api-version= Override the api version used for api requests made by this command. GLOBAL FLAGS --flags-dir= Import flag values from a directory. --json Format output as json. DESCRIPTION - Say hello. + Provision Permission Set Licenses (PSL) into the target org. Successful execution sets the quantity of seats for the given PSL in the indicated org. - Say hello either to the world or someone you know. + There are two ways to run this command. You can provide the information to identify a single PSL via command line flags, or provision multiple PSLs in a single call by supplying a JSON formatted file. + + See for the format and options contained within the JSON file. EXAMPLES - Say hello to the world: + Provision a single Permission Set License into an org: + + $ sf license provision --target-org myScratchOrg --namespace demo --license newLicense --quantity 5 --start-date '2026-03-30' --end-date '2027-03-30' + + Use a JSON formatted input file to provision one or more Permission Set Licenses into an org: + + $ sf license provision --target-org myScratchOrg --definition-file test/config/provisionPSLs.json - $ sf hello world +HUMAN READABLE OUTPUT - Say hello to someone you know: + Success: + Provisioned 5 licenses for the license definition 'demo__newLicense' - $ sf hello world --name Astro + Success: + Provisioned 5 licenses for the license definition 'demo__newLicense' + Provisioned 8 licenses for the license definition 'demo__premiumLicense' -FLAG DESCRIPTIONS - -n, --name= The name of the person you'd like to say hello to. + Error: Failed to provision licenses. + License Definition not found for 'demo__badLicense'. + Quantity cannot be negative for 'demo__negativeLicense'. - This person can be anyone in the world! +JSON OUTPUT + + { "status": "success" } + + { + "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'" } + ] + } ``` -_See code: [src/commands/hello/world.ts](https://github.com/salesforcecli/plugin-license-management/blob/1.1.84/src/commands/hello/world.ts)_ +_See code: [src/commands/license/provision.ts](https://github.com/salesforcecli/plugin-license-management/blob/1.0.0/src/commands/license/provision.ts)_ + +# Local Testing + +```bash +sf org create scratch --target-dev-hub --definition-file test/config/scratch-org-def.json + +sf package install --package --target-org + +sf package install report -i -o + +sf license provision -o --license premium --namespace demo --quantity 10 --start-date '2026-03-20' --end-date '2027-03-20' +``` diff --git a/command-snapshot.json b/command-snapshot.json index be1e5d4..383e85e 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -1,10 +1,21 @@ [ { "alias": [], - "command": "hello:world", - "flagAliases": [], - "flagChars": ["n"], - "flags": ["flags-dir", "json", "name"], + "command": "license:provision", + "flagAliases": ["apiversion", "targetusername", "u"], + "flagChars": ["e", "f", "l", "n", "o", "q", "s"], + "flags": [ + "api-version", + "definition-file", + "end-date", + "flags-dir", + "json", + "license", + "namespace", + "quantity", + "start-date", + "target-org" + ], "plugin": "@salesforce/plugin-license-management" } ] diff --git a/messages/hello.world.md b/messages/hello.world.md deleted file mode 100644 index 804f848..0000000 --- a/messages/hello.world.md +++ /dev/null @@ -1,29 +0,0 @@ -# summary - -Say hello. - -# description - -Say hello either to the world or someone you know. - -# flags.name.summary - -The name of the person you'd like to say hello to. - -# flags.name.description - -This person can be anyone in the world! - -# examples - -- Say hello to the world: - - <%= config.bin %> <%= command.id %> - -- Say hello to someone you know: - - <%= config.bin %> <%= command.id %> --name Astro - -# info.hello - -Hello %s at %s. diff --git a/messages/license.provision.md b/messages/license.provision.md new file mode 100644 index 0000000..083e905 --- /dev/null +++ b/messages/license.provision.md @@ -0,0 +1,73 @@ +# summary + +Provision Permission Set Licenses (PSL) into a target org. + +# description + +Provision Permission Set Licenses (PSL) into the target org. Successful execution sets the quantity of seats for the given PSL in the indicated org. + +There are two ways to run this command. You can provide the information to identify a single PSL via command line flags, or provision multiple PSLs in a single call by supplying a JSON formatted file. + +See for the format and options contained within the JSON file. + +# flags.namespace.summary + +License package namespace. + +# flags.license.summary + +Permission Set License name. + +# flags.quantity.summary + +Number of licenses to provision. + +# flags.start-date.summary + +License start date in YYYY-MM-DD format. Defaults to today. + +# flags.end-date.summary + +License end date in YYYY-MM-DD format. Default is no expiration. + +# flags.definition-file.summary + +Path to a JSON file that contains the PSL provisioning request information. + +# examples + +- Provision a single Permission Set License into an org: + + <%= config.bin %> <%= command.id %> --target-org myScratchOrg --namespace demo --license newLicense --quantity 5 --start-date '2026-03-30' --end-date '2027-03-30' + +- Use a JSON formatted input file to provision one or more Permission Set Licenses into an org: + + <%= config.bin %> <%= command.id %> --target-org myScratchOrg --definition-file test/config/provisionPSLs.json + +# success.provisioned + +Provisioned %s licenses for the license definition '%s' + +# error.missingLicenseFlag + +Either --license or --definition-file is required. + +# error.mutuallyExclusiveFlags + +The --definition-file flag cannot be used with --namespace, --license, --quantity, --start-date, or --end-date. + +# error.emptyDefinitionFile + +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: diff --git a/package.json b/package.json index b5ac117..218da85 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,8 @@ "@salesforce/plugin-command-reference" ], "topics": { - "hello": { - "description": "Commands to say hello." + "license": { + "description": "description for license" } }, "flexibleTaxonomy": true diff --git a/schemas/hello-world.json b/schemas/hello-world.json deleted file mode 100644 index d28ac19..0000000 --- a/schemas/hello-world.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$ref": "#/definitions/HelloWorldResult", - "definitions": { - "HelloWorldResult": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "time": { - "type": "string" - } - }, - "required": ["name", "time"], - "additionalProperties": false - } - } -} diff --git a/schemas/license-provision.json b/schemas/license-provision.json new file mode 100644 index 0000000..9396b6a --- /dev/null +++ b/schemas/license-provision.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/LicenseProvisionResult", + "definitions": { + "LicenseProvisionResult": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "errorCode": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["errorCode", "message"], + "additionalProperties": false + } + } + }, + "required": ["status"], + "additionalProperties": false + } + } +} diff --git a/src/commands/hello/world.ts b/src/commands/hello/world.ts deleted file mode 100644 index a21da17..0000000 --- a/src/commands/hello/world.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2026, Salesforce, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; -import { Messages } from '@salesforce/core'; - -Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); -const messages = Messages.loadMessages('@salesforce/plugin-license-management', 'hello.world'); - -export type HelloWorldResult = { - name: string; - time: string; -}; - -export default class World extends SfCommand { - public static readonly summary = messages.getMessage('summary'); - public static readonly description = messages.getMessage('description'); - public static readonly examples = messages.getMessages('examples'); - - public static readonly flags = { - name: Flags.string({ - char: 'n', - summary: messages.getMessage('flags.name.summary'), - description: messages.getMessage('flags.name.description'), - default: 'World', - }), - }; - - public async run(): Promise { - const { flags } = await this.parse(World); - const time = new Date().toDateString(); - this.log(messages.getMessage('info.hello', [flags.name, time])); - return { - name: flags.name, - time, - }; - } -} diff --git a/src/commands/license/provision.ts b/src/commands/license/provision.ts new file mode 100644 index 0000000..fafce40 --- /dev/null +++ b/src/commands/license/provision.ts @@ -0,0 +1,195 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { readFile } from 'node:fs/promises'; +import { Flags, SfCommand } from '@salesforce/sf-plugins-core'; +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; + quantity?: number; + startDate?: string; + endDate?: string; +}; + +type ProvisionPslRequest = { + licenses: ProvisionLicenseSpec[]; +}; + +type ProvisionErrorMessage = { + errorCode: string; + message: string; +}; + +type ProvisionPslResponse = { + status: string; + licensesProvisioned?: number; + message?: string; + messages?: ProvisionErrorMessage[]; +}; + +export type LicenseProvisionResult = { + status: string; + messages?: ProvisionErrorMessage[]; +}; + +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 { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + + public static readonly flags = { + 'target-org': Flags.requiredOrg(), + 'api-version': Flags.orgApiVersion(), + namespace: Flags.string({ + char: 'n', + summary: messages.getMessage('flags.namespace.summary'), + }), + license: Flags.string({ + char: 'l', + summary: messages.getMessage('flags.license.summary'), + }), + quantity: Flags.integer({ + char: 'q', + summary: messages.getMessage('flags.quantity.summary'), + min: 0, + max: Number.MAX_SAFE_INTEGER, + }), + 'start-date': Flags.string({ + char: 's', + summary: messages.getMessage('flags.start-date.summary'), + }), + 'end-date': Flags.string({ + char: 'e', + summary: messages.getMessage('flags.end-date.summary'), + }), + 'definition-file': Flags.string({ + char: 'f', + summary: messages.getMessage('flags.definition-file.summary'), + }), + }; + + // Protected to allow stubbing in tests + protected static async loadSpecsFromFile( + filePath: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + flags: Record + ): Promise { + if ( + flags['license'] || + flags['namespace'] || + flags['quantity'] !== undefined || + flags['start-date'] || + flags['end-date'] + ) { + throw messages.createError('error.mutuallyExclusiveFlags'); + } + + const fileContent = await readFile(filePath, 'utf-8'); + const definition = JSON.parse(fileContent) as ProvisionPslRequest; + + if (!Array.isArray(definition.licenses) || definition.licenses.length === 0) { + throw messages.createError('error.emptyDefinitionFile'); + } + + return definition.licenses; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private static buildSpecsFromFlags(flags: Record): ProvisionLicenseSpec[] { + if (!flags['license']) { + throw messages.createError('error.missingLicenseFlag'); + } + + const startDate = (flags['start-date'] as string | undefined) ?? new Date().toISOString().slice(0, 10); + + return [ + { + namespacePrefix: flags['namespace'] as string | undefined, + permissionSetLicense: flags['license'] as string, + quantity: flags['quantity'] as number | undefined, + startDate, + endDate: flags['end-date'] as string | undefined, + }, + ]; + } + + public async run(): Promise { + const { flags } = await this.parse(LicenseProvision); + + const connection = flags['target-org'].getConnection(flags['api-version']); + + const licenseSpecs = flags['definition-file'] + ? 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 }; + + let response: ProvisionPslResponse; + try { + response = await connection.request({ + method: 'POST', + url: endpoint, + body: JSON.stringify(requestBody), + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error: unknown) { + throw SfError.wrap(error instanceof Error ? error : new Error(String(error))); + } + + 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]), + name: 'PROVISION_FAILED', + data: { status: 'error', messages: errorMessages }, + }); + } + + this.log(messages.getMessage('success')); + for (const spec of licenseSpecs) { + this.log(messages.getMessage('success.provisioned', [spec.quantity ?? 0, getLicenseDefinitionName(spec)])); + } + + return { status: 'success' }; + } +} diff --git a/test/commands/hello/world.test.ts b/test/commands/hello/world.test.ts deleted file mode 100644 index dd9d269..0000000 --- a/test/commands/hello/world.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2026, Salesforce, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { TestContext } from '@salesforce/core/testSetup'; -import { expect } from 'chai'; -import { stubSfCommandUx } from '@salesforce/sf-plugins-core'; -import World from '../../../src/commands/hello/world.js'; - -describe('hello world', () => { - const $$ = new TestContext(); - let sfCommandStubs: ReturnType; - - beforeEach(() => { - sfCommandStubs = stubSfCommandUx($$.SANDBOX); - }); - - afterEach(() => { - $$.restore(); - }); - - it('runs hello world', async () => { - await World.run([]); - const output = sfCommandStubs.log - .getCalls() - .flatMap((c) => c.args) - .join('\n'); - expect(output).to.include('Hello World'); - }); - - it('runs hello world with --json and no provided name', async () => { - const result = await World.run([]); - expect(result.name).to.equal('World'); - }); - - it('runs hello world --name Astro', async () => { - await World.run(['--name', 'Astro']); - const output = sfCommandStubs.log - .getCalls() - .flatMap((c) => c.args) - .join('\n'); - expect(output).to.include('Hello Astro'); - }); - - it('runs hello world --name Astro --json', async () => { - const result = await World.run(['--name', 'Astro', '--json']); - expect(result.name).to.equal('Astro'); - }); -}); diff --git a/test/commands/hello/world.nut.ts b/test/commands/license/provision.nut.ts similarity index 51% rename from test/commands/hello/world.nut.ts rename to test/commands/license/provision.nut.ts index fdfdae6..5816c33 100644 --- a/test/commands/hello/world.nut.ts +++ b/test/commands/license/provision.nut.ts @@ -13,31 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import { execCmd, TestSession } from '@salesforce/cli-plugins-testkit'; import { expect } from 'chai'; -import { HelloWorldResult } from '../../../src/commands/hello/world.js'; -let testSession: TestSession; +describe('license provision NUTs', () => { + let session: TestSession; -describe('hello world NUTs', () => { - before('prepare session', async () => { - testSession = await TestSession.create(); + before(async () => { + session = await TestSession.create({ devhubAuthStrategy: 'NONE' }); }); after(async () => { - await testSession?.clean(); - }); - - it('should say hello to the world', () => { - const result = execCmd('hello world --json', { ensureExitCode: 0 }).jsonOutput?.result; - expect(result?.name).to.equal('World'); + await session?.clean(); }); - it('should say hello to a given person', () => { - const result = execCmd('hello world --name Astro --json', { - ensureExitCode: 0, - }).jsonOutput?.result; - expect(result?.name).to.equal('Astro'); + it('should display provided name', () => { + const name = 'license provision '; + const command = 'license provision --help'; + const output = execCmd(command, { ensureExitCode: 0 }).shellOutput.stdout; + expect(output).to.contain(name); }); }); diff --git a/test/commands/license/provision.test.ts b/test/commands/license/provision.test.ts new file mode 100644 index 0000000..0f36529 --- /dev/null +++ b/test/commands/license/provision.test.ts @@ -0,0 +1,361 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { writeFile, unlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; +import { expect } from 'chai'; +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' }; + +describe('license provision', () => { + const $$ = new TestContext(); + let sfCommandStubs: ReturnType; + let testOrg: MockTestOrgData; + let requestStub: sinon.SinonStub; + + function buildMockConnection(response: unknown = SUCCESS_RESPONSE): sinon.SinonStub { + const stub = $$.SANDBOX.stub().resolves(response); + $$.SANDBOX.stub(Org.prototype, 'getConnection').returns({ + getApiVersion: () => '63.0', + request: stub, + // Required by Org.init() → Org.getField(ORG_ID) → getConnection().getAuthInfoFields() + getAuthInfoFields: () => ({ orgId: testOrg.orgId, username: testOrg.username }), + } as unknown as Connection); + return stub; + } + + beforeEach(async () => { + sfCommandStubs = stubSfCommandUx($$.SANDBOX); + testOrg = new MockTestOrgData(); + await $$.stubAuths(testOrg); + requestStub = buildMockConnection(); + }); + + afterEach(() => { + $$.restore(); + }); + + // ─── Success: CLI flags ────────────────────────────────────────────────────── + + it('provisions a single PSL using CLI flags and logs success', async () => { + await LicenseProvision.run([ + '--target-org', + testOrg.username, + '--license', + 'newLicense', + '--namespace', + 'demo', + '--quantity', + '5', + '--start-date', + '2026-03-30', + '--end-date', + '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'"); + }); + + 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'"); + }); + + it('sends the correct POST request payload', async () => { + await LicenseProvision.run([ + '--target-org', + testOrg.username, + '--license', + 'newLicense', + '--namespace', + 'demo', + '--quantity', + '5', + '--start-date', + '2026-03-30', + '--end-date', + '2027-03-30', + ]); + + expect(requestStub.calledOnce).to.be.true; + const callArgs = requestStub.firstCall.args[0] as { method: string; body: string; url: string }; + expect(callArgs.method).to.equal('POST'); + expect(callArgs.url).to.include('/partnerdevelopment/permissionsetlicenses'); + + const body = JSON.parse(callArgs.body) as { licenses: unknown[] }; + expect(body.licenses).to.have.length(1); + expect(body.licenses[0]).to.deep.include({ + namespacePrefix: 'demo', + permissionSetLicense: 'newLicense', + quantity: 5, + startDate: '2026-03-30', + endDate: '2027-03-30', + }); + }); + + it('defaults start-date to today when not provided', async () => { + const today = new Date().toISOString().slice(0, 10); + + await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'myLicense']); + + const callArgs = requestStub.firstCall.args[0] as { body: string }; + const body = JSON.parse(callArgs.body) as { licenses: Array<{ startDate: string }> }; + expect(body.licenses[0].startDate).to.equal(today); + }); + + it('returns status:success result', async () => { + const result = await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'myLicense']); + expect(result).to.deep.equal({ status: 'success' }); + }); + + // ─── Success: definition file ──────────────────────────────────────────────── + + describe('with a definition file', () => { + let tmpFilePath: string; + + beforeEach(() => { + tmpFilePath = join(tmpdir(), `provision-test-${Date.now()}.json`); + }); + + afterEach(async () => { + await unlink(tmpFilePath).catch(() => {}); + }); + + it('provisions multiple PSLs from a definition file and logs each', async () => { + await writeFile( + tmpFilePath, + JSON.stringify({ + licenses: [ + { namespacePrefix: 'demo', permissionSetLicense: 'newLicense', quantity: 5 }, + { namespacePrefix: 'demo', permissionSetLicense: 'premiumLicense', quantity: 8 }, + ], + }) + ); + + 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'"); + }); + + it('sends all PSLs from the definition file in a single request', async () => { + await writeFile( + tmpFilePath, + JSON.stringify({ + licenses: [ + { namespacePrefix: 'ns1', permissionSetLicense: 'licA', quantity: 10 }, + { namespacePrefix: 'ns2', permissionSetLicense: 'licB', quantity: 20 }, + ], + }) + ); + + await LicenseProvision.run(['--target-org', testOrg.username, '--definition-file', tmpFilePath]); + + expect(requestStub.calledOnce).to.be.true; + const callArgs = requestStub.firstCall.args[0] as { body: string }; + const body = JSON.parse(callArgs.body) as { licenses: unknown[] }; + expect(body.licenses).to.have.length(2); + }); + }); + + // ─── Validation errors ─────────────────────────────────────────────────────── + + it('throws when neither --license nor --definition-file is provided', async () => { + try { + await LicenseProvision.run(['--target-org', testOrg.username, '--namespace', 'demo']); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('--license'); + } + }); + + it('throws when --definition-file is combined with --license', async () => { + const tmpFilePath = join(tmpdir(), `provision-excl-${Date.now()}.json`); + await writeFile(tmpFilePath, JSON.stringify({ licenses: [{ permissionSetLicense: 'lic', quantity: 1 }] })); + try { + await LicenseProvision.run([ + '--target-org', + testOrg.username, + '--definition-file', + tmpFilePath, + '--license', + 'lic', + ]); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('--definition-file'); + } finally { + await unlink(tmpFilePath).catch(() => {}); + } + }); + + it('throws when --definition-file is combined with --quantity', async () => { + const tmpFilePath = join(tmpdir(), `provision-excl-qty-${Date.now()}.json`); + await writeFile(tmpFilePath, JSON.stringify({ licenses: [{ permissionSetLicense: 'lic', quantity: 1 }] })); + try { + await LicenseProvision.run([ + '--target-org', + testOrg.username, + '--definition-file', + tmpFilePath, + '--quantity', + '5', + ]); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('--definition-file'); + } finally { + await unlink(tmpFilePath).catch(() => {}); + } + }); + + 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: [] })); + try { + await LicenseProvision.run(['--target-org', testOrg.username, '--definition-file', tmpFilePath]); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('at least one'); + } finally { + await unlink(tmpFilePath).catch(() => {}); + } + }); + + // ─── 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'" }, + ], + }); + + try { + await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'badLicense', '--namespace', 'demo']); + 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'"); + } + }); + + 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 }, + ], + }) + ); + + try { + await LicenseProvision.run(['--target-org', testOrg.username, '--definition-file', tmpFilePath]); + 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(() => {}); + } + }); + + it('falls back to the message field when messages array is absent', async () => { + requestStub.resolves({ status: 'error', message: 'An unexpected error occurred' }); + + try { + await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'anyLicense']); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('An unexpected error occurred'); + } + }); + + it('wraps a network-level error as SfError', async () => { + requestStub.rejects(new Error('ECONNREFUSED')); + + try { + await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'anyLicense']); + expect.fail('Expected an error to be thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('ECONNREFUSED'); + } + }); +}); diff --git a/test/config/scratch-org-def.json b/test/config/scratch-org-def.json new file mode 100644 index 0000000..07bedb3 --- /dev/null +++ b/test/config/scratch-org-def.json @@ -0,0 +1,11 @@ +{ + "orgName": "PSL Test Scratch Org", + "edition": "enterprise", + "namespace": "demo", + "features": ["PartnerLicensingAndProvisioningPlatform"], + "settings": { + "lightningExperienceSettings": { + "enableS1DesktopEnabled": true + } + } +}