diff --git a/.vscode/launch.json b/.vscode/launch.json index 521244a0033..c8ec8010070 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -16,7 +16,11 @@ // 'm365 spo site get --url /', you'd use: // "args": ["spo", "site", "get", "--url", "/"] // after debugging, revert changes so that they won't end up in your PR - "args": [] + "args": [], + "console": "integratedTerminal", + "env": { + "NODE_OPTIONS": "--enable-source-maps" + } }, { "type": "node", diff --git a/docs/docs/_clisettings.mdx b/docs/docs/_clisettings.mdx index 47b42b929a5..0d16d83f3bf 100644 --- a/docs/docs/_clisettings.mdx +++ b/docs/docs/_clisettings.mdx @@ -2,6 +2,11 @@ Setting name|Definition|Default value ------------|----------|------------- `authType`|Default login method to use when running `m365 login` without the `--authType` option.|`deviceCode` `autoOpenLinksInBrowser`|Automatically open the browser for all commands which return a url and expect the user to copy paste this to the browser. For example when logging in, using `m365 login` in device code mode.|`false` +`clientId`|ID of the default Entra ID app use by the CLI to authenticate|`` +`clientSecret`|Secret of the default Entra ID app use by the CLI to authenticate|`` +`clientCertificateFile`|Path to the file containing the client certificate to use for authentication|`` +`clientCertificateBase64Encoded`|Base64-encoded client certificate contents|`` +`clientCertificatePassword`|Password to the client certificate file|`` `copyDeviceCodeToClipboard`|Automatically copy the device code to the clipboard when running `m365 login` command in device code mode|`false` `csvEscape`|Single character used for escaping; only apply to characters matching the quote and the escape options|`"` `csvHeader`|Display the column names on the first line|`true` @@ -18,3 +23,4 @@ Setting name|Definition|Default value `promptListPageSize`|By default, lists of choices longer than 7 will be paginated. Use this option to control how many choices will appear on the screen at once.|7 `showHelpOnFailure`|Automatically display help when executing a command failed|`true` `showSpinner`|Display spinner when executing commands|`true` +`tenantId`|ID of the default tenant to use when authenticating with|`` diff --git a/docs/docs/about/release-notes.mdx b/docs/docs/about/release-notes.mdx index efc0be8f220..00861063684 100644 --- a/docs/docs/about/release-notes.mdx +++ b/docs/docs/about/release-notes.mdx @@ -4,16 +4,53 @@ sidebar_position: 3 # Release notes -## v8.1.0 (beta) +## v9.1.0 (beta) + +### New commands + +**Power Automate:** + +- [flow recyclebinitem list](../cmd/flow/recyclebinitem/recyclebinitem-list.mdx) - lists all soft-deleted Power Automate flows within an environment [#6139](https://github.com/pnp/cli-microsoft365/issues/6139) +- [flow recyclebinitem restore ](../cmd/flow/recyclebinitem/recyclebinitem-restore.mdx) - restores a soft-deleted Power Automate flow [#6140](https://github.com/pnp/cli-microsoft365/issues/6140) + +**SharePoint:** + +- [spo folder sharinglink add](../cmd/spo/folder/folder-sharinglink-add.mdx) - creates a new sharing link to a folder [#5963](https://github.com/pnp/cli-microsoft365/issues/5963) +- [spo folder sharinglink clear](../cmd/spo/folder/folder-sharinglink-clear.mdx) - removes all sharing links of a folder [#5965](https://github.com/pnp/cli-microsoft365/issues/5965) +- [spo folder sharinglink remove](../cmd/spo/folder/folder-sharinglink-remove.mdx) - removes a sharing links from a folder [#5966](https://github.com/pnp/cli-microsoft365/issues/5966) +- [spo site admin add](../cmd/spo/site/site-admin-add.mdx) - adds a user or group as a site collection administrator [#5883](https://github.com/pnp/cli-microsoft365/issues/5883) + +**SharePoint Premium:** + +- [spp contentcenter list](../cmd/spp/contentcenter/contentcenter-list.mdx) - gets the URLs of the SharePoint Premium content centers [#6101](https://github.com/pnp/cli-microsoft365/issues/6101) + +### Changes + +- enhanced 'flow export' endpoints. [#6297](https://github.com/pnp/cli-microsoft365/issues/6297) +- enhanced 'spo file' to use utils. [#5268](https://github.com/pnp/cli-microsoft365/issues/5268) +- enhanced [spo page section add](../cmd/spo/page/page-section-add.mdx) with extended zoneEmphasis options. [#5268](https://github.com/pnp/cli-microsoft365/issues/5268) +- fixed 'VersionPolicies' on generic list. [#6264](https://github.com/pnp/cli-microsoft365/issues/6264) +- fixed user retrieval using the correct property. [#6308](https://github.com/pnp/cli-microsoft365/issues/6308) +- enhanced [spo user get](../cmd/spo/user/user-get.mdx) with extra options. [#5516](https://github.com/pnp/cli-microsoft365/issues/5516) +- fixed logging in with passwordless certificate. [#6337](https://github.com/pnp/cli-microsoft365/issues/6337) +- fixed serializing bool values in CSV output. [#6326](https://github.com/pnp/cli-microsoft365/issues/6326) + +## [v9.0.0](https://github.com/pnp/cli-microsoft365/releases/tag/v9.0.0) ### New commands **Entra ID:** +- [entra enterpriseapp remove](../cmd/entra/enterpriseapp/enterpriseapp-remove.mdx) - deletes an enterprise application (or service principal) [#6111](https://github.com/pnp/cli-microsoft365/issues/6111) +- [entra group set](../cmd/entra/group/group-set.mdx) - updates a Microsoft Entra group [#5479](https://github.com/pnp/cli-microsoft365/issues/5479) - [entra multitenant add](../cmd/entra/multitenant/multitenant-add.mdx) - creates a new multitenant organization [#6006](https://github.com/pnp/cli-microsoft365/issues/6006) - [entra multitenant remove](../cmd/entra/multitenant/multitenant-remove.mdx) - removes a multitenant organization [#6009](https://github.com/pnp/cli-microsoft365/issues/6009) - [entra multitenant set](../cmd/entra/multitenant/multitenant-set.mdx) - updates the properties of a multitenant organization [#6008](https://github.com/pnp/cli-microsoft365/issues/6008) +**Onenote:** + +- [onenote notebook add ](../cmd/onenote/notebook/notebook-add.mdx) - creates a new OneNote notebook. [#3100](https://github.com/pnp/cli-microsoft365/issues/3100) + **SharePoint Embedded:** - [spe containertype list](../cmd/spe/containertype/containertype-list.mdx) - retrieves a list of Container Types created for a SharePoint Embedded Application [#5989](https://github.com/pnp/cli-microsoft365/issues/5989) @@ -23,17 +60,26 @@ sidebar_position: 3 - [spo folder sharinglink get](../cmd/spo/folder/folder-sharinglink-get.mdx) - gets details about a specific sharing link on a folder [#5962](https://github.com/pnp/cli-microsoft365/issues/5962) - [spo folder sharinglink list](../cmd/spo/folder/folder-sharinglink-list.mdx) - lists sharing links on a folder [#5961](https://github.com/pnp/cli-microsoft365/issues/5961) +**Teams:** + +- [teams message restore](../cmd/teams/message/message-restore.mdx) - restores a deleted message from a channel in a Microsoft Teams team [#5860](https://github.com/pnp/cli-microsoft365/issues/5860) + ### Changes -- added 'componentProperties' option to 'spo spfx' commands [#5975](https://github.com/pnp/cli-microsoft365/issues/5975) +- added `componentProperties` option to `spo spfx` commands [#5975](https://github.com/pnp/cli-microsoft365/issues/5975) - added prompting to [connection use](../cmd/connection/connection-use.mdx) [#6173](https://github.com/pnp/cli-microsoft365/issues/6173) -- enhanced [spo list roleassignment](../cmd/spo/list/list-roleassignment-add.mdx) commands with support for Entra groups [#6194](https://github.com/pnp/cli-microsoft365/issues/6194) -- fixed command [teams meeting list](../cmd/teams/meeting/meeting-list.mdx) [#5968](https://github.com/pnp/cli-microsoft365/issues/5968) -- introduced zod validation [#5639](https://github.com/pnp/cli-microsoft365/issues/5639) -- added 'VersionPolicies' to [spo list get](../cmd/spo/list/list-get.mdx) command [#5983](https://github.com/pnp/cli-microsoft365/issues/5983) +- added missing --force in example for [spo app uninstall](../cmd/spo/app/app-uninstall.mdx) command [#6245](https://github.com/pnp/cli-microsoft365/issues/6245) +- added `versionPolicies` to [spo list get](../cmd/spo/list/list-get.mdx) command [#5983](https://github.com/pnp/cli-microsoft365/issues/5983) - added capabilities to add multiple users in an m365group [#6060](https://github.com/pnp/cli-microsoft365/issues/6060) - added capabilities to set multiple users in an m365group [#6059](https://github.com/pnp/cli-microsoft365/issues/6059) -- enhanced the 'flow get' command to return additional properties [#4683](https://github.com/pnp/cli-microsoft365/issues/4683) +- enhanced [spo list roleassignment](../cmd/spo/list/list-roleassignment-add.mdx) commands with support for Entra groups [#6194](https://github.com/pnp/cli-microsoft365/issues/6194) +- enhanced the [flow get](../cmd/flow/flow-get.mdx) command to return additional properties [#4683](https://github.com/pnp/cli-microsoft365/issues/4683) +- introduced zod validation [#5639](https://github.com/pnp/cli-microsoft365/issues/5639) +- fixed [pa app export](../cmd/pa/app/app-export.mdx) without packageDisplayName [#6215](https://github.com/pnp/cli-microsoft365/issues/6215) +- fixed bug when parsing number arguments [#6211](https://github.com/pnp/cli-microsoft365/issues/6211) +- fixed command [teams meeting list](../cmd/teams/meeting/meeting-list.mdx) [#5968](https://github.com/pnp/cli-microsoft365/issues/5968) +- fixed prompting for missing required options in ZOD commands [#6219](https://github.com/pnp/cli-microsoft365/issues/6219) +- extended setup with a custom Entra app ## [v8.0.0](https://github.com/pnp/cli-microsoft365/releases/tag/v8.0.0) diff --git a/docs/docs/cmd/entra/enterpriseapp/enterpriseapp-remove.mdx b/docs/docs/cmd/entra/enterpriseapp/enterpriseapp-remove.mdx new file mode 100644 index 00000000000..413b55b11a6 --- /dev/null +++ b/docs/docs/cmd/entra/enterpriseapp/enterpriseapp-remove.mdx @@ -0,0 +1,65 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# entra enterpriseapp remove + +Deletes an enterprise application (or service principal) + +## Usage + +```sh +m365 entra enterpriseapp remove [options] +``` + +## Alias + +```sh +m365 entra sp remove [options] +``` + +## Options + +```md definition-list +`-i, --id [id]` +: ID of the enterprise application. + +`-n, --displayName [displayName]` +: Display name of the enterprise application. + +`--objectId [objectId]` +: ObjectId of the enterprise application. + +`-f, --force` +: Don't prompt for confirmation. +``` + + + +## Examples + +Delete an enterprise application by application ID. + +```sh +m365 entra enterpriseapp remove --id b2307a39-e878-458b-bc90-03bc578531d6 --force +``` + +Delete an enterprise application by display name. + +```sh +m365 entra enterpriseapp remove --displayName "Contoso app" +``` + +Delete an enterprise application by object ID. + +```sh +m365 entra enterpriseapp remove --objectId b2307a39-e878-458b-bc90-03bc578531dd +``` + +## Response + +The command won't return a response on success. + +## More information + +- Application and service principal objects in Microsoft Entra ID: [https://learn.microsoft.com/azure/active-directory/develop/active-directory-application-objects](https://learn.microsoft.com/azure/active-directory/develop/active-directory-application-objects) diff --git a/docs/docs/cmd/entra/group/group-add.mdx b/docs/docs/cmd/entra/group/group-add.mdx index f31278946d7..dfa9ef81cb3 100644 --- a/docs/docs/cmd/entra/group/group-add.mdx +++ b/docs/docs/cmd/entra/group/group-add.mdx @@ -53,8 +53,6 @@ m365 aad group add [options] ## Remarks -:::info - The `visibility` option affects the behavior of the group. With the `Public` visibility: @@ -74,8 +72,6 @@ With the `HiddenMembership` visibility: - Administrators (global, company, user, and helpdesk) can view the membership of the group. - The group appears in the global address book (GAL). -::: - :::note The `HiddenMembership` visibility can be set only for Microsoft 365 groups when the groups are created. It can't be updated later. diff --git a/docs/docs/cmd/entra/group/group-set.mdx b/docs/docs/cmd/entra/group/group-set.mdx new file mode 100644 index 00000000000..90a1bad4348 --- /dev/null +++ b/docs/docs/cmd/entra/group/group-set.mdx @@ -0,0 +1,89 @@ +import Global from '/docs/cmd/_global.mdx'; + +# entra group set + +Updates a Microsoft Entra group + +## Usage + +```sh +m365 entra group set [options] +``` + +## Options + +```md definition-list +`-i, --id [id]` +: The ID of the Microsoft Entra group to update. Specify either `id` or `displayName` but not both. + +`-n, --displayName [displayName]` +: The display name of the Microsoft Entra group to update. Specify either `id` or `displayName` but not both. + +`--newDisplayName [newDisplayName]` +: The new display name of the Microsoft Entra group. The maximum length is 256 characters. + +`--description [description]` +: The new description for the group. + +`--mailNickname [mailNickname]` +: The new mail alias for the group (part before the @). Use only for mail-enabled groups. Maximum length is 64 characters. + +`--ownerIds [ownerIds]` +: Comma-separated list of IDs of Microsoft Entra users that will be the group owners. Specify either `ownerIds` or `ownerUserNames`, but not both. + +`--ownerUserNames [ownerUserNames]` +: Comma-separated list of UPNs of Microsoft Entra users that will be the group owners. Specify either `ownerIds` or `ownerUserNames`, but not both. + +`--memberIds [memberIds]` +: Comma-separated list of IDs of Microsoft Entra users that will be the group members. Specify either `memberIds` or `memberUserNames`, but not both. + +`--memberUserNames [memberUserNames]` +: Comma-separated list of UPNs of Microsoft Entra users that will be the group members. Specify either `memberIds` or `memberUserNames`, but not both. + +`--visibility [visibility]` +: Specifies the group join policy and group content visibility for Microsoft 365 groups. Possible values are: `Private` or `Public`. Specify only when targeting a Microsoft 365 group. +``` + + + +## Remarks + +The `visibility` option affects the behavior of the group. + +With the `Public` visibility: +- Anyone can join the group without needing owner approval. +- Anyone can view the attributes of the group. +- Anyone can see the members of the group. + +With the `Private` visibilty: +- Owner approval is needed to join the group. +- Anyone can view the attributes of the group. +- Anyone can see the members of the group. + +If the specified option is not found, you will receive a `Resource 'xyz' does not exist or one of its queried reference-property objects are not present.` error. + +Specifying `memberIds` or `memberUserNames` will make only those users members, removing all others. Similarly, specifying `ownerIds` or `ownerUserNames` will make only those users owners, removing all others. + +## Examples + +Update the display name of a group specified by the display name + +```sh +m365 entra group set --displayName Devs --newDisplayName Developers +``` + +Set the owners of a group to the specified people + +```sh +m365 entra group set --id 57fd6b33-54eb-42b0-9ea0-8a9ac04eab7d --ownerUserNames "john.doe@contoso.com,adele.vance@contoso.com" +``` + +Update the description and mail nickname of a group + +```sh +m365 entra group set --id 57fd6b33-54eb-42b0-9ea0-8a9ac04eab7d --description "All developers of the company" --mailNickname developers +``` + +## Response + +The command won't return a response on success. diff --git a/docs/docs/cmd/flow/recyclebinitem/recyclebinitem-list.mdx b/docs/docs/cmd/flow/recyclebinitem/recyclebinitem-list.mdx new file mode 100644 index 00000000000..3584d20212a --- /dev/null +++ b/docs/docs/cmd/flow/recyclebinitem/recyclebinitem-list.mdx @@ -0,0 +1,132 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# flow recyclebinitem list + +Lists all soft-deleted Power Automate flows within an environment + +## Usage + +```sh +m365 flow recyclebinitem list [options] +``` + +## Options + +```md definition-list +`-e, --environmentName ` +: The name of the environment. +``` + + + +## Remarks + +:::warning + +This command is based on an API that is currently in preview and is subject to change once the API reaches general availability. + +::: + +:::info + +To use this command, you must be a Global or Power Platform administrator. + +::: + +A Power Automate flow is soft-deleted when: +- It's a non-solution flow. +- It's been deleted less than 21 days ago. + +If the environment with the name you specified doesn't exist, you will get the `Access to the environment 'xyz' is denied.` error. + +## Examples + +List all soft-deleted flows within a specific environment + +```sh +m365 flow recyclebinitem list --environmentName Default-d87a7535-dd31-4437-bfe1-95340acd55c5 +``` + +## Response + + + + + ```json + [ + { + "name": "26a9a283-af42-4c09-aa3e-60c3cc166b90", + "id": "/providers/Microsoft.ProcessSimple/environments/Default-d87a7535-dd31-4437-bfe1-95340acd55c5/flows/26a9a283-af42-4c09-aa3e-60c3cc166b90", + "type": "Microsoft.ProcessSimple/environments/flows", + "properties": { + "apiId": "/providers/Microsoft.PowerApps/apis/shared_logicflows", + "displayName": "Invoicing flow", + "state": "Deleted", + "createdTime": "2024-08-05T23:13:54Z", + "lastModifiedTime": "2024-08-05T23:14:00Z", + "flowSuspensionReason": "None", + "environment": { + "name": "Default-d87a7535-dd31-4437-bfe1-95340acd55c5", + "type": "Microsoft.ProcessSimple/environments", + "id": "/providers/Microsoft.ProcessSimple/environments/Default-d87a7535-dd31-4437-bfe1-95340acd55c5" + }, + "definitionSummary": { + "triggers": [], + "actions": [] + }, + "creator": { + "tenantId": "a16e76a1-837f-4bf9-82dc-78874d18e434", + "objectId": "bd51c64d-c262-4184-ba3f-5361ea553820", + "userId": "bd51c64d-c262-4184-ba3f-5361ea553820", + "userType": "ActiveDirectory" + }, + "flowFailureAlertSubscribed": false, + "isManaged": false, + "machineDescriptionData": {}, + "flowOpenAiData": { + "isConsequential": false, + "isConsequentialFlagOverwritten": false + } + } + } + ] + ``` + + + + + ```text + name displayName + ------------------------------------ -------------- + 26a9a283-af42-4c09-aa3e-60c3cc166b90 Invoicing flow + ``` + + + + + ```csv + name,id,type + 26a9a283-af42-4c09-aa3e-60c3cc166b90,/providers/Microsoft.ProcessSimple/environments/Default-d87a7535-dd31-4437-bfe1-95340acd55c5/flows/26a9a283-af42-4c09-aa3e-60c3cc166b90,Microsoft.ProcessSimple/environments/flows + ``` + + + + + ```md + # flow recyclebinitem list --environmentName "Default-d87a7535-dd31-4437-bfe1-95340acd55c5" + + Date: 06/08/2024 + + ## 26a9a283-af42-4c09-aa3e-60c3cc166b90 (/providers/Microsoft.ProcessSimple/environments/Default-d87a7535-dd31-4437-bfe1-95340acd55c5/flows/26a9a283-af42-4c09-aa3e-60c3cc166b90) + + Property | Value + ---------|------- + name | 26a9a283-af42-4c09-aa3e-60c3cc166b90 + id | /providers/Microsoft.ProcessSimple/environments/Default-d87a7535-dd31-4437-bfe1-95340acd55c5/flows/26a9a283-af42-4c09-aa3e-60c3cc166b90 + type | Microsoft.ProcessSimple/environments/flows + ``` + + + diff --git a/docs/docs/cmd/flow/recyclebinitem/recyclebinitem-restore.mdx b/docs/docs/cmd/flow/recyclebinitem/recyclebinitem-restore.mdx new file mode 100644 index 00000000000..028878927b9 --- /dev/null +++ b/docs/docs/cmd/flow/recyclebinitem/recyclebinitem-restore.mdx @@ -0,0 +1,55 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# flow recyclebinitem restore + +Restores a soft-deleted Power Automate flow + +## Usage + +```sh +m365 flow recyclebinitem restore [options] +``` + +## Options + +```md definition-list +`-e, --environmentName ` +: The name of the environment where the flow is located. + +`-n, --flowName ` +: The name of the Power Automate flow. +``` + + + +## Remarks + +:::warning + +This command is based on an API that is currently in preview and is subject to change once the API reaches general availability. + +::: + +:::info + +To use this command, you must be a Global or Power Platform administrator. + +::: + +When a Power Automate flow is restored, it will be automatically disabled. To make it operational again, you must [enable](../flow-enable.mdx) it. + +If the environment with the name you specified doesn't exist, you will get the `Access to the environment 'xyz' is denied.` error. + +## Examples + +Restores a soft-deleted flow within a specific environment + +```sh +m365 flow recyclebinitem restore --environmentName Default-d87a7535-dd31-4437-bfe1-95340acd55c5 --flowName 5923cb07-ce1a-4a5c-ab81-257ce820109a +``` + +## Response + +The command won't return a response on success. diff --git a/docs/docs/cmd/onenote/notebook/notebook-add.mdx b/docs/docs/cmd/onenote/notebook/notebook-add.mdx new file mode 100644 index 00000000000..84a9daafed1 --- /dev/null +++ b/docs/docs/cmd/onenote/notebook/notebook-add.mdx @@ -0,0 +1,169 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# onenote notebook add + +Create a new OneNote notebook. + +## Usage + +```sh +m365 onenote notebook add [options] +``` + +## Options + +```md definition-list +`-n, --name ` +: Name of the notebook. Notebook names must be unique. The name cannot contain more than 128 characters or contain the following characters: `?*/:<>` + +`--userId [userId]` +: Id of the user. Specify either `userId`, `userName`, `groupId`, `groupName` or `webUrl`, but not multiple. + +`--userName [userName]` +: Name of the user. Specify either `userId`, `userName`, `groupId`, `groupName` or `webUrl`, but not multiple. + +`--groupId [groupId]` +: Id of the SharePoint group. Specify either `userId`, `userName`, `groupId`, `groupName` or `webUrl`, but not multiple. + +`--groupName [groupName]` +: Name of the SharePoint group. Specify either `userId`, `userName`, `groupId`, `groupName` or `webUrl`, but not multiple. + +`-u, --webUrl [webUrl]` +: URL of the SharePoint site. Specify either `userId`, `userName`, `groupId`, `groupName` or `webUrl`, but not multiple. +``` + + + +## Examples + +Create a Microsoft OneNote notebook for the currently logged in user + +```sh +m365 onenote notebook add --name "Private Notebook" +``` + +Create a Microsoft OneNote notebook in a group specified by id. + +```sh +m365 onenote notebook add --name "Private Notebook" --groupId 233e43d0-dc6a-482e-9b4e-0de7a7bce9b4 +``` + +Create a Microsoft OneNote notebook in a group specified by displayName. + +```sh +m365 onenote notebook add --name "Private Notebook" --groupName "MyGroup" +``` + +Create a Microsoft OneNote notebook for a user specified by name + +```sh +m365 onenote notebook add --name "Private Notebook" --userName user1@contoso.onmicrosoft.com +``` + +Create a Microsoft OneNote notebook for a user specified by id + +```sh +m365 onenote notebook add --name "Private Notebook" --userId 2609af39-7775-4f94-a3dc-0dd67657e900 +``` + +Creates a Microsoft OneNote notebooks for a site + +```sh + m365 onenote notebook add --name "Private Notebook" --webUrl https://contoso.sharepoint.com/sites/testsite +``` + +## Response + + + + + ```json + { + "id": "1-08554ffd-b769-4a4a-9563-faaa3191f253", + "self": "https://graph.microsoft.com/v1.0/users/fe36f75e-c103-410b-a18a-2bf6df06ac3a/onenote/notebooks/1-08554ffd-b769-4a4a-9563-faaa3191f253", + "createdDateTime": "2024-04-05T17:58:27Z", + "displayName": "Private Notebook", + "lastModifiedDateTime": "2024-04-05T17:58:27Z", + "isDefault": false, + "userRole": "Owner", + "isShared": false, + "sectionsUrl": "https://graph.microsoft.com/v1.0/users/fe36f75e-c103-410b-a18a-2bf6df06ac3a/onenote/notebooks/1-08554ffd-b769-4a4a-9563-faaa3191f253/sections", + "sectionGroupsUrl": "https://graph.microsoft.com/v1.0/users/fe36f75e-c103-410b-a18a-2bf6df06ac3a/onenote/notebooks/1-08554ffd-b769-4a4a-9563-faaa3191f253/sectionGroups", + "createdBy": { + "user": { + "id": "fe36f75e-c103-410b-a18a-2bf6df06ac3a", + "displayName": "John Doe" + } + }, + "lastModifiedBy": { + "user": { + "id": "fe36f75e-c103-410b-a18a-2bf6df06ac3a", + "displayName": "John Doe" + } + }, + "links": { + "oneNoteClientUrl": { + "href": "onenote:https://contoso-my.sharepoint.com/personal/john_contoso_onmicrosoft_com/Documents/Notebooks/Private Notebook" + }, + "oneNoteWebUrl": { + "href": "https://contoso-my.sharepoint.com/personal/john_contoso_onmicrosoft_com/Documents/Notebooks/Private Notebook" + } + } + } + ``` + + + + + ```text + createdBy : {"user":{"id":"fe36f75e-c103-410b-a18a-2bf6df06ac3a","displayName":"John Doe"}} + createdDateTime : 2024-04-05T17:58:36Z + displayName : Private Notebook + id : 1-f4bba32b-9b3e-4892-8df4-931a15f7eb6f + isDefault : false + isShared : false + lastModifiedBy : {"user":{"id":"fe36f75e-c103-410b-a18a-2bf6df06ac3a","displayName":"John Doe"}} + lastModifiedDateTime: 2024-04-05T17:58:36Z + links : {"oneNoteClientUrl":{"href":"onenote:https://contoso-my.sharepoint.com/personal/john_contoso_onmicrosoft_com/Documents/Notebooks/Private Notebook"},"oneNoteWebUrl":{"href":"https://contoso-my.sharepoint.com/personal/john_contoso_onmicrosoft_com/Documents/Notebooks/Private Notebook"}} + sectionGroupsUrl : https://graph.microsoft.com/v1.0/users/fe36f75e-c103-410b-a18a-2bf6df06ac3a/onenote/notebooks/1-f4bba32b-9b3e-4892-8df4-931a15f7eb6f/sectionGroups + sectionsUrl : https://graph.microsoft.com/v1.0/users/fe36f75e-c103-410b-a18a-2bf6df06ac3a/onenote/notebooks/1-f4bba32b-9b3e-4892-8df4-931a15f7eb6f/sections + self : https://graph.microsoft.com/v1.0/users/fe36f75e-c103-410b-a18a-2bf6df06ac3a/onenote/notebooks/1-f4bba32b-9b3e-4892-8df4-931a15f7eb6f + userRole : Owner + ``` + + + + + ```csv + id,self,createdDateTime,displayName,lastModifiedDateTime,isDefault,userRole,isShared,sectionsUrl,sectionGroupsUrl + 1-272a5791-2c95-45cf-b27d-7f68e07f6149,https://graph.microsoft.com/v1.0/users/fe36f75e-c103-410b-a18a-2bf6df06ac3a/onenote/notebooks/1-272a5791-2c95-45cf-b27d-7f68e07f6149,2024-04-05T18:00:28Z,Private Notebook,2024-04-05T18:00:28Z,,Owner,,https://graph.microsoft.com/v1.0/users/fe36f75e-c103-410b-a18a-2bf6df06ac3a/onenote/notebooks/1-272a5791-2c95-45cf-b27d-7f68e07f6149/sections,https://graph.microsoft.com/v1.0/users/fe36f75e-c103-410b-a18a-2bf6df06ac3a/onenote/notebooks/1-272a5791-2c95-45cf-b27d-7f68e07f6149/sectionGroups + ``` + + + + + ```md + # onenote notebook add --name "Private Notebook" + + Date: 05/04/2024 + + ## Private Notebook (1-fa279d79-4701-43a2-9593-c2abfbe6999f) + + Property | Value + ---------|------- + id | 1-fa279d79-4701-43a2-9593-c2abfbe6999f + self | https://graph.microsoft.com/v1.0/users/fe36f75e-c103-410b-a18a-2bf6df06ac3a/onenote/notebooks/1-fa279d79-4701-43a2-9593-c2abfbe6999f + createdDateTime | 2024-04-05T18:00:58Z + displayName | Private Notebook + lastModifiedDateTime | 2024-04-05T18:00:58Z + isDefault | false + userRole | Owner + isShared | false + sectionsUrl | https://graph.microsoft.com/v1.0/users/fe36f75e-c103-410b-a18a-2bf6df06ac3a/onenote/notebooks/1-fa279d79-4701-43a2-9593-c2abfbe6999f/sections + sectionGroupsUrl | https://graph.microsoft.com/v1.0/users/fe36f75e-c103-410b-a18a-2bf6df06ac3a/onenote/notebooks/1-fa279d79-4701-43a2-9593-c2abfbe6999f/sectionGroups + ``` + + + diff --git a/docs/docs/cmd/pa/app/app-export.mdx b/docs/docs/cmd/pa/app/app-export.mdx index c8dea0619f3..26692c39168 100644 --- a/docs/docs/cmd/pa/app/app-export.mdx +++ b/docs/docs/cmd/pa/app/app-export.mdx @@ -14,25 +14,25 @@ m365 pa app export [options] ```md definition-list `-n, --name ` -: The name (GUID) of the Power Apps app to export +: The name (GUID) of the Power Apps app to export. `-e, --environmentName ` -: The name of the environment for which to export the app +: The name of the environment for which to export the app. `--packageDisplayName [packageDisplayName]` -: The display name to use in the exported package +: The display name to use in the exported package. `-d, --packageDescription [packageDescription]` -: The description to use in the exported package +: The description to use in the exported package. `-c, --packageCreatedBy [packageCreatedBy]` -: The name of the person to be used as the creator of the exported package +: The name of the person to be used as the creator of the exported package. `-s, --packageSourceEnvironment [packageSourceEnvironment]` -: The name of the source environment from which the exported package was taken +: The name of the source environment from which the exported package was taken. `-p, --path [path]` -: The path to save the exported package to. If not specified the app will be exported in the current working directory +: The path to save the exported package to. If not specified the app will be exported in the current working directory. ``` @@ -42,13 +42,19 @@ m365 pa app export [options] Export the specified Power App as a ZIP file ```sh -m365 pa app export --environmentName Default-d87a7535-dd31-4437-bfe1-95340acd55c5 --name 3989cb59-ce1a-4a5c-bb78-257c5c39381d --packageDisplayName "PowerApp" +m365 pa app export --environmentName Default-d87a7535-dd31-4437-bfe1-95340acd55c5 --name 3989cb59-ce1a-4a5c-bb78-257c5c39381d +``` + +Export the specified Power App as a ZIP file with a custom package name + +```sh +m365 pa app export --environmentName Default-d87a7535-dd31-4437-bfe1-95340acd55c5 --name 3989cb59-ce1a-4a5c-bb78-257c5c39381d --packageDisplayName "Assets app" ``` Export the specified Power App as a ZIP file with the package displayname, package description, the one who created it, the package source environment and the path ```sh -m365 pa app export --environmentName Default-d87a7535-dd31-4437-bfe1-95340acd55c5 --name 3989cb59-ce1a-4a5c-bb78-257c5c39381d --packageDisplayName "PowerApp" --packageDescription "Power App Description" --packageCreatedBy "John Doe" --packageSourceEnvironment "Contoso" --path "C:/Users/John/Documents" +m365 pa app export --environmentName Default-d87a7535-dd31-4437-bfe1-95340acd55c5 --name 3989cb59-ce1a-4a5c-bb78-257c5c39381d --packageDisplayName "Assets app" --packageDescription "App to track assets of people" --packageCreatedBy "John Doe" --packageSourceEnvironment "Contoso" --path "C:/Users/John/Documents" ``` ## Response diff --git a/docs/docs/cmd/setup.mdx b/docs/docs/cmd/setup.mdx index 03cc79d980b..c5b14523f0a 100644 --- a/docs/docs/cmd/setup.mdx +++ b/docs/docs/cmd/setup.mdx @@ -18,6 +18,9 @@ m365 setup [options] `--scripting` : Configure CLI for Microsoft 365 for use in scripts without prompting for additional information. + +`--skipApp` +: Skip configuring an Entra app for use with CLI for Microsoft 365. ``` @@ -28,6 +31,10 @@ The `m365 setup` command is a wizard that helps you configure the CLI for Micros The command will ask you the following questions: +- _CLI for Microsoft 365 requires a Microsoft Entra app. Do you want to create a new app registration or use an existing one?_ + + You can choose between using an existing Entra app or creating a new one. If you choose to create a new app, the CLI will ask you to choose between a minimal and a full set of permissions. It then signs in as Azure CLI to your tenant, creates a new app registration, and stores its information in the CLI configuration. + - _How do you plan to use the CLI?_ You can choose between **interactive** and **scripting** use. In interactive mode, the CLI for Microsoft 365 will prompt you for additional information when needed, automatically open links browser, automatically show help on errors and show spinners. In **scripting** mode, the CLI will not use interactivity to prevent blocking your scripts. @@ -71,24 +78,30 @@ The `m365 setup` command uses the following presets: ## Examples -Configure CLI for Microsoft based on your preferences interactively +Configure CLI for Microsoft 365 based on your preferences interactively ```sh m365 setup ``` -Configure CLI for Microsoft for interactive use without prompting for additional information +Configure CLI for Microsoft 365 for interactive use without prompting for additional information ```sh m365 setup --interactive ``` -Configure CLI for Microsoft for use in scripts without prompting for additional information +Configure CLI for Microsoft 365 for use in scripts without prompting for additional information ```sh m365 setup --scripting ``` +Configure CLI for Microsoft 365 without setting up an Entra app + +```sh +m365 setup --skipApp +``` + ## Response The command won't return a response on success. diff --git a/docs/docs/cmd/spo/app/app-uninstall.mdx b/docs/docs/cmd/spo/app/app-uninstall.mdx index 0ec25fd83a4..67646bfd3b4 100644 --- a/docs/docs/cmd/spo/app/app-uninstall.mdx +++ b/docs/docs/cmd/spo/app/app-uninstall.mdx @@ -43,7 +43,7 @@ m365 spo app uninstall --id b2307a39-e878-458b-bc90-03bc578531d6 --siteUrl https Uninstall the app with the specified ID from the specified site without prompting for confirmation. ```sh -m365 spo app uninstall --id b2307a39-e878-458b-bc90-03bc578531d6 --siteUrl https://contoso.sharepoint.com +m365 spo app uninstall --id b2307a39-e878-458b-bc90-03bc578531d6 --siteUrl https://contoso.sharepoint.com --force ``` Uninstall the app with the specified ID from the specified site where the app is deployed to the site collection app catalog. diff --git a/docs/docs/cmd/spo/folder/folder-sharinglink-add.mdx b/docs/docs/cmd/spo/folder/folder-sharinglink-add.mdx new file mode 100644 index 00000000000..a5a6af04c3b --- /dev/null +++ b/docs/docs/cmd/spo/folder/folder-sharinglink-add.mdx @@ -0,0 +1,125 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# spo folder sharinglink add + +Creates a new sharing link to a folder + +## Usage + +```sh +m365 spo folder sharinglink add [options] +``` + +## Options + +```md definition-list +`-u, --webUrl ` +: The URL of the site where the folder is located. + +`--folderUrl [folderUrl]` +: The server- or site-relative decoded URL of the folder. Specify either `folderUrl` or `folderId` but not both. + +`--folderId [folderId]` +: The UniqueId (GUID) of the folder. Specify either `folderUrl` or `folderId` but not both. + +`--type ` +: The type of sharing link to create. Either `view` or `edit`. + +`--expirationDateTime [expirationDateTime]` +: The date and time to set the expiration. This should be defined as a valid ISO 8601 string. + +`--scope [scope]` +: The scope of link to create. Either `anonymous`, `organization` or `users`. If not specified, the default of the organization will be used. + +`--retainInheritedPermissions [retainInheritedPermissions]` +: If `true`, any existing inherited permissions are retained on the shared item when sharing this item for the first time. If `false`, all existing permissions are removed when sharing for the first time. + +`--recipients [recipients]` +: Comma separated list of users with whom we wish to share the item with. Required when using scope `users`. +``` + + + +## Examples + +Creates a view-only anonymous sharing link of a folder by id. + +```sh +m365 spo folder sharinglink add --webUrl https://contoso.sharepoint.com/sites/demo --folderId daebb04b-a773-4baa-b1d1-3625418e3234 --type view --scope anonymous +``` + +Creates an edit organization sharing link of a folder by url with a specific expiration date. + +```sh +m365 spo folder sharinglink add --webUrl https://contoso.sharepoint.com/sites/demo --folderUrl /sites/demo/shared%20documents/Folder --type edit --scope organization --expirationDateTime '2022-11-30T00:00:00Z' +``` + +Creates a user sharing link of a folder by id. + +```sh +m365 spo folder sharinglink add --webUrl https://contoso.sharepoint.com/sites/demo --folderId daebb04b-a773-4baa-b1d1-3625418e3234 --type view --scope users --recipients john@contoso.com,doe@contoso.com +``` + +## Response + + + + + ```json + { + "id": "4fe11ccb-6c83-4927-8072-95642422b8ae", + "roles": [ + "read" + ], + "shareId": "u!aHR0cHM6Ly83NTY2YXZhLnNoYXJlcG9pbnQuY29tLzpmOi9nL0V2QVFpdnpLV2ZoT3ZJOHJiNm1UVEhjQjNHWkRRNlVhQ3VwNEhWeFpiR25mRkE", + "hasPassword": false, + "link": { + "scope": "anonymous", + "type": "view", + "webUrl": "https://contoso.sharepoint.com/:f:/g/EvAQivzKWfhOvI8rb6mTTHcB3GZDQ6UaCup4HVxZbGnfFA", + "preventsDownload": false + } + } + ``` + + + + + ```text + hasPassword: false + id : 4fe11ccb-6c83-4927-8072-95642422b8ae + link : {"scope":"anonymous","type":"view","webUrl":"https://contoso.sharepoint.com/:f:/g/EvAQivzKWfhOvI8rb6mTTHcB3GZDQ6UaCup4HVxZbGnfFA","preventsDownload":false} + roles : ["read"] + shareId : u!aHR0cHM6Ly83NTY2YXZhLnNoYXJlcG9pbnQuY29tLzpmOi9nL0V2QVFpdnpLV2ZoT3ZJOHJiNm1UVEhjQjNHWkRRNlVhQ3VwNEhWeFpiR25mRkE + ``` + + + + + ```csv + id,shareId,hasPassword + 4fe11ccb-6c83-4927-8072-95642422b8ae,u!aHR0cHM6Ly83NTY2YXZhLnNoYXJlcG9pbnQuY29tLzpmOi9nL0V2QVFpdnpLV2ZoT3ZJOHJiNm1UVEhjQjNHWkRRNlVhQ3VwNEhWeFpiR25mRkE, + ``` + + + + + ```md + # spo folder sharinglink add --webUrl "https://contoso.sharepoint.com" --folderUrl "/shared documents/folder1" --type "view" --scope "anonymous" + + Date: 29/04/2024 + + ## 4fe11ccb-6c83-4927-8072-95642422b8ae + + Property | Value + ---------|------- + id | 4fe11ccb-6c83-4927-8072-95642422b8ae + shareId | u!aHR0cHM6Ly83NTY2YXZhLnNoYXJlcG9pbnQuY29tLzpmOi9nL0V2QVFpdnpLV2ZoT3ZJOHJiNm1UVEhjQjNHWkRRNlVhQ3VwNEhWeFpiR25mRkE + hasPassword | false + ``` + + + + diff --git a/docs/docs/cmd/spo/folder/folder-sharinglink-clear.mdx b/docs/docs/cmd/spo/folder/folder-sharinglink-clear.mdx new file mode 100644 index 00000000000..0ae03e22adf --- /dev/null +++ b/docs/docs/cmd/spo/folder/folder-sharinglink-clear.mdx @@ -0,0 +1,50 @@ +import Global from '/docs/cmd/_global.mdx'; + +# spo folder sharinglink clear + +Removes sharing links of a folder + +## Usage + +```sh +m365 spo folder sharinglink clear [options] +``` + +## Options + +```md definition-list +`-u, --webUrl ` +: The URL of the site where the folder is located. + +`--folderUrl [folderUrl]` +: The server- or site-relative decoded URL of the folder. Specify either `folderUrl` or `folderId` but not both. + +`--folderId [folderId]` +: The Unique ID (GUID) of the folder. Specify either `folderUrl` or `folderId` but not both. + +`-s, --scope [scope]` +: Scope of the sharing link. Possible options are: `anonymous`, `users` or `organization`. If not specified, all links will be removed. + +`-f, --force` +: Don't prompt for confirmation. +``` + + + +## Examples + +Removes all sharing links from a folder specified by id without prompting for confirmation. + +```sh +m365 spo folder sharinglink clear --webUrl https://contoso.sharepoint.com/sites/demo --folderId daebb04b-a773-4baa-b1d1-3625418e3234 --force +``` + +Removes sharing links of type anonymous from a folder specified by url with prompting for confirmation. + +```sh +m365 spo folder sharinglink clear --webUrl https://contoso.sharepoint.com/sites/demo --folderUrl '/sites/demo/Shared Documents/folder1' --scope anonymous +``` + +## Response + +The command won't return a response on success. diff --git a/docs/docs/cmd/spo/folder/folder-sharinglink-remove.mdx b/docs/docs/cmd/spo/folder/folder-sharinglink-remove.mdx new file mode 100644 index 00000000000..6c36ebeaaa8 --- /dev/null +++ b/docs/docs/cmd/spo/folder/folder-sharinglink-remove.mdx @@ -0,0 +1,50 @@ +import Global from '/docs/cmd/_global.mdx'; + +# spo folder sharinglink remove + +Removes a specific sharing link of a folder + +## Usage + +```sh +m365 spo folder sharinglink remove [options] +``` + +## Options + +```md definition-list +`-u, --webUrl ` +: The URL of the site where the folder is located. + +`--folderUrl [folderUrl]` +: The server- or site-relative decoded URL of the folder. Specify either `folderUrl` or `folderId` but not both. + +`--folderId [folderId]` +: The UniqueId (GUID) of the folder. Specify either `folderUrl` or `folderId` but not both. + +`-i, --id ` +: The ID of the sharing link. + +`-f, --force` +: Don't prompt for confirmation. +``` + + + +## Examples + +Removes a specific sharing link from a folder by id without prompting for confirmation. + +```sh +m365 spo folder sharinglink remove --webUrl https://contoso.sharepoint.com/sites/demo --folderId daebb04b-a773-4baa-b1d1-3625418e3234 --id c391b57d-5783-4c53-9236-cefb5c6ef323 --force +``` + +Removes a specific sharing link from a folder by url with prompting for confirmation. + +```sh +m365 spo folder sharinglink remove --webUrl https://contoso.sharepoint.com/sites/demo --folderUrl /sites/demo/shared%20documents/Folder --id c391b57d-5783-4c53-9236-cefb5c6ef323 +``` + +## Response + +The command won't return a response on success. diff --git a/docs/docs/cmd/spo/page/page-section-add.mdx b/docs/docs/cmd/spo/page/page-section-add.mdx index 2dd5d98fbb8..e555d000549 100644 --- a/docs/docs/cmd/spo/page/page-section-add.mdx +++ b/docs/docs/cmd/spo/page/page-section-add.mdx @@ -26,10 +26,46 @@ m365 spo page section add [options] : Order of the section to add. `--zoneEmphasis [zoneEmphasis]` -: Section background shading. Allowed values `None`, `Neutral`, `Soft`, `Strong` +: Section background shading. Allowed values `None`, `Neutral`, `Soft`, `Strong`, `Image`,`Gradient` `--isLayoutReflowOnTop` -: The position of the Vertical section for smaller screens. Applied only for Vertical section. +: The position of the Vertical section for smaller screens. Applied only for `Vertical` section. + +`--isCollapsibleSection` +: Set section to be collapsible. + +`--showDivider` +: Shows a divider line between sections. + +`--iconAlignment [iconAlignment]` +: Specifies the alignment of the expand/collapse icon. Sets `Left` alignment if not specified. + +`--isExpanded` +: Sets the default display state of the collapsible section. Sets `false` if not specified. + +`--gradientText [gradientText]` +: Sets the gradient setting of the background of a section. Required when `zoneEmphasis` is `Gradient`. + +`--imageUrl [imageUrl]` +: The background image URL. Required when `zoneEmphasis` is `Image`. + +`--imageHeight [imageHeight]` +: The height of the background image. Applied only when when `zoneEmphasis` is `Image`. Sets `955` value if not specified. + +`--imageWidth [imageWidth]` +: The width of the background image. Applied only when `zoneEmphasis` is `Image`. Sets `555` value if not specified. + +`--fillMode [fillMode]` +: The fill mode of the background image. Applied only when `zoneEmphasis` is `Image`. Possible values are `ScaleToFill`, `ScaleToFit`, `Tile`, `OriginalSize`. Sets `ScaleToFill` value if not specified. + +`--useLightText` +: Specifies whether to use light text for the background. Applied only when `zoneEmphasis` is `Image`. + +`--overlayColor [overlayColor]` +: The overlay color for the background in #RRGGBB format. Applied only when `zoneEmphasis` is `Image` or `Gradient`. Sets `#ffffff` value if not specified. + +`--overlayOpacity [overlayOpacity]` +: The overlay opacity for the background. Applied only when `zoneEmphasis` is `Image` or `Gradient`. Sets `60` value if not specified. ``` @@ -64,6 +100,25 @@ Add Vertical section with background shading to the modern page with adjusting t m365 spo page section add --pageName home.aspx --webUrl https://contoso.sharepoint.com/sites/newsletter --sectionTemplate Vertical --zoneEmphasis Neutral --isLayoutReflowOnTop ``` +Add OneColumn section as a collapsible section as expanded with icon alligned to the left + +```sh +m365 spo page section add --pageName home.aspx --webUrl https://contoso.sharepoint.com/sites/newsletter --sectionTemplate OneColumn --isCollapsibleSection --isExpanded --iconAlignment Left +``` + +Add TwoColumn section with Image background + +```sh +m365 spo page section add --pageName home.aspx --webUrl https://contoso.sharepoint.com/sites/newsletter --sectionTemplate TwoColumn --imageUrl "https://contoso.com/image.jpg" --zoneEmphasis Image --fillMode Tile +``` + +Add OneColumn section with Gradient background + +```sh +m365 spo page section add --pageName home.aspx --webUrl https://contoso.sharepoint.com/sites/newsletter --sectionTemplate TwoColumn --zoneEmphasis Gradient --gradientText "linear-gradient(72.44deg, #E6FBFE 0%, #EDDDFB 100%)" +``` + + ## Response The command won't return a response on success. diff --git a/docs/docs/cmd/spo/site/site-admin-add.mdx b/docs/docs/cmd/spo/site/site-admin-add.mdx new file mode 100644 index 00000000000..779ae796516 --- /dev/null +++ b/docs/docs/cmd/spo/site/site-admin-add.mdx @@ -0,0 +1,67 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# spo site admin add + +Adds a user or group as a site collection administrator + +## Usage + +```sh +m365 spo site admin add [options] +``` + +## Options + +```md definition-list +`-u, --siteUrl ` +: The URL of the SharePoint site + +`--userId [userId]` +: The ID of the user to add as a site collection admin + +`--userName [userName]` +: The user principal name of the user to add as a site collection admin + +`--groupId [groupId]` +: The ID of the Microsoft Entra ID group to add as a site collection admin + +`--groupName [groupName]` +: The name of the Microsoft Entra ID group to add as a site collection admin + +`--primary` +: If set, will add the user as primary site collection admin. The old primary site collection admin will be replaced and set as secondary site collection admin + +`--asAdmin` +: If specified, we will use the SharePoint admin center to execute the command +``` + + + +## Remarks + +:::info + +To use this command with the `--asAdmin` mode, you have to have permissions to access the tenant admin site. + +Without this parameter, you have to have site collection admin permissions for the requested site. + +::: + +## Examples + +Add user as primary site collection administrator + +```sh +m365 spo site admin add --siteUrl https://contoso.sharepoint.com --userId 600713c5-53c6-4f24-b454-3c35e22b2639 --primary +``` + +Adds group as secondary site collection administrator as SharePoint admin + +```sh +m365 spo site admin add --siteUrl https://contoso.sharepoint.com --groupName SP_Administrators --asAdmin +``` +## Response + +The command won't return a response on success. \ No newline at end of file diff --git a/docs/docs/cmd/spo/user/user-get.mdx b/docs/docs/cmd/spo/user/user-get.mdx index 42b69e884c5..419267caac6 100644 --- a/docs/docs/cmd/spo/user/user-get.mdx +++ b/docs/docs/cmd/spo/user/user-get.mdx @@ -16,41 +16,68 @@ m365 spo user get [options] ```md definition-list `-u, --webUrl ` -: URL of the web to get the user within +: URL of the web to get the user within. `-i, --id [id]` -: ID of the user to retrieve information for. Use either `email`, `id` or `loginName`, but not all. +: ID of the user to retrieve information for. Specify either `id`, `loginName`, `email`, `userName`, `entraGroupId`, or `entraGroupName`. `--email [email]` -: Email of the user to retrieve information for. Use either `email`, `id` or `loginName`, but not all. +: Email of the user to retrieve information for. Specify either `id`, `loginName`, `email`, `userName`, `entraGroupId`, or `entraGroupName`. `--loginName [loginName]` -: Login name of the user to retrieve information for. Use either `email`, `id` or `loginName`, but not all. +: Login name of the user to retrieve information for. Specify either `id`, `loginName`, `email`, `userName`, `entraGroupId`, or `entraGroupName`. + +`--userName [userName]` +: User's UPN (user principal name, eg. megan.bowen@contoso.com). Specify either `id`, `loginName`, `email`, `userName`, `entraGroupId`, or `entraGroupName`. + +`--entraGroupId [entraGroupId]` +: The object ID of the Microsoft Entra group. Specify either `id`, `loginName`, `email`, `userName`, `entraGroupId`, or `entraGroupName`. + +`--entraGroupName [entraGroupName]` +: The name of the Microsoft Entra group. Specify either `id`, `loginName`, `email`, `userName`, `entraGroupId`, or `entraGroupName`. ``` ## Examples -Get user by email for a web +Get user by email for a web. ```sh m365 spo user get --webUrl https://contoso.sharepoint.com/sites/project-x --email john.doe@mytenant.onmicrosoft.com ``` -Get user by ID for a web +Get user by ID for a web. ```sh m365 spo user get --webUrl https://contoso.sharepoint.com/sites/project-x --id 6 ``` -Get user by login name for a web +Get user by login name for a web. ```sh m365 spo user get --webUrl https://contoso.sharepoint.com/sites/project-x --loginName "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com" ``` -Get the currently logged-in user +Get user by user's UPN for a web. + +```sh +m365 spo user get --webUrl https://contoso.sharepoint.com/sites/project-x --userName "john.doe@mytenant.onmicrosoft.com" +``` + +Get user by entraGroupId for a web. + +```sh +m365 spo user get --webUrl https://contoso.sharepoint.com/sites/project-x --entraGroupId f832a493-de73-4fef-87ed-8c6fffd91be6 +``` + +Get user by entraGroupName for a web. + +```sh +m365 spo user get --webUrl https://contoso.sharepoint.com/sites/project-x --entraGroupName "Test Members" +``` + +Get the currently logged-in user. ```sh m365 spo user get --webUrl https://contoso.sharepoint.com/sites/project-x @@ -134,4 +161,3 @@ m365 spo user get --webUrl https://contoso.sharepoint.com/sites/project-x - diff --git a/docs/docs/cmd/spp/contentcenter/contentcenter-list.mdx b/docs/docs/cmd/spp/contentcenter/contentcenter-list.mdx new file mode 100644 index 00000000000..1c48b4cbe85 --- /dev/null +++ b/docs/docs/cmd/spp/contentcenter/contentcenter-list.mdx @@ -0,0 +1,289 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# spp contentcenter list + +Gets information about the SharePoint Premium content centers + +## Usage + +```sh +spp contentcenter list +``` + +## Options + +No options required + + + +## Remarks + +:::info + +To use this command you have to have permissions to access the tenant admin site. + +::: + +## Examples + +Gets information about the SharePoint Premium content centers + +```sh +m365 spp contentcenter list +``` + +## Response + + + + + ```json + [ + { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SiteProperties", + "_ObjectIdentity_": "855b40a1-6024-9000-87b1-7d412d935b3c|908bed80-a04a-4433-b4a0-883d9847d110:dc109ffd-4298-487e-9cbc-6b9b1a2cd3e2\\\nSiteProperties\\\nhttps%3a%2f%2fcontoso.sharepoint.com%2fsites%2fContentCentre", + "AllowDownloadingNonWebViewableFiles": false, + "AllowEditing": false, + "AllowSelfServiceUpgrade": true, + "AnonymousLinkExpirationInDays": 0, + "ApplyToExistingDocumentLibraries": false, + "ApplyToNewDocumentLibraries": false, + "ArchiveStatus": "NotArchived", + "AuthContextStrength": null, + "AuthenticationContextLimitedAccess": false, + "AuthenticationContextName": null, + "AverageResourceUsage": 0, + "BlockDownloadLinksFileType": 0, + "BlockDownloadMicrosoft365GroupIds": null, + "BlockDownloadPolicy": false, + "BlockDownloadPolicyFileTypeIds": null, + "BlockGuestsAsSiteAdmin": 0, + "BonusDiskQuota": 0, + "ClearRestrictedAccessControl": false, + "CommentsOnSitePagesDisabled": false, + "CompatibilityLevel": 15, + "ConditionalAccessPolicy": 0, + "CurrentResourceUsage": 0, + "DefaultLinkPermission": 0, + "DefaultLinkToExistingAccess": false, + "DefaultLinkToExistingAccessReset": false, + "DefaultShareLinkRole": 0, + "DefaultShareLinkScope": 0, + "DefaultSharingLinkType": 0, + "DenyAddAndCustomizePages": 2, + "Description": null, + "DisableAppViews": 0, + "DisableCompanyWideSharingLinks": 0, + "DisableFlows": 0, + "EnableAutoExpirationVersionTrim": false, + "ExcludeBlockDownloadPolicySiteOwners": false, + "ExcludeBlockDownloadSharePointGroups": [], + "ExcludedBlockDownloadGroupIds": [], + "ExpireVersionsAfterDays": 0, + "ExternalUserExpirationInDays": 0, + "GroupId": "/Guid(00000000-0000-0000-0000-000000000000)/", + "GroupOwnerLoginName": null, + "HasHolds": false, + "HubSiteId": "/Guid(00000000-0000-0000-0000-000000000000)/", + "IBMode": null, + "IBSegments": null, + "IBSegmentsToAdd": null, + "IBSegmentsToRemove": null, + "InheritVersionPolicyFromTenant": false, + "IsGroupOwnerSiteAdmin": false, + "IsHubSite": false, + "IsTeamsChannelConnected": false, + "IsTeamsConnected": false, + "LastContentModifiedDate": "/Date(2024,6,22,2,59,8,0)/", + "Lcid": 1045, + "LimitedAccessFileType": 0, + "ListsShowHeaderAndNavigation": false, + "LockIssue": null, + "LockReason": 0, + "LockState": "Unlock", + "LoopDefaultSharingLinkRole": 0, + "LoopDefaultSharingLinkScope": 0, + "MajorVersionLimit": 0, + "MajorWithMinorVersionsLimit": 0, + "MediaTranscription": 0, + "OverrideBlockUserInfoVisibility": 0, + "OverrideSharingCapability": false, + "OverrideTenantAnonymousLinkExpirationPolicy": false, + "OverrideTenantExternalUserExpirationPolicy": false, + "Owner": "user@contoso.onmicrosoft.com", + "OwnerEmail": null, + "OwnerLoginName": null, + "OwnerName": null, + "PWAEnabled": 1, + "ReadOnlyAccessPolicy": false, + "ReadOnlyForBlockDownloadPolicy": false, + "ReadOnlyForUnmanagedDevices": false, + "RelatedGroupId": "/Guid(00000000-0000-0000-0000-000000000000)/", + "RequestFilesLinkEnabled": false, + "RequestFilesLinkExpirationInDays": 0, + "RestrictContentOrgWideSearch": false, + "RestrictedAccessControl": false, + "RestrictedAccessControlGroups": null, + "RestrictedAccessControlGroupsToAdd": null, + "RestrictedAccessControlGroupsToRemove": null, + "RestrictedToRegion": 3, + "SandboxedCodeActivationCapability": 0, + "SensitivityLabel": "/Guid(00000000-0000-0000-0000-000000000000)/", + "SensitivityLabel2": null, + "SetOwnerWithoutUpdatingSecondaryAdmin": false, + "SharingAllowedDomainList": null, + "SharingBlockedDomainList": null, + "SharingCapability": 0, + "SharingDomainRestrictionMode": 0, + "SharingLockDownCanBeCleared": false, + "SharingLockDownEnabled": false, + "ShowPeoplePickerSuggestionsForGuestUsers": false, + "SiteDefinedSharingCapability": 0, + "SiteId": "/Guid(5fd4f5b5-38e6-423f-a1c6-96d2f78eeba7)/", + "SocialBarOnSitePagesDisabled": false, + "Status": "Active", + "StorageMaximumLevel": 26214400, + "StorageQuotaType": null, + "StorageUsage": 1, + "StorageWarningLevel": 25574400, + "TeamsChannelType": 0, + "Template": "CONTENTCTR#0", + "TimeZoneId": 13, + "Title": "ContentCentre", + "TitleTranslations": null, + "Url": "https://contoso.sharepoint.com/sites/ContentCentre", + "UserCodeMaximumLevel": 0, + "UserCodeWarningLevel": 0, + "WebsCount": 0 + } + ] + ``` + + + + + ```text + Title Url + ------------- -------------------------------------------------- + ContentCentre https://contoso.sharepoint.com/sites/ContentCentre + ``` + + + + + ```csv + _ObjectType_,_ObjectIdentity_,AllowDownloadingNonWebViewableFiles,AllowEditing,AllowSelfServiceUpgrade,AnonymousLinkExpirationInDays,ApplyToExistingDocumentLibraries,ApplyToNewDocumentLibraries,ArchiveStatus,AuthContextStrength,AuthenticationContextLimitedAccess,AuthenticationContextName,AverageResourceUsage,BlockDownloadLinksFileType,BlockDownloadMicrosoft365GroupIds,BlockDownloadPolicy,BlockDownloadPolicyFileTypeIds,BlockGuestsAsSiteAdmin,BonusDiskQuota,ClearRestrictedAccessControl,CommentsOnSitePagesDisabled,CompatibilityLevel,ConditionalAccessPolicy,CurrentResourceUsage,DefaultLinkPermission,DefaultLinkToExistingAccess,DefaultLinkToExistingAccessReset,DefaultShareLinkRole,DefaultShareLinkScope,DefaultSharingLinkType,DenyAddAndCustomizePages,Description,DisableAppViews,DisableCompanyWideSharingLinks,DisableFlows,EnableAutoExpirationVersionTrim,ExcludeBlockDownloadPolicySiteOwners,ExpireVersionsAfterDays,ExternalUserExpirationInDays,GroupId,GroupOwnerLoginName,HasHolds,HubSiteId,IBMode,IBSegments,IBSegmentsToAdd,IBSegmentsToRemove,InheritVersionPolicyFromTenant,IsGroupOwnerSiteAdmin,IsHubSite,IsTeamsChannelConnected,IsTeamsConnected,LastContentModifiedDate,Lcid,LimitedAccessFileType,ListsShowHeaderAndNavigation,LockIssue,LockReason,LockState,LoopDefaultSharingLinkRole,LoopDefaultSharingLinkScope,MajorVersionLimit,MajorWithMinorVersionsLimit,MediaTranscription,OverrideBlockUserInfoVisibility,OverrideSharingCapability,OverrideTenantAnonymousLinkExpirationPolicy,OverrideTenantExternalUserExpirationPolicy,Owner,OwnerEmail,OwnerLoginName,OwnerName,PWAEnabled,ReadOnlyAccessPolicy,ReadOnlyForBlockDownloadPolicy,ReadOnlyForUnmanagedDevices,RelatedGroupId,RequestFilesLinkEnabled,RequestFilesLinkExpirationInDays,RestrictContentOrgWideSearch,RestrictedAccessControl,RestrictedAccessControlGroups,RestrictedAccessControlGroupsToAdd,RestrictedAccessControlGroupsToRemove,RestrictedToRegion,SandboxedCodeActivationCapability,SensitivityLabel,SensitivityLabel2,SetOwnerWithoutUpdatingSecondaryAdmin,SharingAllowedDomainList,SharingBlockedDomainList,SharingCapability,SharingDomainRestrictionMode,SharingLockDownCanBeCleared,SharingLockDownEnabled,ShowPeoplePickerSuggestionsForGuestUsers,SiteDefinedSharingCapability,SiteId,SocialBarOnSitePagesDisabled,Status,StorageMaximumLevel,StorageQuotaType,StorageUsage,StorageWarningLevel,TeamsChannelType,Template,TimeZoneId,Title,TitleTranslations,Url,UserCodeMaximumLevel,UserCodeWarningLevel,WebsCount + Microsoft.Online.SharePoint.TenantAdministration.SiteProperties,"035c40a1-703b-9000-87b1-77f1c297d218|908bed80-a04a-4433-b4a0-883d9847d110:dc109ffd-4298-487e-9cbc-6b9b1a2cd3e2 + SiteProperties + https%3a%2f%2fcontoso.sharepoint.com%2fsites%2fContentCentre",,,1,0,,,NotArchived,,,,0,0,,,,0,0,,,15,0,0,0,,,0,0,0,2,,0,0,0,,,0,0,/Guid(00000000-0000-0000-0000-000000000000)/,,,/Guid(00000000-0000-0000-0000-000000000000)/,,,,,,,,,,"/Date(2024,6,22,2,59,8,0)/",1045,0,,,0,Unlock,0,0,0,0,0,0,,,,user@contoso.onmicrosoft.com,,,,1,,,,/Guid(00000000-0000-0000-0000-000000000000)/,,0,,,,,,3,0,/Guid(00000000-0000-0000-0000-000000000000)/,,,,,0,0,,,,0,/Guid(5fd4f5b5-38e6-423f-a1c6-96d2f78eeba7)/,,Active,26214400,,1,25574400,0,CONTENTCTR#0,13,ContentCentre,,https://contoso.sharepoint.com/sites/ContentCentre,0,0,0 + ``` + + + + + ```md + # spp contentcenter list + + Date: 27/07/2024 + + ## ContentCentre (https://contoso.sharepoint.com/sites/ContentCentre) + + Property | Value + ---------|------- + \_ObjectType\_ | Microsoft.Online.SharePoint.TenantAdministration.SiteProperties + \_ObjectIdentity\_ | 0a5c40a1-2068-9000-8001-70e27f49586f\|908bed80-a04a-4433-b4a0-883d9847d110:dc109ffd-4298-487e-9cbc-6b9b1a2cd3e2
SiteProperties
https%3a%2f%2fcontoso.sharepoint.com%2fsites%2fContentCentre + AllowDownloadingNonWebViewableFiles | false + AllowEditing | false + AllowSelfServiceUpgrade | true + AnonymousLinkExpirationInDays | 0 + ApplyToExistingDocumentLibraries | false + ApplyToNewDocumentLibraries | false + ArchiveStatus | NotArchived + AuthenticationContextLimitedAccess | false + AverageResourceUsage | 0 + BlockDownloadLinksFileType | 0 + BlockDownloadPolicy | false + BlockGuestsAsSiteAdmin | 0 + BonusDiskQuota | 0 + ClearRestrictedAccessControl | false + CommentsOnSitePagesDisabled | false + CompatibilityLevel | 15 + ConditionalAccessPolicy | 0 + CurrentResourceUsage | 0 + DefaultLinkPermission | 0 + DefaultLinkToExistingAccess | false + DefaultLinkToExistingAccessReset | false + DefaultShareLinkRole | 0 + DefaultShareLinkScope | 0 + DefaultSharingLinkType | 0 + DenyAddAndCustomizePages | 2 + DisableAppViews | 0 + DisableCompanyWideSharingLinks | 0 + DisableFlows | 0 + EnableAutoExpirationVersionTrim | false + ExcludeBlockDownloadPolicySiteOwners | false + ExpireVersionsAfterDays | 0 + ExternalUserExpirationInDays | 0 + GroupId | /Guid(00000000-0000-0000-0000-000000000000)/ + HasHolds | false + HubSiteId | /Guid(00000000-0000-0000-0000-000000000000)/ + InheritVersionPolicyFromTenant | false + IsGroupOwnerSiteAdmin | false + IsHubSite | false + IsTeamsChannelConnected | false + IsTeamsConnected | false + LastContentModifiedDate | /Date(2024,6,22,2,59,8,0)/ + Lcid | 1045 + LimitedAccessFileType | 0 + ListsShowHeaderAndNavigation | false + LockReason | 0 + LockState | Unlock + LoopDefaultSharingLinkRole | 0 + LoopDefaultSharingLinkScope | 0 + MajorVersionLimit | 0 + MajorWithMinorVersionsLimit | 0 + MediaTranscription | 0 + OverrideBlockUserInfoVisibility | 0 + OverrideSharingCapability | false + OverrideTenantAnonymousLinkExpirationPolicy | false + OverrideTenantExternalUserExpirationPolicy | false + Owner | user@contoso.onmicrosoft.com + PWAEnabled | 1 + ReadOnlyAccessPolicy | false + ReadOnlyForBlockDownloadPolicy | false + ReadOnlyForUnmanagedDevices | false + RelatedGroupId | /Guid(00000000-0000-0000-0000-000000000000)/ + RequestFilesLinkEnabled | false + RequestFilesLinkExpirationInDays | 0 + RestrictContentOrgWideSearch | false + RestrictedAccessControl | false + RestrictedToRegion | 3 + SandboxedCodeActivationCapability | 0 + SensitivityLabel | /Guid(00000000-0000-0000-0000-000000000000)/ + SetOwnerWithoutUpdatingSecondaryAdmin | false + SharingCapability | 0 + SharingDomainRestrictionMode | 0 + SharingLockDownCanBeCleared | false + SharingLockDownEnabled | false + ShowPeoplePickerSuggestionsForGuestUsers | false + SiteDefinedSharingCapability | 0 + SiteId | /Guid(5fd4f5b5-38e6-423f-a1c6-96d2f78eeba7)/ + SocialBarOnSitePagesDisabled | false + Status | Active + StorageMaximumLevel | 26214400 + StorageUsage | 1 + StorageWarningLevel | 25574400 + TeamsChannelType | 0 + Template | CONTENTCTR#0 + TimeZoneId | 13 + Title | ContentCentre + Url | https://contoso.sharepoint.com/sites/ContentCentre + UserCodeMaximumLevel | 0 + UserCodeWarningLevel | 0 + WebsCount | 0 + ``` + +
+
\ No newline at end of file diff --git a/docs/docs/cmd/teams/message/message-remove.mdx b/docs/docs/cmd/teams/message/message-remove.mdx index 114b4977600..f9567fd77eb 100644 --- a/docs/docs/cmd/teams/message/message-remove.mdx +++ b/docs/docs/cmd/teams/message/message-remove.mdx @@ -36,7 +36,6 @@ m365 teams message remove [options] ## Remarks -You can only remove Microsoft Teams messages that you created yourself. :::info @@ -44,6 +43,8 @@ This command does only support delegated permissions. ::: +You can only remove Microsoft Teams messages that you created yourself. + ## Examples Remove a message by using IDs diff --git a/docs/docs/cmd/teams/message/message-restore.mdx b/docs/docs/cmd/teams/message/message-restore.mdx new file mode 100644 index 00000000000..b203ec681ec --- /dev/null +++ b/docs/docs/cmd/teams/message/message-restore.mdx @@ -0,0 +1,62 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# teams message restore + +Restores a deleted message from a channel in a Microsoft Teams team + +## Usage + +```sh +m365 teams message restore [options] +``` + +## Options + +```md definition-list +`--teamId [teamId]` +: ID of the Microsoft Teams team. Specify either `teamId` or `teamName` but not both. + +`--teamName [teamName]` +: Name of the Microsoft Teams team. Specify either `teamId` or `teamName` but not both. + +`--channelId [channelId]` +: Channel ID of the Microsoft Teams team. Specify either `channelId` or `channelName` but not both. + +`--channelName [channelName]` +: Channel name of the Microsoft Teams team. Specify either `channelId` or `channelName` but not both. + +`-i, --id ` +: The ID of the Teams message. +``` + + + +## Remarks + +:::info + +This command does only support delegated permissions. + +::: + +You can only restore Microsoft Teams messages that you created yourself. + +## Examples + +Restore a deleted message by using IDs + +```sh +m365 teams message restore --teamId 5f5d7b71-1161-44d8-bcc1-3da710eb4171 --channelId 19:4a95f7d8db4c4e7fae857bcebe0623e6@thread.tacv2 --id 1540747442203 +``` + +Restore a deleted message by using display names + +```sh +m365 teams message restore --teamName Marketing --channelName Branding --id 1540747442203 +``` + +## Response + +The command won't return a response on success. diff --git a/docs/docs/index.mdx b/docs/docs/index.mdx index 04d5d0a940b..5ebb5d5b0a8 100644 --- a/docs/docs/index.mdx +++ b/docs/docs/index.mdx @@ -27,7 +27,13 @@ yarn global add @pnp/cli-microsoft365 ## Getting started -Start managing the settings of your Microsoft 365 tenant by logging in to it, using the `login` command, for example: +Start, by configuring CLI for Microsoft 365 to your preferences. Configuration includes specifying an Entra app registration that the CLI should use. You can choose between using an existing app registration or creating a new one. To configure the CLI, run the [setup](./cmd/setup) command: + +```sh +m365 setup +``` + +After configuring the CLI, you can start using it. Start managing the settings of your Microsoft 365 tenant by logging in to it, using the [login](./cmd/login) command, for example: ```sh m365 login diff --git a/docs/docs/sample-scripts/spo/copy-files-to-another-library/assets/sample.json b/docs/docs/sample-scripts/spo/copy-files-to-another-library/assets/sample.json index c4537a0c8b4..1f4cc55a29c 100644 --- a/docs/docs/sample-scripts/spo/copy-files-to-another-library/assets/sample.json +++ b/docs/docs/sample-scripts/spo/copy-files-to-another-library/assets/sample.json @@ -22,7 +22,7 @@ "metadata": [ { "key": "CLI-FOR-MICROSOFT365", - "value": "6.3.0" + "value": "8.0.0" } ], "thumbnails": [ diff --git a/docs/docs/sample-scripts/spo/copy-files-to-another-library/index.mdx b/docs/docs/sample-scripts/spo/copy-files-to-another-library/index.mdx index 7f86be8a68e..666b5117505 100644 --- a/docs/docs/sample-scripts/spo/copy-files-to-another-library/index.mdx +++ b/docs/docs/sample-scripts/spo/copy-files-to-another-library/index.mdx @@ -40,7 +40,7 @@ This script shows how you can use the CLI to: } } - $allFiles = m365 spo file list --webUrl "$tenatUrl$sourceSite" --folder $folder.substring(1) --output 'json' + $allFiles = m365 spo file list --webUrl "$tenatUrl$sourceSite" --folderUrl $folder.substring(1) --output 'json' $allFiles = $allFiles | ConvertFrom-Json foreach ($file in $allFiles) { $fileUrl = $file.ServerRelativeUrl -replace $sourceSite, '' @@ -57,17 +57,17 @@ This script shows how you can use the CLI to: [Parameter(Mandatory = $True)][bool] $copyKeepingSameFolderStructure) { if ($copyKeepingSameFolderStructure) { Write-host "Copy the same structure" - + $allFolders = m365 spo folder list --webUrl "$tenatUrl$sourceSite" --parentFolderUrl "/$sourceLibrary" --output 'json' $allFolders = $allFolders | ConvertFrom-Json foreach ($folder in $allFolders) { if ($folder.Name -ne 'Forms') { $folderName = $folder.Name - m365 spo folder copy --webUrl "$tenatUrl$sourceSite" --sourceUrl "/$sourceLibrary/$folderName" --targetUrl "$targetSite/$targetLibrary" --allowSchemaMismatch + m365 spo folder copy --webUrl "$tenatUrl$sourceSite" --sourceUrl "/$sourceLibrary/$folderName" --targetUrl "$targetSite/$targetLibrary" } } - - $allFiles = m365 spo file list --webUrl "$tenatUrl$sourceSite" --folder $sourceLibrary --output 'json' + + $allFiles = m365 spo file list --webUrl "$tenatUrl$sourceSite" --folderUrl $sourceLibrary --output 'json' $allFiles = $allFiles | ConvertFrom-Json foreach ($file in $allFiles) { $fileUrl = $file.ServerRelativeUrl -replace $sourceSite, '' @@ -88,9 +88,9 @@ This script shows how you can use the CLI to: } $tenatUrl = 'https://contoso.sharepoint.com' - $sourceLibrary = 'Shared%20Documents' + $sourceLibrary = 'Shared Documents' $sourceSite = '/sites/FromSite' - $targetLibrary = 'Shared%20Documents' + $targetLibrary = 'Shared Documents' $targetSite = '/sites/ToSite' $copyKeepingSameFolderStructure = $false Copy-LibraryToLibrary -tenatUrl $tenatUrl -sourceLibrary $sourceLibrary -sourceSite $sourceSite -targetLibrary $targetLibrary -targetSite $targetSite -copyKeepingSameFolderStructure $copyKeepingSameFolderStructure diff --git a/docs/docs/sample-scripts/teams/recognize-most-active-users-specific-team/index.mdx b/docs/docs/sample-scripts/teams/recognize-most-active-users-specific-team/index.mdx index 8833a527b32..0c3b65ca72e 100644 --- a/docs/docs/sample-scripts/teams/recognize-most-active-users-specific-team/index.mdx +++ b/docs/docs/sample-scripts/teams/recognize-most-active-users-specific-team/index.mdx @@ -89,7 +89,7 @@ Retrieves all activities for a specific Microsoft Teams Team and shares the top #Score per user foreach ($teamsUser in $resultsGrouped) { - $user = m365 entra user get --id $teamsUser.UserId --output json | ConvertFrom-Json + $user = m365 entra user get --id $teamsUser.Name --output json | ConvertFrom-Json # Count points # Each post is two points, 1 extra point awarded for each Post with Subject diff --git a/docs/docs/v9-upgrade-guidance.mdx b/docs/docs/v9-upgrade-guidance.mdx new file mode 100644 index 00000000000..fed8aae99b5 --- /dev/null +++ b/docs/docs/v9-upgrade-guidance.mdx @@ -0,0 +1,13 @@ +# v9 Upgrade Guidance + +The v9 of CLI for Microsoft 365 introduces breaking change in default login method. To help you upgrade to the latest version of CLI for Microsoft 365, we've listed this change along with any actions you may need to take. + +## CLI + +### Updated login command default behavior + +When running `m365 login` CLI will now first check if you defined the `appId` which is now required to your own single tenant Entra app registration. + +#### What action do I need to take? + +`appId` may be defined in several ways: as an option passed to the `login` command, as a `clientId` CLI setting, or as an environment variable. If you have not defined the `appId` in any of these ways, you will need to do so to continue using the CLI in your scripts. diff --git a/docs/package-lock.json b/docs/package-lock.json index ea231492a05..b3f145c7c21 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -21,7 +21,7 @@ "prism-react-renderer": "^2.3.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "sass": "^1.77.6", + "sass": "^1.77.8", "unist-util-visit": "^5.0.0" }, "devDependencies": { @@ -29,7 +29,7 @@ "@docusaurus/tsconfig": "^3.4.0", "asciinema-player": "^3.8.0", "docusaurus-node-polyfills": "^1.0.0", - "typescript": "^5.5.3" + "typescript": "^5.5.4" }, "engines": { "node": ">=18.0" @@ -14565,9 +14565,9 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sass": { - "version": "1.77.6", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.6.tgz", - "integrity": "sha512-ByXE1oLD79GVq9Ht1PeHWCPMPB8XHpBuz1r85oByKHjZY6qV6rWnQovQzXJXuQ/XyE1Oj3iPk3lo28uzaRA2/Q==", + "version": "1.77.8", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz", + "integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==", "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -15793,9 +15793,9 @@ } }, "node_modules/typescript": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", - "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/docs/package.json b/docs/package.json index d1e120285d6..d3a4e22e23a 100644 --- a/docs/package.json +++ b/docs/package.json @@ -28,7 +28,7 @@ "prism-react-renderer": "^2.3.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "sass": "^1.77.6", + "sass": "^1.77.8", "unist-util-visit": "^5.0.0" }, "devDependencies": { @@ -36,7 +36,7 @@ "@docusaurus/tsconfig": "^3.4.0", "asciinema-player": "^3.8.0", "docusaurus-node-polyfills": "^1.0.0", - "typescript": "^5.5.3" + "typescript": "^5.5.4" }, "browserslist": { "production": [ diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 5265404e492..33739b0495a 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -3,6 +3,7 @@ import type { SidebarsConfig } from '@docusaurus/plugin-content-docs'; const sidebars: SidebarsConfig = { home: [ 'index', + 'v9-upgrade-guidance', 'v8-upgrade-guidance', 'v7-upgrade-guidance', 'v6-upgrade-guidance', @@ -331,6 +332,11 @@ const sidebars: SidebarsConfig = { type: 'doc', label: 'enterpriseapp list', id: 'cmd/entra/enterpriseapp/enterpriseapp-list' + }, + { + type: 'doc', + label: 'enterpriseapp remove', + id: 'cmd/entra/enterpriseapp/enterpriseapp-remove' } ] }, @@ -356,6 +362,11 @@ const sidebars: SidebarsConfig = { label: 'group remove', id: 'cmd/entra/group/group-remove' }, + { + type: 'doc', + label: 'group set', + id: 'cmd/entra/group/group-set' + }, { type: 'doc', label: 'group user add', @@ -1079,6 +1090,11 @@ const sidebars: SidebarsConfig = { 'OneNote (onenote)': [ { notebook: [ + { + type: 'doc', + label: 'notebook add', + id: 'cmd/onenote/notebook/notebook-add' + }, { type: 'doc', label: 'notebook list', @@ -1550,6 +1566,20 @@ const sidebars: SidebarsConfig = { } ] }, + { + recyclebinitem: [ + { + type: 'doc', + label: 'recyclebinitem list', + id: 'cmd/flow/recyclebinitem/recyclebinitem-list' + }, + { + type: 'doc', + label: 'recyclebinitem restore', + id: 'cmd/flow/recyclebinitem/recyclebinitem-restore' + } + ] + }, { run: [ { @@ -2568,6 +2598,16 @@ const sidebars: SidebarsConfig = { label: 'folder roleinheritance reset', id: 'cmd/spo/folder/folder-roleinheritance-reset' }, + { + type: 'doc', + label: 'folder sharinglink add', + id: 'cmd/spo/folder/folder-sharinglink-add' + }, + { + type: 'doc', + label: 'folder sharinglink clear', + id: 'cmd/spo/folder/folder-sharinglink-clear' + }, { type: 'doc', label: 'folder sharinglink get', @@ -2577,6 +2617,11 @@ const sidebars: SidebarsConfig = { type: 'doc', label: 'folder sharinglink list', id: 'cmd/spo/folder/folder-sharinglink-list' + }, + { + type: 'doc', + label: 'folder sharinglink remove', + id: 'cmd/spo/folder/folder-sharinglink-remove' } ] }, @@ -3331,11 +3376,6 @@ const sidebars: SidebarsConfig = { label: 'site add', id: 'cmd/spo/site/site-add' }, - { - type: 'doc', - label: 'site admin list', - id: 'cmd/spo/site/site-admin-list' - }, { type: 'doc', label: 'site ensure', @@ -3371,6 +3411,16 @@ const sidebars: SidebarsConfig = { label: 'site set', id: 'cmd/spo/site/site-set' }, + { + type: 'doc', + label: 'site admin add', + id: 'cmd/spo/site/site-admin-add' + }, + { + type: 'doc', + label: 'site admin list', + id: 'cmd/spo/site/site-admin-list' + }, { type: 'doc', label: 'site appcatalog add', @@ -3881,6 +3931,19 @@ const sidebars: SidebarsConfig = { } ] }, + { + 'SharePoint Premium (spp)': [ + { + contentcenter: [ + { + type: 'doc', + label: 'contentcenter list', + id: 'cmd/spp/contentcenter/contentcenter-list' + } + ] + } + ] + }, { 'Teams (teams)': [ { @@ -4107,6 +4170,11 @@ const sidebars: SidebarsConfig = { label: 'message remove', id: 'cmd/teams/message/message-remove' }, + { + type: 'doc', + label: 'message restore', + id: 'cmd/teams/message/message-restore' + }, { type: 'doc', label: 'message send', diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index cdb81de0b69..f6576cf6ed1 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,45 +1,44 @@ { "name": "@pnp/cli-microsoft365", - "version": "8.1.0", + "version": "9.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pnp/cli-microsoft365", - "version": "8.1.0", + "version": "9.1.0", "license": "MIT", "dependencies": { - "@azure/msal-common": "^14.11.0", - "@azure/msal-node": "^2.10.0", - "@inquirer/confirm": "^3.1.12", - "@inquirer/input": "^2.1.12", - "@inquirer/select": "^2.3.8", - "@xmldom/xmldom": "^0.8.10", - "adaptive-expressions": "^4.22.3", + "@azure/msal-common": "^14.14.2", + "@azure/msal-node": "^2.13.1", + "@inquirer/confirm": "^3.2.0", + "@inquirer/input": "^2.3.0", + "@inquirer/select": "^2.5.0", + "@xmldom/xmldom": "^0.9.2", + "adaptive-expressions": "^4.23.0", "adaptivecards": "^3.0.4", "adaptivecards-templating": "^2.3.1", - "adm-zip": "^0.5.14", - "applicationinsights": "^2.9.5", - "axios": "^1.7.2", + "adm-zip": "^0.5.16", + "applicationinsights": "^2.9.6", + "axios": "^1.7.7", "chalk": "^5.3.0", "clipboardy": "^4.0.0", - "configstore": "^6.0.0", - "csv-stringify": "^6.5.0", + "configstore": "^7.0.0", + "csv-stringify": "^6.5.1", "easy-table": "^1.2.0", "jmespath": "^0.16.0", "json-to-ast": "^2.1.0", - "minimist": "^1.2.8", "node-forge": "^1.3.1", "omelette": "^0.4.17", "open": "^10.1.0", - "semver": "^7.6.2", + "semver": "^7.6.3", "strip-json-comments": "^5.0.1", - "typescript": "^5.5.3", - "update-notifier": "^7.0.0", - "uuid": "^9.0.1", - "yaml": "^2.4.5", + "typescript": "^5.5.4", + "update-notifier": "^7.3.0", + "uuid": "^10.0.0", + "yaml": "^2.5.1", "yargs-parser": "^21.1.1", - "zod": "^3.22.4" + "zod": "^3.23.8" }, "bin": { "m365": "dist/index.js", @@ -54,25 +53,24 @@ "@types/jmespath": "^0.15.2", "@types/json-schema": "^7.0.15", "@types/json-to-ast": "^2.1.4", - "@types/minimist": "^1.2.5", "@types/mocha": "^10.0.7", - "@types/node": "^20.14.9", + "@types/node": "^20.16.5", "@types/node-forge": "^1.3.11", "@types/omelette": "^0.4.4", "@types/semver": "^7.5.8", "@types/sinon": "^17.0.3", "@types/update-notifier": "^6.0.8", - "@types/uuid": "^9.0.8", + "@types/uuid": "^10.0.0", "@types/yargs-parser": "^21.0.3", - "@typescript-eslint/eslint-plugin": "^7.8.0", - "@typescript-eslint/parser": "^7.15.0", - "c8": "^9.1.0", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "c8": "^10.1.2", "eslint": "^8.57.0", "eslint-plugin-cli-microsoft365": "file:eslint-rules", - "eslint-plugin-mocha": "^10.4.3", - "mocha": "^10.5.2", - "rimraf": "^5.0.7", - "sinon": "^17.0.2", + "eslint-plugin-mocha": "^10.5.0", + "mocha": "^10.7.3", + "rimraf": "^6.0.1", + "sinon": "^18.0.0", "source-map-support": "^0.5.21" } }, @@ -120,55 +118,45 @@ } }, "node_modules/@azure/abort-controller": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", - "integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", "dependencies": { - "tslib": "^2.2.0" + "tslib": "^2.6.2" }, "engines": { - "node": ">=12.0.0" + "node": ">=18.0.0" } }, "node_modules/@azure/core-auth": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.5.0.tgz", - "integrity": "sha512-udzoBuYG1VBoHVohDTrvKjyzel34zt77Bhp7dQntVGGD0ehVq48owENbBG8fIgkHRNUBQH5k1r0hpoMu5L8+kw==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.7.2.tgz", + "integrity": "sha512-Igm/S3fDYmnMq1uKS38Ae1/m37B3zigdlZw+kocwEhh5GjyKjPrXKO2J6rzpC1wAxrNil/jX9BJRqBshyjnF3g==", "dependencies": { - "@azure/abort-controller": "^1.0.0", + "@azure/abort-controller": "^2.0.0", "@azure/core-util": "^1.1.0", - "tslib": "^2.2.0" + "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=18.0.0" } }, "node_modules/@azure/core-rest-pipeline": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.10.1.tgz", - "integrity": "sha512-Kji9k6TOFRDB5ZMTw8qUf2IJ+CeJtsuMdAHox9eqpTf1cefiNMpzrfnF6sINEBZJsaVaWgQ0o48B6kcUH68niA==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.16.3.tgz", + "integrity": "sha512-VxLk4AHLyqcHsfKe4MZ6IQ+D+ShuByy+RfStKfSjxJoL3WBWq17VNmrz8aT8etKzqc2nAeIyLxScjpzsS4fz8w==", "dependencies": { - "@azure/abort-controller": "^1.0.0", + "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.4.0", "@azure/core-tracing": "^1.0.1", - "@azure/core-util": "^1.0.0", + "@azure/core-util": "^1.9.0", "@azure/logger": "^1.0.0", - "form-data": "^4.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "tslib": "^2.2.0", - "uuid": "^8.3.0" + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@azure/core-rest-pipeline/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" + "node": ">=18.0.0" } }, "node_modules/@azure/core-tracing": { @@ -183,15 +171,15 @@ } }, "node_modules/@azure/core-util": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.2.0.tgz", - "integrity": "sha512-ffGIw+Qs8bNKNLxz5UPkz4/VBM/EZY07mPve1ZYFqYUdPwFqRj0RPk0U7LZMOfT7GCck9YjuT1Rfp1PApNl1ng==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.9.2.tgz", + "integrity": "sha512-l1Qrqhi4x1aekkV+OlcqsJa4AnAkj5p0JV8omgwjaV9OAbP41lvrMvs+CptfetKkeEaGRGSzby7sjPZEX7+kkQ==", "dependencies": { - "@azure/abort-controller": "^1.0.0", - "tslib": "^2.2.0" + "@azure/abort-controller": "^2.0.0", + "tslib": "^2.6.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=18.0.0" } }, "node_modules/@azure/logger": { @@ -206,21 +194,19 @@ } }, "node_modules/@azure/msal-common": { - "version": "14.13.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.13.0.tgz", - "integrity": "sha512-b4M/tqRzJ4jGU91BiwCsLTqChveUEyFK3qY2wGfZ0zBswIBZjAxopx5CYt5wzZFKuN15HqRDYXQbztttuIC3nA==", - "license": "MIT", + "version": "14.14.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.14.2.tgz", + "integrity": "sha512-XV0P5kSNwDwCA/SjIxTe9mEAsKB0NqGNSuaVrkCCE2lAyBr/D6YtD80Vkdp4tjWnPFwjzkwldjr1xU/facOJog==", "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-node": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.10.0.tgz", - "integrity": "sha512-JxsSE0464a8IA/+q5EHKmchwNyUFJHtCH00tSXsLaOddwLjG6yVvTH6lGgPcWMhO7YWUXj/XVgVgeE9kZtsPUQ==", - "license": "MIT", + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.13.1.tgz", + "integrity": "sha512-sijfzPNorKt6+9g1/miHwhj6Iapff4mPQx1azmmZExgzUROqWTM1o3ACyxDja0g47VpowFy/sxTM/WsuCyXTiw==", "dependencies": { - "@azure/msal-common": "14.13.0", + "@azure/msal-common": "14.14.2", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" }, @@ -274,9 +260,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -339,6 +325,7 @@ "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "deprecated": "Use @eslint/config-array instead", "dev": true, "dependencies": { "@humanwhocodes/object-schema": "^2.0.2", @@ -363,34 +350,33 @@ } }, "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", "dev": true }, "node_modules/@inquirer/confirm": { - "version": "3.1.14", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.1.14.tgz", - "integrity": "sha512-nbLSX37b2dGPtKWL3rPuR/5hOuD30S+pqJ/MuFiUEgN6GiMs8UMxiurKAMDzKt6C95ltjupa8zH6+3csXNHWpA==", - "license": "MIT", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-3.2.0.tgz", + "integrity": "sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw==", "dependencies": { - "@inquirer/core": "^9.0.2", - "@inquirer/type": "^1.4.0" + "@inquirer/core": "^9.1.0", + "@inquirer/type": "^1.5.3" }, "engines": { "node": ">=18" } }, "node_modules/@inquirer/core": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-9.0.2.tgz", - "integrity": "sha512-nguvH3TZar3ACwbytZrraRTzGqyxJfYJwv+ZwqZNatAosdWQMP1GV8zvmkNlBe2JeZSaw0WYBHZk52pDpWC9qA==", - "license": "MIT", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-9.1.0.tgz", + "integrity": "sha512-RZVfH//2ytTjmaBIzeKT1zefcQZzuruwkpTwwbe/i2jTl4o9M+iML5ChULzz6iw1Ok8iUBBsRCjY2IEbD8Ft4w==", "dependencies": { - "@inquirer/figures": "^1.0.3", - "@inquirer/type": "^1.4.0", + "@inquirer/figures": "^1.0.5", + "@inquirer/type": "^1.5.3", "@types/mute-stream": "^0.0.4", - "@types/node": "^20.14.9", + "@types/node": "^22.5.2", "@types/wrap-ansi": "^3.0.0", "ansi-escapes": "^4.3.2", "cli-spinners": "^2.9.2", @@ -405,37 +391,42 @@ "node": ">=18" } }, + "node_modules/@inquirer/core/node_modules/@types/node": { + "version": "22.5.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", + "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", + "dependencies": { + "undici-types": "~6.19.2" + } + }, "node_modules/@inquirer/figures": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.3.tgz", - "integrity": "sha512-ErXXzENMH5pJt5/ssXV0DfWUZqly8nGzf0UcBV9xTnP+KyffE2mqyxIMBrZ8ijQck2nU0TQm40EQB53YreyWHw==", - "license": "MIT", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.5.tgz", + "integrity": "sha512-79hP/VWdZ2UVc9bFGJnoQ/lQMpL74mGgzSYX1xUqCVk7/v73vJCMw1VuyWN1jGkZ9B3z7THAbySqGbCNefcjfA==", "engines": { "node": ">=18" } }, "node_modules/@inquirer/input": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-2.2.1.tgz", - "integrity": "sha512-Yl1G6h7qWydzrJwqN777geeJVaAFL5Ly83aZlw4xHf8Z/BoTMfKRheyuMaQwOG7LQ4e5nQP7PxXdEg4SzQ+OKw==", - "license": "MIT", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-2.3.0.tgz", + "integrity": "sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw==", "dependencies": { - "@inquirer/core": "^9.0.2", - "@inquirer/type": "^1.4.0" + "@inquirer/core": "^9.1.0", + "@inquirer/type": "^1.5.3" }, "engines": { "node": ">=18" } }, "node_modules/@inquirer/select": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-2.3.10.tgz", - "integrity": "sha512-rr7iR0Zj1YFfgM8IUGimPD9Yukd+n/U63CnYT9kdum6DbRXtMxR45rrreP+EA9ixCnShr+W4xj7suRxC1+8t9g==", - "license": "MIT", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-2.5.0.tgz", + "integrity": "sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA==", "dependencies": { - "@inquirer/core": "^9.0.2", - "@inquirer/figures": "^1.0.3", - "@inquirer/type": "^1.4.0", + "@inquirer/core": "^9.1.0", + "@inquirer/figures": "^1.0.5", + "@inquirer/type": "^1.5.3", "ansi-escapes": "^4.3.2", "yoctocolors-cjs": "^2.1.2" }, @@ -444,10 +435,9 @@ } }, "node_modules/@inquirer/type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.4.0.tgz", - "integrity": "sha512-AjOqykVyjdJQvtfkNDGUyMYGF8xN50VUxftCQWsOyIo4DFRLr6VQhW0VItGI1JIyQGCGgIpKa7hMMwNhZb4OIw==", - "license": "MIT", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-1.5.3.tgz", + "integrity": "sha512-xUQ14WQGR/HK5ei+2CvgcwoH9fQ4PgPGmVFSN0pc1+fVyDL3MREhyAY7nxEErSu6CkllBM3D7e3e+kOvtu+eIg==", "dependencies": { "mute-stream": "^1.0.0" }, @@ -730,9 +720,9 @@ "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" }, "node_modules/@pnpm/npm-conf": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.2.2.tgz", - "integrity": "sha512-UA91GwWPhFExt3IizW6bOeY/pQ0BkuNwKjk9iQW9KqxluGCrg4VenZ0/L+2Y0+ZOtme72EVvg6v0zo3AMQRCeA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.3.0.tgz", + "integrity": "sha512-DqrO+oXGR7HCuicNy6quk6ALJSDDPKI7RZz1bP5im8mSL8J2e+9w26LdkjuAfpAjOutYUJVbnXnx4IbTQeIgfw==", "dependencies": { "@pnpm/config.env-replace": "^1.1.0", "@pnpm/network.ca-file": "^1.0.1", @@ -742,23 +732,11 @@ "node": ">=12" } }, - "node_modules/@sindresorhus/is": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", - "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" } @@ -768,7 +746,6 @@ "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.0" } @@ -797,27 +774,7 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", - "dev": true, - "license": "(Unlicense OR Apache-2.0)" - }, - "node_modules/@szmarczak/http-timer": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", - "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", - "dependencies": { - "defer-to-connect": "^2.0.1" - }, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "engines": { - "node": ">= 10" - } + "dev": true }, "node_modules/@types/adm-zip": { "version": "0.5.5", @@ -844,11 +801,6 @@ "integrity": "sha512-OS//b51j9uyR3zvwD04Kfs5kHpve2qalQ18JhY/ho3voGYUTPLEG90/ocfKPI48hyHH8T04f7KEEbK6Ue60oZQ==", "dev": true }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==" - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -892,12 +844,6 @@ "resolved": "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==" }, - "node_modules/@types/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", - "dev": true - }, "node_modules/@types/mocha": { "version": "10.0.7", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.7.tgz", @@ -915,12 +861,11 @@ } }, "node_modules/@types/node": { - "version": "20.14.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", - "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", - "license": "MIT", + "version": "20.16.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz", + "integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/node-forge": { @@ -975,9 +920,9 @@ } }, "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", "dev": true }, "node_modules/@types/wrap-ansi": { @@ -998,17 +943,16 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.15.0.tgz", - "integrity": "sha512-uiNHpyjZtFrLwLDpHnzaDlP3Tt6sGMqTCiqmxaN4n4RP0EfYZDODJyddiFDF44Hjwxr5xAcaYxVKm9QKQFJFLA==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.15.0", - "@typescript-eslint/type-utils": "7.15.0", - "@typescript-eslint/utils": "7.15.0", - "@typescript-eslint/visitor-keys": "7.15.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1032,16 +976,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.15.0.tgz", - "integrity": "sha512-k9fYuQNnypLFcqORNClRykkGOMOj+pV6V91R4GO/l1FDGwpqmSwoOQrOHo3cGaH63e+D3ZiCAOsuS/D2c99j/A==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "7.15.0", - "@typescript-eslint/types": "7.15.0", - "@typescript-eslint/typescript-estree": "7.15.0", - "@typescript-eslint/visitor-keys": "7.15.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", "debug": "^4.3.4" }, "engines": { @@ -1061,14 +1004,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.15.0.tgz", - "integrity": "sha512-Q/1yrF/XbxOTvttNVPihxh1b9fxamjEoz2Os/Pe38OHwxC24CyCqXxGTOdpb4lt6HYtqw9HetA/Rf6gDGaMPlw==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.15.0", - "@typescript-eslint/visitor-keys": "7.15.0" + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -1079,14 +1021,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.15.0.tgz", - "integrity": "sha512-SkgriaeV6PDvpA6253PDVep0qCqgbO1IOBiycjnXsszNTVQe5flN5wR5jiczoEoDEnAqYFSFFc9al9BSGVltkg==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "7.15.0", - "@typescript-eslint/utils": "7.15.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1107,11 +1048,10 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.15.0.tgz", - "integrity": "sha512-aV1+B1+ySXbQH0pLK0rx66I3IkiZNidYobyfn0WFsdGhSXw+P3YOqeTq5GED458SfB24tg+ux3S+9g118hjlTw==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", "dev": true, - "license": "MIT", "engines": { "node": "^18.18.0 || >=20.0.0" }, @@ -1121,14 +1061,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.15.0.tgz", - "integrity": "sha512-gjyB/rHAopL/XxfmYThQbXbzRMGhZzGw6KpcMbfe8Q3nNQKStpxnUKeXb0KiN/fFDR42Z43szs6rY7eHk0zdGQ==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "7.15.0", - "@typescript-eslint/visitor-keys": "7.15.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1154,7 +1093,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -1164,7 +1102,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, - "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -1176,16 +1113,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.15.0.tgz", - "integrity": "sha512-hfDMDqaqOqsUVGiEPSMLR/AjTSCsmJwjpKkYQRo1FNbmW4tBwBspYDwO9eh7sKSTwMQgBw9/T4DHudPaqshRWA==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.15.0", - "@typescript-eslint/types": "7.15.0", - "@typescript-eslint/typescript-estree": "7.15.0" + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -1199,13 +1135,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.15.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.15.0.tgz", - "integrity": "sha512-Hqgy/ETgpt2L5xueA/zHHIl4fJI2O4XUE9l4+OIfbJIRSnTJb/QscncdqqZzofQegIJugRIF57OJea1khw2SDw==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "7.15.0", + "@typescript-eslint/types": "7.18.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1223,17 +1158,17 @@ "dev": true }, "node_modules/@xmldom/xmldom": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", - "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.2.tgz", + "integrity": "sha512-afP3lpLtalPxgNGU4bxlsru4wSDsZwdSFKnHs6PR0q3KIEWWcAlBqAdx4aWlVtP1gV1FBWlJ3d0MgaRRdj/ucA==", "engines": { - "node": ">=10.0.0" + "node": ">=14.0.0" } }, "node_modules/acorn": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", - "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "bin": { "acorn": "bin/acorn" }, @@ -1259,10 +1194,9 @@ } }, "node_modules/adaptive-expressions": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/adaptive-expressions/-/adaptive-expressions-4.22.3.tgz", - "integrity": "sha512-ks2kYbmVIWtYVRV8Sh9snCEDPtoFutL1W1p/AHfKz3Z0VCxCaDYU/QooVUxVFgOvvMOCWi1yYNFW3UlMaNs99g==", - "license": "MIT", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/adaptive-expressions/-/adaptive-expressions-4.23.0.tgz", + "integrity": "sha512-OvbrD0hlmxyyaTJMpGmFIcfZG0b7GvQTucnQ3apXD6LzI3gQWVfur5eFADFzUOa78/fDjh81ZcKuGFcPt283pg==", "dependencies": { "@microsoft/recognizers-text-data-types-timex-expression": "1.3.0", "@types/atob-lite": "^2.0.0", @@ -1277,7 +1211,7 @@ "btoa-lite": "^1.0.0", "d3-format": "^1.4.4", "dayjs": "^1.10.3", - "fast-xml-parser": "^4.2.5", + "fast-xml-parser": "^4.4.1", "jspath": "^0.4.0", "lodash.isequal": "^4.5.0", "lru-cache": "^5.1.1", @@ -1285,6 +1219,14 @@ "xpath": "^0.0.32" } }, + "node_modules/adaptive-expressions/node_modules/@xmldom/xmldom": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/adaptive-expressions/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -1311,23 +1253,22 @@ } }, "node_modules/adm-zip": { - "version": "0.5.14", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.14.tgz", - "integrity": "sha512-DnyqqifT4Jrcvb8USYjp6FHtBpEIz1mnXu6pTRHZ0RL69LbQYiO+0lDFg5+OKA7U29oWSs3a/i8fhn8ZcceIWg==", - "license": "MIT", + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", "engines": { "node": ">=12.0" } }, "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", "dependencies": { - "debug": "4" + "debug": "^4.3.4" }, "engines": { - "node": ">= 6.0.0" + "node": ">= 14" } }, "node_modules/ajv": { @@ -1438,13 +1379,12 @@ } }, "node_modules/applicationinsights": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-2.9.5.tgz", - "integrity": "sha512-APQ8IWyYDHFvKbitFKpsmZXxkzQh0yYTFacQqoVW7HwlPo3eeLprwnq5RFNmmG6iqLmvQ+xRJSDLEQCgqPh+bw==", + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-2.9.6.tgz", + "integrity": "sha512-BLeBYJUZaKmnzqG/6Q/IFSCqpiVECjSTIvwozLex/1ZZpSxOjPiBxGMev+iIBfNZ2pc7vvnV7DuPOtsoG2DJeQ==", "dependencies": { - "@azure/core-auth": "^1.5.0", - "@azure/core-rest-pipeline": "1.10.1", - "@azure/core-util": "1.2.0", + "@azure/core-auth": "1.7.2", + "@azure/core-rest-pipeline": "1.16.3", "@azure/opentelemetry-instrumentation-azure-sdk": "^1.0.0-beta.5", "@microsoft/applicationinsights-web-snippet": "1.0.1", "@opentelemetry/api": "^1.7.0", @@ -1479,7 +1419,6 @@ "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -1525,11 +1464,19 @@ "resolved": "https://registry.npmjs.org/atob-lite/-/atob-lite-2.0.0.tgz", "integrity": "sha512-LEeSAWeh2Gfa2FtlQE1shxQ8zi5F9GHarrGKz08TMdODD5T4eH6BMsvtnhbWZ+XQn+Gb6om/917ucvRu7l7ukw==" }, + "node_modules/atomically": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.0.3.tgz", + "integrity": "sha512-kU6FmrwZ3Lx7/7y3hPS5QnbJfaohcIul5fGqf7ok+4KklIEk9tJ0C2IQPdacSbVUWv6zVHXEBWoWd6NrVMT7Cw==", + "dependencies": { + "stubborn-fs": "^1.2.5", + "when-exit": "^2.1.1" + } + }, "node_modules/axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", - "license": "MIT", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -1563,6 +1510,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==", + "dev": true, "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^7.0.1", @@ -1584,6 +1532,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, "engines": { "node": ">=12" }, @@ -1595,6 +1544,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, "engines": { "node": ">=12" }, @@ -1606,6 +1556,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -1620,6 +1571,7 @@ "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, "engines": { "node": ">=12.20" }, @@ -1631,6 +1583,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -1703,9 +1656,9 @@ } }, "node_modules/c8": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz", - "integrity": "sha512-mBWcT5iqNir1zIkzSPyI3NCR9EZCVI3WUD+AVO17MVWTSFNyUueXE82qTeampNtTr+ilN/5Ua3j24LgbCKjDVg==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.2.tgz", + "integrity": "sha512-Qr6rj76eSshu5CgRYvktW0uM0CFY0yi4Fd5D0duDXO6sYinyopmftUiJVuzBQxQcwQLor7JWDVRP+dUfCmzgJw==", "dev": true, "dependencies": { "@bcoe/v8-coverage": "^0.2.3", @@ -1715,7 +1668,7 @@ "istanbul-lib-coverage": "^3.2.0", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.6", - "test-exclude": "^6.0.0", + "test-exclude": "^7.0.1", "v8-to-istanbul": "^9.0.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1" @@ -1724,7 +1677,15 @@ "c8": "bin/c8.js" }, "engines": { - "node": ">=14.14.0" + "node": ">=18" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } } }, "node_modules/c8/node_modules/cliui": { @@ -1796,31 +1757,6 @@ "node": ">=12" } }, - "node_modules/cacheable-lookup": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", - "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/cacheable-request": { - "version": "10.2.14", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", - "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", - "dependencies": { - "@types/http-cache-semantics": "^4.0.2", - "get-stream": "^6.0.1", - "http-cache-semantics": "^4.1.1", - "keyv": "^4.5.3", - "mimic-response": "^4.0.0", - "normalize-url": "^8.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1834,6 +1770,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, "engines": { "node": ">=14.16" }, @@ -2077,18 +2014,17 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, "node_modules/configstore": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-6.0.0.tgz", - "integrity": "sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-7.0.0.tgz", + "integrity": "sha512-yk7/5PN5im4qwz0WFZW3PXnzHgPu9mX29Y8uZ3aefe2lBPC1FYttWZRcaW9fKkT0pBCJyuQ2HfbmPVaODi9jcQ==", "dependencies": { - "dot-prop": "^6.0.1", - "graceful-fs": "^4.2.6", - "unique-string": "^3.0.0", - "write-file-atomic": "^3.0.3", - "xdg-basedir": "^5.0.1" + "atomically": "^2.0.3", + "dot-prop": "^9.0.0", + "graceful-fs": "^4.2.11", + "xdg-basedir": "^5.1.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/yeoman/configstore?sponsor=1" @@ -2122,36 +2058,10 @@ "node": ">= 8" } }, - "node_modules/crypto-random-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", - "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", - "dependencies": { - "type-fest": "^1.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/crypto-random-string/node_modules/type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/csv-stringify": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.5.0.tgz", - "integrity": "sha512-edlXFVKcUx7r8Vx5zQucsuMg4wb/xT6qyz+Sr1vnLrdXqlLD1+UKyWNyZ9zn6mUW1ewmGxrpVwAcChGF0HQ/2Q==", - "license": "MIT" + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.5.1.tgz", + "integrity": "sha512-+9lpZfwpLntpTIEpFbwQyWuW/hmI/eHuJZD1XzeZpfZTqkf1fyvBbBLXTJJMsBuuS11uTShMqPwzx4A6ffXgRQ==" }, "node_modules/d3-format": { "version": "1.4.5", @@ -2192,31 +2102,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -2269,14 +2154,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "engines": { - "node": ">=10" - } - }, "node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -2327,7 +2204,6 @@ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "dev": true, - "license": "MIT", "dependencies": { "path-type": "^4.0.0" }, @@ -2357,14 +2233,25 @@ } }, "node_modules/dot-prop": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", - "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", "dependencies": { - "is-obj": "^2.0.0" + "type-fest": "^4.18.2" }, "engines": { - "node": ">=10" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop/node_modules/type-fest": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.0.tgz", + "integrity": "sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==", + "engines": { + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2373,7 +2260,8 @@ "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true }, "node_modules/easy-table": { "version": "1.2.0", @@ -2405,7 +2293,8 @@ "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true }, "node_modules/escalade": { "version": "3.1.1", @@ -2499,11 +2388,10 @@ "link": true }, "node_modules/eslint-plugin-mocha": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-10.4.3.tgz", - "integrity": "sha512-emc4TVjq5Ht0/upR+psftuz6IBG5q279p+1dSRDeHf+NS9aaerBi3lXKo1SEzwC29hFIW21gO89CEWSvRsi8IQ==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-10.5.0.tgz", + "integrity": "sha512-F2ALmQVPT1GoP27O1JTZGrV9Pqg8k79OeIuvw63UxMtQKREZtmkK1NFgkZQ2TW7L2JSSFKHFPTtHu5z8R9QNRw==", "dev": true, - "license": "MIT", "dependencies": { "eslint-utils": "^3.0.0", "globals": "^13.24.0", @@ -2516,6 +2404,22 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint-utils": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", @@ -2571,31 +2475,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/eslint/node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -2625,15 +2504,6 @@ "node": ">=0.10" } }, - "node_modules/esquery/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -2646,7 +2516,7 @@ "node": ">=4.0" } }, - "node_modules/esrecurse/node_modules/estraverse": { + "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", @@ -2746,9 +2616,9 @@ "dev": true }, "node_modules/fast-xml-parser": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.3.2.tgz", - "integrity": "sha512-rmrXUXwbJedoXkStenj1kkljNF7ugn5ZjR9FJcwmCfcCbtOMDghPajbc+Tck6vE6F5XsDmx+Pr2le9fw8+pXBg==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz", + "integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==", "funding": [ { "type": "github", @@ -2826,9 +2696,9 @@ } }, "node_modules/flat-cache": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz", - "integrity": "sha512-/qM2b3LUIaIgviBQovTLvijfyOQXPtSRnRK26ksj2J7rzPIecePUIpJsZ4T02Qg+xiAEKIs5K8dsHEd+VaKa/Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "dependencies": { "flatted": "^3.2.9", @@ -2836,13 +2706,14 @@ "rimraf": "^3.0.2" }, "engines": { - "node": ">=12.0.0" + "node": "^10.12.0 || >=12.0.0" } }, "node_modules/flat-cache/node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "dependencies": { "glob": "^7.1.3" @@ -2855,9 +2726,9 @@ } }, "node_modules/flatted": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", - "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, "node_modules/follow-redirects": { @@ -2908,14 +2779,6 @@ "node": ">= 6" } }, - "node_modules/form-data-encoder": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", - "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", - "engines": { - "node": ">= 14.17" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2953,27 +2816,28 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "node_modules/get-east-asian-width": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", + "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==", "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", - "minimatch": "^3.0.4", + "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" }, @@ -2996,15 +2860,15 @@ "node": ">=10.13.0" } }, - "node_modules/global-dirs": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", - "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", "dependencies": { - "ini": "2.0.0" + "ini": "4.1.1" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -3042,7 +2906,6 @@ "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, - "license": "MIT", "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", @@ -3058,30 +2921,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/got": { - "version": "12.6.1", - "resolved": "https://registry.npmjs.org/got/-/got-12.6.1.tgz", - "integrity": "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==", - "dependencies": { - "@sindresorhus/is": "^5.2.0", - "@szmarczak/http-timer": "^5.0.1", - "cacheable-lookup": "^7.0.0", - "cacheable-request": "^10.2.8", - "decompress-response": "^6.0.0", - "form-data-encoder": "^2.1.2", - "get-stream": "^6.0.1", - "http2-wrapper": "^2.1.10", - "lowercase-keys": "^3.0.0", - "p-cancelable": "^3.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3133,46 +2972,28 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, - "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" - }, "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/http2-wrapper": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.0.tgz", - "integrity": "sha512-kZB0wxMo0sh1PehyjJUWRFEd99KC5TLjZ2cULC4f9iqJBAmKQQXEICjxl5iPJRwP40dpeHFqqhm7tYCvODpqpQ==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.2.0" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">=10.19.0" + "node": ">= 14" } }, "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", "dependencies": { - "agent-base": "6", + "agent-base": "^7.0.2", "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/human-signals": { @@ -3220,18 +3041,11 @@ "module-details-from-path": "^1.0.3" } }, - "node_modules/import-lazy": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", - "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", - "engines": { - "node": ">=8" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "engines": { "node": ">=0.8.19" } @@ -3253,11 +3067,11 @@ "dev": true }, "node_modules/ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", "engines": { - "node": ">=10" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/is-binary-path": { @@ -3313,9 +3127,9 @@ } }, "node_modules/is-in-ci": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-0.1.0.tgz", - "integrity": "sha512-d9PXLEY0v1iJ64xLiQMJ51J128EYHAaOR4yZqQi8aHGfw6KgifM3/Viw1oZZ1GCVmb3gBuyhLyHj0HgR2DhSXQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", + "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", "bin": { "is-in-ci": "cli.js" }, @@ -3358,15 +3172,26 @@ } }, "node_modules/is-installed-globally": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", - "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-1.0.0.tgz", + "integrity": "sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==", "dependencies": { - "global-dirs": "^3.0.0", - "is-path-inside": "^3.0.2" + "global-directory": "^4.0.1", + "is-path-inside": "^4.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-installed-globally/node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "engines": { + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -3393,18 +3218,11 @@ "node": ">=0.12.0" } }, - "node_modules/is-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "engines": { - "node": ">=8" - } - }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -3429,11 +3247,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" - }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -3516,15 +3329,15 @@ } }, "node_modules/jackspeak": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", - "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.1.tgz", + "integrity": "sha512-cub8rahkh0Q/bw1+GxP7aeSe29hHHn2V4m29nnDlvCdlgU+3UGxkZp7Z53jLUdpX3jdTO0nJZUDl3xvbWc2Xog==", "dev": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, "engines": { - "node": ">=14" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3556,7 +3369,8 @@ "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true }, "node_modules/json-schema-traverse": { "version": "0.4.1", @@ -3615,8 +3429,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/jwa": { "version": "1.4.1", @@ -3641,19 +3454,31 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "dependencies": { "json-buffer": "3.0.1" } }, + "node_modules/ky": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.5.0.tgz", + "integrity": "sha512-bkQo+UqryW6Zmo/DsixYZE4Z9t2mzvNMhceyIhuMuInb3knm5Q+GNGMKveydJAj+Z6piN1SwI6eR/V0G+Z0BtA==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, "node_modules/latest-version": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-7.0.0.tgz", - "integrity": "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz", + "integrity": "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA==", "dependencies": { - "package-json": "^8.1.0" + "package-json": "^10.0.0" }, "engines": { - "node": ">=14.16" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -3771,17 +3596,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/lowercase-keys": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", - "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3864,17 +3678,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mimic-response": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", - "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3896,20 +3699,19 @@ } }, "node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true, "engines": { "node": ">=16 || 14 >=14.17" } }, "node_modules/mocha": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.6.0.tgz", - "integrity": "sha512-hxjt4+EEB0SA0ZDygSS015t65lJw/I2yRCS3Ae+SJ5FrbzrXgfYwJr96f0OvIXdj7h4lv/vLCrH3rkiuizFSvw==", + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.7.3.tgz", + "integrity": "sha512-uQWxAu44wwiACGqjbPYmjo7Lg8sFrS3dQe7PP2FQI+woptP4vZXSMcfMyFL/e1yFEeEpV4RtyTpZROOKmxis+A==", "dev": true, - "license": "MIT", "dependencies": { "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", @@ -4050,11 +3852,10 @@ "dev": true }, "node_modules/nise": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.9.tgz", - "integrity": "sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.0.0.tgz", + "integrity": "sha512-K8ePqo9BFvN31HXwEtTNGzgrPpmvgciDsFz8aztFjt4LqKO/JeFD8tBOeuDiCMXrIl/m1YvfH8auSpxfaD09wg==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.0", "@sinonjs/fake-timers": "^11.2.2", @@ -4080,17 +3881,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-url": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.0.tgz", - "integrity": "sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/npm-run-path": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", @@ -4181,14 +3971,6 @@ "node": ">= 0.8.0" } }, - "node_modules/p-cancelable": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", - "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", - "engines": { - "node": ">=12.20" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4220,22 +4002,28 @@ } }, "node_modules/package-json": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/package-json/-/package-json-8.1.1.tgz", - "integrity": "sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", + "integrity": "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==", "dependencies": { - "got": "^12.1.0", - "registry-auth-token": "^5.0.1", - "registry-url": "^6.0.0", - "semver": "^7.3.7" + "ky": "^1.2.0", + "registry-auth-token": "^5.0.2", + "registry-url": "^6.0.1", + "semver": "^7.6.0" }, "engines": { - "node": ">=14.16" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "dev": true + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4280,43 +4068,38 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "dependencies": { - "lru-cache": "^9.1.1 || ^10.0.0", + "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", - "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", - "dev": true, - "engines": { - "node": "14 || >=16.14" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true }, "node_modules/path-to-regexp": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -4395,17 +4178,6 @@ } ] }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/rambda": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/rambda/-/rambda-7.5.0.tgz", @@ -4524,11 +4296,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4538,20 +4305,6 @@ "node": ">=4" } }, - "node_modules/responselike": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", - "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", - "dependencies": { - "lowercase-keys": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -4563,19 +4316,19 @@ } }, "node_modules/rimraf": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz", - "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", + "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", "dev": true, - "license": "ISC", "dependencies": { - "glob": "^10.3.7" + "glob": "^11.0.0", + "package-json-from-dist": "^1.0.0" }, "bin": { "rimraf": "dist/esm/bin.mjs" }, "engines": { - "node": ">=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4591,37 +4344,63 @@ } }, "node_modules/rimraf/node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.0.tgz", + "integrity": "sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==", "dev": true, "dependencies": { "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.0.tgz", + "integrity": "sha512-Qv32eSV1RSCfhY3fpPE2GNZ8jgM9X7rdAfemLWqTUxwiyIC4jJ6Sy0fZ8H+oLWevO6i4/bizg7c8d8i6bxrzbA==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/rimraf/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "dev": true, + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4681,10 +4460,9 @@ ] }, "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "license": "ISC", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "bin": { "semver": "bin/semver.js" }, @@ -4692,20 +4470,6 @@ "node": ">=10" } }, - "node_modules/semver-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-4.0.0.tgz", - "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -4752,18 +4516,16 @@ } }, "node_modules/sinon": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.2.tgz", - "integrity": "sha512-uihLiaB9FhzesElPDFZA7hDcNABzsVHwr3YfmM9sBllVwab3l0ltGlRV1XhpNfIacNDLGD1QRZNLs5nU5+hTuA==", - "deprecated": "There", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.0.tgz", + "integrity": "sha512-+dXDXzD1sBO6HlmZDd7mXZCR/y5ECiEiGCBSGuFD/kZ0bDTofPYc6JaeGmPSF+1j1MejGUWkORbYOLDyvqCWpA==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1", "@sinonjs/fake-timers": "^11.2.2", "@sinonjs/samsam": "^8.0.0", "diff": "^5.2.0", - "nise": "^5.1.9", + "nise": "^6.0.0", "supports-color": "^7" }, "funding": { @@ -4776,7 +4538,6 @@ "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -4815,6 +4576,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -4852,6 +4614,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, "engines": { "node": ">=12" }, @@ -4863,6 +4626,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -4924,6 +4688,11 @@ "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" }, + "node_modules/stubborn-fs": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", + "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4983,17 +4752,76 @@ } }, "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", "dev": true, "dependencies": { "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "glob": "^10.4.1", + "minimatch": "^9.0.4" }, "engines": { - "node": ">=8" + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/text-table": { @@ -5074,19 +4902,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, "node_modules/typescript": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", - "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", - "license": "Apache-2.0", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5108,47 +4927,161 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, - "node_modules/unique-string": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", - "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", + "node_modules/update-notifier": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.3.0.tgz", + "integrity": "sha512-nA5Zoy3rahYd/Lx1s6jZYHfrKKYOgw0kThkLdwgJtXEFsXqEbMnwdVNPT9D+HELlEXqTR7Iq8rjg/NjenGLIvg==", "dependencies": { - "crypto-random-string": "^4.0.0" + "boxen": "^8.0.0", + "chalk": "^5.3.0", + "configstore": "^7.0.0", + "is-in-ci": "^1.0.0", + "is-installed-globally": "^1.0.0", + "is-npm": "^6.0.0", + "latest-version": "^9.0.0", + "pupa": "^3.1.0", + "semver": "^7.6.3", + "xdg-basedir": "^5.1.0" }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/update-notifier": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-7.0.0.tgz", - "integrity": "sha512-Hv25Bh+eAbOLlsjJreVPOs4vd51rrtCrmhyOJtbpAojro34jS4KQaEp4/EvlHJX7jSO42VvEFpkastVyXyIsdQ==", + "node_modules/update-notifier/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/boxen": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-8.0.1.tgz", + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", "dependencies": { - "boxen": "^7.1.1", + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", "chalk": "^5.3.0", - "configstore": "^6.0.0", - "import-lazy": "^4.0.0", - "is-in-ci": "^0.1.0", - "is-installed-globally": "^0.4.0", - "is-npm": "^6.0.0", - "latest-version": "^7.0.0", - "pupa": "^3.1.0", - "semver": "^7.5.4", - "semver-diff": "^4.0.0", - "xdg-basedir": "^5.1.0" + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" }, "engines": { "node": ">=18" }, "funding": { - "url": "https://github.com/yeoman/update-notifier?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-notifier/node_modules/camelcase": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-notifier/node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==" + }, + "node_modules/update-notifier/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-notifier/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/type-fest": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.24.0.tgz", + "integrity": "sha512-spAaHzc6qre0TlZQQ2aA/nGMe+2Z/wyGk5Z+Ru2VUfdNwT6kWO6TjevOlpebsATEG1EIQ2sOiDszud3lO5mt/Q==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-notifier/node_modules/widest-line": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-notifier/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/uri-js": { @@ -5161,9 +5094,9 @@ } }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -5195,6 +5128,11 @@ "defaults": "^1.0.3" } }, + "node_modules/when-exit": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.3.tgz", + "integrity": "sha512-uVieSTccFIr/SFQdFWN/fFaQYmV37OKtuaGphMAzi4DmmUlrvRBJW5WSLkHyjNQY/ePJMz3LoiX9R3yy1Su6Hw==" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5213,6 +5151,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, "dependencies": { "string-width": "^5.0.1" }, @@ -5308,22 +5247,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, - "node_modules/write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", - "dependencies": { - "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" - } - }, - "node_modules/write-file-atomic/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" - }, "node_modules/xdg-basedir": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", @@ -5358,10 +5281,9 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "node_modules/yaml": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", - "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", - "license": "ISC", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", "bin": { "yaml": "bin.mjs" }, @@ -5476,9 +5398,9 @@ } }, "node_modules/zod": { - "version": "3.23.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.4.tgz", - "integrity": "sha512-/AtWOKbBgjzEYYQRNfoGKHObgfAZag6qUJX1VbHo2PRBgS+wfWagEY2mizjfyAPcGesrJOcx/wcl0L9WnVrHFw==", + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 538b0272a8b..f524b82d627 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pnp/cli-microsoft365", - "version": "8.1.0", + "version": "9.1.0", "description": "Manage Microsoft 365 and SharePoint Framework projects on any platform", "license": "MIT", "main": "./dist/api.js", @@ -252,37 +252,36 @@ "Zachariassen Laksafoss, Trygvi " ], "dependencies": { - "@azure/msal-common": "^14.11.0", - "@azure/msal-node": "^2.10.0", - "@inquirer/confirm": "^3.1.12", - "@inquirer/input": "^2.1.12", - "@inquirer/select": "^2.3.8", - "@xmldom/xmldom": "^0.8.10", - "adaptive-expressions": "^4.22.3", + "@azure/msal-common": "^14.14.2", + "@azure/msal-node": "^2.13.1", + "@inquirer/confirm": "^3.2.0", + "@inquirer/input": "^2.3.0", + "@inquirer/select": "^2.5.0", + "@xmldom/xmldom": "^0.9.2", + "adaptive-expressions": "^4.23.0", "adaptivecards": "^3.0.4", "adaptivecards-templating": "^2.3.1", - "adm-zip": "^0.5.14", - "applicationinsights": "^2.9.5", - "axios": "^1.7.2", + "adm-zip": "^0.5.16", + "applicationinsights": "^2.9.6", + "axios": "^1.7.7", "chalk": "^5.3.0", "clipboardy": "^4.0.0", - "configstore": "^6.0.0", - "csv-stringify": "^6.5.0", + "configstore": "^7.0.0", + "csv-stringify": "^6.5.1", "easy-table": "^1.2.0", "jmespath": "^0.16.0", "json-to-ast": "^2.1.0", - "minimist": "^1.2.8", "node-forge": "^1.3.1", "omelette": "^0.4.17", "open": "^10.1.0", - "semver": "^7.6.2", + "semver": "^7.6.3", "strip-json-comments": "^5.0.1", - "typescript": "^5.5.3", - "update-notifier": "^7.0.0", - "uuid": "^9.0.1", - "yaml": "^2.4.5", + "typescript": "^5.5.4", + "update-notifier": "^7.3.0", + "uuid": "^10.0.0", + "yaml": "^2.5.1", "yargs-parser": "^21.1.1", - "zod": "^3.22.4" + "zod": "^3.23.8" }, "devDependencies": { "@actions/core": "^1.10.1", @@ -291,25 +290,24 @@ "@types/jmespath": "^0.15.2", "@types/json-schema": "^7.0.15", "@types/json-to-ast": "^2.1.4", - "@types/minimist": "^1.2.5", "@types/mocha": "^10.0.7", - "@types/node": "^20.14.9", + "@types/node": "^20.16.5", "@types/node-forge": "^1.3.11", "@types/omelette": "^0.4.4", "@types/semver": "^7.5.8", "@types/sinon": "^17.0.3", "@types/update-notifier": "^6.0.8", - "@types/uuid": "^9.0.8", + "@types/uuid": "^10.0.0", "@types/yargs-parser": "^21.0.3", - "@typescript-eslint/eslint-plugin": "^7.8.0", - "@typescript-eslint/parser": "^7.15.0", - "c8": "^9.1.0", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "c8": "^10.1.2", "eslint": "^8.57.0", "eslint-plugin-cli-microsoft365": "file:eslint-rules", - "eslint-plugin-mocha": "^10.4.3", - "mocha": "^10.5.2", - "rimraf": "^5.0.7", - "sinon": "^17.0.2", + "eslint-plugin-mocha": "^10.5.0", + "mocha": "^10.7.3", + "rimraf": "^6.0.1", + "sinon": "^18.0.0", "source-map-support": "^0.5.21" } } diff --git a/src/Auth.spec.ts b/src/Auth.spec.ts index f9c8cb4207e..ae4870f8ec3 100644 --- a/src/Auth.spec.ts +++ b/src/Auth.spec.ts @@ -2115,7 +2115,8 @@ describe('Auth', () => { it('returns confidential client for certificate auth', async () => { auth.connection.authType = AuthType.Certificate; auth.connection.thumbprint = 'ccf4f2a3c3d209c512b3724bb883a5474c0921dc'; - const actualClientApp = await (auth as any).getConfidentialClient(logger, false, auth.connection.thumbprint as string, auth.connection.password, undefined); + auth.connection.password = 'pass@word1'; + const actualClientApp = await (auth as any).getConfidentialClient(logger, false, auth.connection.thumbprint, auth.connection.password, undefined); assert(actualClientApp instanceof msal.ConfidentialClientApplication); }); diff --git a/src/Auth.ts b/src/Auth.ts index cc86d97f3fb..04596587df4 100644 --- a/src/Auth.ts +++ b/src/Auth.ts @@ -10,7 +10,6 @@ import { TokenStorage } from './auth/TokenStorage.js'; import { msalCachePlugin } from './auth/msalCachePlugin.js'; import { Logger } from './cli/Logger.js'; import { cli } from './cli/cli.js'; -import config from './config.js'; import { ConnectionDetails } from './m365/commands/ConnectionDetails.js'; import request from './request.js'; import { settingsNames } from './settingsNames.js'; @@ -69,15 +68,13 @@ export class Connection { // SharePoint tenantId used to execute CSOM requests spoTenantId?: string; // ID of the Microsoft Entra ID app used to authenticate - appId: string; + appId?: string; // ID of the tenant where the Microsoft Entra app is registered; common if multi-tenant - tenant: string; + tenant: string = 'common'; cloudType: CloudType = CloudType.Public; constructor() { this.accessTokens = {}; - this.appId = config.cliEntraAppId; - this.tenant = config.tenant; this.cloudType = CloudType.Public; } @@ -97,18 +94,18 @@ export class Connection { this.thumbprint = undefined; this.spoUrl = undefined; this.spoTenantId = undefined; - this.appId = config.cliEntraAppId; - this.tenant = config.tenant; + this.appId = cli.getClientId(); + this.tenant = cli.getTenant(); } } export enum AuthType { - DeviceCode, - Password, - Certificate, - Identity, - Browser, - Secret + DeviceCode = 'deviceCode', + Password = 'password', + Certificate = 'certificate', + Identity = 'identity', + Browser = 'browser', + Secret = 'secret' } export enum CertificateType { @@ -327,8 +324,8 @@ export class Auth { break; } - const config = { - clientId: this.connection.appId, + const config: Msal.NodeAuthOptions = { + clientId: this.connection.appId!, authority: `${Auth.getEndpointForResource('https://login.microsoftonline.com', this.connection.cloudType)}/${this.connection.tenant}`, azureCloudOptions: { azureCloudInstance, @@ -336,7 +333,7 @@ export class Auth { } }; - const authConfig = cert + const authConfig: Msal.NodeAuthOptions = cert ? { ...config, clientCertificate: cert } : { ...config, clientSecret }; @@ -884,7 +881,7 @@ export class Auth { const details: ConnectionDetails = { connectionName: connection.name, connectedAs: connection.identityName, - authType: AuthType[connection.authType], + authType: connection.authType, appId: connection.appId, appTenant: connection.tenant, cloudType: CloudType[connection.cloudType] diff --git a/src/Command.spec.ts b/src/Command.spec.ts index 2082e346682..0e5f9a06fd8 100644 --- a/src/Command.spec.ts +++ b/src/Command.spec.ts @@ -601,6 +601,18 @@ describe('Command', () => { assert(actual.indexOf(JSON.stringify(commandOutput[0].property)) === -1); }); + it('correctly serialize bool values to csv output', async () => { + const command = new MockCommand1(); + const commandOutput = [ + { + 'property1': true, + 'property2': false + } + ]; + const actual = await command.getCsvOutput(commandOutput, { options: { output: 'csv' } }); + assert.strictEqual(actual,"property1,property2\n1,0\n"); + }); + it('passes validation when csv output specified', async () => { const cmd = new MockCommand2(); assert.strictEqual(await cmd.validate({ options: { output: 'csv' } }, cli.getCommandInfo(cmd)), true); diff --git a/src/Command.ts b/src/Command.ts index 44012c8e677..54c28bcd8e6 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -194,10 +194,7 @@ export default abstract class Command { await cli.error('🌶️ Provide values for the following parameters:'); } - const answer = optionInfo.autocomplete !== undefined - ? await prompt.forSelection({ message: `${optionInfo.name}: `, choices: optionInfo.autocomplete.map((choice: any) => { return { name: choice, value: choice }; }) }) - : await prompt.forInput({ message: `${optionInfo.name}: ` }); - + const answer = await cli.promptForValue(optionInfo); args.options[optionInfo.name] = answer; } @@ -685,7 +682,10 @@ export default abstract class Command { quote: cli.getConfig().get(settingsNames.csvQuote), quoted: cli.getSettingWithDefaultValue(settingsNames.csvQuoted, false), // eslint-disable-next-line camelcase - quoted_empty: cli.getSettingWithDefaultValue(settingsNames.csvQuotedEmpty, false) + quoted_empty: cli.getSettingWithDefaultValue(settingsNames.csvQuotedEmpty, false), + cast: { + boolean: (value: boolean) => value ? '1' : '0' + } }); } diff --git a/src/cli/cli.spec.ts b/src/cli/cli.spec.ts index 2e0273848e7..fa5891a3709 100644 --- a/src/cli/cli.spec.ts +++ b/src/cli/cli.spec.ts @@ -196,6 +196,18 @@ class MockCommandWithConfirmationPrompt extends AnonymousCommand { } } +class MockCommandWithInputPrompt extends AnonymousCommand { + public get name(): string { + return 'cli mock prompt'; + } + public get description(): string { + return 'Mock command with prompt'; + } + public async commandAction(): Promise { + await cli.promptForInput({ message: `ID` }); + } +} + class MockCommandWithHandleMultipleResultsFound extends AnonymousCommand { public get name(): string { return 'cli mock interactive prompt'; @@ -250,6 +262,24 @@ class MockCommandWithSchema extends AnonymousCommand { } } +class MockCommandWithSchemaAndRequiredOptions extends AnonymousCommand { + public get name(): string { + return 'cli mock schema required'; + } + public get description(): string { + return 'Mock command with schema and required options'; + } + public get schema(): z.ZodTypeAny { + return globalOptionsZod + .extend({ + url: z.string() + }) + .strict(); + } + public async commandAction(): Promise { + } +} + describe('cli', () => { let rootFolder: string; let cliLogStub: sinon.SinonStub; @@ -264,6 +294,7 @@ describe('cli', () => { let mockCommandWithAlias: Command; let mockCommandWithValidation: Command; let mockCommandWithSchema: Command; + let mockCommandWithSchemaAndRequiredOptions: Command; let log: string[] = []; let mockCommandWithBooleanRewrite: Command; @@ -286,6 +317,7 @@ describe('cli', () => { mockCommandWithBooleanRewrite = new MockCommandWithBooleanRewrite(); mockCommandWithValidation = new MockCommandWithValidation(); mockCommandWithSchema = new MockCommandWithSchema(); + mockCommandWithSchemaAndRequiredOptions = new MockCommandWithSchemaAndRequiredOptions(); mockCommandWithOptionSets = new MockCommandWithOptionSets(); mockCommandActionSpy = sinon.spy(mockCommand, 'action'); @@ -306,6 +338,7 @@ describe('cli', () => { cli.getCommandInfo(mockCommandWithAlias, 'cli-alias-mock.js', 'help.mdx'), cli.getCommandInfo(mockCommandWithValidation, 'cli-validation-mock.js', 'help.mdx'), cli.getCommandInfo(mockCommandWithSchema, 'cli-schema-mock.js', 'help.mdx'), + cli.getCommandInfo(mockCommandWithSchemaAndRequiredOptions, 'cli-schema-mock.js', 'help.mdx'), cli.getCommandInfo(cliCompletionUpdateCommand, 'cli/commands/completion/completion-clink-update.js', 'cli/completion/completion-clink-update.mdx'), cli.getCommandInfo(mockCommandWithBooleanRewrite, 'cli-boolean-rewrite-mock.js', 'help.mdx') ]; @@ -1015,6 +1048,51 @@ describe('cli', () => { }, e => done(e)); }); + it(`prompts for missing required options when validating schema and prompting enabled`, (done) => { + cli.commandToExecute = cli.commands.find(c => c.name === 'cli mock schema required'); + sinon.stub(prompt, 'forInput').resolves('test'); + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return true; + } + return defaultValue; + }); + const executeCommandSpy = sinon.spy(cli, 'executeCommand'); + cli + .execute(['cli', 'mock', 'schema', 'required']) + .then(_ => { + try { + assert(executeCommandSpy.called); + done(); + } + catch (e) { + done(e); + } + }, e => done(e)); + }); + + it(`shows validation error for missing required options when validating schema and prompting disabled`, (done) => { + cli.commandToExecute = cli.commands.find(c => c.name === 'cli mock schema required'); + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + return defaultValue; + }); + const executeCommandSpy = sinon.spy(cli, 'executeCommand'); + cli + .execute(['cli', 'mock', 'schema', 'required']) + .then(_ => done('Promise fulfilled while error expected'), _ => { + try { + assert(executeCommandSpy.notCalled); + done(); + } + catch (e) { + done(e); + } + }); + }); + it(`throws an error when command's schema-based validation failed`, (done) => { cli.commandToExecute = cli.commands.find(c => c.name === 'cli mock schema'); const mockCommandWithSchemaActionSpy: sinon.SinonSpy = sinon.spy(mockCommandWithSchema, 'action'); @@ -1080,6 +1158,14 @@ describe('cli', () => { assert(promptStub.called); }); + it('calls input prompt tool when command shows prompt', async () => { + const promptStub: sinon.SinonStub = sinon.stub(prompt, 'forInput').resolves('abc'); + const mockCommandWithInputPrompt = new MockCommandWithInputPrompt(); + + await cli.executeCommand(mockCommandWithInputPrompt, { options: { _: [] } }); + assert(promptStub.called); + }); + it('prints command output with formatting', async () => { const commandWithOutput: MockCommandWithOutput = new MockCommandWithOutput(); @@ -1603,7 +1689,7 @@ describe('cli', () => { await cli.loadCommandFromArgs(['spo', 'site', 'list']); cli.printAvailableCommands(); - assert(cliLogStub.calledWith(' cli * 9 commands')); + assert(cliLogStub.calledWith(' cli * 10 commands')); }); it(`prints commands from the specified group`, async () => { @@ -1616,7 +1702,7 @@ describe('cli', () => { }; cli.printAvailableCommands(); - assert(cliLogStub.calledWith(' cli mock * 6 commands')); + assert(cliLogStub.calledWith(' cli mock * 7 commands')); }); it(`prints commands from the root group when the specified string doesn't match any group`, async () => { @@ -1629,7 +1715,7 @@ describe('cli', () => { }; cli.printAvailableCommands(); - assert(cliLogStub.calledWith(' cli * 9 commands')); + assert(cliLogStub.calledWith(' cli * 10 commands')); }); it(`runs properly when context file not found`, async () => { @@ -1859,4 +1945,17 @@ describe('cli', () => { await cli.execute(['cli', 'completion', 'sh', 'update']); assert(loadAllCommandsInfoStub.calledWith(true)); }); + + it('validates if yargs parser has correct configuration', async () => { + const yargsConfiguration = cli.yargsConfiguration; + + assert.deepStrictEqual(yargsConfiguration, { + 'parse-numbers': true, + 'strip-aliased': true, + 'strip-dashed': true, + 'dot-notation': false, + 'boolean-negation': true, + 'camel-case-expansion': false + }); + }); }); \ No newline at end of file diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 9849e126c53..cb6d6848fbf 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -17,7 +17,7 @@ import { app } from '../utils/app.js'; import { browserUtil } from '../utils/browserUtil.js'; import { formatting } from '../utils/formatting.js'; import { md } from '../utils/md.js'; -import { ConfirmationConfig, SelectionConfig, prompt } from '../utils/prompt.js'; +import { ConfirmationConfig, InputConfig, SelectionConfig, prompt } from '../utils/prompt.js'; import { validation } from '../utils/validation.js'; import { zod } from '../utils/zod.js'; import { CommandInfo } from './CommandInfo.js'; @@ -48,6 +48,14 @@ const defaultHelpMode = 'options'; const defaultHelpTarget = 'console'; const helpModes: string[] = ['options', 'examples', 'remarks', 'response', 'full']; const helpTargets: string[] = ['console', 'web']; +const yargsConfiguration: Partial = { + 'parse-numbers': true, + 'strip-aliased': true, + 'strip-dashed': true, + 'dot-notation': false, + 'boolean-negation': true, + 'camel-case-expansion': false +}; function getConfig(): Configstore { if (!_config) { @@ -67,6 +75,14 @@ function getSettingWithDefaultValue(settingName: string, defaultValue: T } } +function getClientId(): string | undefined { + return cli.getSettingWithDefaultValue(settingsNames.clientId, process.env.CLIMICROSOFT365_ENTRAAPPID || process.env.CLIMICROSOFT365_AADAPPID); +} + +function getTenant(): string { + return cli.getSettingWithDefaultValue(settingsNames.tenantId, process.env.CLIMICROSOFT365_TENANT || 'common'); +} + async function execute(rawArgs: string[]): Promise { const start = process.hrtime.bigint(); @@ -159,15 +175,41 @@ async function execute(rawArgs: string[]): Promise { let finalArgs: any = cli.optionsFromArgs.options; if (cli.commandToExecute?.command.schema) { - const startValidation = process.hrtime.bigint(); - const result = cli.commandToExecute.command.getSchemaToParse()!.safeParse(cli.optionsFromArgs.options); - const endValidation = process.hrtime.bigint(); - timings.validation.push(Number(endValidation - startValidation)); - if (!result.success) { - return cli.closeWithError(result.error, cli.optionsFromArgs, true); + while (true) { + const startValidation = process.hrtime.bigint(); + const result = cli.commandToExecute.command.getSchemaToParse()!.safeParse(cli.optionsFromArgs.options); + const endValidation = process.hrtime.bigint(); + timings.validation.push(Number(endValidation - startValidation)); + + if (result.success) { + finalArgs = result.data; + break; + } + else { + const hasNonRequiredErrors = result.error.errors.some(e => e.code !== 'invalid_type' || e.received !== 'undefined'); + const shouldPrompt = cli.getSettingWithDefaultValue(settingsNames.prompt, true); + + if (hasNonRequiredErrors === false && + shouldPrompt) { + await cli.error('🌶️ Provide values for the following parameters:'); + + for (const error of result.error.errors) { + const optionInfo = cli.commandToExecute!.options.find(o => o.name === error.path.join('.')); + const answer = await cli.promptForValue(optionInfo!); + cli.optionsFromArgs!.options[error.path.join('.')] = answer; + } + } + else { + result.error.errors.forEach(e => { + if (e.code === 'invalid_type' && + e.received === 'undefined') { + e.message = `Required option not specified`; + } + }); + return cli.closeWithError(result.error, cli.optionsFromArgs, true); + } + } } - - finalArgs = result.data; } else { const startValidation = process.hrtime.bigint(); @@ -477,11 +519,7 @@ function getCommandOptions(command: Command): CommandOptionInfo[] { function getCommandOptionsFromArgs(args: string[], commandInfo: CommandInfo | undefined): yargs.Arguments { const yargsOptions: yargs.Options = { alias: {}, - configuration: { - "parse-numbers": false, - "strip-aliased": true, - "strip-dashed": true - } + configuration: yargsConfiguration }; let argsToParse = args; @@ -892,7 +930,7 @@ async function closeWithError(error: any, args: CommandArgs, showHelpIfEnabled: let errorMessage: string = error instanceof CommandError ? error.message : error; if (error instanceof ZodError) { - errorMessage = error.errors.map(e => `${e.path}: ${e.message}`).join(os.EOL); + errorMessage = error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(os.EOL); } if ((!args.options.output || args.options.output === 'json') && @@ -941,6 +979,17 @@ async function error(message?: any, ...optionalParams: any[]): Promise { } } +async function promptForValue(optionInfo: CommandOptionInfo): Promise { + return optionInfo.autocomplete !== undefined + ? await prompt.forSelection({ + message: `${optionInfo.name}: `, + choices: optionInfo.autocomplete.map((choice: any) => { + return { name: choice, value: choice }; + }) + }) + : await prompt.forInput({ message: `${optionInfo.name}: ` }); +} + async function promptForSelection(config: SelectionConfig): Promise { const answer = await prompt.forSelection(config); await cli.error(''); @@ -955,6 +1004,13 @@ async function promptForConfirmation(config: ConfirmationConfig): Promise { + const answer = await prompt.forInput(config); + await cli.error(''); + + return answer; +} + async function handleMultipleResultsFound(message: string, values: { [key: string]: T }): Promise { const prompt: boolean = cli.getSettingWithDefaultValue(settingsNames.prompt, true); if (!prompt) { @@ -995,7 +1051,9 @@ export const cli = { closeWithError, commands, commandToExecute, + getClientId, getConfig, + getTenant, currentCommandName, error, execute, @@ -1014,6 +1072,9 @@ export const cli = { optionsFromArgs, printAvailableCommands, promptForConfirmation, + promptForInput, promptForSelection, - shouldTrimOutput + promptForValue, + shouldTrimOutput, + yargsConfiguration }; \ No newline at end of file diff --git a/src/config.spec.ts b/src/config.spec.ts deleted file mode 100644 index db8a8d3f231..00000000000 --- a/src/config.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import assert from 'assert'; - -describe('Config', () => { - it('returns process.env CLIMICROSOFT365_TENANT value', async () => { - process.env.CLIMICROSOFT365_TENANT = 'tenant123'; - - const config = await import(`./config.js#${Math.random()}`); - assert.strictEqual(config.default.tenant, 'tenant123'); - }); - - it('returns process.env CLIMICROSOFT365_AADAPPID value', async () => { - process.env.CLIMICROSOFT365_AADAPPID = 'appId123'; - - const config = await import(`./config.js#${Math.random()}`); - assert.strictEqual(config.default.cliEntraAppId, 'appId123'); - }); - - it('returns process.env CLIMICROSOFT365_ENTRAAPPID value', async () => { - process.env.CLIMICROSOFT365_ENTRAAPPID = 'appId123'; - - const config = await import(`./config.js#${Math.random()}`); - assert.strictEqual(config.default.cliEntraAppId, 'appId123'); - }); - - it('returns default value since env CLIMICROSOFT365_TENANT not present', async () => { - delete process.env.CLIMICROSOFT365_TENANT; - - const config = await import(`./config.js#${Math.random()}`); - assert.strictEqual(config.default.tenant, 'common'); - }); - - it('returns default value since env CLIMICROSOFT365_AADAPPID or CLIMICROSOFT365_ENTRAAPPID not present', async () => { - delete process.env.CLIMICROSOFT365_AADAPPID; - delete process.env.CLIMICROSOFT365_ENTRAAPPID; - - const config = await import(`./config.js#${Math.random()}`); - assert.strictEqual(config.default.cliEntraAppId, '31359c7f-bd7e-475c-86db-fdb8c937548e'); - }); -}); \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index bd0c50f4ffc..4f2dae94c29 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,11 +1,65 @@ -import { app } from "./utils/app.js"; - -const cliEntraAppId: string = '31359c7f-bd7e-475c-86db-fdb8c937548e'; +import { app } from './utils/app.js'; export default { + allScopes: [ + 'https://graph.windows.net/Directory.AccessAsUser.All', + 'https://management.azure.com/user_impersonation', + 'https://admin.services.crm.dynamics.com/user_impersonation', + 'https://graph.microsoft.com/AppCatalog.ReadWrite.All', + 'https://graph.microsoft.com/AuditLog.Read.All', + 'https://graph.microsoft.com/Bookings.Read.All', + 'https://graph.microsoft.com/Calendars.Read', + 'https://graph.microsoft.com/ChannelMember.ReadWrite.All', + 'https://graph.microsoft.com/ChannelMessage.Read.All', + 'https://graph.microsoft.com/ChannelMessage.ReadWrite', + 'https://graph.microsoft.com/ChannelMessage.Send', + 'https://graph.microsoft.com/ChannelSettings.ReadWrite.All', + 'https://graph.microsoft.com/Chat.ReadWrite', + 'https://graph.microsoft.com/Directory.AccessAsUser.All', + 'https://graph.microsoft.com/Directory.ReadWrite.All', + 'https://graph.microsoft.com/ExternalConnection.ReadWrite.All', + 'https://graph.microsoft.com/ExternalItem.ReadWrite.All', + 'https://graph.microsoft.com/Group.ReadWrite.All', + 'https://graph.microsoft.com/IdentityProvider.ReadWrite.All', + 'https://graph.microsoft.com/InformationProtectionPolicy.Read', + 'https://graph.microsoft.com/Mail.Read.Shared', + 'https://graph.microsoft.com/Mail.ReadWrite', + 'https://graph.microsoft.com/Mail.Send', + 'https://graph.microsoft.com/Notes.ReadWrite.All', + 'https://graph.microsoft.com/OnlineMeetingArtifact.Read.All', + 'https://graph.microsoft.com/OnlineMeetings.ReadWrite', + 'https://graph.microsoft.com/OnlineMeetingTranscript.Read.All', + 'https://graph.microsoft.com/PeopleSettings.ReadWrite.All', + 'https://graph.microsoft.com/Place.Read.All', + 'https://graph.microsoft.com/Policy.Read.All', + 'https://graph.microsoft.com/RecordsManagement.ReadWrite.All', + 'https://graph.microsoft.com/Reports.Read.All', + 'https://graph.microsoft.com/RoleAssignmentSchedule.ReadWrite.Directory', + 'https://graph.microsoft.com/RoleEligibilitySchedule.Read.Directory', + 'https://graph.microsoft.com/SecurityEvents.Read.All', + 'https://graph.microsoft.com/ServiceHealth.Read.All', + 'https://graph.microsoft.com/ServiceMessage.Read.All', + 'https://graph.microsoft.com/ServiceMessageViewpoint.Write', + 'https://graph.microsoft.com/Sites.Read.All', + 'https://graph.microsoft.com/Tasks.ReadWrite', + 'https://graph.microsoft.com/Team.Create', + 'https://graph.microsoft.com/TeamMember.ReadWrite.All', + 'https://graph.microsoft.com/TeamsAppInstallation.ReadWriteForUser', + 'https://graph.microsoft.com/TeamSettings.ReadWrite.All', + 'https://graph.microsoft.com/TeamsTab.ReadWrite.All', + 'https://graph.microsoft.com/User.Invite.All', + 'https://manage.office.com/ActivityFeed.Read', + 'https://manage.office.com/ServiceHealth.Read', + 'https://analysis.windows.net/powerbi/api/Dataset.Read.All', + 'https://api.powerapps.com//User', + 'https://microsoft.sharepoint-df.com/AllSites.FullControl', + 'https://microsoft.sharepoint-df.com/TermStore.ReadWrite.All', + 'https://microsoft.sharepoint-df.com/User.ReadWrite.All' + ], applicationName: `CLI for Microsoft 365 v${app.packageJson().version}`, delimiter: 'm365\$', - cliEntraAppId: process.env.CLIMICROSOFT365_ENTRAAPPID || process.env.CLIMICROSOFT365_AADAPPID || cliEntraAppId, - tenant: process.env.CLIMICROSOFT365_TENANT || 'common', - configstoreName: 'cli-m365-config' + configstoreName: 'cli-m365-config', + minimalScopes: [ + 'https://graph.microsoft.com/User.Read' + ] }; \ No newline at end of file diff --git a/src/index.spec.ts b/src/index.spec.ts index 2da5b5c2bca..e85381a1405 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -64,6 +64,7 @@ describe('Lazy loading commands', () => { 'entra sp add', 'entra sp get', 'entra sp list', + 'entra sp remove', 'entra appregistration add', 'entra appregistration get', 'entra appregistration list', diff --git a/src/m365/base/PowerAutomateCommand.spec.ts b/src/m365/base/PowerAutomateCommand.spec.ts index 0ba57dbc938..d00eb7d393c 100644 --- a/src/m365/base/PowerAutomateCommand.spec.ts +++ b/src/m365/base/PowerAutomateCommand.spec.ts @@ -48,8 +48,7 @@ describe('PowerAutomateCommand', () => { }); it('returns correct resource', () => { - const command = new MockCommand(); - assert.strictEqual((command as any).resource, 'https://api.flow.microsoft.com'); + assert.strictEqual(MockCommand.resource, 'https://api.flow.microsoft.com'); }); it(`doesn't throw error when not connected`, async () => { diff --git a/src/m365/base/PowerAutomateCommand.ts b/src/m365/base/PowerAutomateCommand.ts index b184f4d483e..a66f9d40ff5 100644 --- a/src/m365/base/PowerAutomateCommand.ts +++ b/src/m365/base/PowerAutomateCommand.ts @@ -4,7 +4,7 @@ import Command, { CommandArgs, CommandError } from '../../Command.js'; import { accessToken } from '../../utils/accessToken.js'; export default abstract class PowerAutomateCommand extends Command { - protected get resource(): string { + public static get resource(): string { return 'https://api.flow.microsoft.com'; } diff --git a/src/m365/base/SpoCommand.spec.ts b/src/m365/base/SpoCommand.spec.ts index 68583aea381..9d77500e7eb 100644 --- a/src/m365/base/SpoCommand.spec.ts +++ b/src/m365/base/SpoCommand.spec.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import sinon from 'sinon'; import { telemetry } from '../../telemetry.js'; -import auth from '../../Auth.js'; +import auth, { AuthType } from '../../Auth.js'; import { Logger } from '../../cli/Logger.js'; import { CommandError } from '../../Command.js'; import request from '../../request.js'; @@ -235,7 +235,7 @@ describe('SpoCommand', () => { }); it('Shows an error when CLI is connected with authType "Secret"', async () => { - sinon.stub(auth.connection, 'authType').value(5); + sinon.stub(auth.connection, 'authType').value(AuthType.Secret); const mock = new MockCommand(); await assert.rejects(mock.action(logger, { options: {} }), diff --git a/src/m365/base/SpoCommand.ts b/src/m365/base/SpoCommand.ts index d38bc2088a9..fba2e6b4dc7 100644 --- a/src/m365/base/SpoCommand.ts +++ b/src/m365/base/SpoCommand.ts @@ -110,7 +110,7 @@ export default abstract class SpoCommand extends Command { throw new CommandError(error); } - if (auth.connection.active && AuthType[auth.connection.authType] === AuthType[AuthType.Secret]) { + if (auth.connection.active && auth.connection.authType === AuthType.Secret) { throw new CommandError(`SharePoint does not support authentication using client ID and secret. Please use a different login type to use SharePoint commands.`); } diff --git a/src/m365/cli/commands/cli-consent.spec.ts b/src/m365/cli/commands/cli-consent.spec.ts index edbf11b5271..2316406f4a7 100644 --- a/src/m365/cli/commands/cli-consent.spec.ts +++ b/src/m365/cli/commands/cli-consent.spec.ts @@ -3,27 +3,23 @@ import sinon from 'sinon'; import { cli } from '../../../cli/cli.js'; import { CommandInfo } from '../../../cli/CommandInfo.js'; import { Logger } from '../../../cli/Logger.js'; -import config from '../../../config.js'; import { telemetry } from '../../../telemetry.js'; import { pid } from '../../../utils/pid.js'; import { session } from '../../../utils/session.js'; import commands from '../commands.js'; import command from './cli-consent.js'; +import { sinonUtil } from '../../../utils/sinonUtil.js'; describe(commands.CONSENT, () => { let log: any[]; let logger: Logger; let loggerLogSpy: any; let commandInfo: CommandInfo; - let originalTenant: string; - let originalAadAppId: string; before(() => { sinon.stub(telemetry, 'trackEvent').callsFake(() => { }); sinon.stub(pid, 'getProcessName').callsFake(() => ''); sinon.stub(session, 'getId').callsFake(() => ''); - originalTenant = config.tenant; - originalAadAppId = config.cliEntraAppId; commandInfo = cli.getCommandInfo(command); }); @@ -44,8 +40,11 @@ describe(commands.CONSENT, () => { }); afterEach(() => { - config.tenant = originalTenant; - config.cliEntraAppId = originalAadAppId; + sinonUtil.restore([ + cli.getTenant, + cli.getClientId, + (command as any).warn + ]); }); after(() => { @@ -60,21 +59,17 @@ describe(commands.CONSENT, () => { assert.notStrictEqual(command.description, null); }); - it('shows consent URL for VivaEngage permissions for the default multi-tenant app', async () => { + it('shows consent URL for VivaEngage permissions for a custom single-tenant app', async () => { + sinon.stub(cli, 'getTenant').returns('fb5cb38f-ecdb-4c6a-a93b-b8cfd56b4a89'); + sinon.stub(cli, 'getClientId').returns('2587b55d-a41e-436d-bb1d-6223eb185dd4'); await command.action(logger, { options: { service: 'VivaEngage' } }); - assert(loggerLogSpy.calledWith(`To consent permissions for executing VivaEngage commands, navigate in your web browser to https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=31359c7f-bd7e-475c-86db-fdb8c937548e&response_type=code&scope=https%3A%2F%2Fapi.yammer.com%2Fuser_impersonation`)); + assert(loggerLogSpy.calledWith(`To consent permissions for executing VivaEngage commands, navigate in your web browser to https://login.microsoftonline.com/fb5cb38f-ecdb-4c6a-a93b-b8cfd56b4a89/oauth2/v2.0/authorize?client_id=2587b55d-a41e-436d-bb1d-6223eb185dd4&response_type=code&scope=https%3A%2F%2Fapi.yammer.com%2Fuser_impersonation`)); }); - it('shows consent URL for yammer permissions for the default multi-tenant app', async () => { + it('shows warning for Yammer permissions', async () => { + const warnSpy = sinon.spy(command as any, 'warn'); await command.action(logger, { options: { service: 'yammer' } }); - assert(loggerLogSpy.calledWith(`To consent permissions for executing yammer commands, navigate in your web browser to https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=31359c7f-bd7e-475c-86db-fdb8c937548e&response_type=code&scope=https%3A%2F%2Fapi.yammer.com%2Fuser_impersonation`)); - }); - - it('shows consent URL for VivaEngage permissions for a custom single-tenant app', async () => { - config.tenant = 'fb5cb38f-ecdb-4c6a-a93b-b8cfd56b4a89'; - config.cliEntraAppId = '2587b55d-a41e-436d-bb1d-6223eb185dd4'; - await command.action(logger, { options: { service: 'VivaEngage' } }); - assert(loggerLogSpy.calledWith(`To consent permissions for executing VivaEngage commands, navigate in your web browser to https://login.microsoftonline.com/fb5cb38f-ecdb-4c6a-a93b-b8cfd56b4a89/oauth2/v2.0/authorize?client_id=2587b55d-a41e-436d-bb1d-6223eb185dd4&response_type=code&scope=https%3A%2F%2Fapi.yammer.com%2Fuser_impersonation`)); + assert(warnSpy.called); }); it('supports specifying service', () => { diff --git a/src/m365/cli/commands/cli-consent.ts b/src/m365/cli/commands/cli-consent.ts index b97f4d954e1..50c4be25fb0 100644 --- a/src/m365/cli/commands/cli-consent.ts +++ b/src/m365/cli/commands/cli-consent.ts @@ -1,5 +1,5 @@ +import { cli } from '../../../cli/cli.js'; import { Logger } from '../../../cli/Logger.js'; -import config from '../../../config.js'; import GlobalOptions from '../../../GlobalOptions.js'; import AnonymousCommand from '../../base/AnonymousCommand.js'; import commands from '../commands.js'; @@ -68,7 +68,7 @@ class CliConsentCommand extends AnonymousCommand { break; } - await logger.log(`To consent permissions for executing ${args.options.service} commands, navigate in your web browser to https://login.microsoftonline.com/${config.tenant}/oauth2/v2.0/authorize?client_id=${config.cliEntraAppId}&response_type=code&scope=${encodeURIComponent(scope)}`); + await logger.log(`To consent permissions for executing ${args.options.service} commands, navigate in your web browser to https://login.microsoftonline.com/${cli.getTenant()}/oauth2/v2.0/authorize?client_id=${cli.getClientId()}&response_type=code&scope=${encodeURIComponent(scope)}`); } public async action(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/cli/commands/cli-doctor.spec.ts b/src/m365/cli/commands/cli-doctor.spec.ts index b020c4ebecd..9425702c642 100644 --- a/src/m365/cli/commands/cli-doctor.spec.ts +++ b/src/m365/cli/commands/cli-doctor.spec.ts @@ -2,7 +2,7 @@ import assert from 'assert'; import { createRequire } from 'module'; import os from 'os'; import sinon from 'sinon'; -import auth from '../../../Auth.js'; +import auth, { AuthType } from '../../../Auth.js'; import { cli } from '../../../cli/cli.js'; import { Logger } from '../../../cli/Logger.js'; import { telemetry } from '../../../telemetry.js'; @@ -83,14 +83,16 @@ describe(commands.DOCTOR, () => { sinon.stub(os, 'release').returns('10.0.19043'); sinon.stub(packageJSON, 'version').value('3.11.0'); sinon.stub(process, 'version').value('v14.17.0'); - sinon.stub(auth.connection, 'appId').value('31359c7f-bd7e-475c-86db-fdb8c937548e'); + // must be a direct assignment rather than a stub, because appId is optional + // and undefined by default, which means it can't be stubbed + auth.connection.appId = '31359c7f-bd7e-475c-86db-fdb8c937548e'; sinon.stub(auth.connection, 'tenant').value('common'); - sinon.stub(auth.connection, 'authType').value(0); + sinon.stub(auth.connection, 'authType').value(AuthType.DeviceCode); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); await command.action(logger, { options: {} }); assert(loggerLogSpy.calledWith({ - authMode: 'DeviceCode', + authMode: 'deviceCode', cliAadAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', cliAadAppTenant: 'common', cliEnvironment: '', @@ -156,14 +158,14 @@ describe(commands.DOCTOR, () => { sinon.stub(os, 'release').returns('10.0.19043'); sinon.stub(packageJSON, 'version').value('3.11.0'); sinon.stub(process, 'version').value('v14.17.0'); - sinon.stub(auth.connection, 'appId').value('31359c7f-bd7e-475c-86db-fdb8c937548e'); + auth.connection.appId = '31359c7f-bd7e-475c-86db-fdb8c937548e'; sinon.stub(auth.connection, 'tenant').value('common'); - sinon.stub(auth.connection, 'authType').value(0); + sinon.stub(auth.connection, 'authType').value(AuthType.DeviceCode); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); await command.action(logger, { options: {} }); assert(loggerLogSpy.calledWith({ - authMode: 'DeviceCode', + authMode: 'deviceCode', cliAadAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', cliAadAppTenant: 'common', cliEnvironment: '', @@ -205,12 +207,12 @@ describe(commands.DOCTOR, () => { sinon.stub(process, 'version').value('v14.17.0'); sinon.stub(auth.connection, 'appId').value('31359c7f-bd7e-475c-86db-fdb8c937548e'); sinon.stub(auth.connection, 'tenant').value('common'); - sinon.stub(auth.connection, 'authType').value(0); + sinon.stub(auth.connection, 'authType').value(AuthType.DeviceCode); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); await command.action(logger, { options: {} }); assert(loggerLogSpy.calledWith({ - authMode: 'DeviceCode', + authMode: 'deviceCode', cliAadAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', cliAadAppTenant: 'common', cliEnvironment: '', @@ -249,12 +251,12 @@ describe(commands.DOCTOR, () => { sinon.stub(process, 'version').value('v14.17.0'); sinon.stub(auth.connection, 'appId').value('31359c7f-bd7e-475c-86db-fdb8c937548e'); sinon.stub(auth.connection, 'tenant').value('common'); - sinon.stub(auth.connection, 'authType').value(0); + sinon.stub(auth.connection, 'authType').value(AuthType.DeviceCode); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); await command.action(logger, { options: {} }); assert(loggerLogSpy.calledWith({ - authMode: 'DeviceCode', + authMode: 'deviceCode', cliAadAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', cliAadAppTenant: 'common', cliEnvironment: '', @@ -286,12 +288,12 @@ describe(commands.DOCTOR, () => { sinon.stub(process, 'version').value('v14.17.0'); sinon.stub(auth.connection, 'appId').value('31359c7f-bd7e-475c-86db-fdb8c937548e'); sinon.stub(auth.connection, 'tenant').value('common'); - sinon.stub(auth.connection, 'authType').value(0); + sinon.stub(auth.connection, 'authType').value(AuthType.DeviceCode); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); await command.action(logger, { options: {} }); assert(loggerLogSpy.calledWith({ - authMode: 'DeviceCode', + authMode: 'deviceCode', cliAadAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', cliAadAppTenant: 'common', cliEnvironment: '', @@ -330,12 +332,12 @@ describe(commands.DOCTOR, () => { sinon.stub(process, 'version').value('v14.17.0'); sinon.stub(auth.connection, 'appId').value('31359c7f-bd7e-475c-86db-fdb8c937548e'); sinon.stub(auth.connection, 'tenant').value('common'); - sinon.stub(auth.connection, 'authType').value(0); + sinon.stub(auth.connection, 'authType').value(AuthType.DeviceCode); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); await command.action(logger, { options: {} }); assert(loggerLogSpy.calledWith({ - authMode: 'DeviceCode', + authMode: 'deviceCode', cliAadAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', cliAadAppTenant: 'common', cliEnvironment: '', @@ -366,12 +368,12 @@ describe(commands.DOCTOR, () => { sinon.stub(process, 'version').value('v14.17.0'); sinon.stub(auth.connection, 'appId').value('31359c7f-bd7e-475c-86db-fdb8c937548e'); sinon.stub(auth.connection, 'tenant').value('common'); - sinon.stub(auth.connection, 'authType').value(2); + sinon.stub(auth.connection, 'authType').value(AuthType.Certificate); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); await command.action(logger, { options: {} }); assert(loggerLogSpy.calledWith({ - authMode: 'Certificate', + authMode: 'certificate', cliAadAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', cliAadAppTenant: 'common', cliEnvironment: '', @@ -402,12 +404,12 @@ describe(commands.DOCTOR, () => { sinon.stub(process, 'version').value('v14.17.0'); sinon.stub(auth.connection, 'appId').value('31359c7f-bd7e-475c-86db-fdb8c937548e'); sinon.stub(auth.connection, 'tenant').value('923d42f0-6d23-41eb-b68d-c036d242654f'); - sinon.stub(auth.connection, 'authType').value(2); + sinon.stub(auth.connection, 'authType').value(AuthType.Certificate); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); await command.action(logger, { options: { debug: true } }); assert(loggerLogSpy.calledWith({ - authMode: 'Certificate', + authMode: 'certificate', cliAadAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', cliAadAppTenant: 'single', cliEnvironment: '', @@ -438,12 +440,12 @@ describe(commands.DOCTOR, () => { sinon.stub(process, 'version').value('v14.17.0'); sinon.stub(auth.connection, 'appId').value('31359c7f-bd7e-475c-86db-fdb8c937548e'); sinon.stub(auth.connection, 'tenant').value('common'); - sinon.stub(auth.connection, 'authType').value(2); + sinon.stub(auth.connection, 'authType').value(AuthType.Certificate); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); await command.action(logger, { options: { debug: true } }); assert(loggerLogSpy.calledWith({ - authMode: 'Certificate', + authMode: 'certificate', cliAadAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', cliAadAppTenant: 'common', cliEnvironment: '', @@ -474,12 +476,12 @@ describe(commands.DOCTOR, () => { sinon.stub(process, 'version').value('v14.17.0'); sinon.stub(auth.connection, 'appId').value('31359c7f-bd7e-475c-86db-fdb8c937548e'); sinon.stub(auth.connection, 'tenant').value('common'); - sinon.stub(auth.connection, 'authType').value(2); + sinon.stub(auth.connection, 'authType').value(AuthType.Certificate); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': 'docker' }); await command.action(logger, { options: { debug: true } }); assert(loggerLogSpy.calledWith({ - authMode: 'Certificate', + authMode: 'certificate', cliAadAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', cliAadAppTenant: 'common', cliEnvironment: 'docker', @@ -503,12 +505,12 @@ describe(commands.DOCTOR, () => { sinon.stub(process, 'version').value('v14.17.0'); sinon.stub(auth.connection, 'appId').value('31359c7f-bd7e-475c-86db-fdb8c937548e'); sinon.stub(auth.connection, 'tenant').value('common'); - sinon.stub(auth.connection, 'authType').value(2); + sinon.stub(auth.connection, 'authType').value(AuthType.Certificate); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); await command.action(logger, { options: { debug: true } }); assert(loggerLogSpy.calledWith({ - authMode: 'Certificate', + authMode: 'certificate', cliAadAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', cliAadAppTenant: 'common', cliEnvironment: '', @@ -533,12 +535,12 @@ describe(commands.DOCTOR, () => { sinon.stub(process, 'version').value('v14.17.0'); sinon.stub(auth.connection, 'appId').value('31359c7f-bd7e-475c-86db-fdb8c937548e'); sinon.stub(auth.connection, 'tenant').value('common'); - sinon.stub(auth.connection, 'authType').value(2); + sinon.stub(auth.connection, 'authType').value(AuthType.Certificate); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); await command.action(logger, { options: { debug: true } }); assert(loggerLogSpy.calledWith({ - authMode: 'Certificate', + authMode: 'certificate', cliAadAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', cliAadAppTenant: 'common', cliEnvironment: '', @@ -569,14 +571,14 @@ describe(commands.DOCTOR, () => { sinon.stub(process, 'version').value('v14.17.0'); sinon.stub(auth.connection, 'appId').value('31359c7f-bd7e-475c-86db-fdb8c937548e'); sinon.stub(auth.connection, 'tenant').value('common'); - sinon.stub(auth.connection, 'authType').value(0); + sinon.stub(auth.connection, 'authType').value(AuthType.DeviceCode); sinon.stub(process, 'env').value({ 'CLIMICROSOFT365_ENV': '' }); sinonUtil.restore(cli.getConfig().all); sinon.stub(cli.getConfig(), 'all').value({ "showHelpOnFailure": false }); await command.action(logger, { options: {} }); assert(loggerLogSpy.calledWith({ - authMode: 'DeviceCode', + authMode: 'deviceCode', cliAadAppId: '31359c7f-bd7e-475c-86db-fdb8c937548e', cliAadAppTenant: 'common', cliEnvironment: '', diff --git a/src/m365/cli/commands/cli-doctor.ts b/src/m365/cli/commands/cli-doctor.ts index c3ec399c2ec..9070ef5faa0 100644 --- a/src/m365/cli/commands/cli-doctor.ts +++ b/src/m365/cli/commands/cli-doctor.ts @@ -1,5 +1,5 @@ import os from 'os'; -import auth, { AuthType } from '../../../Auth.js'; +import auth from '../../../Auth.js'; import { cli } from '../../../cli/cli.js'; import { Logger } from '../../../cli/Logger.js'; import Command from '../../../Command.js'; @@ -14,7 +14,7 @@ interface CliDiagnosticInfo { release: string; }; authMode: string; - cliAadAppId: string; + cliAadAppId?: string; cliAadAppTenant: string; cliEnvironment: string; nodeVersion: string; @@ -57,7 +57,7 @@ class CliDoctorCommand extends Command { nodeVersion: process.version, cliAadAppId: auth.connection.appId, cliAadAppTenant: validation.isValidGuid(auth.connection.tenant) ? 'single' : auth.connection.tenant, - authMode: AuthType[auth.connection.authType], + authMode: auth.connection.authType, cliEnvironment: process.env.CLIMICROSOFT365_ENV ? process.env.CLIMICROSOFT365_ENV : '', cliConfig: cli.getConfig().all, roles: roles, diff --git a/src/m365/cli/commands/cli-reconsent.spec.ts b/src/m365/cli/commands/cli-reconsent.spec.ts index df8287108bd..cafe1ef12bd 100644 --- a/src/m365/cli/commands/cli-reconsent.spec.ts +++ b/src/m365/cli/commands/cli-reconsent.spec.ts @@ -9,6 +9,7 @@ import { pid } from '../../../utils/pid.js'; import { session } from '../../../utils/session.js'; import commands from '../commands.js'; import command from './cli-reconsent.js'; +import { sinonUtil } from '../../../utils/sinonUtil.js'; describe(commands.RECONSENT, () => { let log: string[]; @@ -45,6 +46,10 @@ describe(commands.RECONSENT, () => { loggerLogSpy.restore(); getSettingWithDefaultValueStub.restore(); openStub.restore(); + sinonUtil.restore([ + cli.getTenant, + cli.getClientId + ]); }); after(() => { @@ -60,11 +65,15 @@ describe(commands.RECONSENT, () => { }); it('shows message with url (not using autoOpenLinksInBrowser)', async () => { + sinon.stub(cli, 'getClientId').returns('31359c7f-bd7e-475c-86db-fdb8c937548e'); + sinon.stub(cli, 'getTenant').returns('common'); await command.action(logger, { options: {} }); - assert(loggerLogSpy.calledWith(`To re-consent the PnP Microsoft 365 Management Shell Microsoft Entra application navigate in your web browser to https://login.microsoftonline.com/common/oauth2/authorize?client_id=31359c7f-bd7e-475c-86db-fdb8c937548e&response_type=code&prompt=admin_consent`)); + assert(loggerLogSpy.calledWith(`To re-consent your Microsoft Entra application, navigate in your web browser to https://login.microsoftonline.com/common/oauth2/authorize?client_id=31359c7f-bd7e-475c-86db-fdb8c937548e&response_type=code&prompt=admin_consent.`)); }); it('shows message with url (using autoOpenLinksInBrowser)', async () => { + sinon.stub(cli, 'getClientId').returns('31359c7f-bd7e-475c-86db-fdb8c937548e'); + sinon.stub(cli, 'getTenant').returns('common'); getSettingWithDefaultValueStub.restore(); getSettingWithDefaultValueStub = sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((() => true)); @@ -81,6 +90,8 @@ describe(commands.RECONSENT, () => { }); it('throws error when open in browser fails', async () => { + sinon.stub(cli, 'getClientId').returns('31359c7f-bd7e-475c-86db-fdb8c937548e'); + sinon.stub(cli, 'getTenant').returns('common'); getSettingWithDefaultValueStub.restore(); getSettingWithDefaultValueStub = sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((() => true)); diff --git a/src/m365/cli/commands/cli-reconsent.ts b/src/m365/cli/commands/cli-reconsent.ts index 4130c49b772..0db740eadcb 100644 --- a/src/m365/cli/commands/cli-reconsent.ts +++ b/src/m365/cli/commands/cli-reconsent.ts @@ -1,13 +1,11 @@ import { cli } from '../../../cli/cli.js'; import { Logger } from '../../../cli/Logger.js'; -import config from '../../../config.js'; import { settingsNames } from '../../../settingsNames.js'; import { browserUtil } from '../../../utils/browserUtil.js'; import AnonymousCommand from '../../base/AnonymousCommand.js'; import commands from '../commands.js'; class CliReconsentCommand extends AnonymousCommand { - public get name(): string { return commands.RECONSENT; } @@ -17,10 +15,10 @@ class CliReconsentCommand extends AnonymousCommand { } public async commandAction(logger: Logger): Promise { - const url = `https://login.microsoftonline.com/${config.tenant}/oauth2/authorize?client_id=${config.cliEntraAppId}&response_type=code&prompt=admin_consent`; + const url = `https://login.microsoftonline.com/${cli.getTenant()}/oauth2/authorize?client_id=${cli.getClientId()}&response_type=code&prompt=admin_consent`; if (cli.getSettingWithDefaultValue(settingsNames.autoOpenLinksInBrowser, false) === false) { - await logger.log(`To re-consent the PnP Microsoft 365 Management Shell Microsoft Entra application navigate in your web browser to ${url}`); + await logger.log(`To re-consent your Microsoft Entra application, navigate in your web browser to ${url}.`); return; } diff --git a/src/m365/cli/commands/config/config-set.spec.ts b/src/m365/cli/commands/config/config-set.spec.ts index 922ee3252d9..15d74140f97 100644 --- a/src/m365/cli/commands/config/config-set.spec.ts +++ b/src/m365/cli/commands/config/config-set.spec.ts @@ -370,4 +370,29 @@ describe(commands.CONFIG_SET, () => { const actual = await command.validate({ options: { key: settingsNames.helpTarget, value: 'console' } }, commandInfo); assert.strictEqual(actual, true); }); + + it('fails validation if specified clientId is not a GUID', async () => { + const actual = await command.validate({ options: { key: settingsNames.clientId, value: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation if specified clientId is a GUID', async () => { + const actual = await command.validate({ options: { key: settingsNames.clientId, value: '00000000-0000-0000-c000-000000000001' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation if specified tenantId is not a GUID or common', async () => { + const actual = await command.validate({ options: { key: settingsNames.tenantId, value: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation if specified tenantId is a GUID', async () => { + const actual = await command.validate({ options: { key: settingsNames.tenantId, value: '00000000-0000-0000-c000-000000000001' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation if specified tenantId is common', async () => { + const actual = await command.validate({ options: { key: settingsNames.tenantId, value: 'common' } }, commandInfo); + assert.strictEqual(actual, true); + }); }); diff --git a/src/m365/cli/commands/config/config-set.ts b/src/m365/cli/commands/config/config-set.ts index 2b6dda26050..64c6e47ec59 100644 --- a/src/m365/cli/commands/config/config-set.ts +++ b/src/m365/cli/commands/config/config-set.ts @@ -1,7 +1,9 @@ +import { AuthType } from "../../../../Auth.js"; import { cli } from "../../../../cli/cli.js"; import { Logger } from "../../../../cli/Logger.js"; import GlobalOptions from "../../../../GlobalOptions.js"; import { settingsNames } from "../../../../settingsNames.js"; +import { validation } from "../../../../utils/validation.js"; import AnonymousCommand from "../../../base/AnonymousCommand.js"; import commands from "../../commands.js"; @@ -85,10 +87,9 @@ class CliConfigSetCommand extends AnonymousCommand { return `${args.options.value} is not a valid value for the option ${args.options.key}. Allowed values: ${cli.helpModes.join(', ')}`; } - const allowedAuthTypes = ['certificate', 'deviceCode', 'password', 'identity', 'browser', 'secret']; if (args.options.key === settingsNames.authType && - allowedAuthTypes.indexOf(args.options.value) === -1) { - return `${args.options.value} is not a valid value for the option ${args.options.key}. Allowed values: ${allowedAuthTypes.join(', ')}`; + !Object.values(AuthType).map(String).includes(args.options.value)) { + return `${args.options.value} is not a valid value for the option ${args.options.key}. Allowed values: ${Object.values(AuthType).join(', ')}`; } if (args.options.key === settingsNames.helpTarget && @@ -96,6 +97,16 @@ class CliConfigSetCommand extends AnonymousCommand { return `${args.options.value} is not a valid value for the option ${args.options.key}. Allowed values: ${cli.helpTargets.join(', ')}`; } + if (args.options.key === settingsNames.clientId && + !validation.isValidGuid(args.options.value)) { + return `${args.options.value} is not a valid value for the option ${args.options.key}. The value has to be a valid GUID.`; + } + + if (args.options.key === settingsNames.tenantId && + !(args.options.value === 'common' || validation.isValidGuid(args.options.value))) { + return `${args.options.value} is not a valid value for the option ${args.options.key}. The value has to be a valid GUID or 'common'.`; + } + return true; } ); diff --git a/src/m365/commands/ConnectionDetails.ts b/src/m365/commands/ConnectionDetails.ts index 6955371a745..e9a63223365 100644 --- a/src/m365/commands/ConnectionDetails.ts +++ b/src/m365/commands/ConnectionDetails.ts @@ -2,7 +2,7 @@ export interface ConnectionDetails { connectionName: string; connectedAs: string; authType: string; - appId: string; + appId?: string; appTenant: string; cloudType: string; } \ No newline at end of file diff --git a/src/m365/commands/login.spec.ts b/src/m365/commands/login.spec.ts index 7acf7aaa4aa..0fafe12e25e 100644 --- a/src/m365/commands/login.spec.ts +++ b/src/m365/commands/login.spec.ts @@ -1,4 +1,5 @@ import assert from 'assert'; +import Configstore from 'configstore'; import fs from 'fs'; import sinon from 'sinon'; import { z } from 'zod'; @@ -7,6 +8,7 @@ import { CommandArgs, CommandError } from '../../Command.js'; import { CommandInfo } from '../../cli/CommandInfo.js'; import { Logger } from '../../cli/Logger.js'; import { cli } from '../../cli/cli.js'; +import { settingsNames } from '../../settingsNames.js'; import { telemetry } from '../../telemetry.js'; import { pid } from '../../utils/pid.js'; import { session } from '../../utils/session.js'; @@ -19,6 +21,7 @@ describe(commands.LOGIN, () => { let logger: Logger; let commandInfo: CommandInfo; let commandOptionsSchema: z.ZodTypeAny; + let config: Configstore; before(() => { sinon.stub(auth, 'restoreAuth').callsFake(() => Promise.resolve()); @@ -33,6 +36,7 @@ describe(commands.LOGIN, () => { expiresOn: '123', accessToken: 'abc' }; + config = cli.getConfig(); }); beforeEach(() => { @@ -49,13 +53,14 @@ describe(commands.LOGIN, () => { } }; sinon.stub(auth.connection, 'deactivate').callsFake(() => { }); - sinon.stub(auth, 'ensureAccessToken').callsFake(() => { + sinon.stub(auth, 'ensureAccessToken').callsFake(async () => { auth.connection.name = '028de82d-7fd9-476e-a9fd-be9714280ff3'; auth.connection.identityName = 'alexw@contoso.com'; auth.connection.identityId = '028de82d-7fd9-476e-a9fd-be9714280ff3'; auth.connection.identityTenantId = 'db308122-52f3-4241-af92-1734aa6e2e50'; - return Promise.resolve(''); + return ''; }); + sinon.stub(config, 'get').returns(undefined); }); afterEach(() => { @@ -63,7 +68,8 @@ describe(commands.LOGIN, () => { fs.existsSync, fs.readFileSync, auth.connection.deactivate, - auth.ensureAccessToken + auth.ensureAccessToken, + config.get ]); }); @@ -86,17 +92,55 @@ describe(commands.LOGIN, () => { }); it('logs in to Microsoft 365', async () => { - await command.action(logger, { options: commandOptionsSchema.parse({}) }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000' + }) + }); + assert(auth.connection.active); + }); + + it('logs in to Microsoft 365 using appId and tenant set in CLI config', async () => { + sinonUtil.restore(config.get); + sinon.stub(config, 'get').callsFake(setting => { + if (setting === settingsNames.clientId) { + return '00000000-0000-0000-0000-000000000000'; + } + else if (setting === settingsNames.tenantId) { + return '00000000-0000-0000-0000-000000000000'; + } + else { + return undefined; + } + }); + await command.action(logger, { + options: commandOptionsSchema.parse({}) + }); assert(auth.connection.active); }); it('logs in to Microsoft 365 (debug)', async () => { - await command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + debug: true + }) + }); assert(auth.connection.active); }); it('logs in to Microsoft 365 using username and password when authType password set', async () => { - await command.action(logger, { options: commandOptionsSchema.parse({ authType: 'password', userName: 'user', password: 'password' }) }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'password', + userName: 'user', + password: 'password' + }) + }); assert.strictEqual(auth.connection.authType, AuthType.Password, 'Incorrect authType set'); assert.strictEqual(auth.connection.userName, 'user', 'Incorrect user name set'); assert.strictEqual(auth.connection.password, 'password', 'Incorrect password set'); @@ -106,159 +150,430 @@ describe(commands.LOGIN, () => { sinon.stub(fs, 'readFileSync').callsFake(() => 'certificate'); sinon.stub(fs, 'existsSync').callsFake(() => true); - await command.action(logger, { options: commandOptionsSchema.parse({ authType: 'certificate', certificateFile: 'certificate' }) }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'certificate', + certificateFile: 'certificate' + }) + }); assert.strictEqual(auth.connection.authType, AuthType.Certificate, 'Incorrect authType set'); assert.strictEqual(auth.connection.certificate, 'certificate', 'Incorrect certificate set'); }); + it('logs in to Microsoft 365 using certificate when authType certificate set and certificate password is provided', async () => { + sinon.stub(fs, 'readFileSync').callsFake(() => 'certificate'); + sinon.stub(fs, 'existsSync').callsFake(() => true); + + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'certificate', + certificateFile: 'certificate', + password: 'p@$$w0rd' + }) + }); + assert.strictEqual(auth.connection.authType, AuthType.Certificate, 'Incorrect authType set'); + assert.strictEqual(auth.connection.certificate, 'certificate', 'Incorrect certificate set'); + assert.strictEqual(auth.connection.password, 'p@$$w0rd', 'Incorrect password set'); + }); + + it('logs in to Microsoft 365 using certificate when authType certificate set and certificate password is empty', async () => { + sinon.stub(fs, 'readFileSync').callsFake(() => 'certificate'); + sinon.stub(fs, 'existsSync').callsFake(() => true); + + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'certificate', + certificateFile: 'certificate', + password: '' + }) + }); + assert.strictEqual(auth.connection.authType, AuthType.Certificate, 'Incorrect authType set'); + assert.strictEqual(auth.connection.certificate, 'certificate', 'Incorrect certificate set'); + assert.strictEqual(auth.connection.password, '', 'Incorrect password set'); + }); + it('logs in to Microsoft 365 using certificate when authType certificate set with thumbprint', async () => { sinon.stub(fs, 'readFileSync').callsFake(() => 'certificate'); sinon.stub(fs, 'existsSync').callsFake(() => true); - await command.action(logger, { options: commandOptionsSchema.parse({ authType: 'certificate', certificateFile: 'certificate', thumbprint: 'thumbprint' }) }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'certificate', + certificateFile: 'certificate', + thumbprint: 'thumbprint' + }) + }); assert.strictEqual(auth.connection.authType, AuthType.Certificate, 'Incorrect authType set'); assert.strictEqual(auth.connection.certificate, 'certificate', 'Incorrect certificate set'); assert.strictEqual(auth.connection.thumbprint, 'thumbprint', 'Incorrect thumbprint set'); }); it('logs in to Microsoft 365 using certificate when authType certificate set and certificateBase64Encoded is provided', async () => { - sinon.stub(fs, 'readFileSync').callsFake(() => 'certificate'); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'certificate', + certificateBase64Encoded: 'certificate', + thumbprint: 'thumbprint' + }) + }); + assert.strictEqual(auth.connection.authType, AuthType.Certificate, 'Incorrect authType set'); + assert.strictEqual(auth.connection.certificate, 'certificate', 'Incorrect certificate set'); + assert.strictEqual(auth.connection.thumbprint, 'thumbprint', 'Incorrect thumbprint set'); + }); - await command.action(logger, { options: commandOptionsSchema.parse({ authType: 'certificate', certificateBase64Encoded: 'certificate', thumbprint: 'thumbprint' }) }); + it('logs in to Microsoft 365 using certificate when authType certificate set and certificateBase64Encoded is set in CLI config', async () => { + sinonUtil.restore(config.get); + sinon.stub(config, 'get').callsFake(setting => { + if (setting === settingsNames.clientCertificateBase64Encoded) { + return 'certificate'; + } + else { + return undefined; + } + }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'certificate', + thumbprint: 'thumbprint' + }) + }); + assert.strictEqual(auth.connection.authType, AuthType.Certificate, 'Incorrect authType set'); + assert.strictEqual(auth.connection.certificate, 'certificate', 'Incorrect certificate set'); + assert.strictEqual(auth.connection.thumbprint, 'thumbprint', 'Incorrect thumbprint set'); + }); + + it('logs in to Microsoft 365 using certificate when authType certificate set and clientCertificatePassword is set in CLI config', async () => { + sinonUtil.restore(config.get); + sinon.stub(config, 'get').callsFake(setting => { + if (setting === settingsNames.clientCertificateBase64Encoded) { + return 'certificate'; + } + if (setting === settingsNames.clientCertificatePassword) { + return 'p@$$w0rd'; + } + return undefined; + }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'certificate' + }) + }); + assert.strictEqual(auth.connection.authType, AuthType.Certificate, 'Incorrect authType set'); + assert.strictEqual(auth.connection.certificate, 'certificate', 'Incorrect certificate set'); + assert.strictEqual(auth.connection.password, 'p@$$w0rd', 'Incorrect password set'); + }); + + it('logs in to Microsoft 365 using certificate when authType certificate set and certificateFile is set in CLI config', async () => { + sinonUtil.restore(config.get); + sinon.stub(config, 'get').callsFake(setting => { + if (setting === settingsNames.clientCertificateFile) { + return 'certificate'; + } + else { + return undefined; + } + }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'certificate', + thumbprint: 'thumbprint' + }) + }); assert.strictEqual(auth.connection.authType, AuthType.Certificate, 'Incorrect authType set'); assert.strictEqual(auth.connection.certificate, 'certificate', 'Incorrect certificate set'); assert.strictEqual(auth.connection.thumbprint, 'thumbprint', 'Incorrect thumbprint set'); }); it('logs in to Microsoft 365 using system managed identity when authType identity set', async () => { - await command.action(logger, { options: commandOptionsSchema.parse({ authType: 'identity', userName: 'ac9fbed5-804c-4362-a369-21a4ec51109e' }) }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'identity', + userName: 'ac9fbed5-804c-4362-a369-21a4ec51109e' + }) + }); assert.strictEqual(auth.connection.authType, AuthType.Identity, 'Incorrect authType set'); assert.strictEqual(auth.connection.userName, 'ac9fbed5-804c-4362-a369-21a4ec51109e', 'Incorrect userName set'); }); it('logs in to Microsoft 365 using user-assigned managed identity when authType identity set', async () => { - await command.action(logger, { options: commandOptionsSchema.parse({ authType: 'identity' }) }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'identity' + }) + }); assert.strictEqual(auth.connection.authType, AuthType.Identity, 'Incorrect authType set'); assert.strictEqual(auth.connection.userName, undefined, 'Incorrect userName set'); }); + it('logs in to Microsoft 365 using client secret authType "secret" set', async () => { - await command.action(logger, { options: commandOptionsSchema.parse({ authType: 'secret', secret: 'unBrEakaBle@123' }) }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'secret', + secret: 'unBrEakaBle@123' + }) + }); + assert.strictEqual(auth.connection.authType, AuthType.Secret, 'Incorrect authType set'); + assert.strictEqual(auth.connection.secret, 'unBrEakaBle@123', 'Incorrect secret set'); + }); + + it('logs in to Microsoft 365 using client secret authType "secret" with secret set in CLI config', async () => { + sinonUtil.restore(config.get); + sinon.stub(config, 'get').callsFake(setting => { + if (setting === settingsNames.clientSecret) { + return 'unBrEakaBle@123'; + } + else { + return undefined; + } + }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'secret' + }) + }); assert.strictEqual(auth.connection.authType, AuthType.Secret, 'Incorrect authType set'); assert.strictEqual(auth.connection.secret, 'unBrEakaBle@123', 'Incorrect secret set'); }); it('logs in to Microsoft 365 using the specified cloud', async () => { - await command.action(logger, { options: commandOptionsSchema.parse({ cloud: 'USGov' }) }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + cloud: 'USGov' + }) + }); assert.strictEqual(auth.connection.cloudType, CloudType.USGov); }); it('fails validation if invalid authType specified', () => { - const actual = commandOptionsSchema.safeParse({ authType: 'invalid authType' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'invalid authType' + }); assert.strictEqual(actual.success, false); }); it('fails validation if authType is set to password and userName and password not specified', () => { - const actual = commandOptionsSchema.safeParse({ authType: 'password' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'password' + }); assert.strictEqual(actual.success, false); }); it('fails validation if authType is set to password and userName not specified', () => { - const actual = commandOptionsSchema.safeParse({ authType: 'password', password: 'password' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'password', + password: 'password' + }); assert.strictEqual(actual.success, false); }); it('fails validation if authType is set to password and password not specified', () => { - const actual = commandOptionsSchema.safeParse({ authType: 'password', userName: 'user' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'password', + userName: 'user' + }); assert.strictEqual(actual.success, false); }); it('fails validation if authType is set to certificate and both certificateFile and certificateBase64Encoded are specified', () => { - const actual = commandOptionsSchema.safeParse({ authType: 'certificate', certificateFile: 'certificate', certificateBase64Encoded: 'certificateB64', thumbprint: 'thumbprint' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'certificate', + certificateFile: 'certificate', + certificateBase64Encoded: 'certificateB64', + thumbprint: 'thumbprint' + }); assert.strictEqual(actual.success, false); }); it('fails validation if authType is set to certificate and neither certificateFile nor certificateBase64Encoded are specified', () => { - const actual = commandOptionsSchema.safeParse({ authType: 'certificate' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'certificate' + }); assert.strictEqual(actual.success, false); }); it('fails validation if authType is set to certificate and certificateFile does not exist', () => { sinon.stub(fs, 'existsSync').callsFake(() => false); - const actual = commandOptionsSchema.safeParse({ authType: 'certificate', certificateFile: 'certificate' }); + const actual = commandOptionsSchema.safeParse({ + authType: 'certificate', + certificateFile: 'certificate' + }); assert.strictEqual(actual.success, false); }); it('fails validation cloud is set to an invalid value', () => { - const actual = commandOptionsSchema.safeParse({ cloud: 'invalid' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + cloud: 'invalid' + }); assert.strictEqual(actual.success, false); }); it('passes validation if authType is set to certificate and certificateFile and thumbprint are specified', () => { sinon.stub(fs, 'existsSync').callsFake(() => true); - const actual = commandOptionsSchema.safeParse({ authType: 'certificate', certificateFile: 'certificate', thumbprint: 'thumbprint' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'certificate', + certificateFile: 'certificate', + thumbprint: 'thumbprint' + }); assert.strictEqual(actual.success, true); }); it('passes validation if authType is set to certificate and certificateFile are specified', () => { sinon.stub(fs, 'existsSync').callsFake(() => true); - const actual = commandOptionsSchema.safeParse({ authType: 'certificate', certificateFile: 'certificate' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'certificate', + certificateFile: 'certificate' + }); assert.strictEqual(actual.success, true); }); it('passes validation if authType is set to password and userName and password specified', () => { - const actual = commandOptionsSchema.safeParse({ authType: 'password', userName: 'user', password: 'password' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'password', + userName: 'user', + password: 'password' + }); assert.strictEqual(actual.success, true); }); it('passes validation if authType is set to deviceCode and userName and password not specified', () => { - const actual = commandOptionsSchema.safeParse({ authType: 'deviceCode' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'deviceCode' + }); assert.strictEqual(actual.success, true); }); it('passes validation if authType is not set and userName and password not specified', () => { - const actual = commandOptionsSchema.safeParse({}); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000' + }); assert.strictEqual(actual.success, true); }); it('passes validation when cloud is set to Public', () => { - const actual = commandOptionsSchema.safeParse({ cloud: 'Public' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + cloud: 'Public' + }); assert.strictEqual(actual.success, true); }); it('passes validation when cloud is set to USGov', () => { - const actual = commandOptionsSchema.safeParse({ cloud: 'USGov' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + cloud: 'USGov' + }); assert.strictEqual(actual.success, true); }); it('passes validation when cloud is set to USGovHigh', () => { - const actual = commandOptionsSchema.safeParse({ cloud: 'USGovHigh' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + cloud: 'USGovHigh' + }); assert.strictEqual(actual.success, true); }); it('passes validation when cloud is set to USGovDoD', () => { - const actual = commandOptionsSchema.safeParse({ cloud: 'USGovDoD' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + cloud: 'USGovDoD' + }); assert.strictEqual(actual.success, true); }); it('passes validation when cloud is set to China', () => { - const actual = commandOptionsSchema.safeParse({ cloud: 'China' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + cloud: 'China' + }); assert.strictEqual(actual.success, true); }); it('correctly handles error in device code auth flow', async () => { sinonUtil.restore(auth.ensureAccessToken); sinon.stub(auth, 'ensureAccessToken').callsFake(() => { return Promise.reject(new Error('Error')); }); - await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({}) }), new CommandError('Error')); + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000' + }) + }), new CommandError('Error')); }); it('correctly handles error in device code auth flow (debug)', async () => { sinonUtil.restore(auth.ensureAccessToken); sinon.stub(auth, 'ensureAccessToken').callsFake(() => { return Promise.reject(new Error('Error')); }); - await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }), new CommandError('Error')); + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + debug: true + }) + }), new CommandError('Error')); }); it('logs in to Microsoft 365 using browser authentication', async () => { - await command.action(logger, { options: commandOptionsSchema.parse({ authType: 'browser' }) }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'browser' + }) + }); assert.strictEqual(auth.connection.authType, AuthType.Browser, 'Incorrect authType set'); }); @@ -267,7 +582,12 @@ describe(commands.LOGIN, () => { sinon.stub(auth, 'clearConnectionInfo').callsFake(() => Promise.reject('An error has occurred')); try { - await command.action(logger, { options: commandOptionsSchema.parse({}) }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000' + }) + }); } finally { sinonUtil.restore([ @@ -281,7 +601,13 @@ describe(commands.LOGIN, () => { sinon.stub(auth, 'clearConnectionInfo').callsFake(() => Promise.reject('An error has occurred')); try { - await command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) }); + await command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + debug: true + }) + }); } finally { sinonUtil.restore([ @@ -294,7 +620,13 @@ describe(commands.LOGIN, () => { sinonUtil.restore(auth.restoreAuth); sinon.stub(auth, 'restoreAuth').callsFake(() => Promise.reject('An error has occurred')); try { - await assert.rejects(command.action(logger, { options: commandOptionsSchema.parse({ debug: true }) } as any), new CommandError('An error has occurred')); + await assert.rejects(command.action(logger, { + options: commandOptionsSchema.parse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + debug: true + }) + } as any), new CommandError('An error has occurred')); } finally { sinonUtil.restore([ @@ -304,7 +636,11 @@ describe(commands.LOGIN, () => { }); it('fails validation if authType is set to secret and secret option is not specified', () => { - const actual = commandOptionsSchema.safeParse({ authType: 'secret' }); + const actual = commandOptionsSchema.safeParse({ + appId: '00000000-0000-0000-0000-000000000000', + tenant: '00000000-0000-0000-0000-000000000000', + authType: 'secret' + }); assert.strictEqual(actual.success, false); }); }); diff --git a/src/m365/commands/login.ts b/src/m365/commands/login.ts index 8d6c6522b43..345cc8fbb0d 100644 --- a/src/m365/commands/login.ts +++ b/src/m365/commands/login.ts @@ -6,14 +6,13 @@ import Command, { } from '../../Command.js'; import { Logger } from '../../cli/Logger.js'; import { cli } from '../../cli/cli.js'; -import config from '../../config.js'; import { settingsNames } from '../../settingsNames.js'; import { zod } from '../../utils/zod.js'; import commands from './commands.js'; const options = globalOptionsZod .extend({ - authType: zod.alias('t', z.enum(['certificate', 'deviceCode', 'password', 'identity', 'browser', 'secret']).optional()), + authType: zod.alias('t', z.nativeEnum(AuthType).optional()), cloud: z.nativeEnum(CloudType).optional().default(CloudType.Public), userName: zod.alias('u', z.string().optional()), password: zod.alias('p', z.string().optional()), @@ -50,20 +49,34 @@ class LoginCommand extends Command { public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { return schema + .refine(options => typeof options.appId !== 'undefined' || cli.getConfig().get(settingsNames.clientId), { + message: `appId is required. TIP: use the "m365 setup" command to configure the default appId` + }) .refine(options => options.authType !== 'password' || options.userName, { - message: 'Username is required when using password authentication' + message: 'Username is required when using password authentication', + path: ['userName'] }) .refine(options => options.authType !== 'password' || options.password, { - message: 'Password is required when using password authentication' + message: 'Password is required when using password authentication', + path: ['password'] }) .refine(options => options.authType !== 'certificate' || !(options.certificateFile && options.certificateBase64Encoded), { - message: 'Specify either certificateFile or certificateBase64Encoded, but not both.' + message: 'Specify either certificateFile or certificateBase64Encoded, but not both.', + path: ['certificateBase64Encoded'] }) - .refine(options => options.authType !== 'certificate' || options.certificateFile || options.certificateBase64Encoded, { - message: 'Specify either certificateFile or certificateBase64Encoded' + .refine(options => options.authType !== 'certificate' || + options.certificateFile || + options.certificateBase64Encoded || + cli.getConfig().get(settingsNames.clientCertificateFile) || + cli.getConfig().get(settingsNames.clientCertificateBase64Encoded), { + message: 'Specify either certificateFile or certificateBase64Encoded', + path: ['certificateFile'] }) - .refine(options => options.authType !== 'secret' || options.secret, { - message: 'Secret is required when using secret authentication' + .refine(options => options.authType !== 'secret' || + options.secret || + cli.getConfig().get(settingsNames.clientSecret), { + message: 'Secret is required when using secret authentication', + path: ['secret'] }); } @@ -75,14 +88,26 @@ class LoginCommand extends Command { const deactivate: () => void = (): void => auth.connection.deactivate(); + const getCertificate: (options: Options) => string | undefined = (options): string | undefined => { + // command args take precedence over settings + if (options.certificateFile) { + return fs.readFileSync(options.certificateFile).toString('base64'); + } + if (options.certificateBase64Encoded) { + return options.certificateBase64Encoded; + } + return cli.getConfig().get(settingsNames.clientCertificateFile) || + cli.getConfig().get(settingsNames.clientCertificateBase64Encoded); + }; + const login: () => Promise = async (): Promise => { if (this.verbose) { await logger.logToStderr(`Signing in to Microsoft 365...`); } const authType = args.options.authType || cli.getSettingWithDefaultValue(settingsNames.authType, 'deviceCode'); - auth.connection.appId = args.options.appId || config.cliEntraAppId; - auth.connection.tenant = args.options.tenant || config.tenant; + auth.connection.appId = args.options.appId || cli.getClientId(); + auth.connection.tenant = args.options.tenant || cli.getTenant(); auth.connection.name = args.options.connectionName; switch (authType) { @@ -93,9 +118,9 @@ class LoginCommand extends Command { break; case 'certificate': auth.connection.authType = AuthType.Certificate; - auth.connection.certificate = args.options.certificateBase64Encoded ? args.options.certificateBase64Encoded : fs.readFileSync(args.options.certificateFile as string, 'base64'); + auth.connection.certificate = getCertificate(args.options); auth.connection.thumbprint = args.options.thumbprint; - auth.connection.password = args.options.password; + auth.connection.password = args.options.password ?? cli.getConfig().get(settingsNames.clientCertificatePassword); break; case 'identity': auth.connection.authType = AuthType.Identity; @@ -106,7 +131,7 @@ class LoginCommand extends Command { break; case 'secret': auth.connection.authType = AuthType.Secret; - auth.connection.secret = args.options.secret; + auth.connection.secret = args.options.secret || cli.getConfig().get(settingsNames.clientSecret); break; } @@ -132,7 +157,6 @@ class LoginCommand extends Command { (details as any).accessToken = JSON.stringify(auth.connection.accessTokens, null, 2); } - await logger.log(details); }; diff --git a/src/m365/commands/setup.spec.ts b/src/m365/commands/setup.spec.ts index db90dc87f53..5ab03b57f7a 100644 --- a/src/m365/commands/setup.spec.ts +++ b/src/m365/commands/setup.spec.ts @@ -1,29 +1,40 @@ import assert from 'assert'; +import Configstore from 'configstore'; import sinon from 'sinon'; +import auth from '../../Auth.js'; import { cli } from '../../cli/cli.js'; import { CommandInfo } from '../../cli/CommandInfo.js'; import { Logger } from '../../cli/Logger.js'; +import { settingsNames } from '../../settingsNames.js'; import { telemetry } from '../../telemetry.js'; +import { accessToken } from '../../utils/accessToken.js'; +import { entraApp } from '../../utils/entraApp.js'; import { CheckStatus, formatting } from '../../utils/formatting.js'; import { pid } from '../../utils/pid.js'; +import { ConfirmationConfig, SelectionConfig } from '../../utils/prompt.js'; import { session } from '../../utils/session.js'; import { sinonUtil } from '../../utils/sinonUtil.js'; import commands from './commands.js'; -import command, { SettingNames } from './setup.js'; +import command, { CliExperience, CliUsageMode, EntraAppConfig, HelpMode, NewEntraAppScopes, Preferences, SettingNames } from './setup.js'; import { interactivePreset, powerShellPreset, scriptingPreset } from './setupPresets.js'; -import { ConfirmationConfig, SelectionConfig } from '../../utils/prompt.js'; describe(commands.SETUP, () => { let log: any[]; let logger: Logger; let loggerLogToStderrSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let config: Configstore; + let configSetSpy: sinon.SinonSpy; + let configDeleteSpy: sinon.SinonSpy; before(() => { sinon.stub(telemetry, 'trackEvent').callsFake(() => { }); sinon.stub(pid, 'getProcessName').callsFake(() => ''); sinon.stub(session, 'getId').callsFake(() => ''); commandInfo = cli.getCommandInfo(command); + config = cli.getConfig(); + configDeleteSpy = sinon.stub(config, 'delete').returns(); + configSetSpy = sinon.stub(config, 'set').returns(); }); beforeEach(() => { @@ -47,17 +58,28 @@ describe(commands.SETUP, () => { sinonUtil.restore([ (command as any).configureSettings, cli.promptForConfirmation, + cli.promptForInput, cli.promptForSelection, - cli.getConfig().set, - pid.isPowerShell + pid.isPowerShell, + auth.clearConnectionInfo, + auth.ensureAccessToken, + accessToken.getTenantIdFromAccessToken, + entraApp.resolveApis, + entraApp.createAppRegistration, + entraApp.grantAdminConsent ]); + configSetSpy.resetHistory(); + configDeleteSpy.resetHistory(); + auth.connection.accessTokens = {}; }); after(() => { sinonUtil.restore([ telemetry.trackEvent, pid.getProcessName, - session.getId + session.getId, + config.delete, + config.set ]); }); @@ -73,9 +95,9 @@ describe(commands.SETUP, () => { sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { switch (config.message) { case 'How do you plan to use the CLI?': - return 'Interactively'; + return CliUsageMode.Interactively; case 'How experienced are you in using the CLI?': - return 'Beginner'; + return CliExperience.Beginner; default: return ''; } @@ -89,25 +111,21 @@ describe(commands.SETUP, () => { } }); - const configureSettingsStub = sinon.stub(command as any, 'configureSettings').callsFake(() => { }); - - const expected: SettingNames = {}; - Object.assign(expected, interactivePreset); - expected.helpMode = 'full'; - (command as any).settings = expected; - await command.action(logger, { options: {} }); - assert(configureSettingsStub.calledWith(expected)); + assert(configSetSpy.calledWith(settingsNames.helpMode, HelpMode.Full), 'Incorrect help mode'); + Object.keys(interactivePreset).forEach(setting => { + assert(configSetSpy.calledWith(setting, (interactivePreset as any)[setting]), `Incorrect setting for ${setting}`); + }); }); it('sets correct settings for interactive, proficient', async () => { sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { switch (config.message) { case 'How do you plan to use the CLI?': - return 'Interactively'; + return CliUsageMode.Interactively; case 'How experienced are you in using the CLI?': - return 'Proficient'; + return CliExperience.Proficient; default: return ''; } @@ -120,25 +138,22 @@ describe(commands.SETUP, () => { return true; } }); - const configureSettingsStub = sinon.stub(command as any, 'configureSettings').callsFake(() => { }); - - const expected: SettingNames = {}; - Object.assign(expected, interactivePreset); - expected.helpMode = 'options'; - (command as any).settings = expected; await command.action(logger, { options: {} }); - assert(configureSettingsStub.calledWith(expected)); + assert(configSetSpy.calledWith(settingsNames.helpMode, HelpMode.Options), 'Incorrect help mode'); + Object.keys(interactivePreset).forEach(setting => { + assert(configSetSpy.calledWith(setting, (interactivePreset as any)[setting]), `Incorrect setting for ${setting}`); + }); }); it('sets correct settings for scripting, non-PowerShell, beginner', async () => { sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { switch (config.message) { case 'How do you plan to use the CLI?': - return 'Scripting'; + return CliUsageMode.Scripting; case 'How experienced are you in using the CLI?': - return 'Beginner'; + return CliExperience.Beginner; default: return ''; } @@ -151,25 +166,22 @@ describe(commands.SETUP, () => { return true; } }); - const configureSettingsStub = sinon.stub(command as any, 'configureSettings').callsFake(() => { }); - - const expected: SettingNames = {}; - Object.assign(expected, scriptingPreset); - expected.helpMode = 'full'; - (command as any).settings = expected; await command.action(logger, { options: {} }); - assert(configureSettingsStub.calledWith(expected)); + assert(configSetSpy.calledWith(settingsNames.helpMode, HelpMode.Full), 'Incorrect help mode'); + Object.keys(scriptingPreset).forEach(setting => { + assert(configSetSpy.calledWith(setting, (scriptingPreset as any)[setting]), `Incorrect setting for ${setting}`); + }); }); it('sets correct settings for scripting, PowerShell, beginner', async () => { sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { switch (config.message) { case 'How do you plan to use the CLI?': - return 'Scripting'; + return CliUsageMode.Scripting; case 'How experienced are you in using the CLI?': - return 'Beginner'; + return CliExperience.Beginner; default: return ''; } @@ -182,26 +194,25 @@ describe(commands.SETUP, () => { return true; } }); - const configureSettingsStub = sinon.stub(command as any, 'configureSettings').callsFake(() => { }); - - const expected: SettingNames = {}; - Object.assign(expected, scriptingPreset); - Object.assign(expected, powerShellPreset); - expected.helpMode = 'full'; - (command as any).settings = expected; await command.action(logger, { options: {} }); - assert(configureSettingsStub.calledWith(expected)); + assert(configSetSpy.calledWith(settingsNames.helpMode, HelpMode.Full), 'Incorrect help mode'); + Object.keys(scriptingPreset).forEach(setting => { + assert(configSetSpy.calledWith(setting, (scriptingPreset as any)[setting]), `Incorrect setting for ${setting}`); + }); + Object.keys(powerShellPreset).forEach(setting => { + assert(configSetSpy.calledWith(setting, (powerShellPreset as any)[setting]), `Incorrect setting for ${setting}`); + }); }); it('sets correct settings for scripting, non-PowerShell, proficient', async () => { sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { switch (config.message) { case 'How do you plan to use the CLI?': - return 'Scripting'; + return CliUsageMode.Scripting; case 'How experienced are you in using the CLI?': - return 'Proficient'; + return CliExperience.Proficient; default: return ''; } @@ -214,25 +225,22 @@ describe(commands.SETUP, () => { return true; } }); - const configureSettingsStub = sinon.stub(command as any, 'configureSettings').callsFake(() => { }); - - const expected: SettingNames = {}; - Object.assign(expected, scriptingPreset); - expected.helpMode = 'options'; - (command as any).settings = expected; await command.action(logger, { options: {} }); - assert(configureSettingsStub.calledWith(expected)); + assert(configSetSpy.calledWith(settingsNames.helpMode, HelpMode.Options), 'Incorrect help mode'); + Object.keys(scriptingPreset).forEach(setting => { + assert(configSetSpy.calledWith(setting, (scriptingPreset as any)[setting]), `Incorrect setting for ${setting}`); + }); }); it('sets correct settings for scripting, PowerShell, proficient', async () => { sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { switch (config.message) { case 'How do you plan to use the CLI?': - return 'Scripting'; + return CliUsageMode.Scripting; case 'How experienced are you in using the CLI?': - return 'Proficient'; + return CliExperience.Proficient; default: return ''; } @@ -245,26 +253,25 @@ describe(commands.SETUP, () => { return true; } }); - const configureSettingsStub = sinon.stub(command as any, 'configureSettings').callsFake(() => { }); - - const expected: SettingNames = {}; - Object.assign(expected, scriptingPreset); - Object.assign(expected, powerShellPreset); - expected.helpMode = 'options'; - (command as any).settings = expected; await command.action(logger, { options: {} }); - assert(configureSettingsStub.calledWith(expected)); + assert(configSetSpy.calledWith(settingsNames.helpMode, HelpMode.Options), 'Incorrect help mode'); + Object.keys(scriptingPreset).forEach(setting => { + assert(configSetSpy.calledWith(setting, (scriptingPreset as any)[setting]), `Incorrect setting for ${setting}`); + }); + Object.keys(powerShellPreset).forEach(setting => { + assert(configSetSpy.calledWith(setting, (powerShellPreset as any)[setting]), `Incorrect setting for ${setting}`); + }); }); it(`doesn't apply settings when not confirmed`, async () => { sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { switch (config.message) { case 'How do you plan to use the CLI?': - return 'Scripting'; + return CliUsageMode.Scripting; case 'How experienced are you in using the CLI?': - return 'Beginner'; + return CliExperience.Beginner; default: return ''; } @@ -285,61 +292,502 @@ describe(commands.SETUP, () => { }); it('sets correct settings for interactive, non-PowerShell via option', async () => { - const configureSettingsStub = sinon.stub(command as any, 'configureSettings').callsFake(() => { }); sinon.stub(pid, 'isPowerShell').returns(false); - const expected: SettingNames = {}; - Object.assign(expected, interactivePreset); - await command.action(logger, { options: { interactive: true } }); - assert(configureSettingsStub.calledWith(expected)); + Object.keys(interactivePreset).forEach(setting => { + assert(configSetSpy.calledWith(setting, (interactivePreset as any)[setting]), `Incorrect setting for ${setting}`); + }); }); it('sets correct settings for scripting, non-PowerShell via option', async () => { - const configureSettingsStub = sinon.stub(command as any, 'configureSettings').callsFake(() => { }); sinon.stub(pid, 'isPowerShell').returns(false); - const expected: SettingNames = {}; - Object.assign(expected, scriptingPreset); - await command.action(logger, { options: { scripting: true } }); - assert(configureSettingsStub.calledWith(expected)); + Object.keys(scriptingPreset).forEach(setting => { + assert(configSetSpy.calledWith(setting, (scriptingPreset as any)[setting]), `Incorrect setting for ${setting}`); + }); }); it('sets correct settings for interactive, PowerShell via option', async () => { - const configureSettingsStub = sinon.stub(command as any, 'configureSettings').callsFake(() => { }); sinon.stub(pid, 'isPowerShell').returns(true); - const expected: SettingNames = {}; - Object.assign(expected, interactivePreset); - await command.action(logger, { options: { interactive: true } }); - assert(configureSettingsStub.calledWith(expected)); + Object.keys(interactivePreset).forEach(setting => { + assert(configSetSpy.calledWith(setting, (interactivePreset as any)[setting]), `Incorrect setting for ${setting}`); + }); + Object.keys(powerShellPreset).forEach(setting => { + assert(configSetSpy.calledWith(setting, (powerShellPreset as any)[setting]), `Incorrect setting for ${setting}`); + }); }); it('sets correct settings for scripting, PowerShell via option', async () => { - const configureSettingsStub = sinon.stub(command as any, 'configureSettings').callsFake(() => { }); sinon.stub(pid, 'isPowerShell').returns(true); - const expected: SettingNames = {}; - Object.assign(expected, scriptingPreset); - Object.assign(expected, powerShellPreset); - await command.action(logger, { options: { scripting: true } }); - assert(configureSettingsStub.calledWith(expected)); + Object.keys(scriptingPreset).forEach(setting => { + assert(configSetSpy.calledWith(setting, (scriptingPreset as any)[setting]), `Incorrect setting for ${setting}`); + }); + Object.keys(powerShellPreset).forEach(setting => { + assert(configSetSpy.calledWith(setting, (powerShellPreset as any)[setting]), `Incorrect setting for ${setting}`); + }); + }); + + it('skips configuring Entra app when specified via args', async () => { + sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { + switch (config.message) { + case 'How do you plan to use the CLI?': + return CliUsageMode.Scripting; + case 'How experienced are you in using the CLI?': + return CliExperience.Proficient; + default: + return ''; + } + }); + sinon.stub(cli, 'promptForConfirmation').callsFake(async (config: ConfirmationConfig): Promise => { + switch (config.message) { + case 'Are you going to use the CLI in PowerShell?': + return true; + default: //summary + return true; + } + }); + + await command.action(logger, { options: { skipApp: true } }); + + const expected: SettingNames = { + clientId: '00000000-0000-0000-0000-000000000000', + tenantId: '00000000-0000-0000-0000-000000000000', + clientSecret: '', + clientCertificateFile: '', + clientCertificateBase64Encoded: '' + }; + Object.keys(expected).forEach(setting => { + assert(!configSetSpy.calledWith(setting), `Modified ${setting}`); + }); + }); + + it('configures existing public Entra app', async () => { + sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { + switch (config.message) { + case 'How do you plan to use the CLI?': + return CliUsageMode.Scripting; + case 'How experienced are you in using the CLI?': + return CliExperience.Proficient; + case 'CLI for Microsoft 365 requires a Microsoft Entra app. Do you want to create a new app registration or use an existing one?': + return EntraAppConfig.UseExisting; + default: + return ''; + } + }); + sinon.stub(cli, 'promptForConfirmation').callsFake(async (config: ConfirmationConfig): Promise => { + switch (config.message) { + case 'Are you going to use the CLI in PowerShell?': + return true; + default: //summary + return true; + } + }); + sinon.stub(cli, 'promptForInput').callsFake(async (config: { message: string }): Promise => { + switch (config.message) { + case 'Client ID:': + return '00000000-0000-0000-0000-000000000000'; + case 'Tenant ID (leave common if the app is multitenant):': + return '00000000-0000-0000-0000-000000000000'; + default: + return ''; + } + }); + + await command.action(logger, { options: {} }); + + const expected: SettingNames = { + clientId: '00000000-0000-0000-0000-000000000000', + tenantId: '00000000-0000-0000-0000-000000000000', + clientSecret: '', + clientCertificateFile: '', + clientCertificateBase64Encoded: '' + }; + Object.keys(expected).forEach(setting => { + assert(configSetSpy.calledWith(setting, (expected as any)[setting]), `Incorrect setting for ${setting}`); + }); + }); + + it('configures existing Entra app with secret', async () => { + sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { + switch (config.message) { + case 'How do you plan to use the CLI?': + return CliUsageMode.Scripting; + case 'How experienced are you in using the CLI?': + return CliExperience.Proficient; + case 'CLI for Microsoft 365 requires a Microsoft Entra app. Do you want to create a new app registration or use an existing one?': + return EntraAppConfig.UseExisting; + default: + return ''; + } + }); + sinon.stub(cli, 'promptForConfirmation').callsFake(async (config: ConfirmationConfig): Promise => { + switch (config.message) { + case 'Are you going to use the CLI in PowerShell?': + return true; + default: //summary + return true; + } + }); + sinon.stub(cli, 'promptForInput').callsFake(async (config: { message: string }): Promise => { + switch (config.message) { + case 'Client ID:': + return '00000000-0000-0000-0000-000000000000'; + case 'Tenant ID (leave common if the app is multitenant):': + return '00000000-0000-0000-0000-000000000000'; + case 'Client secret (leave empty if you use a certificate or a public client):': + return 'secret'; + default: + return ''; + } + }); + + await command.action(logger, { options: {} }); + + const expected: SettingNames = { + clientId: '00000000-0000-0000-0000-000000000000', + tenantId: '00000000-0000-0000-0000-000000000000', + clientSecret: 'secret' + }; + Object.keys(expected).forEach(setting => { + assert(configSetSpy.calledWith(setting, (expected as any)[setting]), `Incorrect setting for ${setting}`); + }); + }); + + it('configures existing Entra app with base64 cert', async () => { + sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { + switch (config.message) { + case 'How do you plan to use the CLI?': + return CliUsageMode.Scripting; + case 'How experienced are you in using the CLI?': + return CliExperience.Proficient; + case 'CLI for Microsoft 365 requires a Microsoft Entra app. Do you want to create a new app registration or use an existing one?': + return EntraAppConfig.UseExisting; + default: + return ''; + } + }); + sinon.stub(cli, 'promptForConfirmation').callsFake(async (config: ConfirmationConfig): Promise => { + switch (config.message) { + case 'Are you going to use the CLI in PowerShell?': + return true; + default: //summary + return true; + } + }); + sinon.stub(cli, 'promptForInput').callsFake(async (config: { message: string }): Promise => { + switch (config.message) { + case 'Client ID:': + return '00000000-0000-0000-0000-000000000000'; + case 'Tenant ID (leave common if the app is multitenant):': + return '00000000-0000-0000-0000-000000000000'; + case `Base64-encoded certificate string (leave empty if you don't connect using a certificate):`: + return 'base64'; + default: + return ''; + } + }); + + await command.action(logger, { options: {} }); + + const expected: SettingNames = { + clientId: '00000000-0000-0000-0000-000000000000', + tenantId: '00000000-0000-0000-0000-000000000000', + clientSecret: '', + clientCertificateFile: '', + clientCertificateBase64Encoded: 'base64' + }; + Object.keys(expected).forEach(setting => { + assert(configSetSpy.calledWith(setting, (expected as any)[setting]), `Incorrect setting for ${setting}.`); + }); + }); + + it('configures existing Entra app with file cert', async () => { + sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { + switch (config.message) { + case 'How do you plan to use the CLI?': + return CliUsageMode.Scripting; + case 'How experienced are you in using the CLI?': + return CliExperience.Proficient; + case 'CLI for Microsoft 365 requires a Microsoft Entra app. Do you want to create a new app registration or use an existing one?': + return EntraAppConfig.UseExisting; + default: + return ''; + } + }); + sinon.stub(cli, 'promptForConfirmation').callsFake(async (config: ConfirmationConfig): Promise => { + switch (config.message) { + case 'Are you going to use the CLI in PowerShell?': + return true; + default: //summary + return true; + } + }); + sinon.stub(cli, 'promptForInput').callsFake(async (config: { message: string }): Promise => { + switch (config.message) { + case 'Client ID:': + return '00000000-0000-0000-0000-000000000000'; + case 'Tenant ID (leave common if the app is multitenant):': + return '00000000-0000-0000-0000-000000000000'; + case 'Path to the client certificate file (leave empty if you want to specify a base64-encoded certificate string):': + return 'file'; + default: + return ''; + } + }); + + await command.action(logger, { options: {} }); + + const expected: SettingNames = { + clientId: '00000000-0000-0000-0000-000000000000', + tenantId: '00000000-0000-0000-0000-000000000000', + clientSecret: '', + clientCertificateFile: 'file' + }; + Object.keys(expected).forEach(setting => { + assert(configSetSpy.calledWith(setting, (expected as any)[setting]), `Incorrect setting for ${setting}`); + }); + }); + + it('configures existing Entra app with file cert secured with password', async () => { + sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { + switch (config.message) { + case 'How do you plan to use the CLI?': + return CliUsageMode.Scripting; + case 'How experienced are you in using the CLI?': + return CliExperience.Proficient; + case 'CLI for Microsoft 365 requires a Microsoft Entra app. Do you want to create a new app registration or use an existing one?': + return EntraAppConfig.UseExisting; + default: + return ''; + } + }); + sinon.stub(cli, 'promptForConfirmation').callsFake(async (config: ConfirmationConfig): Promise => { + switch (config.message) { + case 'Are you going to use the CLI in PowerShell?': + return true; + default: //summary + return true; + } + }); + sinon.stub(cli, 'promptForInput').callsFake(async (config: { message: string }): Promise => { + switch (config.message) { + case 'Client ID:': + return '00000000-0000-0000-0000-000000000000'; + case 'Tenant ID (leave common if the app is multitenant):': + return '00000000-0000-0000-0000-000000000000'; + case 'Path to the client certificate file (leave empty if you want to specify a base64-encoded certificate string):': + return 'file'; + case 'Password for the client certificate (leave empty if the certificate is not password-protected):': + return 'password'; + default: + return ''; + } + }); + + await command.action(logger, { options: {} }); + + const expected: SettingNames = { + clientId: '00000000-0000-0000-0000-000000000000', + tenantId: '00000000-0000-0000-0000-000000000000', + clientSecret: '', + clientCertificateFile: 'file', + clientCertificatePassword: 'password' + }; + Object.keys(expected).forEach(setting => { + assert(configSetSpy.calledWith(setting, (expected as any)[setting]), `Incorrect setting for ${setting}`); + }); + }); + + it('creates a new Entra app with minimal scopes', async () => { + sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { + switch (config.message) { + case 'How do you plan to use the CLI?': + return CliUsageMode.Scripting; + case 'How experienced are you in using the CLI?': + return CliExperience.Proficient; + case 'CLI for Microsoft 365 requires a Microsoft Entra app. Do you want to create a new app registration or use an existing one?': + return EntraAppConfig.Create; + case 'What scopes should the new app registration have?': + return NewEntraAppScopes.Minimal; + default: + return ''; + } + }); + sinon.stub(cli, 'promptForConfirmation').callsFake(async (config: ConfirmationConfig): Promise => { + switch (config.message) { + case 'Are you going to use the CLI in PowerShell?': + return true; + default: //summary + return true; + } + }); + sinon.stub(auth, 'clearConnectionInfo').resolves(); + sinon.stub(auth, 'ensureAccessToken').resolves(); + sinon.stub(accessToken, 'getTenantIdFromAccessToken').returns('00000000-0000-0000-0000-000000000003'); + const scopes = [ + { + resourceAppId: '00000000-0000-0000-0000-000000000000', + resourceAccess: [ + { + id: '00000000-0000-0000-0000-000000000000', + type: 'Minimal' + } + ] + } + ]; + sinon.stub(entraApp, 'resolveApis').resolves(scopes); + const createAppRegistrationSpy = sinon.stub(entraApp, 'createAppRegistration').resolves({ + appId: '00000000-0000-0000-0000-000000000001', + id: '00000000-0000-0000-0000-000000000002', + tenantId: '00000000-0000-0000-0000-000000000003', + requiredResourceAccess: scopes + }); + sinon.stub(entraApp, 'grantAdminConsent').resolves(); + auth.connection.accessTokens[auth.defaultResource] = { + accessToken: 'abc', + expiresOn: new Date().toString() + }; + + await command.action(logger, { options: {} }); + + const expected: SettingNames = { + clientId: '00000000-0000-0000-0000-000000000001', + tenantId: '00000000-0000-0000-0000-000000000003' + }; + const deleted: SettingNames = { + clientSecret: '', + clientCertificateFile: '', + clientCertificateBase64Encoded: '', + clientCertificatePassword: '' + }; + assert.deepEqual(createAppRegistrationSpy.getCall(0).args[0].apis, scopes); + Object.keys(expected).forEach(setting => { + assert(configSetSpy.calledWith(setting, (expected as any)[setting]), `Incorrect setting for ${setting}`); + }); + Object.keys(deleted).forEach(setting => { + assert(configDeleteSpy.calledWith(setting), `Not deleted ${setting}`); + }); + }); + + it('creates a new Entra app with all scopes (verbose)', async () => { + sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { + switch (config.message) { + case 'How do you plan to use the CLI?': + return CliUsageMode.Scripting; + case 'How experienced are you in using the CLI?': + return CliExperience.Proficient; + case 'CLI for Microsoft 365 requires a Microsoft Entra app. Do you want to create a new app registration or use an existing one?': + return EntraAppConfig.Create; + case 'What scopes should the new app registration have?': + return NewEntraAppScopes.All; + default: + return ''; + } + }); + sinon.stub(cli, 'promptForConfirmation').callsFake(async (config: ConfirmationConfig): Promise => { + switch (config.message) { + case 'Are you going to use the CLI in PowerShell?': + return true; + default: //summary + return true; + } + }); + sinon.stub(auth, 'clearConnectionInfo').resolves(); + sinon.stub(auth, 'ensureAccessToken').resolves(); + sinon.stub(accessToken, 'getTenantIdFromAccessToken').returns('00000000-0000-0000-0000-000000000003'); + const scopes = [ + { + resourceAppId: '00000000-0000-0000-0000-000000000000', + resourceAccess: [ + { + id: '00000000-0000-0000-0000-000000000000', + type: 'All' + } + ] + } + ]; + sinon.stub(entraApp, 'resolveApis').resolves(scopes); + const createAppRegistrationSpy = sinon.stub(entraApp, 'createAppRegistration').resolves({ + appId: '00000000-0000-0000-0000-000000000001', + id: '00000000-0000-0000-0000-000000000002', + tenantId: '00000000-0000-0000-0000-000000000003', + requiredResourceAccess: scopes + }); + sinon.stub(entraApp, 'grantAdminConsent').resolves(); + auth.connection.accessTokens[auth.defaultResource] = { + accessToken: 'abc', + expiresOn: new Date().toString() + }; + + await command.action(logger, { options: { verbose: true } }); + + const expected: SettingNames = { + clientId: '00000000-0000-0000-0000-000000000001', + tenantId: '00000000-0000-0000-0000-000000000003' + }; + const deleted: SettingNames = { + clientSecret: '', + clientCertificateFile: '', + clientCertificateBase64Encoded: '', + clientCertificatePassword: '' + }; + assert.deepEqual(createAppRegistrationSpy.getCall(0).args[0].apis, scopes); + Object.keys(expected).forEach(setting => { + assert(configSetSpy.calledWith(setting, (expected as any)[setting]), `Incorrect setting for ${setting}`); + }); + Object.keys(deleted).forEach(setting => { + assert(configDeleteSpy.calledWith(setting), `Not deleted ${setting}`); + }); + }); + + it(`doesn't create a new Entra app when creation not confirmed`, async () => { + sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { + switch (config.message) { + case 'How do you plan to use the CLI?': + return CliUsageMode.Scripting; + case 'How experienced are you in using the CLI?': + return CliExperience.Proficient; + case 'CLI for Microsoft 365 requires a Microsoft Entra app. Do you want to create a new app registration or use an existing one?': + return EntraAppConfig.Create; + case 'What scopes should the new app registration have?': + return NewEntraAppScopes.Minimal; + default: + return ''; + } + }); + sinon.stub(cli, 'promptForConfirmation').callsFake(async (config: ConfirmationConfig): Promise => { + switch (config.message) { + case 'Are you going to use the CLI in PowerShell?': + return true; + case 'CLI for Microsoft 365 will now sign in to your Microsoft 365 tenant as Microsoft Azure CLI to create a new app registration. Continue?': + return false; + default: //summary + return true; + } + }); + const clearConnectionInfoSpy = sinon.stub(auth, 'clearConnectionInfo').resolves(); + + await assert.rejects(async () => await command.action(logger, { options: {} })); + assert(clearConnectionInfoSpy.notCalled); }); it('outputs settings to configure to console in debug mode', async () => { sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { switch (config.message) { case 'How do you plan to use the CLI?': - return 'Interactively'; + return CliUsageMode.Interactively; case 'How experienced are you in using the CLI?': - return 'Beginner'; + return CliExperience.Beginner; default: return ''; } @@ -352,12 +800,10 @@ describe(commands.SETUP, () => { return true; } }); - sinon.stub(cli.getConfig(), 'set').callsFake(() => { }); const expected: SettingNames = {}; Object.assign(expected, interactivePreset); - expected.helpMode = 'full'; - (command as any).settings = expected; + expected.helpMode = HelpMode.Full; await command.action(logger, { options: { debug: true } }); @@ -368,9 +814,9 @@ describe(commands.SETUP, () => { sinon.stub(cli, 'promptForSelection').callsFake(async (config: SelectionConfig): Promise => { switch (config.message) { case 'How do you plan to use the CLI?': - return 'Interactively'; + return CliUsageMode.Interactively; case 'How experienced are you in using the CLI?': - return 'Beginner'; + return CliExperience.Beginner; default: return ''; } @@ -383,12 +829,10 @@ describe(commands.SETUP, () => { return true; } }); - sinon.stub(cli.getConfig(), 'set').callsFake(() => { }); const expected: SettingNames = {}; Object.assign(expected, interactivePreset); - expected.helpMode = 'full'; - (command as any).settings = expected; + expected.helpMode = HelpMode.Full; await command.action(logger, { options: {} }); @@ -398,10 +842,13 @@ describe(commands.SETUP, () => { }); it('in the confirmation message lists all settings and their values', async () => { - const settings: SettingNames = {}; - Object.assign(settings, interactivePreset); - settings.helpMode = 'full'; - const actual = (command as any).getSummaryMessage(settings); + const preferences: Preferences = { + experience: CliExperience.Beginner, + usageMode: CliUsageMode.Interactively, + usedInPowerShell: false + }; + const settings = (command as any).getSettings(preferences); + const actual = (command as any).getSummaryMessage(preferences); for (const [key, value] of Object.entries(settings)) { assert(actual.indexOf(`- ${key}: ${value}`) > -1, `Expected ${key} to be set to ${value}`); diff --git a/src/m365/commands/setup.ts b/src/m365/commands/setup.ts index 31385a5c650..5844b95fe6d 100644 --- a/src/m365/commands/setup.ts +++ b/src/m365/commands/setup.ts @@ -1,20 +1,33 @@ import chalk from 'chalk'; import os from 'os'; +import auth, { AuthType } from '../../Auth.js'; import { cli } from '../../cli/cli.js'; import { Logger } from '../../cli/Logger.js'; +import config from '../../config.js'; import GlobalOptions from '../../GlobalOptions.js'; import { settingsNames } from '../../settingsNames.js'; +import { accessToken } from '../../utils/accessToken.js'; +import { AppCreationOptions, AppInfo, entraApp } from '../../utils/entraApp.js'; import { CheckStatus, formatting } from '../../utils/formatting.js'; import { pid } from '../../utils/pid.js'; +import { ConfirmationConfig, SelectionConfig } from '../../utils/prompt.js'; +import { validation } from '../../utils/validation.js'; import AnonymousCommand from '../base/AnonymousCommand.js'; import commands from './commands.js'; import { interactivePreset, powerShellPreset, scriptingPreset } from './setupPresets.js'; -import { ConfirmationConfig, SelectionConfig } from '../../utils/prompt.js'; -interface Preferences { - experience?: string; +export interface Preferences { + clientId?: string; + tenantId?: string; + clientSecret?: string; + clientCertificateFile?: string; + clientCertificateBase64Encoded?: string; + clientCertificatePassword?: string; + entraApp?: EntraAppConfig; + experience?: CliExperience; + newEntraAppScopes?: NewEntraAppScopes; summary?: boolean; - usageMode?: string; + usageMode?: CliUsageMode; usedInPowerShell?: boolean; } @@ -25,6 +38,33 @@ interface CommandArgs { interface Options extends GlobalOptions { interactive?: boolean; scripting?: boolean; + skipApp?: boolean; +} + +export enum CliUsageMode { + Interactively = 'interactively', + Scripting = 'scripting' +} + +export enum CliExperience { + Beginner = 'beginner', + Proficient = 'proficient' +} + +export enum EntraAppConfig { + Create = 'create', + UseExisting = 'useExisting', + Skip = 'skip' +} + +export enum NewEntraAppScopes { + Minimal = 'minimal', + All = 'all' +} + +export enum HelpMode { + Full = 'full', + Options = 'options' } export type SettingNames = { @@ -51,8 +91,9 @@ class SetupCommand extends AnonymousCommand { #initTelemetry(): void { this.telemetry.push((args: CommandArgs) => { const properties: any = { - interactive: args.options.interactive, - scripting: args.options.scripting + interactive: !!args.options.interactive, + scripting: !!args.options.scripting, + skipApp: !!args.options.skipApp }; Object.assign(this.telemetryProperties, properties); @@ -62,7 +103,8 @@ class SetupCommand extends AnonymousCommand { #initOptions(): void { this.options.unshift( { option: '--interactive' }, - { option: '--scripting' } + { option: '--scripting' }, + { option: '--skipApp' } ); } @@ -89,13 +131,13 @@ class SetupCommand extends AnonymousCommand { } else if (args.options.scripting) { Object.assign(settings, scriptingPreset); + } - if (pid.isPowerShell()) { - Object.assign(settings, powerShellPreset); - } + if (pid.isPowerShell()) { + Object.assign(settings, powerShellPreset); } - await this.configureSettings(settings, true, logger); + await this.configureSettings({ preferences: {}, settings, silent: true, logger }); return; } @@ -106,16 +148,47 @@ class SetupCommand extends AnonymousCommand { const preferences: Preferences = {}; - const usageModeConfig: SelectionConfig = { + if (!args.options.skipApp) { + const entraAppConfig: SelectionConfig = { + message: 'CLI for Microsoft 365 requires a Microsoft Entra app. Do you want to create a new app registration or use an existing one?', + choices: [ + { name: 'Create a new app registration', value: EntraAppConfig.Create }, + { name: 'Use an existing app registration', value: EntraAppConfig.UseExisting }, + { name: 'Skip configuring app registration', value: EntraAppConfig.Skip } + ] + }; + preferences.entraApp = await cli.promptForSelection(entraAppConfig); + switch (preferences.entraApp) { + case EntraAppConfig.Create: + const newEntraAppScopesConfig: SelectionConfig = { + message: 'What scopes should the new app registration have?', + choices: [ + { name: 'User.Read (you will need to add the necessary permissions yourself)', value: NewEntraAppScopes.Minimal }, + { name: 'All (easy way to use all CLI commands)', value: NewEntraAppScopes.All } + ] + }; + preferences.newEntraAppScopes = await cli.promptForSelection(newEntraAppScopesConfig); + break; + case EntraAppConfig.UseExisting: + const existingApp = await this.configureExistingEntraApp(logger); + Object.assign(preferences, existingApp); + break; + } + } + else { + preferences.entraApp = EntraAppConfig.Skip; + } + + const usageModeConfig: SelectionConfig = { message: 'How do you plan to use the CLI?', choices: [ - { name: 'Interactively', value: 'Interactively' }, - { name: 'Scripting', value: 'Scripting' } + { name: 'Interactively', value: CliUsageMode.Interactively }, + { name: 'Scripting', value: CliUsageMode.Scripting } ] }; preferences.usageMode = await cli.promptForSelection(usageModeConfig); - if (preferences.usageMode === 'Scripting') { + if (preferences.usageMode === CliUsageMode.Scripting) { const usedInPowerShellConfig: ConfirmationConfig = { message: 'Are you going to use the CLI in PowerShell?', default: pid.isPowerShell() @@ -123,42 +196,167 @@ class SetupCommand extends AnonymousCommand { preferences.usedInPowerShell = await cli.promptForConfirmation(usedInPowerShellConfig); } - const experienceConfig: SelectionConfig = { + const experienceConfig: SelectionConfig = { message: 'How experienced are you in using the CLI?', choices: [ - { name: 'Beginner', value: 'Beginner' }, - { name: 'Proficient', value: 'Proficient' } + { name: 'Beginner', value: CliExperience.Beginner }, + { name: 'Proficient', value: CliExperience.Proficient } ] }; preferences.experience = await cli.promptForSelection(experienceConfig); const summaryConfig: ConfirmationConfig = { - message: this.getSummaryMessage(this.getSettings(preferences)) + message: this.getSummaryMessage(preferences) }; preferences.summary = await cli.promptForConfirmation(summaryConfig); - if (preferences.summary) { - // used only for testing. Normally, we'd get the settings from the answers - /* c8 ignore next 3 */ - if (!settings) { - settings = this.getSettings(preferences); - } + if (!preferences.summary) { + return; + } + // used only for testing. Normally, we'd get the settings from the answers + /* c8 ignore next 3 */ + if (!settings) { + settings = this.getSettings(preferences); + } + + await logger.logToStderr(''); + await logger.logToStderr('Configuring settings...'); + await logger.logToStderr(''); + + await this.configureSettings({ preferences, settings, silent: false, logger }); + + if (!this.verbose) { await logger.logToStderr(''); - await logger.logToStderr('Configuring settings...'); - await logger.logToStderr(''); + await logger.logToStderr(chalk.green('DONE')); + } + } - await this.configureSettings(settings, false, logger); + private async configureExistingEntraApp(logger: Logger): Promise { + await logger.logToStderr('Please provide the details of the existing app registration.'); + let clientCertificateFile: string | undefined; + let clientCertificateBase64Encoded: string | undefined; + let clientCertificatePassword: string | undefined; - if (!this.verbose) { - await logger.logToStderr(''); - await logger.logToStderr(chalk.green('DONE')); + const clientId = await cli.promptForInput({ + message: 'Client ID:', + /* c8 ignore next */ + validate: value => validation.isValidGuid(value) ? true : 'The specified value is not a valid GUID.' + }); + const tenantId = await cli.promptForInput({ + message: 'Tenant ID (leave common if the app is multitenant):', + default: 'common', + /* c8 ignore next */ + validate: value => value === 'common' || validation.isValidGuid(value) ? true : `Tenant ID must be a valid GUID or 'common'.` + }); + const clientSecret = await cli.promptForInput({ + message: 'Client secret (leave empty if you use a certificate or a public client):' + }); + if (!clientSecret) { + clientCertificateFile = await cli.promptForInput({ + message: `Path to the client certificate file (leave empty if you want to specify a base64-encoded certificate string):` + }); + if (!clientCertificateFile) { + clientCertificateBase64Encoded = await cli.promptForInput({ + message: `Base64-encoded certificate string (leave empty if you don't connect using a certificate):` + }); + } + if (clientCertificateFile || clientCertificateBase64Encoded) { + clientCertificatePassword = await cli.promptForInput({ + message: 'Password for the client certificate (leave empty if the certificate is not password-protected):' + }); } } + + return { + clientId, + tenantId, + clientSecret, + clientCertificateFile, + clientCertificateBase64Encoded, + clientCertificatePassword + }; } - private getSummaryMessage(settings: SettingNames): string { + private async createNewEntraApp(preferences: Preferences, logger: Logger): Promise { + if (!await cli.promptForConfirmation({ + message: 'CLI for Microsoft 365 will now sign in to your Microsoft 365 tenant as Microsoft Azure CLI to create a new app registration. Continue?', + default: false + })) { + throw 'Cancelled'; + } + + // setup auth + auth.connection.authType = AuthType.Browser; + // Microsoft Azure CLI app ID + auth.connection.appId = '04b07795-8ddb-461a-bbee-02f9e1bf7b46'; + auth.connection.tenant = 'common'; + await auth.ensureAccessToken(auth.defaultResource, logger, this.debug); + auth.connection.active = true; + + const options: AppCreationOptions = { + allowPublicClientFlows: true, + apisDelegated: (preferences.newEntraAppScopes === NewEntraAppScopes.All ? config.allScopes : config.minimalScopes).join(','), + implicitFlow: false, + multitenant: false, + name: 'CLI for Microsoft 365', + platform: 'publicClient', + redirectUris: 'http://localhost,https://localhost,https://login.microsoftonline.com/common/oauth2/nativeclient' + }; + const apis = await entraApp.resolveApis({ + options, + logger, + verbose: this.verbose, + debug: this.debug + }); + const appInfo: AppInfo = await entraApp.createAppRegistration({ + options, + apis, + logger, + verbose: this.verbose, + debug: this.debug + }); + appInfo.tenantId = accessToken.getTenantIdFromAccessToken(auth.connection.accessTokens[auth.defaultResource].accessToken); + await entraApp.grantAdminConsent({ + appInfo, + appPermissions: entraApp.appPermissions, + adminConsent: true, + logger, + debug: this.debug + }); + + return appInfo; + } + + private getSummaryMessage(preferences: Preferences): string { const messageLines = [`Based on your preferences, we'll configure the following settings:`]; + switch (preferences.entraApp) { + case EntraAppConfig.Create: + messageLines.push(`- Entra app: Create a new app registration with ${preferences.newEntraAppScopes} scopes`); + break; + case EntraAppConfig.UseExisting: + messageLines.push(`- Entra app: use existing`); + messageLines.push(` - Client ID: ${preferences.clientId}`); + messageLines.push(` - Tenant ID: ${preferences.tenantId}`); + if (preferences.clientSecret) { + messageLines.push(` - Client secret: ${preferences.clientSecret}`); + } + if (preferences.clientCertificateFile) { + messageLines.push(` - Client certificate file: ${preferences.clientCertificateFile}`); + } + if (preferences.clientCertificateBase64Encoded) { + messageLines.push(` - Client certificate base64-encoded: ${preferences.clientCertificateBase64Encoded}`); + } + if (preferences.clientCertificatePassword) { + messageLines.push(` - Client certificate password: ${preferences.clientCertificatePassword}`); + } + break; + case EntraAppConfig.Skip: + messageLines.push(`- Entra app: skip`); + break; + } + + const settings: SettingNames = this.getSettings(preferences); for (const [key, value] of Object.entries(settings)) { messageLines.push(`- ${key}: ${value}`); } @@ -171,10 +369,10 @@ class SetupCommand extends AnonymousCommand { const settings: SettingNames = {}; switch (answers.usageMode) { - case 'Interactively': + case CliUsageMode.Interactively: Object.assign(settings, interactivePreset); break; - case 'Scripting': + case CliUsageMode.Scripting: Object.assign(settings, scriptingPreset); break; } @@ -184,18 +382,69 @@ class SetupCommand extends AnonymousCommand { } switch (answers.experience) { - case 'Beginner': - settings.helpMode = 'full'; + case CliExperience.Beginner: + settings.helpMode = HelpMode.Full; break; - case 'Proficient': - settings.helpMode = 'options'; + case CliExperience.Proficient: + settings.helpMode = HelpMode.Options; + break; + } + + switch (answers.entraApp) { + case EntraAppConfig.Create: + settings.authType = 'browser'; + break; + case EntraAppConfig.UseExisting: + if (answers.clientSecret) { + settings.authType = 'secret'; + break; + } + if (answers.clientCertificateFile || answers.clientCertificateBase64Encoded) { + settings.authType = 'certificate'; + break; + } + settings.authType = 'browser'; break; } return settings; } - private async configureSettings(settings: SettingNames, silent: boolean, logger: Logger): Promise { + private async configureSettings({ preferences, settings, silent, logger }: { + preferences: Preferences, + settings: SettingNames, + silent: boolean, + logger: Logger + }): Promise { + switch (preferences.entraApp) { + case EntraAppConfig.Create: + if (this.verbose) { + await logger.logToStderr('Creating a new Entra app...'); + } + const appSettings = await this.createNewEntraApp(preferences, logger); + Object.assign(settings, { + clientId: appSettings.appId, + tenantId: appSettings.tenantId + }); + cli.getConfig().delete(settingsNames.clientSecret); + cli.getConfig().delete(settingsNames.clientCertificateFile); + cli.getConfig().delete(settingsNames.clientCertificateBase64Encoded); + cli.getConfig().delete(settingsNames.clientCertificatePassword); + break; + case EntraAppConfig.UseExisting: + Object.assign(settings, { + clientId: preferences.clientId, + tenantId: preferences.tenantId, + clientSecret: preferences.clientSecret, + clientCertificateFile: preferences.clientCertificateFile, + clientCertificateBase64Encoded: preferences.clientCertificateBase64Encoded, + clientCertificatePassword: preferences.clientCertificatePassword + }); + break; + case EntraAppConfig.Skip: + break; + } + if (this.debug) { await logger.logToStderr('Configuring settings...'); await logger.logToStderr(JSON.stringify(settings, null, 2)); diff --git a/src/m365/commands/status.spec.ts b/src/m365/commands/status.spec.ts index 49b1e354337..afdf9f64526 100644 --- a/src/m365/commands/status.spec.ts +++ b/src/m365/commands/status.spec.ts @@ -171,7 +171,7 @@ describe(commands.STATUS, () => { assert(loggerLogSpy.calledWith({ connectedAs: 'alexw@contoso.com', connectionName: '028de82d-7fd9-476e-a9fd-be9714280ff3', - authType: 'DeviceCode', + authType: 'deviceCode', appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', appTenant: 'common', cloudType: 'Public' @@ -191,7 +191,7 @@ describe(commands.STATUS, () => { assert(loggerLogSpy.calledWith({ connectedAs: 'alexw@contoso.com', connectionName: '028de82d-7fd9-476e-a9fd-be9714280ff3', - authType: 'DeviceCode', + authType: 'deviceCode', appId: '31359c7f-bd7e-475c-86db-fdb8c937548e', appTenant: 'common', accessTokens: '{\n "https://graph.microsoft.com": {\n "expiresOn": "123",\n "accessToken": "abc"\n }\n}', diff --git a/src/m365/connection/commands/connection-list.spec.ts b/src/m365/connection/commands/connection-list.spec.ts index 03d3aa0e6f5..6a200c0c5b1 100644 --- a/src/m365/connection/commands/connection-list.spec.ts +++ b/src/m365/connection/commands/connection-list.spec.ts @@ -18,16 +18,16 @@ describe(commands.LIST, () => { const mockListResponse = [ { - "name": "028de82d-7fd9-476e-a9fd-be9714280ff3", + "active": true, + "authType": "deviceCode", "connectedAs": "alexw@contoso.com", - "authType": "DeviceCode", - "active": true + "name": "028de82d-7fd9-476e-a9fd-be9714280ff3" }, { - "name": "acd6df42-10a9-4315-8928-53334f1c9d01", + "active": false, + "authType": "secret", "connectedAs": "Contoso Application", - "authType": "Secret", - "active": false + "name": "acd6df42-10a9-4315-8928-53334f1c9d01" } ]; @@ -160,10 +160,10 @@ describe(commands.LIST, () => { const mockConnectionsResponse = [ { - "name": "028de82d-7fd9-476e-a9fd-be9714280ff3", + "active": true, + "authType": "deviceCode", "connectedAs": "alexw@contoso.com", - "authType": "DeviceCode", - "active": true + "name": "028de82d-7fd9-476e-a9fd-be9714280ff3" } ]; diff --git a/src/m365/connection/commands/connection-list.ts b/src/m365/connection/commands/connection-list.ts index aeb346cb8c3..2cf2cbdc8b9 100644 --- a/src/m365/connection/commands/connection-list.ts +++ b/src/m365/connection/commands/connection-list.ts @@ -1,8 +1,8 @@ +import assert from 'assert'; +import auth from '../../../Auth.js'; import { Logger } from '../../../cli/Logger.js'; -import auth, { AuthType } from '../../../Auth.js'; -import commands from '../commands.js'; import Command, { CommandArgs, CommandError } from '../../../Command.js'; -import assert from 'assert'; +import commands from '../commands.js'; class ConnectionListCommand extends Command { public get name(): string { @@ -26,7 +26,7 @@ class ConnectionListCommand extends Command { return { name: connection.name, connectedAs: connection.identityName, - authType: AuthType[connection.authType], + authType: connection.authType, active: isCurrentConnection }; }).sort((a, b) => { diff --git a/src/m365/connection/commands/connection-use.spec.ts b/src/m365/connection/commands/connection-use.spec.ts index d1951799030..082760604dc 100644 --- a/src/m365/connection/commands/connection-use.spec.ts +++ b/src/m365/connection/commands/connection-use.spec.ts @@ -20,7 +20,7 @@ describe(commands.USE, () => { const mockContosoApplicationIdentityResponse = { "connectedAs": "Contoso Application", "connectionName": "acd6df42-10a9-4315-8928-53334f1c9d01", - "authType": "Secret", + "authType": "secret", "appId": "39446e2e-5081-4887-980c-f285919fccca", "appTenant": "db308122-52f3-4241-af92-1734aa6e2e50", "cloudType": "Public" @@ -29,7 +29,7 @@ describe(commands.USE, () => { const mockUserIdentityResponse = { "connectedAs": "alexw@contoso.com", "connectionName": "028de82d-7fd9-476e-a9fd-be9714280ff3", - "authType": "DeviceCode", + "authType": "deviceCode", "appId": "31359c7f-bd7e-475c-86db-fdb8c937548e", "appTenant": "common", "cloudType": "Public" diff --git a/src/m365/entra/commands.ts b/src/m365/entra/commands.ts index cf622b8bab4..3162a130868 100644 --- a/src/m365/entra/commands.ts +++ b/src/m365/entra/commands.ts @@ -36,10 +36,12 @@ export default { ENTERPRISEAPP_ADD: `${prefix} enterpriseapp add`, ENTERPRISEAPP_GET: `${prefix} enterpriseapp get`, ENTERPRISEAPP_LIST: `${prefix} enterpriseapp list`, + ENTERPRISEAPP_REMOVE: `${prefix} enterpriseapp remove`, GROUP_ADD: `${prefix} group add`, GROUP_GET: `${prefix} group get`, GROUP_LIST: `${prefix} group list`, GROUP_REMOVE: `${prefix} group remove`, + GROUP_SET: `${prefix} group set`, GROUP_USER_ADD: `${prefix} group user add`, GROUP_USER_LIST: `${prefix} group user list`, GROUP_USER_SET: `${prefix} group user set`, @@ -93,6 +95,7 @@ export default { SP_ADD: `${prefix} sp add`, SP_GET: `${prefix} sp get`, SP_LIST: `${prefix} sp list`, + SP_REMOVE: `${prefix} sp remove`, USER_ADD: `${prefix} user add`, USER_GET: `${prefix} user get`, USER_GUEST_ADD: `${prefix} user guest add`, diff --git a/src/m365/entra/commands/app/app-add.ts b/src/m365/entra/commands/app/app-add.ts index 2a51964bceb..2aa503d9d97 100644 --- a/src/m365/entra/commands/app/app-add.ts +++ b/src/m365/entra/commands/app/app-add.ts @@ -5,56 +5,19 @@ import GlobalOptions from '../../../../GlobalOptions.js'; import { Logger } from '../../../../cli/Logger.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { accessToken } from '../../../../utils/accessToken.js'; -import { odata } from '../../../../utils/odata.js'; +import { AppCreationOptions, AppInfo, entraApp } from '../../../../utils/entraApp.js'; import GraphCommand from '../../../base/GraphCommand.js'; import { M365RcJson } from '../../../base/M365RcJson.js'; -import commands from '../../commands.js'; import aadCommands from '../../aadCommands.js'; - -interface ServicePrincipalInfo { - appId: string; - appRoles: { id: string; value: string; }[]; - id: string; - oauth2PermissionScopes: { id: string; value: string; }[]; - servicePrincipalNames: string[]; -} - -interface RequiredResourceAccess { - resourceAppId: string; - resourceAccess: ResourceAccess[]; -} - -interface ResourceAccess { - id: string; - type: string; -} - -interface AppInfo { - appId: string; - // objectId - id: string; - tenantId: string; - secrets?: { - displayName: string; - value: string; - }[]; - requiredResourceAccess: RequiredResourceAccess[]; -} +import commands from '../../commands.js'; interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - apisApplication?: string; - apisDelegated?: string; +interface Options extends GlobalOptions, AppCreationOptions { grantAdminConsent?: boolean; - implicitFlow: boolean; manifest?: string; - multitenant: boolean; - name?: string; - platform?: string; - redirectUris?: string; save?: boolean; scopeAdminConsentDescription?: string; scopeAdminConsentDisplayName?: string; @@ -62,16 +25,6 @@ interface Options extends GlobalOptions { scopeName?: string; uri?: string; withSecret: boolean; - certificateFile?: string; - certificateBase64Encoded?: string; - certificateDisplayName?: string; - allowPublicClientFlows?: boolean; -} - -interface AppPermissions { - resourceId: string; - resourceAccess: ResourceAccess[]; - scope: string[]; } class EntraAppAddCommand extends GraphCommand { @@ -79,7 +32,6 @@ class EntraAppAddCommand extends GraphCommand { private static entraAppScopeConsentBy: string[] = ['admins', 'adminsAndUsers']; private manifest: any; private appName: string = ''; - private appPermissions: AppPermissions[] = []; public get name(): string { return commands.APP_ADD; @@ -261,16 +213,39 @@ class EntraAppAddCommand extends GraphCommand { public async commandAction(logger: Logger, args: CommandArgs): Promise { await this.showDeprecationWarning(logger, aadCommands.APP_ADD, commands.APP_ADD); + if (!args.options.name && this.manifest) { + args.options.name = this.manifest.name; + } + this.appName = args.options.name!; + try { - const apis = await this.resolveApis(args, logger); - let appInfo: any = await this.createAppRegistration(args, apis, logger); + const apis = await entraApp.resolveApis({ + options: args.options, + manifest: this.manifest, + logger, + verbose: this.verbose, + debug: this.debug + }); + let appInfo: any = await entraApp.createAppRegistration({ + options: args.options, + apis, + logger, + verbose: this.verbose, + debug: this.debug + }); // based on the assumption that we're adding Microsoft Entra app to the current // directory. If we in the future extend the command with allowing // users to create Microsoft Entra app in a different directory, we'll need to // adjust this appInfo.tenantId = accessToken.getTenantIdFromAccessToken(auth.connection.accessTokens[auth.defaultResource].accessToken); appInfo = await this.updateAppFromManifest(args, appInfo); - appInfo = await this.grantAdminConsent(appInfo, args.options.grantAdminConsent, logger); + appInfo = await entraApp.grantAdminConsent({ + appInfo, + appPermissions: entraApp.appPermissions, + adminConsent: args.options.grantAdminConsent, + logger, + debug: this.debug + }); appInfo = await this.configureUri(args, appInfo, logger); appInfo = await this.configureSecret(args, appInfo, logger); const _appInfo = await this.saveAppInfo(args, appInfo, logger); @@ -291,153 +266,52 @@ class EntraAppAddCommand extends GraphCommand { } } - private async createAppRegistration(args: CommandArgs, apis: RequiredResourceAccess[], logger: Logger): Promise { - const applicationInfo: any = { - displayName: args.options.name, - signInAudience: args.options.multitenant ? 'AzureADMultipleOrgs' : 'AzureADMyOrg' - }; - - if (!applicationInfo.displayName && this.manifest) { - applicationInfo.displayName = this.manifest.name; - } - this.appName = applicationInfo.displayName; - - if (apis.length > 0) { - applicationInfo.requiredResourceAccess = apis; - } - - if (args.options.redirectUris) { - applicationInfo[args.options.platform!] = { - redirectUris: args.options.redirectUris.split(',').map(u => u.trim()) - }; - } - - if (args.options.implicitFlow) { - if (!applicationInfo.web) { - applicationInfo.web = {}; - } - applicationInfo.web.implicitGrantSettings = { - enableAccessTokenIssuance: true, - enableIdTokenIssuance: true - }; - } - - if (args.options.certificateFile || args.options.certificateBase64Encoded) { - const certificateBase64Encoded = await this.getCertificateBase64Encoded(args, logger); - - const newKeyCredential = { - type: "AsymmetricX509Cert", - usage: "Verify", - displayName: args.options.certificateDisplayName, - key: certificateBase64Encoded - } as any; - - applicationInfo.keyCredentials = [newKeyCredential]; - } - - if (args.options.allowPublicClientFlows) { - applicationInfo.isFallbackPublicClient = true; + private async configureSecret(args: CommandArgs, appInfo: AppInfo, logger: Logger): Promise { + if (!args.options.withSecret || (appInfo.secrets && appInfo.secrets.length > 0)) { + return appInfo; } if (this.verbose) { - await logger.logToStderr(`Creating Microsoft Entra app registration...`); + await logger.logToStderr(`Configure Microsoft Entra app secret...`); } - const createApplicationRequestOptions: CliRequestOptions = { - url: `${this.resource}/v1.0/myorganization/applications`, - headers: { - accept: 'application/json;odata.metadata=none' - }, - responseType: 'json', - data: applicationInfo - }; - - return request.post(createApplicationRequestOptions); - } - - private async grantAdminConsent(appInfo: AppInfo, adminConsent: boolean | undefined, logger: Logger): Promise { - if (!adminConsent || this.appPermissions.length === 0) { - return appInfo; - } + const secret = await this.createSecret({ appObjectId: appInfo.id }); - const sp = await this.createServicePrincipal(appInfo.appId); - if (this.debug) { - await logger.logToStderr("Service principal created, returned object id: " + sp.id); + if (!appInfo.secrets) { + appInfo.secrets = []; } - - const tasks: Promise[] = []; - - this.appPermissions.forEach(async (permission) => { - if (permission.scope.length > 0) { - tasks.push(this.grantOAuth2Permission(sp.id, permission.resourceId, permission.scope.join(' '))); - - if (this.debug) { - await logger.logToStderr(`Admin consent granted for following resource ${permission.resourceId}, with delegated permissions: ${permission.scope.join(',')}`); - } - } - - permission.resourceAccess.filter(access => access.type === "Role").forEach(async (access: ResourceAccess) => { - tasks.push(this.addRoleToServicePrincipal(sp.id, permission.resourceId, access.id)); - - if (this.debug) { - await logger.logToStderr(`Admin consent granted for following resource ${permission.resourceId}, with application permission: ${access.id}`); - } - }); - }); - - await Promise.all(tasks); + appInfo.secrets.push(secret); return appInfo; } - private async addRoleToServicePrincipal(objectId: string, resourceId: string, appRoleId: string): Promise { - const requestOptions: CliRequestOptions = { - url: `${this.resource}/v1.0/myorganization/servicePrincipals/${objectId}/appRoleAssignments`, - headers: { - 'Content-Type': 'application/json' - }, - responseType: 'json', - data: { - appRoleId: appRoleId, - principalId: objectId, - resourceId: resourceId - } - }; + private async createSecret({ appObjectId, displayName = undefined, expirationDate = undefined }: { appObjectId: string, displayName?: string, expirationDate?: Date }): Promise<{ displayName: string, value: string }> { + let secretExpirationDate = expirationDate; + if (!secretExpirationDate) { + secretExpirationDate = new Date(); + secretExpirationDate.setFullYear(secretExpirationDate.getFullYear() + 1); + } - return request.post(requestOptions); - } + const secretName = displayName ?? 'Default'; - private async grantOAuth2Permission(appId: string, resourceId: string, scopeName: string): Promise { - const grantAdminConsentApplicationRequestOptions: CliRequestOptions = { - url: `${this.resource}/v1.0/myorganization/oauth2PermissionGrants`, + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/myorganization/applications/${appObjectId}/addPassword`, headers: { - accept: 'application/json;odata.metadata=none' + 'content-type': 'application/json' }, responseType: 'json', data: { - clientId: appId, - consentType: "AllPrincipals", - principalId: null, - resourceId: resourceId, - scope: scopeName + passwordCredential: { + displayName: secretName, + endDateTime: secretExpirationDate.toISOString() + } } }; - return request.post(grantAdminConsentApplicationRequestOptions); - } - - private async createServicePrincipal(appId: string): Promise { - const requestOptions: CliRequestOptions = { - url: `${this.resource}/v1.0/myorganization/servicePrincipals`, - headers: { - 'content-type': 'application/json' - }, - data: { - appId: appId - }, - responseType: 'json' + const response = await request.post<{ secretText: string }>(requestOptions); + return { + displayName: secretName, + value: response.secretText }; - - return request.post(requestOptions); } private async updateAppFromManifest(args: CommandArgs, appInfo: AppInfo): Promise { @@ -727,216 +601,6 @@ class EntraAppAddCommand extends GraphCommand { return appInfo; } - private async resolveApis(args: CommandArgs, logger: Logger): Promise { - if (!args.options.apisDelegated && !args.options.apisApplication - && (typeof this.manifest?.requiredResourceAccess === 'undefined' || this.manifest.requiredResourceAccess.length === 0)) { - return []; - } - - if (this.verbose) { - await logger.logToStderr('Resolving requested APIs...'); - } - - const servicePrincipals = await odata.getAllItems(`${this.resource}/v1.0/myorganization/servicePrincipals?$select=appId,appRoles,id,oauth2PermissionScopes,servicePrincipalNames`); - - let resolvedApis: RequiredResourceAccess[] = []; - - try { - if (args.options.apisDelegated || args.options.apisApplication) { - resolvedApis = await this.getRequiredResourceAccessForApis(servicePrincipals, args.options.apisDelegated, 'Scope', logger); - if (this.verbose) { - await logger.logToStderr(`Resolved delegated permissions: ${JSON.stringify(resolvedApis, null, 2)}`); - } - const resolvedApplicationApis = await this.getRequiredResourceAccessForApis(servicePrincipals, args.options.apisApplication, 'Role', logger); - if (this.verbose) { - await logger.logToStderr(`Resolved application permissions: ${JSON.stringify(resolvedApplicationApis, null, 2)}`); - } - // merge resolved application APIs onto resolved delegated APIs - resolvedApplicationApis.forEach(resolvedRequiredResource => { - const requiredResource = resolvedApis.find(api => api.resourceAppId === resolvedRequiredResource.resourceAppId); - if (requiredResource) { - requiredResource.resourceAccess.push(...resolvedRequiredResource.resourceAccess); - } - else { - resolvedApis.push(resolvedRequiredResource); - } - }); - } - else { - const manifestApis = (this.manifest.requiredResourceAccess as RequiredResourceAccess[]); - - manifestApis.forEach(manifestApi => { - resolvedApis.push(manifestApi); - - const app = servicePrincipals.find(servicePrincipals => servicePrincipals.appId === manifestApi.resourceAppId); - - if (app) { - manifestApi.resourceAccess.forEach((res => { - const resourceAccessPermission = { - id: res.id, - type: res.type - }; - - const oAuthValue = app.oauth2PermissionScopes.find(scp => scp.id === res.id)?.value; - this.updateAppPermissions(app.id, resourceAccessPermission, oAuthValue); - })); - } - }); - } - - if (this.verbose) { - await logger.logToStderr(`Merged delegated and application permissions: ${JSON.stringify(resolvedApis, null, 2)}`); - await logger.logToStderr(`App role assignments: ${JSON.stringify(this.appPermissions.flatMap(permission => permission.resourceAccess.filter(access => access.type === "Role")), null, 2)}`); - await logger.logToStderr(`OAuth2 permissions: ${JSON.stringify(this.appPermissions.flatMap(permission => permission.scope), null, 2)}`); - } - - return resolvedApis; - } - catch (e) { - throw e; - } - } - - private async getRequiredResourceAccessForApis(servicePrincipals: ServicePrincipalInfo[], apis: string | undefined, scopeType: string, logger: Logger): Promise { - if (!apis) { - return []; - } - - const resolvedApis: RequiredResourceAccess[] = []; - const requestedApis: string[] = apis!.split(',').map(a => a.trim()); - for (const api of requestedApis) { - const pos: number = api.lastIndexOf('/'); - const permissionName: string = api.substr(pos + 1); - const servicePrincipalName: string = api.substr(0, pos); - if (this.debug) { - await logger.logToStderr(`Resolving ${api}...`); - await logger.logToStderr(`Permission name: ${permissionName}`); - await logger.logToStderr(`Service principal name: ${servicePrincipalName}`); - } - const servicePrincipal = servicePrincipals.find(sp => ( - sp.servicePrincipalNames.indexOf(servicePrincipalName) > -1 || - sp.servicePrincipalNames.indexOf(`${servicePrincipalName}/`) > -1)); - if (!servicePrincipal) { - throw `Service principal ${servicePrincipalName} not found`; - } - - const scopesOfType = scopeType === 'Scope' ? servicePrincipal.oauth2PermissionScopes : servicePrincipal.appRoles; - const permission = scopesOfType.find(scope => scope.value === permissionName); - if (!permission) { - throw `Permission ${permissionName} for service principal ${servicePrincipalName} not found`; - } - - let resolvedApi = resolvedApis.find(a => a.resourceAppId === servicePrincipal.appId); - if (!resolvedApi) { - resolvedApi = { - resourceAppId: servicePrincipal.appId, - resourceAccess: [] - }; - resolvedApis.push(resolvedApi); - } - - const resourceAccessPermission = { - id: permission.id, - type: scopeType - }; - - resolvedApi.resourceAccess.push(resourceAccessPermission); - - this.updateAppPermissions(servicePrincipal.id, resourceAccessPermission, permission.value); - } - - return resolvedApis; - } - - private updateAppPermissions(spId: string, resourceAccessPermission: ResourceAccess, oAuth2PermissionValue?: string): void { - // During API resolution, we store globally both app role assignments and oauth2permissions - // So that we'll be able to parse them during the admin consent process - let existingPermission = this.appPermissions.find(oauth => oauth.resourceId === spId); - if (!existingPermission) { - existingPermission = { - resourceId: spId, - resourceAccess: [], - scope: [] - }; - - this.appPermissions.push(existingPermission); - } - - if (resourceAccessPermission.type === 'Scope' && oAuth2PermissionValue && !existingPermission.scope.find(scp => scp === oAuth2PermissionValue)) { - existingPermission.scope.push(oAuth2PermissionValue); - } - - if (!existingPermission.resourceAccess.find(res => res.id === resourceAccessPermission.id)) { - existingPermission.resourceAccess.push(resourceAccessPermission); - } - } - - private async configureSecret(args: CommandArgs, appInfo: AppInfo, logger: Logger): Promise { - if (!args.options.withSecret || (appInfo.secrets && appInfo.secrets.length > 0)) { - return appInfo; - } - - if (this.verbose) { - await logger.logToStderr(`Configure Microsoft Entra app secret...`); - } - - const secret = await this.createSecret({ appObjectId: appInfo.id }); - - if (!appInfo.secrets) { - appInfo.secrets = []; - } - appInfo.secrets.push(secret); - return appInfo; - - } - - private async createSecret({ appObjectId, displayName = undefined, expirationDate = undefined }: { appObjectId: string, displayName?: string, expirationDate?: Date }): Promise<{ displayName: string, value: string }> { - let secretExpirationDate = expirationDate; - if (!secretExpirationDate) { - secretExpirationDate = new Date(); - secretExpirationDate.setFullYear(secretExpirationDate.getFullYear() + 1); - } - - const secretName = displayName ?? 'Default'; - - const requestOptions: CliRequestOptions = { - url: `${this.resource}/v1.0/myorganization/applications/${appObjectId}/addPassword`, - headers: { - 'content-type': 'application/json' - }, - responseType: 'json', - data: { - passwordCredential: { - displayName: secretName, - endDateTime: secretExpirationDate.toISOString() - } - } - }; - - const response = await request.post<{ secretText: string }>(requestOptions); - return { - displayName: secretName, - value: response.secretText - }; - } - - private async getCertificateBase64Encoded(args: CommandArgs, logger: Logger): Promise { - if (args.options.certificateBase64Encoded) { - return args.options.certificateBase64Encoded; - } - - if (this.debug) { - await logger.logToStderr(`Reading existing ${args.options.certificateFile}...`); - } - - try { - return fs.readFileSync(args.options.certificateFile as string, { encoding: 'base64' }); - } - catch (e) { - throw new Error(`Error reading certificate file: ${e}. Please add the certificate using base64 option '--certificateBase64Encoded'.`); - } - } - private async saveAppInfo(args: CommandArgs, appInfo: AppInfo, logger: Logger): Promise { if (!args.options.save) { return appInfo; diff --git a/src/m365/entra/commands/enterpriseapp/enterpriseapp-remove.spec.ts b/src/m365/entra/commands/enterpriseapp/enterpriseapp-remove.spec.ts new file mode 100644 index 00000000000..9746ae096a2 --- /dev/null +++ b/src/m365/entra/commands/enterpriseapp/enterpriseapp-remove.spec.ts @@ -0,0 +1,308 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { CommandError } from '../../../../Command.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command from './enterpriseapp-remove.js'; +import { settingsNames } from '../../../../settingsNames.js'; + +describe(commands.ENTERPRISEAPP_REMOVE, () => { + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + let promptIssued: boolean = false; + + const spAppInfo = { + "value": [ + { + "id": "59e617e5-e447-4adc-8b88-00af644d7c92", + "appId": "65415bb1-9267-4313-bbf5-ae259732ee12", + "displayName": "foo", + "createdDateTime": "2021-03-07T15:04:11Z", + "description": null, + "homepage": null, + "loginUrl": null, + "logoutUrl": null, + "notes": null + } + ] + }; + + const deleteRequestStub = (): sinon.SinonStub => { + return sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/servicePrincipals/59e617e5-e447-4adc-8b88-00af644d7c92` || + opts.url === `https://graph.microsoft.com/v1.0/servicePrincipals(appId='65415bb1-9267-4313-bbf5-ae259732ee12')` + ) { + return; + } + + throw 'Invalid request'; + }); + }; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => settingName === settingsNames.prompt ? false : defaultValue); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + + sinon.stub(cli, 'promptForConfirmation').callsFake(async () => { + promptIssued = true; + return false; + }); + promptIssued = false; + }); + + afterEach(() => { + sinonUtil.restore([ + request.get, + request.delete, + cli.promptForConfirmation, + cli.handleMultipleResultsFound + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.ENTERPRISEAPP_REMOVE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('defines correct alias', () => { + const alias = command.alias(); + assert.deepStrictEqual(alias, [commands.SP_REMOVE]); + }); + + it('fails when the specified enterprise applications does not exist', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/servicePrincipals?$filter=displayName eq 'Invalid'&$select=id`) { + return { + value: [] + }; + } + + throw `Invalid request`; + }); + + await assert.rejects(command.action(logger, { + options: { + verbose: true, + displayName: 'Invalid', + force: true + } + }), new CommandError(`The specified enterprise application does not exist.`)); + }); + + it('fails validation if neither the id nor the displayName option is specified', async () => { + const actual = await command.validate({ options: {} }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the id is not a valid GUID', async () => { + const actual = await command.validate({ options: { id: '123' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation when the id option is specified', async () => { + const actual = await command.validate({ options: { id: '6a7b1395-d313-4682-8ed4-65a6265a6320' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when the displayName option is specified', async () => { + const actual = await command.validate({ options: { displayName: 'Contoso app' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation when both the id and displayName are specified', async () => { + const actual = await command.validate({ options: { id: '6a7b1395-d313-4682-8ed4-65a6265a6320', displayName: 'Contoso app' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the objectId is not a valid GUID', async () => { + const actual = await command.validate({ options: { objectId: '123' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if both id and displayName are specified', async () => { + const actual = await command.validate({ options: { id: '123', displayName: 'abc' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if objectId and displayName are specified', async () => { + const actual = await command.validate({ options: { displayName: 'abc', objectId: '123' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('prompts before removing the enterprise application when force option not passed', async () => { + await command.action(logger, { options: { id: '65415bb1-9267-4313-bbf5-ae259732ee12' } }); + + assert(promptIssued); + }); + + it('aborts removing the enterprise application when prompt not confirmed', async () => { + const deleteCallsSpy = sinon.stub(request, 'delete').resolves(); + await command.action(logger, { options: { id: '65415bb1-9267-4313-bbf5-ae259732ee12' } }); + assert(deleteCallsSpy.notCalled); + }); + + it('deletes the specified enterprise application using its display name', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/servicePrincipals?$filter=displayName eq 'foo'&$select=id`) { + return spAppInfo; + } + + throw 'Invalid request'; + }); + + const deleteCallsSpy: sinon.SinonStub = deleteRequestStub(); + await command.action(logger, { options: { verbose: true, displayName: 'foo', force: true } }); + assert(deleteCallsSpy.calledOnce); + }); + + it('deletes the specified enterprise application using its id', async () => { + const deleteCallsSpy: sinon.SinonStub = deleteRequestStub(); + await command.action(logger, { options: { id: '65415bb1-9267-4313-bbf5-ae259732ee12', force: true } }); + assert(deleteCallsSpy.calledOnce); + }); + + it('deletes the specified enterprise application using its objectId', async () => { + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + const deleteCallsSpy: sinon.SinonStub = deleteRequestStub(); + await command.action(logger, { options: { objectId: '59e617e5-e447-4adc-8b88-00af644d7c92', verbose: true } }); + assert(deleteCallsSpy.calledOnce); + }); + + it('correctly handles API OData error', async () => { + sinon.stub(request, 'get').rejects({ + error: { + 'odata.error': { + code: '-1, InvalidOperationException', + message: { + value: 'An error has occurred' + } + } + } + }); + + await assert.rejects(command.action(logger, { options: { displayName: 'foo', force: true } } as any), + new CommandError('An error has occurred')); + }); + + it('fails when enterprise applications with same name exists', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/servicePrincipals?$filter=displayName eq 'foo'&$select=id`) { + return { + "value": [ + { + "id": "be559819-b036-470f-858b-281c4e808403", + "appId": "ee091f63-9e48-4697-8462-7cfbf7410b8e", + "displayName": "foo", + "createdDateTime": "2021-03-07T15:04:11Z", + "description": null, + "homepage": null, + "loginUrl": null, + "logoutUrl": null, + "notes": null + }, + { + "id": "93d75ef9-ba9b-4361-9a47-1f6f7478f05f", + "appId": "e9fd0957-049f-40d0-8d1d-112320fb1cbd", + "displayName": "foo", + "createdDateTime": "2021-03-07T15:04:11Z", + "description": null, + "homepage": null, + "loginUrl": null, + "logoutUrl": null, + "notes": null + } + ] + }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { + options: { + verbose: true, + displayName: 'foo', + force: true + } + }), new CommandError("Multiple enterprise applications with name 'foo' found. Found: be559819-b036-470f-858b-281c4e808403, 93d75ef9-ba9b-4361-9a47-1f6f7478f05f.")); + }); + + it('handles selecting single result when multiple enterprise applications with the specified name found and cli is set to prompt', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/servicePrincipals?$filter=displayName eq 'foo'&$select=id`) { + return { + "value": [ + { + "id": "be559819-b036-470f-858b-281c4e808403", + "appId": "ee091f63-9e48-4697-8462-7cfbf7410b8e", + "displayName": "foo", + "createdDateTime": "2021-03-07T15:04:11Z", + "description": null, + "homepage": null, + "loginUrl": null, + "logoutUrl": null, + "notes": null + }, + { + "id": "93d75ef9-ba9b-4361-9a47-1f6f7478f05f", + "appId": "e9fd0957-049f-40d0-8d1d-112320fb1cbd", + "displayName": "foo", + "createdDateTime": "2021-03-07T15:04:11Z", + "description": null, + "homepage": null, + "loginUrl": null, + "logoutUrl": null, + "notes": null + } + ] + }; + } + + throw 'Invalid request'; + }); + + sinon.stub(cli, 'handleMultipleResultsFound').resolves(spAppInfo.value[0]); + const deleteCallsSpy: sinon.SinonStub = deleteRequestStub(); + await command.action(logger, { options: { verbose: true, displayName: 'foo', force: true } }); + assert(deleteCallsSpy.calledOnce); + }); +}); diff --git a/src/m365/entra/commands/enterpriseapp/enterpriseapp-remove.ts b/src/m365/entra/commands/enterpriseapp/enterpriseapp-remove.ts new file mode 100644 index 00000000000..1cfae670c48 --- /dev/null +++ b/src/m365/entra/commands/enterpriseapp/enterpriseapp-remove.ts @@ -0,0 +1,165 @@ +import { cli } from '../../../../cli/cli.js'; +import { Logger } from '../../../../cli/Logger.js'; +import GlobalOptions from '../../../../GlobalOptions.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { odata } from '../../../../utils/odata.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { validation } from '../../../../utils/validation.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + id?: string; + displayName?: string; + objectId?: string; + force?: boolean; +} + +class EntraEnterpriseAppRemoveCommand extends GraphCommand { + public get name(): string { + return commands.ENTERPRISEAPP_REMOVE; + } + + public get description(): string { + return 'Deletes an enterprise application (or service principal)'; + } + + public alias(): string[] | undefined { + return [commands.SP_REMOVE]; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + this.#initOptionSets(); + this.#initTypes(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + id: typeof args.options.id !== 'undefined', + displayName: typeof args.options.displayName !== 'undefined', + objectId: typeof args.options.objectId !== 'undefined', + force: !!args.options.force + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-i, --id [id]' + }, + { + option: '-n, --displayName [displayName]' + }, + { + option: '--objectId [objectId]' + }, + { + option: '-f, --force' + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.id && !validation.isValidGuid(args.options.id)) { + return `The option 'id' with value '${args.options.id}' is not a valid GUID.`; + } + + if (args.options.objectId && !validation.isValidGuid(args.options.objectId)) { + return `The option 'objectId' with value '${args.options.objectId}' is not a valid GUID.`; + } + + return true; + } + ); + } + + #initOptionSets(): void { + this.optionSets.push({ options: ['id', 'displayName', 'objectId'] }); + } + + #initTypes(): void { + this.types.string.push('id', 'displayName', 'objectId'); + this.types.boolean.push('force'); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + const removeEnterpriseApplication = async (): Promise => { + if (this.verbose) { + await logger.logToStderr(`Removing enterprise application ${args.options.id || args.options.displayName || args.options.objectId}...`); + } + + try { + let url = `${this.resource}/v1.0`; + + if (args.options.id) { + url += `/servicePrincipals(appId='${args.options.id}')`; + } + else { + const id = await this.getSpId(args.options); + url += `/servicePrincipals/${id}`; + } + + const requestOptions: CliRequestOptions = { + url: url, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + await request.delete(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + }; + + if (args.options.force) { + await removeEnterpriseApplication(); + } + else { + const result = await cli.promptForConfirmation({ message: `Are you sure you want to remove enterprise application '${args.options.id || args.options.displayName || args.options.objectId}'?` }); + + if (result) { + await removeEnterpriseApplication(); + } + } + } + + private async getSpId(options: Options): Promise { + if (options.objectId) { + return options.objectId; + } + + const spItemsResponse = await odata.getAllItems<{ id: string }>(`${this.resource}/v1.0/servicePrincipals?$filter=displayName eq '${formatting.encodeQueryParameter(options.displayName!)}'&$select=id`); + + if (spItemsResponse.length === 0) { + throw `The specified enterprise application does not exist.`; + } + + if (spItemsResponse.length > 1) { + const resultAsKeyValuePair = formatting.convertArrayToHashTable('id', spItemsResponse); + const result = await cli.handleMultipleResultsFound<{ id: string }>(`Multiple enterprise applications with name '${options.displayName}' found.`, resultAsKeyValuePair); + return result.id; + } + + const spItem = spItemsResponse[0]; + + return spItem.id; + } +} + +export default new EntraEnterpriseAppRemoveCommand(); \ No newline at end of file diff --git a/src/m365/entra/commands/group/group-set.spec.ts b/src/m365/entra/commands/group/group-set.spec.ts new file mode 100644 index 00000000000..1b1897337ae --- /dev/null +++ b/src/m365/entra/commands/group/group-set.spec.ts @@ -0,0 +1,435 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import commands from '../../commands.js'; +import { Logger } from '../../../../cli/Logger.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import command from './group-set.js'; +import { entraUser } from '../../../../utils/entraUser.js'; +import { CommandError } from '../../../../Command.js'; +import { entraGroup } from '../../../../utils/entraGroup.js'; + +describe(commands.GROUP_SET, () => { + const groupId = '7167b488-1ffb-43f1-9547-35969469bada'; + const userUpns = ['user1@contoso.com', 'user2@contoso.com', 'user3@contoso.com', 'user4@contoso.com', 'user5@contoso.com', 'user6@contoso.com', 'user7@contoso.com', 'user8@contoso.com', 'user9@contoso.com', 'user10@contoso.com', 'user11@contoso.com', 'user12@contoso.com', 'user13@contoso.com', 'user14@contoso.com', 'user15@contoso.com', 'user16@contoso.com', 'user17@contoso.com', 'user18@contoso.com', 'user19@contoso.com', 'user20@contoso.com', 'user21@contoso.com', 'user22@contoso.com', 'user23@contoso.com', 'user24@contoso.com', 'user25@contoso.com']; + const userIds = ['3f2504e0-4f89-11d3-9a0c-0305e82c3301', '6dcd4ce0-4f89-11d3-9a0c-0305e82c3302', '9b76f130-4f89-11d3-9a0c-0305e82c3303', 'c835f5e0-4f89-11d3-9a0c-0305e82c3304', 'f4f3fa90-4f89-11d3-9a0c-0305e82c3305', '2230f6a0-4f8a-11d3-9a0c-0305e82c3306', '4f6df5b0-4f8a-11d3-9a0c-0305e82c3307', '7caaf4c0-4f8a-11d3-9a0c-0305e82c3308', 'a9e8f3d0-4f8a-11d3-9a0c-0305e82c3309', 'd726f2e0-4f8a-11d3-9a0c-0305e82c330a', '0484f1f0-4f8b-11d3-9a0c-0305e82c330b', '31e2f100-4f8b-11d3-9a0c-0305e82c330c', '5f40f010-4f8b-11d3-9a0c-0305e82c330d', '8c9eef20-4f8b-11d3-9a0c-0305e82c330e', 'b9fce030-4f8b-11d3-9a0c-0305e82c330f', 'e73cdf40-4f8b-11d3-9a0c-0305e82c3310', '1470ce50-4f8c-11d3-9a0c-0305e82c3311', '41a3cd60-4f8c-11d3-9a0c-0305e82c3312', '6ed6cc70-4f8c-11d3-9a0c-0305e82c3313', '9c09cb80-4f8c-11d3-9a0c-0305e82c3314', 'c93cca90-4f8c-11d3-9a0c-0305e82c3315', 'f66cc9a0-4f8c-11d3-9a0c-0305e82c3316', '2368c8b0-4f8d-11d3-9a0c-0305e82c3317', '5064c7c0-4f8d-11d3-9a0c-0305e82c3318', '7d60c6d0-4f8d-11d3-9a0c-0305e82c3319']; + + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + sinon.stub(entraGroup, 'getGroupIdByDisplayName').withArgs('Microsoft 365 Group').resolves(groupId); + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + }); + + afterEach(() => { + sinonUtil.restore([ + request.get, + request.patch, + request.post, + entraUser.getUserIdsByUpns + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.GROUP_SET); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if the length of newDisplayName is more than 256 characters', async () => { + const displayName = 'lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum'; + const actual = await command.validate({ options: { id: groupId, newDisplayName: displayName } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the length of mailNickname is more than 64 characters', async () => { + const mailNickname = 'loremipsumloremipsumloremipsumloremipsumloremipsumloremipsumloremipsumlorem'; + const actual = await command.validate({ options: { displayName: 'Cli group', mailNickname: mailNickname } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if mailNickname is not valid', async () => { + const mailNickname = 'lorem ipsum'; + const actual = await command.validate({ options: { displayName: 'Cli group', mailNickname: mailNickname } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if ownerIds contains invalid GUID', async () => { + const ownerIds = ['7167b488-1ffb-43f1-9547-35969469bada', 'foo']; + const actual = await command.validate({ options: { displayName: 'Cli group', ownerIds: ownerIds.join(',') } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if ownerUserNames contains invalid user principal name', async () => { + const ownerUserNames = ['john.doe@contoso.com', 'foo']; + const actual = await command.validate({ options: { displayName: 'Cli group', ownerUserNames: ownerUserNames.join(',') } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if memberIds contains invalid GUID', async () => { + const memberIds = ['7167b488-1ffb-43f1-9547-35969469bada', 'foo']; + const actual = await command.validate({ options: { displayName: 'Cli group', memberIds: memberIds.join(',') } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if memberUserNames contains invalid user principal name', async () => { + const memberUserNames = ['john.doe@contoso.com', 'foo']; + const actual = await command.validate({ options: { displayName: 'Cli group', memberUserNames: memberUserNames.join(',') } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if visibility contains invalid value', async () => { + const actual = await command.validate({ options: { displayName: 'Cli group', visibility: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation if id is a valid GUID', async () => { + const actual = await command.validate({ options: { id: groupId, newDisplayName: 'Cli group' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation if id is not a valid GUID', async () => { + const actual = await command.validate({ options: { id: 'foo', newDisplayName: 'Cli group' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation when all required parameters are valid with ids', async () => { + const actual = await command.validate({ options: { displayName: 'Cli group', ownerIds: userIds.join(',') } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when all required parameters are valid with user names', async () => { + const actual = await command.validate({ options: { displayName: 'Cli group', memberUserNames: userUpns.join(',') } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation if no options to be updated are specified', async () => { + const actual = await command.validate({ options: { id: groupId } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('successfully updates group specified by id', async () => { + const patchRequestStub = sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { id: groupId, description: 'Microsoft 365 group', mailNickname: 'Microsoft365Group', visibility: 'Public', newDisplayName: '365 group', verbose: true } }); + assert.deepStrictEqual(patchRequestStub.lastCall.args[0].data, { + displayName: '365 group', + description: 'Microsoft 365 group', + mailNickName: 'Microsoft365Group', + visibility: 'Public' + }); + }); + + it('successfully updates group specified by displayName', async () => { + const patchRequestStub = sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupId}`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { displayName: 'Microsoft 365 Group', description: '', mailNickname: 'Microsoft365Group', visibility: 'Public', newDisplayName: '365 group' } }); + assert.deepStrictEqual(patchRequestStub.lastCall.args[0].data, { + displayName: '365 group', + description: null, + mailNickName: 'Microsoft365Group', + visibility: 'Public' + }); + }); + + it('successfully updates group with owners specified by ids and removes current owners', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupId}/members/microsoft.graph.user?$select=id`) { + return { + value: [] + }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupId}/owners/microsoft.graph.user?$select=id`) { + return { + value: [{ id: '717f1683-00fa-488c-b68d-5d0051f6bcfa' }] + }; + } + + throw 'Invalid request'; + }); + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupId}`) { + return; + } + + throw 'Invalid request'; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'PATCH') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'DELETE') { + return { + responses: [ + { + status: 204, + body: {} + } + ] + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { id: groupId, description: 'Microsoft 365 group', mailNickname: 'Microsoft365Group', visibility: 'Public', ownerIds: userIds.join(',') } }); + assert.deepStrictEqual(postStub.firstCall.args[0].data.requests, [ + { + id: 1, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { + accept: 'application/json;odata.metadata=none', + 'content-type': 'application/json;odata.metadata=none' + }, + body: { + 'owners@odata.bind': userIds.slice(0, 20).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + }, + { + id: 21, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { + accept: 'application/json;odata.metadata=none', + 'content-type': 'application/json;odata.metadata=none' + }, + body: { + 'owners@odata.bind': userIds.slice(20, 40).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + } + ]); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, [ + { + id: '717f1683-00fa-488c-b68d-5d0051f6bcfa', + method: 'DELETE', + url: `/groups/${groupId}/owners/717f1683-00fa-488c-b68d-5d0051f6bcfa/$ref` + } + ]); + }); + + it('successfully updates group with members specified by user names and removes current members', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupId}/owners/microsoft.graph.user?$select=id`) { + return { + value: [] + }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupId}/members/microsoft.graph.user?$select=id`) { + return { + value: [{ id: '717f1683-00fa-488c-b68d-5d0051f6bcfa' }] + }; + } + + throw 'Invalid request'; + }); + sinon.stub(entraUser, 'getUserIdsByUpns').withArgs(userUpns).resolves(userIds); + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupId}`) { + return; + } + + throw 'Invalid request'; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'PATCH') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'DELETE') { + return { + responses: [ + { + status: 204, + body: {} + } + ] + }; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { displayName: 'Microsoft 365 Group', description: 'Microsoft 365 group', mailNickname: 'Microsoft365Group', visibility: 'Private', memberUserNames: userUpns.join(','), verbose: true } }); + assert.deepStrictEqual(postStub.firstCall.args[0].data.requests, [ + { + id: 1, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { + accept: 'application/json;odata.metadata=none', + 'content-type': 'application/json;odata.metadata=none' + }, + body: { + 'members@odata.bind': userIds.slice(0, 20).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + }, + { + id: 21, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { + accept: 'application/json;odata.metadata=none', + 'content-type': 'application/json;odata.metadata=none' + }, + body: { + 'members@odata.bind': userIds.slice(20, 40).map(u => `https://graph.microsoft.com/v1.0/directoryObjects/${u}`) + } + } + ]); + assert.deepStrictEqual(postStub.lastCall.args[0].data.requests, [ + { + id: '717f1683-00fa-488c-b68d-5d0051f6bcfa', + method: 'DELETE', + url: `/groups/${groupId}/members/717f1683-00fa-488c-b68d-5d0051f6bcfa/$ref` + } + ]); + }); + + it('handles API error when adding users to a group', async () => { + sinon.stub(request, 'get').resolves({ value: [] }); + sinon.stub(request, 'patch').resolves(); + sinon.stub(request, 'post').callsFake(async () => { + return { + responses: [ + { + id: 1, + status: 204, + body: {} + }, + { + id: 2, + status: 400, + body: { + error: { + message: `One or more added object references already exist for the following modified properties: 'members'.` + } + } + } + ] + }; + }); + + await assert.rejects(command.action(logger, { options: { displayName: 'Microsoft 365 Group', description: 'Microsoft 365 group', mailNickname: 'Microsoft365Group', visibility: 'Public', ownerIds: userIds.join(',') } }), + new CommandError(`One or more added object references already exist for the following modified properties: 'members'.`)); + }); + + it('handles API error when removing users from a group', async () => { + sinon.stub(request, 'get').resolves({ value: [{ id: '717f1683-00fa-488c-b68d-5d0051f6bcfa' }] }); + sinon.stub(request, 'patch').resolves(); + sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'PATCH') { + return { + responses: Array(2).fill({ + status: 204, + body: {} + }) + }; + } + + if (opts.url === 'https://graph.microsoft.com/v1.0/$batch' && + opts.data.requests[0].method === 'DELETE') { + return { + responses: [ + { + status: 500, + body: { + error: { + message: 'Service unavailable.' + } + } + } + ] + }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { displayName: 'Microsoft 365 Group', description: 'Microsoft 365 group', ownerIds: userIds.join(',') } }), + new CommandError('Service unavailable.')); + }); + + it('correctly handles API OData error', async () => { + sinon.stub(request, 'patch').rejects({ + error: { + 'odata.error': { + code: '-1, InvalidOperationException', + message: { + value: 'Invalid request' + } + } + } + }); + + await assert.rejects(command.action(logger, { options: { id: groupId, description: 'Microsoft 365 group', mailNickname: 'Microsoft365Group', visibility: 'Public' } }), + new CommandError('Invalid request')); + }); +}); \ No newline at end of file diff --git a/src/m365/entra/commands/group/group-set.ts b/src/m365/entra/commands/group/group-set.ts new file mode 100644 index 00000000000..1e1430310b0 --- /dev/null +++ b/src/m365/entra/commands/group/group-set.ts @@ -0,0 +1,330 @@ +import GlobalOptions from '../../../../GlobalOptions.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; +import { validation } from '../../../../utils/validation.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { entraUser } from '../../../../utils/entraUser.js'; +import { entraGroup } from '../../../../utils/entraGroup.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { odata } from '../../../../utils/odata.js'; +import { User } from '@microsoft/microsoft-graph-types'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + id?: string; + displayName?: string; + newDisplayName?: string; + description?: string; + mailNickname?: string; + ownerIds?: string; + ownerUserNames?: string; + memberIds?: string; + memberUserNames?: string; + visibility?: string; +} + +class EntraGroupSetCommand extends GraphCommand { + private readonly allowedVisibility: string[] = ['Public', 'Private']; + + public get name(): string { + return commands.GROUP_SET; + } + + public get description(): string { + return 'Updates a Microsoft Entra group'; + } + + constructor(){ + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + this.#initOptionSets(); + this.#initTypes(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + id: typeof args.options.id !== 'undefined', + displayName: typeof args.options.displayName !== 'undefined', + newDisplayName: typeof args.options.newDisplayName !== 'undefined', + description: typeof args.options.description !== 'undefined', + mailNickname: typeof args.options.mailNickname !== 'undefined', + ownerIds: typeof args.options.ownerIds !== 'undefined', + ownerUserNames: typeof args.options.ownerUserNames !== 'undefined', + memberIds: typeof args.options.memberIds !== 'undefined', + memberUserNames: typeof args.options.memberUserNames !== 'undefined', + visibility: typeof args.options.visibility !== 'undefined' + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-i, --id [id]' + }, + { + option: '--mailNickname [mailNickname]' + }, + { + option: '-n, --displayName [displayName]' + }, + { + option: '--newDisplayName [newDisplayName]' + }, + { + option: '--description [description]' + }, + { + option: '--ownerIds [ownerIds]' + }, + { + option: '--ownerUserNames [ownerUserNames]' + }, + { + option: '--memberIds [memberIds]' + }, + { + option: '--memberUserNames [memberUserNames]' + }, + { + option: '--visibility [visibility]', + autocomplete: this.allowedVisibility + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.id && !validation.isValidGuid(args.options.id)) { + return `Value '${args.options.id}' is not a valid GUID for option 'id'.`; + } + + if (args.options.newDisplayName && args.options.newDisplayName.length > 256) { + return `The maximum amount of characters for 'newDisplayName' is 256.`; + } + + if (args.options.mailNickname) { + if (!validation.isValidMailNickname(args.options.mailNickname)) { + return `Value '${args.options.mailNickname}' for option 'mailNickname' must contain only characters in the ASCII character set 0-127 except the following: @ () \ [] " ; : <> , SPACE.`; + } + + if (args.options.mailNickname.length > 64) { + return `The maximum amount of characters for 'mailNickname' is 64.`; + } + } + + if (args.options.ownerIds) { + const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.ownerIds); + if (isValidGUIDArrayResult !== true) { + return `The following GUIDs are invalid for option 'ownerIds': ${isValidGUIDArrayResult}.`; + } + } + + if (args.options.ownerUserNames) { + const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.ownerUserNames); + if (isValidUPNArrayResult !== true) { + return `The following user principal names are invalid for option 'ownerUserNames': ${isValidUPNArrayResult}.`; + } + } + + if (args.options.memberIds) { + const isValidGUIDArrayResult = validation.isValidGuidArray(args.options.memberIds); + if (isValidGUIDArrayResult !== true) { + return `The following GUIDs are invalid for option 'memberIds': ${isValidGUIDArrayResult}.`; + } + } + + if (args.options.memberUserNames) { + const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.memberUserNames); + if (isValidUPNArrayResult !== true) { + return `The following user principal names are invalid for option 'memberUserNames': ${isValidUPNArrayResult}.`; + } + } + + if (args.options.visibility && !this.allowedVisibility.includes(args.options.visibility)) { + return `Option 'visibility' must be one of the following values: ${this.allowedVisibility.join(', ')}.`; + } + + if (args.options.newDisplayName === undefined && args.options.description === undefined && args.options.visibility === undefined + && args.options.ownerIds === undefined && args.options.ownerUserNames === undefined && args.options.memberIds === undefined + && args.options.memberUserNames === undefined && args.options.mailNickname === undefined) { + return `Specify at least one of the following options: 'newDisplayName', 'description', 'visibility', 'ownerIds', 'ownerUserNames', 'memberIds', 'memberUserNames', 'mailNickname'.`; + } + + return true; + } + ); + } + + #initOptionSets(): void { + this.optionSets.push( + { options: ['id', 'displayName'] }, + { + options: ['ownerIds', 'ownerUserNames'], + runsWhen: (args) => args.options.ownerIds || args.options.ownerUserNames + }, + { + options: ['memberIds', 'memberUserNames'], + runsWhen: (args) => args.options.memberIds || args.options.memberUserNames + } + ); + } + + #initTypes(): void { + this.types.string.push('id', 'displayName', 'newDisplayName', 'description', 'mailNickname', 'ownerIds', 'ownerUserNames', 'memberIds', 'memberUserNames', 'visibility'); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + let groupId = args.options.id; + + try { + if (args.options.displayName) { + if (this.verbose) { + await logger.logToStderr(`Retrieving group id...`); + } + + groupId = await entraGroup.getGroupIdByDisplayName(args.options.displayName); + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/groups/${groupId}`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + data: { + displayName: args.options.newDisplayName, + description: args.options.description === '' ? null : args.options.description, + mailNickName: args.options.mailNickname, + visibility: args.options.visibility + } + }; + + await request.patch(requestOptions); + + const ownerIds = await this.getUserIds(logger, args.options.ownerIds, args.options.ownerUserNames); + if (ownerIds.length !== 0) { + await this.updateUsers(logger, groupId!, 'owners', ownerIds); + } + else if (this.verbose) { + await logger.logToStderr(`No owners to update.`); + } + + const memberIds = await this.getUserIds(logger, args.options.memberIds, args.options.memberUserNames); + if (memberIds.length !== 0) { + await this.updateUsers(logger, groupId!, 'members', memberIds); + } + else if (this.verbose) { + await logger.logToStderr(`No members to update.`); + } + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + }; + + private async getUserIds(logger: Logger, userIds?: string, userNames?: string): Promise { + if (userIds) { + return formatting.splitAndTrim(userIds); + } + + if (userNames) { + if (this.verbose) { + await logger.logToStderr(`Retrieving user IDs...`); + } + return entraUser.getUserIdsByUpns(formatting.splitAndTrim(userNames)); + } + + return []; + } + + private async updateUsers(logger: Logger, groupId: string, role: 'members' | 'owners', userIds: string[]): Promise { + const groupUsers = await odata.getAllItems(`${this.resource}/v1.0/groups/${groupId}/${role}/microsoft.graph.user?$select=id`); + const userIdsToAdd = userIds.filter(userId => !groupUsers.some(groupUser => groupUser.id === userId)); + const userIdsToRemove = groupUsers.filter(groupUser => !userIds.some(userId => groupUser.id === userId)).map(user => user.id); + + if (this.verbose) { + await logger.logToStderr(`Adding ${userIdsToAdd.length} ${role}...`); + } + + for (let i = 0; i < userIdsToAdd.length; i += 400) { + const userIdsBatch = userIdsToAdd.slice(i, i + 400); + const batchRequestOptions = this.getBatchRequestOptions(); + + // only 20 requests per one batch are allowed + for (let j = 0; j < userIdsBatch.length; j += 20) { + // only 20 users can be added in one request + const userIdsChunk = userIdsBatch.slice(j, j + 20); + batchRequestOptions.data.requests.push({ + id: j + 1, + method: 'PATCH', + url: `/groups/${groupId}`, + headers: { + 'content-type': 'application/json;odata.metadata=none', + accept: 'application/json;odata.metadata=none' + }, + body: { + [`${role}@odata.bind`]: userIdsChunk.map(u => `${this.resource}/v1.0/directoryObjects/${u}`) + } + }); + } + + const res = await request.post<{ responses: { status: number; body: any }[] }>(batchRequestOptions); + for (const response of res.responses) { + if (response.status !== 204) { + throw response.body; + } + } + } + + if (this.verbose) { + await logger.logToStderr(`Removing ${userIdsToRemove.length} ${role}...`); + } + + for (let i = 0; i < userIdsToRemove.length; i += 20) { + const userIdsBatch = userIdsToRemove.slice(i, i + 20); + const batchRequestOptions = this.getBatchRequestOptions(); + + userIdsBatch.map(userId => { + batchRequestOptions.data.requests.push({ + id: userId, + method: 'DELETE', + url: `/groups/${groupId}/${role}/${userId}/$ref` + }); + }); + + const res = await request.post<{ responses: { id: string, status: number; body: any }[] }>(batchRequestOptions); + for (const response of res.responses) { + if (response.status !== 204) { + throw response.body; + } + } + } + } + + private getBatchRequestOptions(): CliRequestOptions { + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/$batch`, + headers: { + 'content-type': 'application/json;odata.metadata=none', + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: { + requests: [] + } + }; + + return requestOptions; + } +} + +export default new EntraGroupSetCommand(); \ No newline at end of file diff --git a/src/m365/flow/commands.ts b/src/m365/flow/commands.ts index 8af0bcef85a..04237cc6b25 100644 --- a/src/m365/flow/commands.ts +++ b/src/m365/flow/commands.ts @@ -13,6 +13,8 @@ export default { OWNER_ENSURE: `${prefix} owner ensure`, OWNER_LIST: `${prefix} owner list`, OWNER_REMOVE: `${prefix} owner remove`, + RECYCLEBINITEM_LIST: `${prefix} recyclebinitem list`, + RECYCLEBINITEM_RESTORE: `${prefix} recyclebinitem restore`, REMOVE: `${prefix} remove`, RUN_CANCEL: `${prefix} run cancel`, RUN_GET: `${prefix} run get`, diff --git a/src/m365/flow/commands/environment/environment-get.ts b/src/m365/flow/commands/environment/environment-get.ts index b586717371b..47f323044fc 100644 --- a/src/m365/flow/commands/environment/environment-get.ts +++ b/src/m365/flow/commands/environment/environment-get.ts @@ -55,7 +55,7 @@ class FlowEnvironmentGetCommand extends PowerAutomateCommand { await logger.logToStderr(`Retrieving information about Microsoft Flow environment ${args.options.name ?? ''}...`); } - let requestUrl = `${this.resource}/providers/Microsoft.ProcessSimple/environments/`; + let requestUrl = `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/environments/`; if (args.options.name) { requestUrl += `${formatting.encodeQueryParameter(args.options.name)}`; diff --git a/src/m365/flow/commands/environment/environment-list.ts b/src/m365/flow/commands/environment/environment-list.ts index 86db1a973bc..5402508a1e1 100644 --- a/src/m365/flow/commands/environment/environment-list.ts +++ b/src/m365/flow/commands/environment/environment-list.ts @@ -23,7 +23,7 @@ class FlowEnvironmentListCommand extends PowerAutomateCommand { } try { - const res = await odata.getAllItems<{ name: string, displayName: string; properties: { displayName: string } }>(`${this.resource}/providers/Microsoft.ProcessSimple/environments?api-version=2016-11-01`); + const res = await odata.getAllItems<{ name: string, displayName: string; properties: { displayName: string } }>(`${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/environments?api-version=2016-11-01`); if (res.length > 0) { if (args.options.output !== 'json') { diff --git a/src/m365/flow/commands/flow-disable.ts b/src/m365/flow/commands/flow-disable.ts index 1d5b0f1b903..c43865d2270 100644 --- a/src/m365/flow/commands/flow-disable.ts +++ b/src/m365/flow/commands/flow-disable.ts @@ -59,7 +59,7 @@ class FlowDisableCommand extends PowerAutomateCommand { } const requestOptions: CliRequestOptions = { - url: `${this.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.name)}/stop?api-version=2016-11-01`, + url: `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.name)}/stop?api-version=2016-11-01`, headers: { accept: 'application/json' }, diff --git a/src/m365/flow/commands/flow-enable.ts b/src/m365/flow/commands/flow-enable.ts index a72bf7cefb8..7bbea86c545 100644 --- a/src/m365/flow/commands/flow-enable.ts +++ b/src/m365/flow/commands/flow-enable.ts @@ -59,7 +59,7 @@ class FlowEnableCommand extends PowerAutomateCommand { } const requestOptions: CliRequestOptions = { - url: `${this.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.name)}/start?api-version=2016-11-01`, + url: `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.name)}/start?api-version=2016-11-01`, headers: { accept: 'application/json' }, diff --git a/src/m365/flow/commands/flow-export.spec.ts b/src/m365/flow/commands/flow-export.spec.ts index 24c9d28d9f3..15c740d7852 100644 --- a/src/m365/flow/commands/flow-export.spec.ts +++ b/src/m365/flow/commands/flow-export.spec.ts @@ -6,13 +6,15 @@ import { cli } from '../../../cli/cli.js'; import { CommandInfo } from '../../../cli/CommandInfo.js'; import { Logger } from '../../../cli/Logger.js'; import { CommandError } from '../../../Command.js'; -import request from '../../../request.js'; +import request, { CliRequestOptions } from '../../../request.js'; import { telemetry } from '../../../telemetry.js'; import { pid } from '../../../utils/pid.js'; import { session } from '../../../utils/session.js'; import { sinonUtil } from '../../../utils/sinonUtil.js'; import commands from '../commands.js'; import command from './flow-export.js'; +import { formatting } from '../../../utils/formatting.js'; +import { accessToken } from '../../../utils/accessToken.js'; describe(commands.EXPORT, () => { let log: string[]; @@ -25,17 +27,17 @@ describe(commands.EXPORT, () => { const actualFileUrl = `https://bapfeblobprodml.blob.core.windows.net/20180916t000000zb5faa82a53cb4cd29f2a20fde7dbb785/${actualFilename}?sv=2017-04-17&sr=c&sig=AOp0fzKc0dLpY2yovI%2BSHJnQ92GxaMvbWgxyCX5Wwno%3D&se=2018-09-16T12%3A24%3A28Z&sp=rl`; const flowDisplayName = `Request manager approval for a Page`; const notFoundFlowName = '1c6ee23a-a835-44bc-a4f5-462b658efc12'; - const notFoundEnvironmentId = 'd87a7535-dd31-4437-bfe1-95340acd55c6'; + const notFoundEnvironmentId = 'Default-d87a7535-dd31-4437-bfe1-95340acd55c6'; const foundFlowName = 'f2eb8b37-f624-4b22-9954-b5d0cbb28f8a'; - const foundEnvironmentId = 'cf409f12-a06f-426e-9955-20f5d7a31dd3'; + const foundEnvironmentId = 'Default-cf409f12-a06f-426e-9955-20f5d7a31dd3'; const nonZipFileFlowId = '694d21e4-49be-4e19-987b-074889e45c75'; - const postFakes = async (opts: any) => { - if ((opts.url as string).indexOf(notFoundEnvironmentId) > -1) { + const postFakes = async (opts: CliRequestOptions) => { + if (opts.url === `https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments/${formatting.encodeQueryParameter(notFoundEnvironmentId)}/listPackageResources?api-version=2016-11-01`) { throw { "error": { "code": "EnvironmentAccessDenied", - "message": `Access to the environment 'Default-${notFoundEnvironmentId}' is denied.` + "message": `Access to the environment '${notFoundEnvironmentId}' is denied.` } }; } @@ -47,22 +49,23 @@ describe(commands.EXPORT, () => { }] }; } - if ((opts.url as string).indexOf('/listPackageResources?api-version=2016-11-01') > -1) { + if (opts.url === `https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments/${formatting.encodeQueryParameter(foundEnvironmentId)}/listPackageResources?api-version=2016-11-01`) { return { "baseResourceIds": [`/providers/Microsoft.Flow/flows/${foundFlowName}`], "resources": { "L1BST1ZJREVSUy9NSUNST1NPRlQuRkxPVy9GTE9XUy9GMkVCOEIzNy1GNjI0LTRCMjItOTk1NC1CNUQwQ0JCMjhGOEI=": { "id": `/providers/Microsoft.Flow/flows/${foundFlowName}`, "name": `${foundFlowName}`, "type": "Microsoft.Flow/flows", "creationType": "Existing, New, Update", "details": { "displayName": flowDisplayName }, "configurableBy": "User", "hierarchy": "Root", "dependsOn": ["L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX1NIQVJFUE9JTlRPTkxJTkU=", "L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX1NIQVJFUE9JTlRPTkxJTkUvQ09OTkVDVElPTlMvU0hBUkVELVNIQVJFUE9JTlRPTkwtRjg0NTE4MDktREEwNi00RDQ3LTg3ODYtMTUxMjM4RDUwRTdB", "L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX09GRklDRTM2NVVTRVJT", "L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX09GRklDRTM2NVVTRVJTL0NPTk5FQ1RJT05TL1NIAAZAGFGH1FBSAHJKFS147VBDSxOUI5QjBELTFFQTUtNDhGOS1BQUM4LTgwRjkyQTFGRjE3OH==", "L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJADWNXX8321CGA3JIJDAkVEX0FQUFJPVkFMUw==", "L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX0FQUFJPVkFMUy9DT05ORUNUSU9OUy9TSEFSRUQtQVBQUk9WQUxTLUQ2Njc1AUUJNCSWDD1tNGNSAXZ1CNTY4LUFCRDc3MzMyOTMyMA==", "L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX1NFTkRNQUlM", "L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX1NFTkRNQUlML0NPTk5FQ1RJT05TL1NIQVJFRC1TRU5ETUFJTC05NEUzODVCQi1CNUE3LTRBODgtOURFRC1FMEVFRDAzNTY1Njk="] }, "L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX1NIQVJFUE9JTlRPTkxJTkU=": { "id": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline", "name": "shared_sharepointonline", "type": "Microsoft.PowerApps/apis", "details": { "displayName": "SharePoint", "iconUri": "https://connectoricons-prod.azureedge.net/sharepointonline/icon_1.0.1019.1195.png" }, "configurableBy": "System", "hierarchy": "Child", "dependsOn": [] }, "L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX09GRklDRTM2NVVTRVJT": { "id": "/providers/Microsoft.PowerApps/apis/shared_office365users", "name": "shared_office365users", "type": "Microsoft.PowerApps/apis", "details": { "displayName": "Microsoft 365 Users", "iconUri": "https://connectoricons-prod.azureedge.net/office365users/icon_1.0.1002.1175.png" }, "configurableBy": "System", "hierarchy": "Child", "dependsOn": [] }, "L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FAZFGGHDDCAAVEX0FQUFJPVkFMUw==": { "id": "/providers/Microsoft.PowerApps/apis/shared_approvals", "name": "shared_approvals", "type": "Microsoft.PowerApps/apis", "details": { "displayName": "Approvals", "iconUri": "https://psux.azureedge.net/Content/Images/Connectors/Approvals3.svg" }, "configurableBy": "System", "hierarchy": "Child", "dependsOn": [] }, "L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX1NFTkRNQUlM": { "id": "/providers/Microsoft.PowerApps/apis/shared_sendmail", "name": "shared_sendmail", "type": "Microsoft.PowerApps/apis", "details": { "displayName": "Mail", "iconUri": "https://az818438.vo.msecnd.net/officialicons/sendmail/icon_1.0.979.1161_83e4f20c-51d8-4c0c-a6f4-653249642047.png" }, "configurableBy": "System", "hierarchy": "Child", "dependsOn": [] }, "L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX1NIQVJFUE9JTlRPTkxJTkUvQ09OTkVDVElPTlMvU0hBUkVELVNIQVJFUE9JTlRPTkwtRjg0NTE4MDktREEwNi00RDQ3LTg3ODYtMTUxMjM4RDUwRTdB": { "id": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline/connections/shared-sharepointonl-f8451809-da06-4d47-8786-151238d50e7a", "name": "shared-sharepointonl-f8451809-da06-4d47-8786-151238d50e7a", "type": "Microsoft.PowerApps/apis/connections", "creationType": "Existing", "details": { "displayName": "mark.powney@contoso.onmicrosoft.com", "iconUri": "https://az818438.vo.msecnd.net/icons/sharepointonline.png" }, "configurableBy": "User", "hierarchy": "Child", "dependsOn": ["L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX1NIQVJFUE9JTlRPTkxJTkU="] }, "L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX0FQUFJPVkFMUy9DT05ORUNUSU9OUy1AFFFVAGJXAAGHQUk9WQUxTLUQ2Njc1RUE5LUZDM0QtNDA4MS1CNTY4LUFCRDc3MzMyOTMyMZ==": { "id": "/providers/Microsoft.PowerApps/apis/shared_approvals/connections/shared-approvals-d6675ea9-fc3d-4081-b568-abd773329320", "name": "shared-approvals-d6675ea9-fc3d-4081-b568-abd773329320", "type": "Microsoft.PowerApps/apis/connections", "creationType": "Existing", "details": { "displayName": "Approvals", "iconUri": "https://connectorassets.blob.core.windows.net/assets/Approvals.svg" }, "configurableBy": "User", "hierarchy": "Child", "dependsOn": ["L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hAZAASFCZ1DVHGVkFMUs=="] }, "L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX1NFTkRNQUlML0NPTk5FQ1RJT05TL1NIQVJFRC1TRU5ETUFJTC05NEUzODVCQi1CNUE3LTRBODgtOURFRC1FMEVFRDAzNTY1Njk=": { "id": "/providers/Microsoft.PowerApps/apis/shared_sendmail/connections/shared-sendmail-94e385bb-b5a7-4a88-9ded-e0eed0356569", "name": "shared-sendmail-94e385bb-b5a7-4a88-9ded-e0eed0356569", "type": "Microsoft.PowerApps/apis/connections", "creationType": "Existing", "details": { "displayName": "Mail", "iconUri": "https://az818438.vo.msecnd.net/icons/sendmail.png" }, "configurableBy": "User", "hierarchy": "Child", "dependsOn": ["L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX1NFTkRNQUlM"] }, "L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkAZVVDSSAFBRTM2NVVTRVJTL0NPTk5FQ1RJT05TL1NIQVJFRC1PRkZJQ0UzNjVVU0VSLUExOUI5QjBELTFFQTUtNDhGOS1BQUM4LTgwRjkyQTFGRjE3OB==": { "id": "/providers/Microsoft.PowerApps/apis/shared_office365users/connections/shared-office365user-a19b9b0d-1ea5-48f9-aac8-80f92a1ff178", "name": "shared-office365user-a19b9b0d-1ea5-48f9-aac8-80f92a1ff178", "type": "Microsoft.PowerApps/apis/connections", "creationType": "Existing", "details": { "displayName": "mark.powney@contoso.onmicrosoft.com", "iconUri": "https://az818438.vo.msecnd.net/icons/office365users.png" }, "configurableBy": "User", "hierarchy": "Child", "dependsOn": ["L1BST1ZJREVSUy9NSUNST1NPRlQuUE9XRVJBUFBTL0FQSVMvU0hBUkVEX09GRklDRTM2NVVTRVJT"] } }, "status": "Succeeded" }; } - if ((opts.url as string).indexOf('/exportPackage?api-version=2016-11-01') > -1 && JSON.stringify(opts.data || {}).indexOf(nonZipFileFlowId) > -1) { - return { - "details": { "createdTime": "2018-09-16T04:24:28.365117Z", "packageTelemetryId": "448a7d93-7ce3-4e6a-88c9-57cf2479e62e" }, - "packageLink": { "value": `${actualFileUrl.replace('.zip', '.badextension')}` }, - "resources": { "43e3a371-ae70-455a-8050-4b14968ac474": { "id": `/providers/Microsoft.Flow/flows/${nonZipFileFlowId}`, "name": `${nonZipFileFlowId}`, "type": "Microsoft.Flow/flows", "status": "Succeeded", "creationType": "Existing, New, Update", "details": { "displayName": flowDisplayName }, "configurableBy": "User", "hierarchy": "Root", "dependsOn": ["0a6353d7-0770-447b-8d38-60230a1dc26d", "a6f57810-a099-4bf3-b51e-462afcea449e", "59eab504-a13a-40ed-b1f1-1decea0e1465", "1af3bf3f-97c9-4c45-b0fe-36613b9ff78c", "0e560c22-557c-432d-91a7-34f1562fc522", "e30ccca7-546e-4205-8e80-74f9f100b859", "94f5f489-8b4d-4e48-b50a-93514e16f921", "76995bea-58ce-4845-8298-1e29bf87e145"] }, "0a6353d7-0770-447b-8d38-60230a1dc26d": { "id": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline", "name": "shared_sharepointonline", "type": "Microsoft.PowerApps/apis", "details": { "displayName": "SharePoint", "iconUri": "https://connectoricons-prod.azureedge.net/sharepointonline/icon_1.0.1019.1195.png" }, "configurableBy": "System", "hierarchy": "Child", "dependsOn": [] }, "a6f57810-a099-4bf3-b51e-462afcea449e": { "id": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline/connections/shared-sharepointonl-f8451809-da06-4d47-8786-151238d50e7a", "name": "shared-sharepointonl-f8451809-da06-4d47-8786-151238d50e7a", "type": "Microsoft.PowerApps/apis/connections", "creationType": "Existing", "details": { "displayName": "mark.powney@contoso.onmicrosoft.com", "iconUri": "https://az818438.vo.msecnd.net/icons/sharepointonline.png" }, "configurableBy": "User", "hierarchy": "Child", "dependsOn": ["0a6353d7-0770-447b-8d38-60230a1dc26d"] }, "59eab504-a13a-40ed-b1f1-1decea0e1465": { "id": "/providers/Microsoft.PowerApps/apis/shared_office365users", "name": "shared_office365users", "type": "Microsoft.PowerApps/apis", "details": { "displayName": "Microsoft 365 Users", "iconUri": "https://connectoricons-prod.azureedge.net/office365users/icon_1.0.1002.1175.png" }, "configurableBy": "System", "hierarchy": "Child", "dependsOn": [] }, "1af3bf3f-97c9-4c45-b0fe-36613b9ff78c": { "id": "/providers/Microsoft.PowerApps/apis/shared_office365users/connections/shared-office365user-a19b9b0d-1ea5-48f9-aac8-80f92a1ff178", "name": "shared-office365user-a19b9b0d-1ea5-48f9-aac8-80f92a1ff178", "type": "Microsoft.PowerApps/apis/connections", "creationType": "Existing", "details": { "displayName": "mark.powney@contoso.onmicrosoft.com", "iconUri": "https://az818438.vo.msecnd.net/icons/office365users.png" }, "configurableBy": "User", "hierarchy": "Child", "dependsOn": ["59eab504-a13a-40ed-b1f1-1decea0e1465"] }, "0e560c22-557c-432d-91a7-34f1562fc522": { "id": "/providers/Microsoft.PowerApps/apis/shared_approvals", "name": "shared_approvals", "type": "Microsoft.PowerApps/apis", "details": { "displayName": "Approvals", "iconUri": "https://psux.azureedge.net/Content/Images/Connectors/Approvals3.svg" }, "configurableBy": "System", "hierarchy": "Child", "dependsOn": [] }, "e30ccca7-546e-4205-8e80-74f9f100b859": { "id": "/providers/Microsoft.PowerApps/apis/shared_approvals/connections/shared-approvals-d6675ea9-fc3d-4081-b568-abd773329320", "name": "shared-approvals-d6675ea9-fc3d-4081-b568-abd773329320", "type": "Microsoft.PowerApps/apis/connections", "creationType": "Existing", "details": { "displayName": "Approvals", "iconUri": "https://connectorassets.blob.core.windows.net/assets/Approvals.svg" }, "configurableBy": "User", "hierarchy": "Child", "dependsOn": ["0e560c22-557c-432d-91a7-34f1562fc522"] }, "94f5f489-8b4d-4e48-b50a-93514e16f921": { "id": "/providers/Microsoft.PowerApps/apis/shared_sendmail", "name": "shared_sendmail", "type": "Microsoft.PowerApps/apis", "details": { "displayName": "Mail", "iconUri": "https://az818438.vo.msecnd.net/officialicons/sendmail/icon_1.0.979.1161_83e4f20c-51d8-4c0c-a6f4-653249642047.png" }, "configurableBy": "System", "hierarchy": "Child", "dependsOn": [] }, "76995bea-58ce-4845-8298-1e29bf87e145": { "id": "/providers/Microsoft.PowerApps/apis/shared_sendmail/connections/shared-sendmail-94e385bb-b5a7-4a88-9ded-e0eed0356569", "name": "shared-sendmail-94e385bb-b5a7-4a88-9ded-e0eed0356569", "type": "Microsoft.PowerApps/apis/connections", "creationType": "Existing", "details": { "displayName": "Mail", "iconUri": "https://az818438.vo.msecnd.net/icons/sendmail.png" }, "configurableBy": "User", "hierarchy": "Child", "dependsOn": ["94f5f489-8b4d-4e48-b50a-93514e16f921"] } }, - "status": "Succeeded" - }; - } - if ((opts.url as string).indexOf('/exportPackage?api-version=2016-11-01') > -1) { + if (opts.url === `https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments/${formatting.encodeQueryParameter(foundEnvironmentId)}/exportPackage?api-version=2016-11-01`) { + if (opts.data.includedResourceIds[0] === `/providers/Microsoft.Flow/flows/${nonZipFileFlowId}`) { + return { + "details": { "createdTime": "2018-09-16T04:24:28.365117Z", "packageTelemetryId": "448a7d93-7ce3-4e6a-88c9-57cf2479e62e" }, + "packageLink": { "value": `${actualFileUrl.replace('.zip', '.badextension')}` }, + "resources": { "43e3a371-ae70-455a-8050-4b14968ac474": { "id": `/providers/Microsoft.Flow/flows/${nonZipFileFlowId}`, "name": `${nonZipFileFlowId}`, "type": "Microsoft.Flow/flows", "status": "Succeeded", "creationType": "Existing, New, Update", "details": { "displayName": flowDisplayName }, "configurableBy": "User", "hierarchy": "Root", "dependsOn": ["0a6353d7-0770-447b-8d38-60230a1dc26d", "a6f57810-a099-4bf3-b51e-462afcea449e", "59eab504-a13a-40ed-b1f1-1decea0e1465", "1af3bf3f-97c9-4c45-b0fe-36613b9ff78c", "0e560c22-557c-432d-91a7-34f1562fc522", "e30ccca7-546e-4205-8e80-74f9f100b859", "94f5f489-8b4d-4e48-b50a-93514e16f921", "76995bea-58ce-4845-8298-1e29bf87e145"] }, "0a6353d7-0770-447b-8d38-60230a1dc26d": { "id": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline", "name": "shared_sharepointonline", "type": "Microsoft.PowerApps/apis", "details": { "displayName": "SharePoint", "iconUri": "https://connectoricons-prod.azureedge.net/sharepointonline/icon_1.0.1019.1195.png" }, "configurableBy": "System", "hierarchy": "Child", "dependsOn": [] }, "a6f57810-a099-4bf3-b51e-462afcea449e": { "id": "/providers/Microsoft.PowerApps/apis/shared_sharepointonline/connections/shared-sharepointonl-f8451809-da06-4d47-8786-151238d50e7a", "name": "shared-sharepointonl-f8451809-da06-4d47-8786-151238d50e7a", "type": "Microsoft.PowerApps/apis/connections", "creationType": "Existing", "details": { "displayName": "mark.powney@contoso.onmicrosoft.com", "iconUri": "https://az818438.vo.msecnd.net/icons/sharepointonline.png" }, "configurableBy": "User", "hierarchy": "Child", "dependsOn": ["0a6353d7-0770-447b-8d38-60230a1dc26d"] }, "59eab504-a13a-40ed-b1f1-1decea0e1465": { "id": "/providers/Microsoft.PowerApps/apis/shared_office365users", "name": "shared_office365users", "type": "Microsoft.PowerApps/apis", "details": { "displayName": "Microsoft 365 Users", "iconUri": "https://connectoricons-prod.azureedge.net/office365users/icon_1.0.1002.1175.png" }, "configurableBy": "System", "hierarchy": "Child", "dependsOn": [] }, "1af3bf3f-97c9-4c45-b0fe-36613b9ff78c": { "id": "/providers/Microsoft.PowerApps/apis/shared_office365users/connections/shared-office365user-a19b9b0d-1ea5-48f9-aac8-80f92a1ff178", "name": "shared-office365user-a19b9b0d-1ea5-48f9-aac8-80f92a1ff178", "type": "Microsoft.PowerApps/apis/connections", "creationType": "Existing", "details": { "displayName": "mark.powney@contoso.onmicrosoft.com", "iconUri": "https://az818438.vo.msecnd.net/icons/office365users.png" }, "configurableBy": "User", "hierarchy": "Child", "dependsOn": ["59eab504-a13a-40ed-b1f1-1decea0e1465"] }, "0e560c22-557c-432d-91a7-34f1562fc522": { "id": "/providers/Microsoft.PowerApps/apis/shared_approvals", "name": "shared_approvals", "type": "Microsoft.PowerApps/apis", "details": { "displayName": "Approvals", "iconUri": "https://psux.azureedge.net/Content/Images/Connectors/Approvals3.svg" }, "configurableBy": "System", "hierarchy": "Child", "dependsOn": [] }, "e30ccca7-546e-4205-8e80-74f9f100b859": { "id": "/providers/Microsoft.PowerApps/apis/shared_approvals/connections/shared-approvals-d6675ea9-fc3d-4081-b568-abd773329320", "name": "shared-approvals-d6675ea9-fc3d-4081-b568-abd773329320", "type": "Microsoft.PowerApps/apis/connections", "creationType": "Existing", "details": { "displayName": "Approvals", "iconUri": "https://connectorassets.blob.core.windows.net/assets/Approvals.svg" }, "configurableBy": "User", "hierarchy": "Child", "dependsOn": ["0e560c22-557c-432d-91a7-34f1562fc522"] }, "94f5f489-8b4d-4e48-b50a-93514e16f921": { "id": "/providers/Microsoft.PowerApps/apis/shared_sendmail", "name": "shared_sendmail", "type": "Microsoft.PowerApps/apis", "details": { "displayName": "Mail", "iconUri": "https://az818438.vo.msecnd.net/officialicons/sendmail/icon_1.0.979.1161_83e4f20c-51d8-4c0c-a6f4-653249642047.png" }, "configurableBy": "System", "hierarchy": "Child", "dependsOn": [] }, "76995bea-58ce-4845-8298-1e29bf87e145": { "id": "/providers/Microsoft.PowerApps/apis/shared_sendmail/connections/shared-sendmail-94e385bb-b5a7-4a88-9ded-e0eed0356569", "name": "shared-sendmail-94e385bb-b5a7-4a88-9ded-e0eed0356569", "type": "Microsoft.PowerApps/apis/connections", "creationType": "Existing", "details": { "displayName": "Mail", "iconUri": "https://az818438.vo.msecnd.net/icons/sendmail.png" }, "configurableBy": "User", "hierarchy": "Child", "dependsOn": ["94f5f489-8b4d-4e48-b50a-93514e16f921"] } }, + "status": "Succeeded" + }; + } + return { "details": { "createdTime": "2018-09-16T04:24:28.365117Z", "packageTelemetryId": "448a7d93-7ce3-4e6a-88c9-57cf2479e62e" }, "packageLink": { "value": `${actualFileUrl}` }, @@ -70,22 +73,22 @@ describe(commands.EXPORT, () => { "status": "Succeeded" }; } - if ((opts.url as string).indexOf('/exportToARMTemplate?api-version=2016-11-01') > -1) { + if (opts.url === `https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/environments/${formatting.encodeQueryParameter(foundEnvironmentId)}/flows/${formatting.encodeQueryParameter(foundFlowName)}/exportToARMTemplate?api-version=2016-11-01`) { return {}; } throw 'Invalid request'; }; - const getFakes = async (opts: any) => { - if ((opts.url as string).indexOf(notFoundEnvironmentId) > -1) { + const getFakes = async (opts: CliRequestOptions) => { + if (opts.url === `https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments/${formatting.encodeQueryParameter(notFoundEnvironmentId)}/exportPackage?api-version=2016-11-01`) { throw { "error": { "code": "EnvironmentAccessDenied", - "message": `Access to the environment 'Default-${notFoundEnvironmentId}' is denied.` + "message": `Access to the environment '${notFoundEnvironmentId}' is denied.` } }; } - if ((opts.url as string).indexOf(notFoundFlowName) > -1) { + if (opts.url === `https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/environments/${formatting.encodeQueryParameter(foundEnvironmentId)}/flows/${formatting.encodeQueryParameter(notFoundFlowName)}?api-version=2016-11-01`) { return { errors: [{ "code": "ConnectionAuthorizationFailed", @@ -93,9 +96,9 @@ describe(commands.EXPORT, () => { }] }; } - if (opts.url.match(/\/flows\/[^\?]+\?api-version\=2016-11-01/i)) { + if (opts.url!.match(/\/flows\/[^\?]+\?api-version\=2016-11-01/i)) { return { - "id": `/providers/Microsoft.ProcessSimple/environments/Default-${foundEnvironmentId}/flows/${foundFlowName}`, + "id": `/providers/Microsoft.ProcessSimple/environments/${foundEnvironmentId}/flows/${foundFlowName}`, "name": `${foundFlowName}`, "properties": { "apiId": "/providers/Microsoft.PowerApps/apis/shared_logicflows", "displayName": flowDisplayName }, "type": "Microsoft.ProcessSimple/environments/flows" @@ -117,6 +120,7 @@ describe(commands.EXPORT, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + sinon.stub(accessToken, 'assertDelegatedAccessToken').returns(); }); beforeEach(() => { @@ -157,13 +161,13 @@ describe(commands.EXPORT, () => { assert.notStrictEqual(command.description, null); }); - it('exports the specified flow (debug)', async () => { + it('exports the specified flow (verbose)', async () => { sinon.stub(request, 'get').callsFake(getFakes); sinon.stub(request, 'post').callsFake(postFakes); sinon.stub(fs, 'writeFileSync').callsFake(writeFileSyncFake); - await command.action(logger, { options: { debug: true, name: `${foundFlowName}`, environmentName: `Default-${foundEnvironmentId}`, format: 'zip' } }); - assert(loggerLogToStderrSpy.calledWith(`File saved to path './${actualFilename}'`)); + await command.action(logger, { options: { verbose: true, name: foundFlowName, environmentName: foundEnvironmentId, format: 'zip' } }); + assert(loggerLogToStderrSpy.calledWith(`File saved to path './${actualFilename}'.`)); }); it('exports flow to zip does not contain token', async () => { @@ -171,7 +175,7 @@ describe(commands.EXPORT, () => { sinon.stub(request, 'post').callsFake(postFakes); sinon.stub(fs, 'writeFileSync').callsFake(writeFileSyncFake); - await command.action(logger, { options: { debug: true, name: `${foundFlowName}`, environmentName: `Default-${foundEnvironmentId}`, format: 'zip' } }); + await command.action(logger, { options: { verbose: true, name: foundFlowName, environmentName: foundEnvironmentId, format: 'zip' } }); assert.strictEqual(getRequestsStub.lastCall.args[0].headers['x-anonymous'], true); }); @@ -180,8 +184,8 @@ describe(commands.EXPORT, () => { sinon.stub(request, 'post').callsFake(postFakes); sinon.stub(fs, 'writeFileSync').callsFake(writeFileSyncFake); - await command.action(logger, { options: { debug: true, name: `${nonZipFileFlowId}`, environmentName: `Default-${foundEnvironmentId}`, format: 'zip', path: './output.zip', verbose: true } }); - assert(loggerLogToStderrSpy.calledWith(`File saved to path './output.zip'`)); + await command.action(logger, { options: { verbose: true, name: nonZipFileFlowId, environmentName: foundEnvironmentId, format: 'zip', path: './output.zip' } }); + assert(loggerLogToStderrSpy.calledWith(`File saved to path './output.zip'.`)); }); it('exports the specified flow in json format', async () => { @@ -189,7 +193,7 @@ describe(commands.EXPORT, () => { sinon.stub(request, 'post').callsFake(postFakes); sinon.stub(fs, 'writeFileSync').callsFake(writeFileSyncFake); - await command.action(logger, { options: { name: `${foundFlowName}`, environmentName: `Default-${foundEnvironmentId}`, format: 'json' } }); + await command.action(logger, { options: { name: foundFlowName, environmentName: foundEnvironmentId, format: 'json' } }); assert(loggerLogSpy.calledWith(`./${flowDisplayName}.json`)); }); @@ -197,7 +201,7 @@ describe(commands.EXPORT, () => { sinon.stub(request, 'get').callsFake(async (opts: any) => { if (opts.url.match(/\/flows\/[^\?]+\?api-version\=2016-11-01/i)) { return { - id: `/providers/Microsoft.ProcessSimple/environments/Default-${foundEnvironmentId}/flows/${foundFlowName}`, + id: `/providers/Microsoft.ProcessSimple/environments/${foundEnvironmentId}/flows/${foundFlowName}`, name: `${foundFlowName}`, properties: { apiId: "/providers/Microsoft.PowerApps/apis/shared_logicflows", displayName: '\\Flow " | with: Illegal * characters/?' }, type: "Microsoft.ProcessSimple/environments/flows" @@ -209,7 +213,7 @@ describe(commands.EXPORT, () => { sinon.stub(request, 'post').callsFake(postFakes); sinon.stub(fs, 'writeFileSync').callsFake(writeFileSyncFake); - await command.action(logger, { options: { name: `${foundFlowName}`, environmentName: `Default-${foundEnvironmentId}`, format: 'json' } }); + await command.action(logger, { options: { name: `${foundFlowName}`, environmentName: foundEnvironmentId, format: 'json' } }); assert(loggerLogSpy.calledWith('./_Flow __name_ _ with_ Illegal _ characters__.json')); }); @@ -218,8 +222,8 @@ describe(commands.EXPORT, () => { sinon.stub(request, 'post').callsFake(postFakes); sinon.stub(fs, 'writeFileSync').callsFake(writeFileSyncFake); - await command.action(logger, { options: { debug: true, name: `${foundFlowName}`, environmentName: `Default-${foundEnvironmentId}`, format: 'json' } }); - assert(loggerLogToStderrSpy.calledWith(`File saved to path './${flowDisplayName}.json'`)); + await command.action(logger, { options: { verbose: true, name: foundFlowName, environmentName: foundEnvironmentId, format: 'json' } }); + assert(loggerLogToStderrSpy.calledWith(`File saved to path './${flowDisplayName}.json'.`)); }); it('returns ZIP file location when format specified as ZIP', async () => { @@ -227,7 +231,7 @@ describe(commands.EXPORT, () => { sinon.stub(request, 'post').callsFake(postFakes); sinon.stub(fs, 'writeFileSync').callsFake(writeFileSyncFake); - await command.action(logger, { options: { name: `${foundFlowName}`, environmentName: `Default-${foundEnvironmentId}`, format: 'zip' } }); + await command.action(logger, { options: { name: `${foundFlowName}`, environmentName: foundEnvironmentId, format: 'zip' } }); assert(loggerLogSpy.calledWith(`./${actualFilename}`)); }); @@ -236,7 +240,7 @@ describe(commands.EXPORT, () => { sinon.stub(request, 'post').callsFake(postFakes); sinon.stub(fs, 'writeFileSync').callsFake(writeFileSyncFake); - await command.action(logger, { options: { name: `${foundFlowName}`, environmentName: `Default-${foundEnvironmentId}`, format: 'zip' } }); + await command.action(logger, { options: { name: `${foundFlowName}`, environmentName: foundEnvironmentId, format: 'zip' } }); assert.strictEqual(getRequestsStub.lastCall.args[0].headers['x-anonymous'], true); }); @@ -245,7 +249,7 @@ describe(commands.EXPORT, () => { sinon.stub(request, 'post').callsFake(postFakes); sinon.stub(fs, 'writeFileSync').callsFake(writeFileSyncFake); - await command.action(logger, { options: { name: `${foundFlowName}`, environmentName: `Default-${foundEnvironmentId}`, format: 'zip', path: './output.zip' } }); + await command.action(logger, { options: { name: `${foundFlowName}`, environmentName: foundEnvironmentId, format: 'zip', path: './output.zip' } }); assert(loggerLogSpy.notCalled); }); @@ -254,7 +258,7 @@ describe(commands.EXPORT, () => { sinon.stub(request, 'post').callsFake(postFakes); sinon.stub(fs, 'writeFileSync').callsFake(writeFileSyncFake); - await command.action(logger, { options: { name: `${foundFlowName}`, environmentName: `Default-${foundEnvironmentId}`, format: 'zip', path: './output.zip' } }); + await command.action(logger, { options: { name: `${foundFlowName}`, environmentName: foundEnvironmentId, format: 'zip', path: './output.zip' } }); assert.strictEqual(getRequestsStub.lastCall.args[0].headers['x-anonymous'], true); }); @@ -264,7 +268,7 @@ describe(commands.EXPORT, () => { sinon.stub(request, 'get').callsFake(getFakes); sinon.stub(fs, 'writeFileSync').callsFake(writeFileSyncFake); - await command.action(logger, { options: { name: `${foundFlowName}`, environmentName: `Default-${foundEnvironmentId}`, format: 'zip', path: './output.zip' } }); + await command.action(logger, { options: { name: `${foundFlowName}`, environmentName: foundEnvironmentId, format: 'zip', path: './output.zip' } }); assert.strictEqual(postRequestsStub.lastCall.args[0].data.resources["L1BST1ZJREVSUy9NSUNST1NPRlQuRkxPVy9GTE9XUy9GMkVCOEIzNy1GNjI0LTRCMjItOTk1NC1CNUQwQ0JCMjhGOEI="].suggestedCreationType, 'Update'); resourceIds.forEach((id) => { assert.strictEqual(postRequestsStub.lastCall.args[0].data.resources[id].suggestedCreationType, 'Existing'); @@ -275,62 +279,62 @@ describe(commands.EXPORT, () => { sinon.stub(request, 'get').callsFake(getFakes); sinon.stub(request, 'post').callsFake(postFakes); - await assert.rejects(command.action(logger, { options: { environmentName: `Default-${notFoundEnvironmentId}`, name: `${foundFlowName}` } } as any), - new CommandError(`Access to the environment 'Default-${notFoundEnvironmentId}' is denied.`)); + await assert.rejects(command.action(logger, { options: { environmentName: notFoundEnvironmentId, name: `${foundFlowName}` } } as any), + new CommandError(`Access to the environment '${notFoundEnvironmentId}' is denied.`)); }); it('correctly handles Flow not found', async () => { sinon.stub(request, 'get').callsFake(getFakes); sinon.stub(request, 'post').callsFake(postFakes); - await assert.rejects(command.action(logger, { options: { environmentName: `Default-${foundEnvironmentId}`, name: notFoundFlowName } } as any), + await assert.rejects(command.action(logger, { options: { environmentName: foundEnvironmentId, name: notFoundFlowName } } as any), new CommandError(`The caller with object id '${foundEnvironmentId}' does not have permission for connection '${notFoundFlowName}' under Api 'shared_logicflows'.`)); }); it('fails validation if the id is not a GUID', async () => { - const actual = await command.validate({ options: { environmentName: `Default-${foundEnvironmentId}`, name: 'abc' } }, commandInfo); + const actual = await command.validate({ options: { environmentName: foundEnvironmentId, name: 'abc' } }, commandInfo); assert.notStrictEqual(actual, true); }); it('fails validation if format is specified as neither JSON nor ZIP', async () => { - const actual = await command.validate({ options: { environmentName: `Default-${foundEnvironmentId}`, name: `${foundFlowName}`, format: 'text' } }, commandInfo); + const actual = await command.validate({ options: { environmentName: foundEnvironmentId, name: `${foundFlowName}`, format: 'text' } }, commandInfo); assert.notStrictEqual(actual, true); }); it('fails validation if format is specified as JSON and packageCreatedBy parameter is specified', async () => { - const actual = await command.validate({ options: { environmentName: `Default-${foundEnvironmentId}`, name: `${foundFlowName}`, format: 'json', packageCreatedBy: 'abc' } }, commandInfo); + const actual = await command.validate({ options: { environmentName: foundEnvironmentId, name: `${foundFlowName}`, format: 'json', packageCreatedBy: 'abc' } }, commandInfo); assert.notStrictEqual(actual, true); }); it('fails validation if format is specified as JSON and packageDescription parameter is specified', async () => { - const actual = await command.validate({ options: { environmentName: `Default-${foundEnvironmentId}`, name: `${foundFlowName}`, format: 'json', packageDescription: 'abc' } }, commandInfo); + const actual = await command.validate({ options: { environmentName: foundEnvironmentId, name: `${foundFlowName}`, format: 'json', packageDescription: 'abc' } }, commandInfo); assert.notStrictEqual(actual, true); }); it('fails validation if format is specified as JSON and packageDisplayName parameter is specified', async () => { - const actual = await command.validate({ options: { environmentName: `Default-${foundEnvironmentId}`, name: `${foundFlowName}`, format: 'json', packageDisplayName: 'abc' } }, commandInfo); + const actual = await command.validate({ options: { environmentName: foundEnvironmentId, name: `${foundFlowName}`, format: 'json', packageDisplayName: 'abc' } }, commandInfo); assert.notStrictEqual(actual, true); }); it('fails validation if format is specified as JSON and packageSourceEnvironment parameter is specified', async () => { - const actual = await command.validate({ options: { environmentName: `Default-${foundEnvironmentId}`, name: `${foundFlowName}`, format: 'json', packageSourceEnvironment: 'abc' } }, commandInfo); + const actual = await command.validate({ options: { environmentName: foundEnvironmentId, name: `${foundFlowName}`, format: 'json', packageSourceEnvironment: 'abc' } }, commandInfo); assert.notStrictEqual(actual, true); }); it('fails validation if specified path doesn\'t exist', async () => { sinon.stub(fs, 'existsSync').callsFake(() => false); - const actual = await command.validate({ options: { environmentName: `Default-${foundEnvironmentId}`, name: `${foundFlowName}`, path: '/path/not/found.zip' } }, commandInfo); + const actual = await command.validate({ options: { environmentName: foundEnvironmentId, name: `${foundFlowName}`, path: '/path/not/found.zip' } }, commandInfo); sinonUtil.restore(fs.existsSync); assert.notStrictEqual(actual, true); }); it('passes validation when the id and environment specified', async () => { - const actual = await command.validate({ options: { environmentName: `Default-${foundEnvironmentId}`, name: `${foundFlowName}` } }, commandInfo); + const actual = await command.validate({ options: { environmentName: foundEnvironmentId, name: `${foundFlowName}` } }, commandInfo); assert.strictEqual(actual, true); }); it('passes validation when the id and environment specified and format set to JSON', async () => { - const actual = await command.validate({ options: { environmentName: `Default-${foundEnvironmentId}`, name: `${foundFlowName}`, format: 'json' } }, commandInfo); + const actual = await command.validate({ options: { environmentName: foundEnvironmentId, name: `${foundFlowName}`, format: 'json' } }, commandInfo); assert.strictEqual(actual, true); }); }); diff --git a/src/m365/flow/commands/flow-export.ts b/src/m365/flow/commands/flow-export.ts index d7f444da8c0..0bd3e8d51ae 100644 --- a/src/m365/flow/commands/flow-export.ts +++ b/src/m365/flow/commands/flow-export.ts @@ -7,6 +7,7 @@ import { formatting } from '../../../utils/formatting.js'; import { validation } from '../../../utils/validation.js'; import PowerPlatformCommand from '../../base/PowerPlatformCommand.js'; import commands from '../commands.js'; +import PowerAutomateCommand from '../../base/PowerAutomateCommand.js'; interface CommandArgs { options: Options; @@ -124,7 +125,7 @@ class FlowExportCommand extends PowerPlatformCommand { public async commandAction(logger: Logger, args: CommandArgs): Promise { let filenameFromApi = ''; - const formatArgument = args.options.format ? args.options.format.toLowerCase() : ''; + const formatArgument = args.options.format?.toLowerCase() || ''; if (this.verbose) { await logger.logToStderr(`Retrieving package resources for Microsoft Flow ${args.options.name}...`); @@ -133,8 +134,8 @@ class FlowExportCommand extends PowerPlatformCommand { try { let res: any; if (formatArgument === 'json') { - if (this.debug) { - await logger.logToStderr('format = json, skipping listing package resources step'); + if (this.verbose) { + await logger.logToStderr('format = json, skipping listing package resources step.'); } } else { @@ -164,7 +165,7 @@ class FlowExportCommand extends PowerPlatformCommand { let requestOptions: CliRequestOptions = { url: formatArgument === 'json' ? - `https://management.azure.com/providers/Microsoft.ProcessSimple/environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.name)}?api-version=2016-11-01` + `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.name)}?api-version=2016-11-01` : `${this.resource}/providers/Microsoft.BusinessAppPlatform/environments/${formatting.encodeQueryParameter(args.options.environmentName)}/exportPackage?api-version=2016-11-01`, headers: { accept: 'application/json' @@ -181,17 +182,17 @@ class FlowExportCommand extends PowerPlatformCommand { : res.resources[key].suggestedCreationType = 'Existing'; }); - requestOptions['data'] = { - "includedResourceIds": [ + requestOptions.data = { + includedResourceIds: [ `/providers/Microsoft.Flow/flows/${args.options.name}` ], - "details": { - "displayName": args.options.packageDisplayName, - "description": args.options.packageDescription, - "creator": args.options.packageCreatedBy, - "sourceEnvironment": args.options.packageSourceEnvironment + details: { + displayName: args.options.packageDisplayName, + description: args.options.packageDescription, + creator: args.options.packageCreatedBy, + sourceEnvironment: args.options.packageSourceEnvironment }, - "resources": res.resources + resources: res.resources }; } @@ -208,14 +209,14 @@ class FlowExportCommand extends PowerPlatformCommand { const illegalCharsRegEx = /[\\\/:*?"<>|]/g; filenameFromApi = filenameFromApi.replace(illegalCharsRegEx, '_'); - if (this.debug) { - await logger.logToStderr(`Filename from PowerApps API: ${filenameFromApi}`); + if (this.verbose) { + await logger.logToStderr(`Filename from PowerApps API: ${filenameFromApi}.`); await logger.logToStderr(''); } requestOptions = { url: formatArgument === 'json' ? - `https://management.azure.com/providers/Microsoft.ProcessSimple/environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.name)}/exportToARMTemplate?api-version=2016-11-01` + `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.name)}/exportToARMTemplate?api-version=2016-11-01` : downloadFileUrl, // Set responseType to arraybuffer, otherwise binary data will be encoded // to utf8 and binary data is corrupt @@ -237,7 +238,7 @@ class FlowExportCommand extends PowerPlatformCommand { fs.writeFileSync(path, file, 'binary'); if (!args.options.path || this.verbose) { if (this.verbose) { - await logger.logToStderr(`File saved to path '${path}'`); + await logger.logToStderr(`File saved to path '${path}'.`); } else { await logger.log(path); diff --git a/src/m365/flow/commands/flow-get.ts b/src/m365/flow/commands/flow-get.ts index 67286a4356c..1fcf11ef78f 100644 --- a/src/m365/flow/commands/flow-get.ts +++ b/src/m365/flow/commands/flow-get.ts @@ -88,7 +88,7 @@ class FlowGetCommand extends PowerAutomateCommand { } const requestOptions: CliRequestOptions = { - url: `${this.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.name)}?api-version=2016-11-01&$expand=swagger,properties.connectionreferences.apidefinition,properties.definitionsummary.operations.apioperation,operationDefinition,plan,properties.throttleData,properties.estimatedsuspensiondata,properties.licenseData`, + url: `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.name)}?api-version=2016-11-01&$expand=swagger,properties.connectionreferences.apidefinition,properties.definitionsummary.operations.apioperation,operationDefinition,plan,properties.throttleData,properties.estimatedsuspensiondata,properties.licenseData`, headers: { accept: 'application/json' }, diff --git a/src/m365/flow/commands/flow-list.ts b/src/m365/flow/commands/flow-list.ts index 6be9fbc5e48..87685084e0e 100644 --- a/src/m365/flow/commands/flow-list.ts +++ b/src/m365/flow/commands/flow-list.ts @@ -152,7 +152,7 @@ class FlowListCommand extends PowerAutomateCommand { } private getApiUrl(environmentName: string, asAdmin?: boolean, includeSolutionFlows?: boolean, filter?: 'personal' | 'team',): string { - const baseEndpoint = `${this.resource}/providers/Microsoft.ProcessSimple`; + const baseEndpoint = `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple`; const environmentSegment = `/environments/${formatting.encodeQueryParameter(environmentName)}`; const adminSegment = `/scopes/admin${environmentSegment}/v2`; const flowsEndpoint = '/flows?api-version=2016-11-01'; diff --git a/src/m365/flow/commands/flow-remove.ts b/src/m365/flow/commands/flow-remove.ts index d53e22337cb..115016f4a11 100644 --- a/src/m365/flow/commands/flow-remove.ts +++ b/src/m365/flow/commands/flow-remove.ts @@ -80,7 +80,7 @@ class FlowRemoveCommand extends PowerAutomateCommand { const removeFlow = async (): Promise => { const requestOptions: CliRequestOptions = { - url: `${this.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.name)}?api-version=2016-11-01`, + url: `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.name)}?api-version=2016-11-01`, fullResponse: true, headers: { accept: 'application/json' diff --git a/src/m365/flow/commands/owner/owner-ensure.ts b/src/m365/flow/commands/owner/owner-ensure.ts index 4b6585bedc4..3e1a4b7fb4d 100644 --- a/src/m365/flow/commands/owner/owner-ensure.ts +++ b/src/m365/flow/commands/owner/owner-ensure.ts @@ -146,7 +146,7 @@ class FlowOwnerEnsureCommand extends PowerAutomateCommand { } const requestOptions: CliRequestOptions = { - url: `${this.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.flowName)}/modifyPermissions?api-version=2016-11-01`, + url: `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.flowName)}/modifyPermissions?api-version=2016-11-01`, headers: { accept: 'application/json' }, diff --git a/src/m365/flow/commands/owner/owner-list.ts b/src/m365/flow/commands/owner/owner-list.ts index 280f0026b01..0c09d2bd7c1 100644 --- a/src/m365/flow/commands/owner/owner-list.ts +++ b/src/m365/flow/commands/owner/owner-list.ts @@ -96,7 +96,7 @@ class FlowOwnerListCommand extends PowerAutomateCommand { await logger.logToStderr(`Listing owners for flow ${args.options.flowName} in environment ${args.options.environmentName}`); } - const response = await odata.getAllItems(`${this.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.flowName)}/permissions?api-version=2016-11-01`); + const response = await odata.getAllItems(`${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.flowName)}/permissions?api-version=2016-11-01`); if (!cli.shouldTrimOutput(args.options.output)) { await logger.log(response); } diff --git a/src/m365/flow/commands/owner/owner-remove.ts b/src/m365/flow/commands/owner/owner-remove.ts index 9dd40047c97..8ed8b2f25b7 100644 --- a/src/m365/flow/commands/owner/owner-remove.ts +++ b/src/m365/flow/commands/owner/owner-remove.ts @@ -134,7 +134,7 @@ class FlowOwnerRemoveCommand extends PowerAutomateCommand { } const requestOptions: CliRequestOptions = { - url: `${this.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.flowName)}/modifyPermissions?api-version=2016-11-01`, + url: `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.flowName)}/modifyPermissions?api-version=2016-11-01`, headers: { accept: 'application/json' }, diff --git a/src/m365/flow/commands/recyclebinitem/recyclebinitem-list.spec.ts b/src/m365/flow/commands/recyclebinitem/recyclebinitem-list.spec.ts new file mode 100644 index 00000000000..64dcb29840f --- /dev/null +++ b/src/m365/flow/commands/recyclebinitem/recyclebinitem-list.spec.ts @@ -0,0 +1,253 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { CommandError } from '../../../../Command.js'; +import { Logger } from '../../../../cli/Logger.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command from './recyclebinitem-list.js'; +import { accessToken } from '../../../../utils/accessToken.js'; + +describe(commands.OWNER_LIST, () => { + const environmentName = 'Default-d87a7535-dd31-4437-bfe1-95340acd55c6'; + + const deletedFlows = [ + { + name: '26a9a283-af42-4c09-aa3e-60c3cc166b90', + id: '/providers/Microsoft.ProcessSimple/environments/Default-d87a7535-dd31-4437-bfe1-95340acd55c5/flows/26a9a283-af42-4c09-aa3e-60c3cc166b90', + type: 'Microsoft.ProcessSimple/environments/flows', + properties: { + apiId: '/providers/Microsoft.PowerApps/apis/shared_logicflows', + displayName: 'Invoicing flow', + state: 'Deleted', + createdTime: '2024-08-05T23:13:54Z', + lastModifiedTime: '2024-08-05T23:14:00Z', + flowSuspensionReason: 'None', + environment: { + name: 'Default-d87a7535-dd31-4437-bfe1-95340acd55c5', + type: 'Microsoft.ProcessSimple/environments', + id: '/providers/Microsoft.ProcessSimple/environments/Default-d87a7535-dd31-4437-bfe1-95340acd55c5' + }, + definitionSummary: { + triggers: [], + actions: [] + }, + creator: { + tenantId: 'a16e76a1-837f-4bf9-82dc-78874d18e434', + objectId: 'bd51c64d-c262-4184-ba3f-5361ea553820', + userId: 'bd51c64d-c262-4184-ba3f-5361ea553820', + userType: 'ActiveDirectory' + }, + flowFailureAlertSubscribed: false, + isManaged: false, + machineDescriptionData: {}, + flowOpenAiData: { + isConsequential: false, + isConsequentialFlagOverwritten: false + } + } + }, + { + name: '53768068-1dd5-4cc4-a26b-034bad10bfed', + id: '/providers/Microsoft.ProcessSimple/environments/Default-d87a7535-dd31-4437-bfe1-95340acd55c5/flows/53768068-1dd5-4cc4-a26b-034bad10bfed', + type: 'Microsoft.ProcessSimple/environments/flows', + properties: { + apiId: '/providers/Microsoft.PowerApps/apis/shared_logicflows', + displayName: 'Invoicing flow 2', + state: 'Deleted', + createdTime: '2024-08-05T23:13:54Z', + lastModifiedTime: '2024-08-05T23:14:00Z', + flowSuspensionReason: 'None', + environment: { + name: 'Default-d87a7535-dd31-4437-bfe1-95340acd55c5', + type: 'Microsoft.ProcessSimple/environments', + id: '/providers/Microsoft.ProcessSimple/environments/Default-d87a7535-dd31-4437-bfe1-95340acd55c5' + }, + definitionSummary: { + triggers: [], + actions: [] + }, + creator: { + tenantId: 'a16e76a1-837f-4bf9-82dc-78874d18e434', + objectId: 'bd51c64d-c262-4184-ba3f-5361ea553820', + userId: 'bd51c64d-c262-4184-ba3f-5361ea553820', + userType: 'ActiveDirectory' + }, + flowFailureAlertSubscribed: false, + isManaged: false, + machineDescriptionData: {}, + flowOpenAiData: { + isConsequential: false, + isConsequentialFlagOverwritten: false + } + } + } + ]; + + const flowResponse = { + value: [ + { + name: '7bb4a726-2e02-4b88-ad34-8510bcbbcfa0', + id: '/providers/Microsoft.ProcessSimple/environments/Default-d87a7535-dd31-4437-bfe1-95340acd55c5/flows/7bb4a726-2e02-4b88-ad34-8510bcbbcfa0', + type: 'Microsoft.ProcessSimple/environments/flows', + properties: { + apiId: '/providers/Microsoft.PowerApps/apis/shared_logicflows', + displayName: 'Create a Planner task when a channel post starts with TODO', + state: 'Started', + createdTime: '2024-03-22T15:09:07Z', + lastModifiedTime: '2024-03-22T15:09:07Z', + flowSuspensionReason: 'None', + templateName: '2d30c27107de4d0786be7a2b4574ae70', + environment: { + name: 'Default-d87a7535-dd31-4437-bfe1-95340acd55c5', + type: 'Microsoft.ProcessSimple/environments', + id: '/providers/Microsoft.ProcessSimple/environments/Default-d87a7535-dd31-4437-bfe1-95340acd55c5' + }, + definitionSummary: { + triggers: [], + actions: [] + }, + creator: { + tenantId: 'a16e76a1-837f-4bf9-82dc-78874d18e434', + objectId: 'bd51c64d-c262-4184-ba3f-5361ea553820', + userId: 'bd51c64d-c262-4184-ba3f-5361ea553820', + userType: 'ActiveDirectory' + }, + provisioningMethod: 'FromTemplate', + flowFailureAlertSubscribed: false, + isManaged: false, + machineDescriptionData: {}, + flowOpenAiData: { + isConsequential: false, + isConsequentialFlagOverwritten: false + } + } + }, + ...deletedFlows + ] + }; + + let log: string[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + sinon.stub(accessToken, 'assertDelegatedAccessToken').returns(); + auth.connection.active = true; + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + }); + + afterEach(() => { + sinonUtil.restore([ + request.get + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.RECYCLEBINITEM_LIST); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('defines correct default properties', () => { + assert.deepStrictEqual(command.defaultProperties(), ['name', 'displayName']); + }); + + it('outputs exactly one result when retrieving deleted flows with output json', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/scopes/admin/environments/${formatting.encodeQueryParameter(environmentName)}/v2/flows?api-version=2016-11-01&include=softDeletedFlows`) { + return flowResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { verbose: true, environmentName: environmentName } }); + assert(loggerLogSpy.calledOnce); + }); + + it('outputs exactly one result when retrieving deleted flows with output text', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/scopes/admin/environments/${formatting.encodeQueryParameter(environmentName)}/v2/flows?api-version=2016-11-01&include=softDeletedFlows`) { + return flowResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { verbose: true, environmentName: environmentName, output: 'text' } }); + assert(loggerLogSpy.calledOnce); + }); + + it('correctly retrieves deleted flows with output json', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/scopes/admin/environments/${formatting.encodeQueryParameter(environmentName)}/v2/flows?api-version=2016-11-01&include=softDeletedFlows`) { + return flowResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { verbose: true, environmentName: environmentName } }); + assert.deepStrictEqual(loggerLogSpy.firstCall.args[0], deletedFlows); + }); + + it('correctly retrieves deleted flows with output text', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/scopes/admin/environments/${formatting.encodeQueryParameter(environmentName)}/v2/flows?api-version=2016-11-01&include=softDeletedFlows`) { + return flowResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { verbose: true, environmentName: environmentName, output: 'text' } }); + const textResponse = deletedFlows.map(flow => ({ ...flow, displayName: flow.properties.displayName })); + + assert.deepStrictEqual(loggerLogSpy.firstCall.args[0], textResponse); + }); + + it('throws error when no environment found', async () => { + const error = { + 'error': { + 'code': 'EnvironmentAccessDenied', + 'message': `Access to the environment '${environmentName}' is denied.` + } + }; + sinon.stub(request, 'get').rejects(error); + + await assert.rejects(command.action(logger, { options: { environmentName: environmentName } }), + new CommandError(error.error.message)); + }); +}); \ No newline at end of file diff --git a/src/m365/flow/commands/recyclebinitem/recyclebinitem-list.ts b/src/m365/flow/commands/recyclebinitem/recyclebinitem-list.ts new file mode 100644 index 00000000000..052f97f9dfd --- /dev/null +++ b/src/m365/flow/commands/recyclebinitem/recyclebinitem-list.ts @@ -0,0 +1,62 @@ +import PowerAutomateCommand from '../../../base/PowerAutomateCommand.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import { z } from 'zod'; +import { zod } from '../../../../utils/zod.js'; +import { Logger } from '../../../../cli/Logger.js'; +import commands from '../../commands.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { odata } from '../../../../utils/odata.js'; +import { cli } from '../../../../cli/cli.js'; + +const options = globalOptionsZod + .extend({ + environmentName: zod.alias('e', z.string()) + }) + .strict(); +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class FlowRecycleBinItemListCommand extends PowerAutomateCommand { + public get name(): string { + return commands.RECYCLEBINITEM_LIST; + } + + public get description(): string { + return 'Lists all soft-deleted Power Automate flows within an environment'; + } + + public defaultProperties(): string[] { + return ['name', 'displayName']; + } + + public get schema(): z.ZodTypeAny { + return options; + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + if (this.verbose) { + await logger.logToStderr(`Getting list of soft-deleted flows in environment ${args.options.environmentName}...`); + } + + const flows = await odata.getAllItems(`${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/scopes/admin/environments/${formatting.encodeQueryParameter(args.options.environmentName)}/v2/flows?api-version=2016-11-01&include=softDeletedFlows`); + const deletedFlows = flows.filter(flow => flow.properties.state === 'Deleted'); + + if (cli.shouldTrimOutput(args.options.output)) { + deletedFlows.forEach(flow => { + flow.displayName = flow.properties.displayName; + }); + } + + await logger.log(deletedFlows); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new FlowRecycleBinItemListCommand(); \ No newline at end of file diff --git a/src/m365/flow/commands/recyclebinitem/recyclebinitem-restore.spec.ts b/src/m365/flow/commands/recyclebinitem/recyclebinitem-restore.spec.ts new file mode 100644 index 00000000000..0b87fbce62b --- /dev/null +++ b/src/m365/flow/commands/recyclebinitem/recyclebinitem-restore.spec.ts @@ -0,0 +1,134 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { CommandError } from '../../../../Command.js'; +import { Logger } from '../../../../cli/Logger.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { pid } from '../../../../utils/pid.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command from './recyclebinitem-restore.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import { z } from 'zod'; +import { cli } from '../../../../cli/cli.js'; + +describe(commands.OWNER_LIST, () => { + const environmentName = 'Default-d87a7535-dd31-4437-bfe1-95340acd55c6'; + const flowName = 'd87a7535-dd31-4437-bfe1-95340acd55c6'; + + let log: string[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + sinon.stub(accessToken, 'assertDelegatedAccessToken').returns(); + + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; + auth.connection.active = true; + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + }); + + afterEach(() => { + sinonUtil.restore([ + request.post + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.RECYCLEBINITEM_RESTORE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if the flowName is not a valid GUID', async () => { + const actual = commandOptionsSchema.safeParse({ environmentName: environmentName, flowName: 'invalid' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation when the flowName is a valid GUID', async () => { + const actual = commandOptionsSchema.safeParse({ environmentName: environmentName, flowName: flowName }); + assert.strictEqual(actual.success, true); + }); + + it('outputs no command output', async () => { + sinon.stub(request, 'post').resolves(); + + await command.action(logger, { + options: { + environmentName: environmentName, + flowName: flowName, + verbose: true + } + }); + + assert(loggerLogSpy.notCalled); + }); + + it('correctly restores flow', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/scopes/admin/environments/${formatting.encodeQueryParameter(environmentName)}/flows/${flowName}/restore?api-version=2016-11-01`) { + return; + } + + throw 'Invalid request :' + opts.url; + }); + + await command.action(logger, { + options: { + environmentName: environmentName, + flowName: flowName + } + }); + + assert(postStub.calledOnce); + }); + + it('correctly handles error when restoring flow', async () => { + const message = 'Request to Azure Resource Manager failed.'; + + sinon.stub(request, 'post').rejects({ + error: { + error: { + code: 'AzureResourceManagerRequestFailed', + message: message + } + } + }); + + await assert.rejects(command.action(logger, { options: { environmentName: environmentName, flowName: flowName } }), + new CommandError(message)); + }); +}); \ No newline at end of file diff --git a/src/m365/flow/commands/recyclebinitem/recyclebinitem-restore.ts b/src/m365/flow/commands/recyclebinitem/recyclebinitem-restore.ts new file mode 100644 index 00000000000..5dd9d42f614 --- /dev/null +++ b/src/m365/flow/commands/recyclebinitem/recyclebinitem-restore.ts @@ -0,0 +1,62 @@ +import PowerAutomateCommand from '../../../base/PowerAutomateCommand.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import { z } from 'zod'; +import { zod } from '../../../../utils/zod.js'; +import { Logger } from '../../../../cli/Logger.js'; +import commands from '../../commands.js'; +import { validation } from '../../../../utils/validation.js'; +import { formatting } from '../../../../utils/formatting.js'; +import request, { CliRequestOptions } from '../../../../request.js'; + +const options = globalOptionsZod + .extend({ + environmentName: zod.alias('e', z.string()), + flowName: zod.alias('n', z.string() + .refine(name => validation.isValidGuid(name), name => ({ + message: `'${name}' is not a valid GUID.` + })) + ) + }) + .strict(); +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class FlowRecycleBinItemRestoreCommand extends PowerAutomateCommand { + public get name(): string { + return commands.RECYCLEBINITEM_RESTORE; + } + + public get description(): string { + return 'Restores a soft-deleted Power Automate flow'; + } + + public get schema(): z.ZodTypeAny { + return options; + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + if (this.verbose) { + await logger.logToStderr(`Restoring soft-deleted flow ${args.options.flowName} from environment ${args.options.environmentName}...`); + } + + const requestOptions: CliRequestOptions = { + url: `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/scopes/admin/environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${args.options.flowName}/restore?api-version=2016-11-01`, + headers: { + accept: 'application/json' + }, + responseType: 'json' + }; + + await request.post(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new FlowRecycleBinItemRestoreCommand(); \ No newline at end of file diff --git a/src/m365/flow/commands/run/run-cancel.ts b/src/m365/flow/commands/run/run-cancel.ts index 1975a2d3c67..121e1e06d8e 100644 --- a/src/m365/flow/commands/run/run-cancel.ts +++ b/src/m365/flow/commands/run/run-cancel.ts @@ -79,7 +79,7 @@ class FlowRunCancelCommand extends PowerAutomateCommand { const cancelFlow = async (): Promise => { const requestOptions: CliRequestOptions = { - url: `${this.resource}/providers/Microsoft.ProcessSimple/environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.flowName)}/runs/${formatting.encodeQueryParameter(args.options.name)}/cancel?api-version=2016-11-01`, + url: `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.flowName)}/runs/${formatting.encodeQueryParameter(args.options.name)}/cancel?api-version=2016-11-01`, headers: { accept: 'application/json' }, diff --git a/src/m365/flow/commands/run/run-get.ts b/src/m365/flow/commands/run/run-get.ts index b47df36f035..f7354705fb5 100644 --- a/src/m365/flow/commands/run/run-get.ts +++ b/src/m365/flow/commands/run/run-get.ts @@ -152,7 +152,7 @@ class FlowRunGetCommand extends PowerAutomateCommand { const actionsParameter = args.options.withActions ? '$expand=properties%2Factions&' : ''; const requestOptions: CliRequestOptions = { - url: `${this.resource}/providers/Microsoft.ProcessSimple/environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.flowName)}/runs/${formatting.encodeQueryParameter(args.options.name)}?${actionsParameter}api-version=2016-11-01`, + url: `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.flowName)}/runs/${formatting.encodeQueryParameter(args.options.name)}?${actionsParameter}api-version=2016-11-01`, headers: { accept: 'application/json' }, diff --git a/src/m365/flow/commands/run/run-list.ts b/src/m365/flow/commands/run/run-list.ts index 47aef20059b..c13e5ccfb26 100644 --- a/src/m365/flow/commands/run/run-list.ts +++ b/src/m365/flow/commands/run/run-list.ts @@ -132,7 +132,7 @@ class FlowRunListCommand extends PowerAutomateCommand { await logger.logToStderr(`Retrieving list of runs for Microsoft Flow ${args.options.flowName}...`); } - let url: string = `${this.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.flowName)}/runs?api-version=2016-11-01`; + let url: string = `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/${args.options.asAdmin ? 'scopes/admin/' : ''}environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.flowName)}/runs?api-version=2016-11-01`; const filters = this.getFilters(args.options); if (filters.length > 0) { url += `&$filter=${filters.join(' and ')}`; diff --git a/src/m365/flow/commands/run/run-resubmit.ts b/src/m365/flow/commands/run/run-resubmit.ts index 8f4b40c4538..38e57e93228 100644 --- a/src/m365/flow/commands/run/run-resubmit.ts +++ b/src/m365/flow/commands/run/run-resubmit.ts @@ -87,7 +87,7 @@ class FlowRunResubmitCommand extends PowerAutomateCommand { } const requestOptions: CliRequestOptions = { - url: `${this.resource}/providers/Microsoft.ProcessSimple/environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.flowName)}/triggers/${formatting.encodeQueryParameter(triggerName)}/histories/${formatting.encodeQueryParameter(args.options.name)}/resubmit?api-version=2016-11-01`, + url: `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/environments/${formatting.encodeQueryParameter(args.options.environmentName)}/flows/${formatting.encodeQueryParameter(args.options.flowName)}/triggers/${formatting.encodeQueryParameter(triggerName)}/histories/${formatting.encodeQueryParameter(args.options.name)}/resubmit?api-version=2016-11-01`, headers: { accept: 'application/json' }, @@ -115,7 +115,7 @@ class FlowRunResubmitCommand extends PowerAutomateCommand { private async getTriggerName(environment: string, flow: string): Promise { const requestOptions: CliRequestOptions = { - url: `${this.resource}/providers/Microsoft.ProcessSimple/environments/${formatting.encodeQueryParameter(environment)}/flows/${formatting.encodeQueryParameter(flow)}/triggers?api-version=2016-11-01`, + url: `${PowerAutomateCommand.resource}/providers/Microsoft.ProcessSimple/environments/${formatting.encodeQueryParameter(environment)}/flows/${formatting.encodeQueryParameter(flow)}/triggers?api-version=2016-11-01`, headers: { accept: 'application/json' }, diff --git a/src/m365/onenote/commands.ts b/src/m365/onenote/commands.ts index aafd73d72fa..b4242e721f7 100644 --- a/src/m365/onenote/commands.ts +++ b/src/m365/onenote/commands.ts @@ -1,6 +1,7 @@ const prefix: string = 'onenote'; export default { + NOTEBOOK_ADD: `${prefix} notebook add`, NOTEBOOK_LIST: `${prefix} notebook list`, PAGE_LIST: `${prefix} page list` }; \ No newline at end of file diff --git a/src/m365/onenote/commands/notebook/notebook-add.spec.ts b/src/m365/onenote/commands/notebook/notebook-add.spec.ts new file mode 100644 index 00000000000..a193726f317 --- /dev/null +++ b/src/m365/onenote/commands/notebook/notebook-add.spec.ts @@ -0,0 +1,246 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { CommandError } from '../../../../Command.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command from './notebook-add.js'; +import { entraGroup } from '../../../../utils/entraGroup.js'; +import { spo } from '../../../../utils/spo.js'; + +describe(commands.NOTEBOOK_ADD, () => { + const name = 'My Notebook'; + const addResponse = { + id: '1-2ae2e5d0-2857-4b1a-99d3-cc5426799438', + self: 'https://graph.microsoft.com/v1.0/users/fe36f75e-c103-410b-a18a-2bf6df06ac3a/onenote/notebooks/1-2ae2e5d0-2857-4b1a-99d3-cc5426799438', + createdDateTime: '2024-04-05T17:30:28Z', + displayName: name, + lastModifiedDateTime: '2024-04-05T17:30:28Z', + isDefault: false, + userRole: 'Owner', + isShared: false, + sectionsUrl: 'https://graph.microsoft.com/v1.0/users/fe36f75e-c103-410b-a18a-2bf6df06ac3a/onenote/notebooks/1-2ae2e5d0-2857-4b1a-99d3-cc5426799438/sections', + sectionGroupsUrl: 'https://graph.microsoft.com/v1.0/users/fe36f75e-c103-410b-a18a-2bf6df06ac3a/onenote/notebooks/1-2ae2e5d0-2857-4b1a-99d3-cc5426799438/sectionGroups', + createdBy: { + user: { + id: 'fe36f75e-c103-410b-a18a-2bf6df06ac3a', + displayName: 'John Doe' + } + }, + lastModifiedBy: { + user: { + id: 'fe36f75e-c103-410b-a18a-2bf6df06ac3a', + displayName: 'John Doe' + } + }, + links: { + oneNoteClientUrl: { + href: 'onenote:https://contoso2-my.sharepoint.com/personal/john_contoso2_onmicrosoft_com/Documents/Notebooks/Dummy' + }, + oneNoteWebUrl: { + href: 'https://contoso2-my.sharepoint.com/personal/john_contoso2_onmicrosoft_com/Documents/Notebooks/Dummy' + } + } + }; + + let log: string[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + }); + + afterEach(() => { + sinonUtil.restore([ + request.post + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.NOTEBOOK_ADD); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if name contains invalid characters', async () => { + const actual = await command.validate({ options: { name: 'My notebook /' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if name is longer than 128 characters', async () => { + const longString = 'x'.repeat(129); + const actual = await command.validate({ options: { name: longString } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if webUrl is not a valid webUrl', async () => { + const actual = await command.validate({ options: { name: name, webUrl: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the userId is not a valid GUID', async () => { + const actual = await command.validate({ options: { name: name, userId: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the groupId is not a valid GUID', async () => { + const actual = await command.validate({ options: { name: name, groupId: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation if no option but name specified', async () => { + const actual = await command.validate({ options: { name: name } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('adds notebook for the currently logged in user', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/onenote/notebooks`) { + return addResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { name: name, verbose: true } }); + assert(loggerLogSpy.calledWith(addResponse)); + }); + + it('adds notebook for user by id', async () => { + const userId = '2609af39-7775-4f94-a3dc-0dd67657e900'; + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${userId}/onenote/notebooks`) { + return addResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { name: name, userId: userId, verbose: true } }); + assert(loggerLogSpy.calledWith(addResponse)); + }); + + it('adds notebook in group by id', async () => { + const groupId = '233e43d0-dc6a-482e-9b4e-0de7a7bce9b4'; + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupId}/onenote/notebooks`) { + return addResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { name: name, groupId: groupId, verbose: true } }); + assert(loggerLogSpy.calledWith(addResponse)); + }); + + it('adds notebook in group by name', async () => { + const groupId = '233e43d0-dc6a-482e-9b4e-0de7a7bce9b4'; + const groupName = 'My group'; + sinon.stub(entraGroup, 'getGroupIdByDisplayName').resolves(groupId); + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${groupId}/onenote/notebooks`) { + return addResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { name: name, groupName: groupName, verbose: true } }); + assert(loggerLogSpy.calledWith(addResponse)); + }); + + it('adds notebook for site', async () => { + const siteUrl = 'https://contoso.sharepoint.com/sites/testsite'; + const siteId = 'contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE'; + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/sites/${siteId}/onenote/notebooks`) { + return addResponse; + } + + throw 'Invalid request'; + }); + + sinon.stub(spo, 'getSpoGraphSiteId').resolves(siteId); + + await command.action(logger, { options: { name: name, webUrl: siteUrl, verbose: true } }); + assert(loggerLogSpy.calledWith(addResponse)); + }); + + it('adds notebook for user by name', async () => { + const userName = 'john@contoso.com'; + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/${userName}/onenote/notebooks`) { + return addResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { name: name, userName: userName, verbose: true } }); + assert(loggerLogSpy.calledWith(addResponse)); + }); + + it('handles error when adding notebook fails when it already exists', async () => { + const error = { + error: { + code: '20117', + message: 'An item with this name already exists in this location.', + innerError: { + date: '2024-04-05T17:49:42', + 'request-id': '47cd5f47-2158-4c43-ae0a-22e3b9073e7d', + 'client-request-id': '47cd5f47-2158-4c43-ae0a-22e3b9073e7d' + } + } + }; + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/onenote/notebooks`) { + throw error; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { name: name, verbose: true } } as any), new CommandError(error.error.message)); + }); +}); diff --git a/src/m365/onenote/commands/notebook/notebook-add.ts b/src/m365/onenote/commands/notebook/notebook-add.ts new file mode 100644 index 00000000000..75366bf3620 --- /dev/null +++ b/src/m365/onenote/commands/notebook/notebook-add.ts @@ -0,0 +1,170 @@ +import { Logger } from '../../../../cli/Logger.js'; +import GlobalOptions from '../../../../GlobalOptions.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { entraGroup } from '../../../../utils/entraGroup.js'; +import { validation } from '../../../../utils/validation.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; +import { spo } from '../../../../utils/spo.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + name: string; + userId?: string; + userName?: string; + groupId?: string; + groupName?: string; + webUrl?: string; +} + +class OneNoteNotebookAddCommand extends GraphCommand { + public get name(): string { + return commands.NOTEBOOK_ADD; + } + + public get description(): string { + return 'Create a new OneNote notebook'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + this.#initOptionSets(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + userId: typeof args.options.userId !== 'undefined', + userName: typeof args.options.userName !== 'undefined', + groupId: typeof args.options.groupId !== 'undefined', + groupName: typeof args.options.groupName !== 'undefined', + webUrl: typeof args.options.webUrl !== 'undefined' + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-n, --name ' + }, + { + option: '--userId [userId]' + }, + { + option: '--userName [userName]' + }, + { + option: '--groupId [groupId]' + }, + { + option: '--groupName [groupName]' + }, + { + option: '-u, --webUrl [webUrl]' + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + // check name for invalid characters + if (args.options.name.length > 128) { + return 'The specified name is too long. It should be less than 128 characters'; + } + + if (/[?*/:<>|'"]/.test(args.options.name)) { + return `The specified name contains invalid characters. It cannot contain ?*/:<>|'". Please remove them and try again.`; + } + + if (args.options.userId && !validation.isValidGuid(args.options.userId as string)) { + return `${args.options.userId} is not a valid GUID`; + } + + if (args.options.groupId && !validation.isValidGuid(args.options.groupId as string)) { + return `${args.options.groupId} is not a valid GUID`; + } + + if (args.options.webUrl) { + return validation.isValidSharePointUrl(args.options.webUrl); + } + + return true; + } + ); + } + + #initOptionSets(): void { + this.optionSets.push({ + options: ['userId', 'userName', 'groupId', 'groupName', 'webUrl'], + runsWhen: (args) => { + const options = [args.options.userId, args.options.userName, args.options.groupId, args.options.groupName, args.options.webUrl]; + return options.some(item => item !== undefined); + } + }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + if (this.verbose) { + await logger.logToStderr(`Creating OneNote notebook ${args.options.name}`); + } + + const requestUrl = await this.getRequestUrl(args); + const requestOptions: CliRequestOptions = { + url: requestUrl, + headers: { + accept: 'application/json;odata.metadata=none', + 'content-type': "application/json" + }, + responseType: 'json', + data: { + displayName: args.options.name + } + }; + + const response = await request.post(requestOptions); + await logger.log(response); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private async getRequestUrl(args: CommandArgs): Promise { + let endpoint: string = `${this.resource}/v1.0/`; + + if (args.options.userId) { + endpoint += `users/${args.options.userId}`; + } + else if (args.options.userName) { + endpoint += `users/${args.options.userName}`; + } + else if (args.options.groupId) { + endpoint += `groups/${args.options.groupId}`; + } + else if (args.options.groupName) { + const groupId = await entraGroup.getGroupIdByDisplayName(args.options.groupName); + endpoint += `groups/${groupId}`; + } + else if (args.options.webUrl) { + const siteId = await spo.getSpoGraphSiteId(args.options.webUrl); + endpoint += `sites/${siteId}`; + } + else { + endpoint += 'me'; + } + endpoint += '/onenote/notebooks'; + return endpoint; + } +} + +export default new OneNoteNotebookAddCommand(); \ No newline at end of file diff --git a/src/m365/pa/commands/app/app-export.spec.ts b/src/m365/pa/commands/app/app-export.spec.ts index 7aadd3328cd..8ef9fa25d11 100644 --- a/src/m365/pa/commands/app/app-export.spec.ts +++ b/src/m365/pa/commands/app/app-export.spec.ts @@ -19,9 +19,7 @@ describe(commands.APP_EXPORT, () => { let log: string[]; let logger: Logger; let commandInfo: CommandInfo; - let loggerLogToStderrSpy: sinon.SinonSpy; - const actualFilename = 'Power App.zip'; const packageDisplayName = 'Power App'; const packageDescription = 'Power App Description'; const packageCreatedBy = 'John Doe'; @@ -142,7 +140,7 @@ describe(commands.APP_EXPORT, () => { } }; - const fileBlobResponse = { + const fileBlobResponse: any = { type: 'Buffer', data: [80, 75, 3, 4, 20, 0, 0, 0, 8, 0, 237, 115, 99, 86, 250, 76, 155, 216, 248, 3, 0, 0, 7, 8, 0, 0, 71, 0, 0, 0, 77, 105, 99, 114, 111, 115, 111, 102, 116, 46, 80, 111, 119, 101, 114, 65, 112, 112, 115, 47, 97, 112, 112, 115, 47, 49, 56, 48, 50, 54, 54, 51, 51, 48] }; @@ -171,7 +169,6 @@ describe(commands.APP_EXPORT, () => { log.push(msg); } }; - loggerLogToStderrSpy = sinon.spy(logger, 'logToStderr'); }); afterEach(() => { @@ -195,12 +192,10 @@ describe(commands.APP_EXPORT, () => { assert.notStrictEqual(command.description, null); }); - it('exports the specified App', async () => { - let index = 0; - sinon.stub(request, 'get').callsFake(async (opts) => { + it('exports the specified app correctly', async () => { + const getStub = sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === exportPackageResponse.headers.location) { - if (index === 0) { - index = 1; + if (getStub.calledOnce) { return locationRunningResponse; } else { @@ -226,17 +221,16 @@ describe(commands.APP_EXPORT, () => { throw 'invalid request'; }); - sinon.stub(fs, 'writeFileSync').returns(); + const writeFileStub = sinon.stub(fs, 'writeFileSync').returns(); - await assert.doesNotReject(command.action(logger, { options: { name: appId, environmentName: environmentName, packageDisplayName: packageDisplayName } })); + await command.action(logger, { options: { name: appId, environmentName: environmentName } }); + assert(writeFileStub.calledOnceWithExactly(`./${appId}.zip`, fileBlobResponse, 'binary')); }); - it('exports the specified App (debug)', async () => { - let index = 0; - sinon.stub(request, 'get').callsFake(async (opts) => { + it('exports the specified app correctly with packageDisplayName', async () => { + const getStub = sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === exportPackageResponse.headers.location) { - if (index === 0) { - index = 1; + if (getStub.calledOnce) { return locationRunningResponse; } else { @@ -262,10 +256,45 @@ describe(commands.APP_EXPORT, () => { throw 'invalid request'; }); - sinon.stub(fs, 'writeFileSync').returns(); + const writeFileStub = sinon.stub(fs, 'writeFileSync').returns(); + + await command.action(logger, { options: { name: appId, environmentName: environmentName, packageDisplayName: packageDisplayName } }); + assert(writeFileStub.calledOnceWithExactly(`./${packageDisplayName}.zip`, fileBlobResponse, 'binary')); + }); + + it('exports the specified App correctly with all options', async () => { + const getStub = sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === exportPackageResponse.headers.location) { + if (getStub.calledOnce) { + return locationRunningResponse; + } + else { + return locationSuccessResponse; + } + } + + if (opts.url === locationSuccessResponse.properties.packageLink.value) { + return fileBlobResponse; + } + + throw 'invalid request'; + }); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments/${environmentName}/listPackageResources?api-version=2016-11-01`) { + return listPackageResourcesResponse; + } + + if (opts.url === `https://api.bap.microsoft.com/providers/Microsoft.BusinessAppPlatform/environments/${environmentName}/exportPackage?api-version=2016-11-01`) { + return exportPackageResponse; + } + + throw 'invalid request'; + }); + const writeFileStub = sinon.stub(fs, 'writeFileSync').returns(); await command.action(logger, { options: { verbose: true, name: appId, environmentName: environmentName, packageDisplayName: packageDisplayName, packageDescription: packageDescription, packageCreatedBy: packageCreatedBy, packageSourceEnvironment: packageSourceEnvironment, path: path } }); - assert(loggerLogToStderrSpy.calledWith(`File saved to path '${path}/${actualFilename}'`)); + assert(writeFileStub.calledOnceWithExactly(`${path}/${packageDisplayName}.zip`, fileBlobResponse, 'binary')); }); it('fails validation if the name is not a GUID', async () => { @@ -294,7 +323,7 @@ describe(commands.APP_EXPORT, () => { sinon.stub(request, 'post').rejects(error); - await assert.rejects(command.action(logger, { options: { name: appId, environmentName: environmentName, packageDisplayName: packageDisplayName } } as any), + await assert.rejects(command.action(logger, { options: { name: appId, environmentName: environmentName, packageDisplayName: packageDisplayName } }), new CommandError(error.error.message)); }); }); diff --git a/src/m365/pa/commands/app/app-export.ts b/src/m365/pa/commands/app/app-export.ts index f7368f5cbfc..f3e2d3cbc66 100644 --- a/src/m365/pa/commands/app/app-export.ts +++ b/src/m365/pa/commands/app/app-export.ts @@ -16,7 +16,7 @@ interface CommandArgs { interface Options extends GlobalOptions { name: string; environmentName: string; - packageDisplayName: string; + packageDisplayName?: string; packageDescription?: string; packageCreatedBy?: string; packageSourceEnvironment?: string; @@ -40,6 +40,7 @@ class PaAppExportCommand extends PowerPlatformCommand { this.#initTelemetry(); this.#initOptions(); this.#initValidators(); + this.#initTypes(); } #initTelemetry(): void { @@ -95,13 +96,21 @@ class PaAppExportCommand extends PowerPlatformCommand { ); } + #initTypes(): void { + this.types.string.push('name', 'environmentName', 'packageDisplayName', 'packageDescription', 'packageCreatedBy', 'packageSourceEnvironment', 'path'); + } + public async commandAction(logger: Logger, args: CommandArgs): Promise { try { const location = await this.exportPackage(args, logger); - const packageLink = await this.getPackageLink(args, logger, location); - //Replace all illegal characters from the file name - const illegalCharsRegEx = /[\\\/:*?"<>|]/g; - const filename = args.options.packageDisplayName.replace(illegalCharsRegEx, '_'); + const packageLink = await this.getPackageLink(logger, location); + + let filename = args.options.name; + if (args.options.packageDisplayName) { + //Replace all illegal characters from the file name + const illegalCharsRegEx = /[\\\/:*?"<>|]/g; + filename = args.options.packageDisplayName.replace(illegalCharsRegEx, '_'); + } const requestOptions: CliRequestOptions = { url: packageLink, @@ -113,7 +122,7 @@ class PaAppExportCommand extends PowerPlatformCommand { } }; - const file = await request.get(requestOptions); + const file = await request.get(requestOptions); let path = args.options.path || './'; @@ -179,7 +188,7 @@ class PaAppExportCommand extends PowerPlatformCommand { details: { creator: args.options.packageCreatedBy, description: args.options.packageDescription, - displayName: args.options.packageDisplayName, + displayName: args.options.packageDisplayName || args.options.name, sourceEnvironment: args.options.packageSourceEnvironment }, resources: resources @@ -187,12 +196,12 @@ class PaAppExportCommand extends PowerPlatformCommand { fullResponse: true }; - const response: any = await request.post(requestOptions); + const response = await request.post(requestOptions); return response.headers.location; } - private async getPackageLink(args: CommandArgs, logger: Logger, location: string): Promise { + private async getPackageLink(logger: Logger, location: string): Promise { if (this.verbose) { await logger.logToStderr('Retrieving the package link and waiting on the exported package.'); } diff --git a/src/m365/spo/commands.ts b/src/m365/spo/commands.ts index 11a25d8f8de..c3312aca369 100644 --- a/src/m365/spo/commands.ts +++ b/src/m365/spo/commands.ts @@ -101,8 +101,11 @@ export default { FOLDER_ROLEASSIGNMENT_ADD: `${prefix} folder roleassignment add`, FOLDER_ROLEINHERITANCE_BREAK: `${prefix} folder roleinheritance break`, FOLDER_ROLEINHERITANCE_RESET: `${prefix} folder roleinheritance reset`, + FOLDER_SHARINGLINK_ADD: `${prefix} folder sharinglink add`, + FOLDER_SHARINGLINK_CLEAR: `${prefix} folder sharinglink clear`, FOLDER_SHARINGLINK_GET: `${prefix} folder sharinglink get`, FOLDER_SHARINGLINK_LIST: `${prefix} folder sharinglink list`, + FOLDER_SHARINGLINK_REMOVE: `${prefix} folder sharinglink remove`, GET: `${prefix} get`, GROUP_ADD: `${prefix} group add`, GROUP_GET: `${prefix} group get`, @@ -243,6 +246,7 @@ export default { SERVICEPRINCIPAL_SET: `${prefix} serviceprincipal set`, SET: `${prefix} set`, SITE_ADD: `${prefix} site add`, + SITE_ADMIN_ADD: `${prefix} site admin add`, SITE_ADMIN_LIST: `${prefix} site admin list`, SITE_APPCATALOG_ADD: `${prefix} site appcatalog add`, SITE_APPCATALOG_LIST: `${prefix} site appcatalog list`, diff --git a/src/m365/spo/commands/file/file-roleassignment-add.spec.ts b/src/m365/spo/commands/file/file-roleassignment-add.spec.ts index cfcd9079cc6..2b2e5a48923 100644 --- a/src/m365/spo/commands/file/file-roleassignment-add.spec.ts +++ b/src/m365/spo/commands/file/file-roleassignment-add.spec.ts @@ -11,12 +11,9 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import spoGroupGetCommand from '../group/group-get.js'; -import spoRoleDefinitionListCommand from '../roledefinition/roledefinition-list.js'; -import spoUserGetCommand from '../user/user-get.js'; -import spoFileGetCommand from './file-get.js'; import command from './file-roleassignment-add.js'; import { settingsNames } from '../../../../settingsNames.js'; +import { spo } from '../../../../utils/spo.js'; describe(commands.FILE_ROLEASSIGNMENT_ADD, () => { const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; @@ -25,6 +22,117 @@ describe(commands.FILE_ROLEASSIGNMENT_ADD, () => { let log: any[]; let logger: Logger; let commandInfo: CommandInfo; + const roleDefinitionResponse = { + BasePermissions: { + High: 2147483647, + Low: 4294967295 + }, + Description: 'Has full control.', + Hidden: false, + Id: 1073741827, + Name: 'Full Control', + Order: 1, + RoleTypeKind: 5, + BasePermissionsValue: [ + 'ViewListItems', + 'AddListItems', + 'EditListItems', + 'DeleteListItems', + 'ApproveItems', + 'OpenItems', + 'ViewVersions', + 'DeleteVersions', + 'CancelCheckout', + 'ManagePersonalViews', + 'ManageLists', + 'ViewFormPages', + 'AnonymousSearchAccessList', + 'Open', + 'ViewPages', + 'AddAndCustomizePages', + 'ApplyThemeAndBorder', + 'ApplyStyleSheets', + 'ViewUsageData', + 'CreateSSCSite', + 'ManageSubwebs', + 'CreateGroups', + 'ManagePermissions', + 'BrowseDirectories', + 'BrowseUserInfo', + 'AddDelPrivateWebParts', + 'UpdatePersonalWebParts', + 'ManageWeb', + 'AnonymousSearchAccessWebLists', + 'UseClientIntegration', + 'UseRemoteAPIs', + 'ManageAlerts', + 'CreateAlerts', + 'EditMyUserInfo', + 'EnumeratePermissions' + ], + RoleTypeKindValue: 'Administrator' + }; + + const fileResponse = { + CheckInComment: '', + CheckOutType: 2, + ContentTag: '{F09C4EFE-B8C0-4E89-A166-03418661B89B},9,12', + CustomizedPageStatus: 0, + ETag: '\'{F09C4EFE-B8C0-4E89-A166-03418661B89B},9\'', + Exists: true, + IrmEnabled: false, + Length: '331673', + Level: 1, + LinkingUri: 'https://contoso.sharepoint.com/sites/project-x/documents/Test1.docx?d=wc39926a80d2c4067afa6cff9902eb866', + LinkingUrl: 'https://contoso.sharepoint.com/sites/project-x/documents/Test1.docx?d=wc39926a80d2c4067afa6cff9902eb866', + MajorVersion: 3, + MinorVersion: 0, + Name: 'Test1.docx', + ServerRelativeUrl: '/sites/project-x/documents/Test1.docx', + TimeCreated: '2018-02-05T08:42:36Z', + TimeLastModified: '2018-02-05T08:44:03Z', + Title: '', + UIVersion: 1536, + UIVersionLabel: '3.0', + UniqueId: 'b2307a39-e878-458b-bc90-03bc578531d6', + ListItemAllFields: { + Id: 4, + ID: 4 + } + }; + + const userResponse = { + Id: 11, + IsHiddenInUI: false, + LoginName: 'i:0#.f|membership|someaccount@tenant.onmicrosoft.com', + Title: 'Some Account', + PrincipalType: 1, + Email: 'someaccount@tenant.onmicrosoft.com', + Expiration: '', + IsEmailAuthenticationGuestUser: false, + IsShareByEmailGuestUser: false, + IsSiteAdmin: true, + UserId: { + NameId: '1003200097d06dd6', + NameIdIssuer: 'urn:federation:microsoftonline' + }, + UserPrincipalName: 'someaccount@tenant.onmicrosoft.com' + }; + + const groupResponse = { + Id: 5, + IsHiddenInUI: false, + LoginName: "Group A", + Title: "Group A", + PrincipalType: 8, + AllowMembersEditMembership: false, + AllowRequestToJoinLeave: false, + AutoAcceptRequestToJoinLeave: false, + Description: "", + OnlyAllowMembersViewMembership: true, + OwnerTitle: "Some Account", + RequestToJoinLeaveEmailSetting: null + }; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -52,8 +160,11 @@ describe(commands.FILE_ROLEASSIGNMENT_ADD, () => { afterEach(() => { sinonUtil.restore([ - cli.executeCommandWithOutput, request.post, + spo.getRoleDefinitionByName, + spo.getGroupByName, + spo.getUserByEmail, + spo.getFileById, cli.getSettingWithDefaultValue ]); }); @@ -142,15 +253,7 @@ describe(commands.FILE_ROLEASSIGNMENT_ADD, () => { throw 'Invalid request'; }); - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { - if (command === spoRoleDefinitionListCommand) { - return { - stdout: '[{"BasePermissions": {"High": "2147483647","Low": "4294967295"},"Description": "Has full control.","Hidden": false,"Id": 1073741827,"Name": "Full Control","Order": 1,"RoleTypeKind": 5}]' - }; - } - - throw new CommandError('Unknown case'); - }); + sinon.stub(spo, 'getRoleDefinitionByName').resolves(roleDefinitionResponse); await command.action(logger, { options: { @@ -171,20 +274,8 @@ describe(commands.FILE_ROLEASSIGNMENT_ADD, () => { throw 'Invalid request'; }); - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { - if (command === spoFileGetCommand) { - return ({ - stdout: '{"LinkingUri": "https://contoso.sharepoint.com/sites/project-x/documents/Test1.docx?d=wc39926a80d2c4067afa6cff9902eb866","Name": "Test1.docx","ServerRelativeUrl": "/sites/project-x/documents/Test1.docx","UniqueId": "b2307a39-e878-458b-bc90-03bc578531d6"}' - }); - } - if (command === spoRoleDefinitionListCommand) { - return { - stdout: '[{"BasePermissions": {"High": "2147483647","Low": "4294967295"},"Description": "Has full control.","Hidden": false,"Id": 1073741827,"Name": "Full Control","Order": 1,"RoleTypeKind": 5}]' - }; - } - - throw 'Unknown case'; - }); + sinon.stub(spo, 'getFileById').resolves(fileResponse); + sinon.stub(spo, 'getRoleDefinitionByName').resolves(roleDefinitionResponse); await command.action(logger, { options: { @@ -205,15 +296,7 @@ describe(commands.FILE_ROLEASSIGNMENT_ADD, () => { throw 'Invalid request'; }); - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { - if (command === spoUserGetCommand) { - return { - stdout: '{"Id": 11,"IsHiddenInUI": false,"LoginName": "i:0#.f|membership|someaccount@tenant.onmicrosoft.com","Title": "Some Account","PrincipalType": 1,"Email": "someaccount@tenant.onmicrosoft.com","Expiration": "","IsEmailAuthenticationGuestUser": false,"IsShareByEmailGuestUser": false,"IsSiteAdmin": true,"UserId": {"NameId": "1003200097d06dd6","NameIdIssuer": "urn:federation:microsoftonline"},"UserPrincipalName": "someaccount@tenant.onmicrosoft.com"}' - }; - } - - throw 'Unknown case'; - }); + sinon.stub(spo, 'getUserByEmail').resolves(userResponse); await command.action(logger, { options: { @@ -227,13 +310,7 @@ describe(commands.FILE_ROLEASSIGNMENT_ADD, () => { it('correctly handles error when upn does not exist', async () => { const error = 'no user found'; - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { - if (command === spoUserGetCommand) { - throw error; - } - - throw 'Unknown case'; - }); + sinon.stub(spo, 'getUserByEmail').rejects(new Error(error)); await assert.rejects(command.action(logger, { options: { @@ -254,15 +331,7 @@ describe(commands.FILE_ROLEASSIGNMENT_ADD, () => { throw 'Invalid request'; }); - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { - if (command === spoGroupGetCommand) { - return { - stdout: '{"Id": 5,"IsHiddenInUI": false,"LoginName": "Group A","Title": "Group A","PrincipalType": 8,"AllowMembersEditMembership": false,"AllowRequestToJoinLeave": false,"AutoAcceptRequestToJoinLeave": false,"Description": "","OnlyAllowMembersViewMembership": true,"OwnerTitle": "Some Account","RequestToJoinLeaveEmailSetting": null}' - }; - } - - throw 'Unknown case'; - }); + sinon.stub(spo, 'getGroupByName').resolves(groupResponse); await command.action(logger, { options: { @@ -276,13 +345,7 @@ describe(commands.FILE_ROLEASSIGNMENT_ADD, () => { it('correctly handles error when role definition does not exist', async () => { const error = 'no role definition found'; - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command) => { - if (command === spoRoleDefinitionListCommand) { - throw error; - } - - throw 'Unknown case'; - }); + sinon.stub(spo, 'getRoleDefinitionByName').rejects(new Error(error)); await assert.rejects(command.action(logger, { options: { @@ -296,13 +359,7 @@ describe(commands.FILE_ROLEASSIGNMENT_ADD, () => { it('correctly handles error when group does not exist', async () => { const error = 'no group found'; - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { - if (command === spoGroupGetCommand) { - throw error; - } - - throw 'Unknown case'; - }); + sinon.stub(spo, 'getGroupByName').rejects(new Error(error)); await assert.rejects(command.action(logger, { options: { diff --git a/src/m365/spo/commands/file/file-roleassignment-add.ts b/src/m365/spo/commands/file/file-roleassignment-add.ts index 3fb5a771e4e..13a92026fcc 100644 --- a/src/m365/spo/commands/file/file-roleassignment-add.ts +++ b/src/m365/spo/commands/file/file-roleassignment-add.ts @@ -1,18 +1,14 @@ -import { cli, CommandOutput } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import Command from '../../../../Command.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; +import { spo } from '../../../../utils/spo.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import spoGroupGetCommand, { Options as SpoGroupGetCommandOptions } from '../group/group-get.js'; -import spoRoleDefinitionListCommand, { Options as SpoRoleDefinitionListCommandOptions } from '../roledefinition/roledefinition-list.js'; import { RoleDefinition } from '../roledefinition/RoleDefinition.js'; -import spoUserGetCommand, { Options as SpoUserGetCommandOptions } from '../user/user-get.js'; -import spoFileGetCommand, { Options as SpoFileGetCommandOptions } from './file-get.js'; +import { FileProperties } from './FileProperties.js'; interface CommandArgs { options: Options; @@ -134,14 +130,14 @@ class SpoFileRoleAssignmentAddCommand extends SpoCommand { } try { - const fileUrl: string = await this.getFileURL(args); - const roleDefinitionId = await this.getRoleDefinitionId(args.options); + const fileUrl: string = await this.getFileURL(args, logger); + const roleDefinitionId = await this.getRoleDefinitionId(args.options, logger); if (args.options.upn) { - const upnPrincipalId = await this.getUserPrincipalId(args.options); + const upnPrincipalId = await this.getUserPrincipalId(args.options, logger); await this.addRoleAssignment(fileUrl, args.options.webUrl, upnPrincipalId, roleDefinitionId); } else if (args.options.groupName) { - const groupPrincipalId = await this.getGroupPrincipalId(args.options); + const groupPrincipalId = await this.getGroupPrincipalId(args.options, logger); await this.addRoleAssignment(fileUrl, args.options.webUrl, groupPrincipalId, roleDefinitionId); } else { @@ -166,69 +162,32 @@ class SpoFileRoleAssignmentAddCommand extends SpoCommand { return request.post(requestOptions); } - private async getRoleDefinitionId(options: Options): Promise { + private async getRoleDefinitionId(options: Options, logger: Logger): Promise { if (!options.roleDefinitionName) { return options.roleDefinitionId!; } - const roleDefinitionListCommandOptions: SpoRoleDefinitionListCommandOptions = { - webUrl: options.webUrl, - output: 'json', - debug: this.debug, - verbose: this.verbose - }; - - const output: CommandOutput = await cli.executeCommandWithOutput(spoRoleDefinitionListCommand as Command, { options: { ...roleDefinitionListCommandOptions, _: [] } }); - const getRoleDefinitionListOutput = JSON.parse(output.stdout); - const roleDefinitionId: number = getRoleDefinitionListOutput.find((role: RoleDefinition) => role.Name === options.roleDefinitionName).Id; - return roleDefinitionId; + const roleDefinition: RoleDefinition = await spo.getRoleDefinitionByName(options.webUrl, options.roleDefinitionName, logger, this.verbose); + return roleDefinition.Id; } - private async getGroupPrincipalId(options: Options): Promise { - const groupGetCommandOptions: SpoGroupGetCommandOptions = { - webUrl: options.webUrl, - name: options.groupName, - output: 'json', - debug: this.debug, - verbose: this.verbose - }; - - const output: CommandOutput = await cli.executeCommandWithOutput(spoGroupGetCommand as Command, { options: { ...groupGetCommandOptions, _: [] } }); - const getGroupOutput = JSON.parse(output.stdout); - return getGroupOutput.Id; + private async getGroupPrincipalId(options: Options, logger: Logger): Promise { + const group = await spo.getGroupByName(options.webUrl, options.groupName!, logger, this.verbose); + return group.Id; } - private async getUserPrincipalId(options: Options): Promise { - const userGetCommandOptions: SpoUserGetCommandOptions = { - webUrl: options.webUrl, - email: options.upn, - id: undefined, - output: 'json', - debug: this.debug, - verbose: this.verbose - }; - - const output: CommandOutput = await cli.executeCommandWithOutput(spoUserGetCommand as Command, { options: { ...userGetCommandOptions, _: [] } }); - const getUserOutput = JSON.parse(output.stdout); - return getUserOutput.Id; + private async getUserPrincipalId(options: Options, logger: Logger): Promise { + const user = await spo.getUserByEmail(options.webUrl, options.upn!, logger, this.verbose); + return user.Id; } - private async getFileURL(args: CommandArgs): Promise { + private async getFileURL(args: CommandArgs, logger: Logger): Promise { if (args.options.fileUrl) { return urlUtil.getServerRelativePath(args.options.webUrl, args.options.fileUrl); } - const options: SpoFileGetCommandOptions = { - webUrl: args.options.webUrl, - id: args.options.fileId, - output: 'json', - debug: this.debug, - verbose: this.verbose - }; - - const output = await cli.executeCommandWithOutput(spoFileGetCommand as Command, { options: { ...options, _: [] } }); - const getFileOutput = JSON.parse(output.stdout); - return getFileOutput.ServerRelativeUrl; + const file: FileProperties = await spo.getFileById(args.options.webUrl, args.options.fileId!, logger, this.verbose); + return file.ServerRelativeUrl; } } diff --git a/src/m365/spo/commands/file/file-roleassignment-remove.spec.ts b/src/m365/spo/commands/file/file-roleassignment-remove.spec.ts index e9c5eb339ca..c191ad275b1 100644 --- a/src/m365/spo/commands/file/file-roleassignment-remove.spec.ts +++ b/src/m365/spo/commands/file/file-roleassignment-remove.spec.ts @@ -13,10 +13,8 @@ import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import commands from '../../commands.js'; -import spoGroupGetCommand from '../group/group-get.js'; -import spoUserGetCommand from '../user/user-get.js'; -import spoFileGetCommand from './file-get.js'; import command from './file-roleassignment-remove.js'; +import { spo } from '../../../../utils/spo.js'; describe(commands.FILE_ROLEASSIGNMENT_REMOVE, () => { const webUrl = 'https://contoso.sharepoint.com/sites/contoso-sales'; @@ -25,6 +23,66 @@ describe(commands.FILE_ROLEASSIGNMENT_REMOVE, () => { const principalId = 2; const upn = 'user1@contoso.onmicrosoft.com'; const groupName = 'saleGroup'; + const fileResponse = { + CheckInComment: '', + CheckOutType: 2, + ContentTag: '{F09C4EFE-B8C0-4E89-A166-03418661B89B},9,12', + CustomizedPageStatus: 0, + ETag: '\"{F09C4EFE-B8C0-4E89-A166-03418661B89B},9\"', + Exists: true, + IrmEnabled: false, + Length: '331673', + Level: 1, + LinkingUri: 'https://contoso.sharepoint.com/sites/contoso-sales/documents/Test1.docx?d=wc39926a80d2c4067afa6cff9902eb866', + LinkingUrl: 'https://contoso.sharepoint.com/sites/contoso-sales/documents/Test1.docx?d=wc39926a80d2c4067afa6cff9902eb866', + ListItemAllFields: { + Id: 1, + ID: 1 + }, + MajorVersion: 3, + MinorVersion: 0, + Name: 'Test1.docx', + ServerRelativeUrl: '/sites/contoso-sales/documents/Test1.docx', + TimeCreated: '2018-02-05T08:42:36Z', + TimeLastModified: '2018-02-05T08:44:03Z', + Title: '', + UIVersion: 1536, + UIVersionLabel: '3.0', + UniqueId: 'b2307a39-e878-458b-bc90-03bc578531d6' + }; + + const userResponse = { + Id: 2, + IsHiddenInUI: false, + LoginName: 'i:0#.f|membership|user1@contoso.onmicrosoft.com', + Title: 'User1', + PrincipalType: 1, + Email: 'user1@contoso.onmicrosoft.com', + Expiration: '', + IsEmailAuthenticationGuestUser: false, + IsShareByEmailGuestUser: false, + IsSiteAdmin: false, + UserId: { + NameId: '10032002473c5ae3', + NameIdIssuer: 'urn:federation:microsoftonline' + }, + UserPrincipalName: 'user1@contoso.onmicrosoft.com' + }; + + const groupResponse = { + Id: 2, + IsHiddenInUI: false, + LoginName: "saleGroup", + Title: "saleGroup", + PrincipalType: 8, + AllowMembersEditMembership: false, + AllowRequestToJoinLeave: false, + AutoAcceptRequestToJoinLeave: false, + Description: "", + OnlyAllowMembersViewMembership: true, + OwnerTitle: "John Doe", + RequestToJoinLeaveEmailSetting: null + }; let log: any[]; let logger: Logger; @@ -64,7 +122,9 @@ describe(commands.FILE_ROLEASSIGNMENT_REMOVE, () => { afterEach(() => { sinonUtil.restore([ cli.promptForConfirmation, - cli.executeCommandWithOutput, + spo.getUserByEmail, + spo.getGroupByName, + spo.getFileById, request.post ]); }); @@ -153,21 +213,8 @@ describe(commands.FILE_ROLEASSIGNMENT_REMOVE, () => { }); it('remove role assignment from the file by Id and upn', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { - if (command === spoFileGetCommand) { - return { - stdout: `{"LinkingUri": "https://contoso.sharepoint.com/sites/contoso-sales/documents/Test1.docx?d=wc39926a80d2c4067afa6cff9902eb866","Name": "Test1.docx","ServerRelativeUrl": "/sites/contoso-sales/documents/Test1.docx","UniqueId": "b2307a39-e878-458b-bc90-03bc578531d6"}` - }; - } - - if (command === spoUserGetCommand) { - return { - stdout: '{"Id": 2,"IsHiddenInUI": false,"LoginName": "i:0#.f|membership|user1@contoso.onmicrosoft.com","Title": "User1","PrincipalType": 1,"Email": "user1@contoso.onmicrosoft.com","Expiration": "","IsEmailAuthenticationGuestUser": false,"IsShareByEmailGuestUser": false,"IsSiteAdmin": true,"UserId": {"NameId": "1003200097d06dd6","NameIdIssuer": "urn:federation:microsoftonline"},"UserPrincipalName": "user1@contoso.onmicrosoft.com"}' - }; - } - - throw new CommandError('Unknown case'); - }); + sinon.stub(spo, 'getFileById').resolves(fileResponse); + sinon.stub(spo, 'getUserByEmail').resolves(userResponse); sinon.stub(request, 'post').callsFake(async (opts) => { const serverRelativeUrl: string = urlUtil.getServerRelativePath(webUrl, fileUrl); @@ -190,21 +237,8 @@ describe(commands.FILE_ROLEASSIGNMENT_REMOVE, () => { }); it('remove role assignment from the file by Id and group name', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { - if (command === spoFileGetCommand) { - return { - stdout: `{"LinkingUri": "https://contoso.sharepoint.com/sites/contoso-sales/documents/Test1.docx?d=wc39926a80d2c4067afa6cff9902eb866","Name": "Test1.docx","ServerRelativeUrl": "/sites/contoso-sales/documents/Test1.docx","UniqueId": "b2307a39-e878-458b-bc90-03bc578531d6"}` - }; - } - - if (command === spoGroupGetCommand) { - return { - stdout: '{"Id": 2,"IsHiddenInUI": false,"LoginName": "saleGroup","Title": "saleGroup","PrincipalType": 8,"AllowMembersEditMembership": false,"AllowRequestToJoinLeave": false,"AutoAcceptRequestToJoinLeave": false,"Description": "","OnlyAllowMembersViewMembership": true,"OwnerTitle": "Some Account","RequestToJoinLeaveEmailSetting": null}' - }; - } - - throw new CommandError('Unknown case'); - }); + sinon.stub(spo, 'getFileById').resolves(fileResponse); + sinon.stub(spo, 'getGroupByName').resolves(groupResponse); sinon.stub(request, 'post').callsFake(async (opts) => { const serverRelativeUrl: string = urlUtil.getServerRelativePath(webUrl, fileUrl); diff --git a/src/m365/spo/commands/file/file-roleassignment-remove.ts b/src/m365/spo/commands/file/file-roleassignment-remove.ts index 8538a6b694c..33704bfcb84 100644 --- a/src/m365/spo/commands/file/file-roleassignment-remove.ts +++ b/src/m365/spo/commands/file/file-roleassignment-remove.ts @@ -1,16 +1,14 @@ import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import Command from '../../../../Command.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; +import { spo } from '../../../../utils/spo.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import spoGroupGetCommand, { Options as SpoGroupGetCommandOptions } from '../group/group-get.js'; -import spoUserGetCommand, { Options as SpoUserGetCommandOptions } from '../user/user-get.js'; -import spoFileGetCommand, { Options as SpoFileGetCommandOptions } from './file-get.js'; +import { FileProperties } from './FileProperties.js'; interface CommandArgs { options: Options; @@ -124,14 +122,14 @@ class SpoFileRoleAssignmentRemoveCommand extends SpoCommand { } try { - const fileURL: string = await this.getFileURL(args); + const fileURL: string = await this.getFileURL(args, logger); let principalId: number; if (args.options.groupName) { - principalId = await this.getGroupPrincipalId(args.options); + principalId = await this.getGroupPrincipalId(args.options, logger); } else if (args.options.upn) { - principalId = await this.getUserPrincipalId(args.options); + principalId = await this.getUserPrincipalId(args.options, logger); } else { principalId = args.options.principalId!; @@ -165,51 +163,23 @@ class SpoFileRoleAssignmentRemoveCommand extends SpoCommand { } } - private async getFileURL(args: CommandArgs): Promise { + private async getFileURL(args: CommandArgs, logger: Logger): Promise { if (args.options.fileUrl) { return urlUtil.getServerRelativePath(args.options.webUrl, args.options.fileUrl); } - const options: SpoFileGetCommandOptions = { - webUrl: args.options.webUrl, - id: args.options.fileId, - output: 'json', - debug: this.debug, - verbose: this.verbose - }; - - const output = await cli.executeCommandWithOutput(spoFileGetCommand as Command, { options: { ...options, _: [] } }); - const getFileOutput = JSON.parse(output.stdout); - return getFileOutput.ServerRelativeUrl; + const file: FileProperties = await spo.getFileById(args.options.webUrl, args.options.fileId!, logger, this.verbose); + return file.ServerRelativeUrl; } - private async getUserPrincipalId(options: Options): Promise { - const userGetCommandOptions: SpoUserGetCommandOptions = { - webUrl: options.webUrl, - email: options.upn, - id: undefined, - output: 'json', - debug: this.debug, - verbose: this.verbose - }; - - const output = await cli.executeCommandWithOutput(spoUserGetCommand as Command, { options: { ...userGetCommandOptions, _: [] } }); - const getUserOutput = JSON.parse(output.stdout); - return getUserOutput.Id; + private async getUserPrincipalId(options: Options, logger: Logger): Promise { + const user = await spo.getUserByEmail(options.webUrl, options.upn!, logger, this.verbose); + return user.Id; } - private async getGroupPrincipalId(options: Options): Promise { - const groupGetCommandOptions: SpoGroupGetCommandOptions = { - webUrl: options.webUrl, - name: options.groupName, - output: 'json', - debug: this.debug, - verbose: this.verbose - }; - - const output = await cli.executeCommandWithOutput(spoGroupGetCommand as Command, { options: { ...groupGetCommandOptions, _: [] } }); - const getGroupOutput = JSON.parse(output.stdout); - return getGroupOutput.Id; + private async getGroupPrincipalId(options: Options, logger: Logger): Promise { + const group = await spo.getGroupByName(options.webUrl, options.groupName!, logger, this.verbose); + return group.Id; } } diff --git a/src/m365/spo/commands/file/file-roleinheritance-break.spec.ts b/src/m365/spo/commands/file/file-roleinheritance-break.spec.ts index b9e5dc2ec80..5ac8c993a92 100644 --- a/src/m365/spo/commands/file/file-roleinheritance-break.spec.ts +++ b/src/m365/spo/commands/file/file-roleinheritance-break.spec.ts @@ -12,10 +12,10 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import spoFileGetCommand from './file-get.js'; import command from './file-roleinheritance-break.js'; +import { spo } from '../../../../utils/spo.js'; -describe(commands.FILE_ROLEINHERITANCE_RESET, () => { +describe(commands.FILE_ROLEINHERITANCE_BREAK, () => { const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; const fileUrl = '/sites/project-x/documents/Test1.docx'; const fileId = 'b2307a39-e878-458b-bc90-03bc578531d6'; @@ -24,6 +24,33 @@ describe(commands.FILE_ROLEINHERITANCE_RESET, () => { let logger: Logger; let commandInfo: CommandInfo; let promptIssued: boolean = false; + const fileResponse = { + CheckInComment: '', + CheckOutType: 2, + ContentTag: '{F09C4EFE-B8C0-4E89-A166-03418661B89B},9,12', + CustomizedPageStatus: 0, + ETag: '\"{F09C4EFE-B8C0-4E89-A166-03418661B89B},9\"', + Exists: true, + IrmEnabled: false, + Length: '331673', + Level: 1, + LinkingUri: 'https://contoso.sharepoint.com/sites/project-x/documents/Test1.docx?d=wc39926a80d2c4067afa6cff9902eb866', + LinkingUrl: 'https://contoso.sharepoint.com/sites/project-x/documents/Test1.docx?d=wc39926a80d2c4067afa6cff9902eb866', + ListItemAllFields: { + Id: 1, + ID: 1 + }, + MajorVersion: 3, + MinorVersion: 0, + Name: 'Test1.docx', + ServerRelativeUrl: '/sites/project-x/documents/Test1.docx', + TimeCreated: '2018-02-05T08:42:36Z', + TimeLastModified: '2018-02-05T08:44:03Z', + Title: '', + UIVersion: 1536, + UIVersionLabel: '3.0', + UniqueId: 'b2307a39-e878-458b-bc90-03bc578531d6' + }; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -58,7 +85,7 @@ describe(commands.FILE_ROLEINHERITANCE_RESET, () => { afterEach(() => { sinonUtil.restore([ cli.promptForConfirmation, - cli.executeCommandWithOutput, + spo.getFileById, request.post ]); }); @@ -157,15 +184,7 @@ describe(commands.FILE_ROLEINHERITANCE_RESET, () => { }); it('breaks role inheritance on file by Id when prompt confirmed', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { - if (command === spoFileGetCommand) { - return ({ - stdout: '{"LinkingUri": "https://contoso.sharepoint.com/sites/project-x/documents/Test1.docx?d=wc39926a80d2c4067afa6cff9902eb866","Name": "Test1.docx","ServerRelativeUrl": "/sites/project-x/documents/Test1.docx","UniqueId": "b2307a39-e878-458b-bc90-03bc578531d6"}' - }); - } - - throw new CommandError('Unknown case'); - }); + sinon.stub(spo, 'getFileById').resolves(fileResponse); sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === `${webUrl}/_api/web/GetFileByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(fileUrl)}')/ListItemAllFields/breakroleinheritance(true)`) { diff --git a/src/m365/spo/commands/file/file-roleinheritance-break.ts b/src/m365/spo/commands/file/file-roleinheritance-break.ts index 7888aafd36a..a4516d3b2d5 100644 --- a/src/m365/spo/commands/file/file-roleinheritance-break.ts +++ b/src/m365/spo/commands/file/file-roleinheritance-break.ts @@ -1,14 +1,14 @@ import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import Command from '../../../../Command.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; +import { spo } from '../../../../utils/spo.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import spoFileGetCommand, { Options as SpoFileGetCommandOptions } from './file-get.js'; +import { FileProperties } from './FileProperties.js'; interface CommandArgs { options: Options; @@ -104,7 +104,7 @@ class SpoFileRoleInheritanceBreakCommand extends SpoCommand { await logger.logToStderr(`Breaking role inheritance for file ${args.options.fileId || args.options.fileUrl}`); } try { - const fileURL: string = await this.getFileURL(args); + const fileURL: string = await this.getFileURL(args, logger); const keepExistingPermissions: boolean = !args.options.clearExistingPermissions; @@ -135,22 +135,13 @@ class SpoFileRoleInheritanceBreakCommand extends SpoCommand { } } - private async getFileURL(args: CommandArgs): Promise { + private async getFileURL(args: CommandArgs, logger: Logger): Promise { if (args.options.fileUrl) { return urlUtil.getServerRelativePath(args.options.webUrl, args.options.fileUrl); } - const options: SpoFileGetCommandOptions = { - webUrl: args.options.webUrl, - id: args.options.fileId, - output: 'json', - debug: this.debug, - verbose: this.verbose - }; - - const output = await cli.executeCommandWithOutput(spoFileGetCommand as Command, { options: { ...options, _: [] } }); - const getFileOutput = JSON.parse(output.stdout); - return getFileOutput.ServerRelativeUrl; + const file: FileProperties = await spo.getFileById(args.options.webUrl, args.options.fileId!, logger, this.verbose); + return file.ServerRelativeUrl; } } diff --git a/src/m365/spo/commands/file/file-roleinheritance-reset.spec.ts b/src/m365/spo/commands/file/file-roleinheritance-reset.spec.ts index c196bcbb3d3..ea146430b2c 100644 --- a/src/m365/spo/commands/file/file-roleinheritance-reset.spec.ts +++ b/src/m365/spo/commands/file/file-roleinheritance-reset.spec.ts @@ -12,8 +12,8 @@ import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; -import spoFileGetCommand from './file-get.js'; import command from './file-roleinheritance-reset.js'; +import { spo } from '../../../../utils/spo.js'; describe(commands.FILE_ROLEINHERITANCE_RESET, () => { const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; @@ -24,6 +24,33 @@ describe(commands.FILE_ROLEINHERITANCE_RESET, () => { let logger: Logger; let commandInfo: CommandInfo; let promptIssued: boolean = false; + const fileResponse = { + CheckInComment: '', + CheckOutType: 2, + ContentTag: '{F09C4EFE-B8C0-4E89-A166-03418661B89B},9,12', + CustomizedPageStatus: 0, + ETag: '\"{F09C4EFE-B8C0-4E89-A166-03418661B89B},9\"', + Exists: true, + IrmEnabled: false, + Length: '331673', + Level: 1, + LinkingUri: 'https://contoso.sharepoint.com/sites/project-x/documents/Test1.docx?d=wc39926a80d2c4067afa6cff9902eb866', + LinkingUrl: 'https://contoso.sharepoint.com/sites/project-x/documents/Test1.docx?d=wc39926a80d2c4067afa6cff9902eb866', + ListItemAllFields: { + Id: 1, + ID: 1 + }, + MajorVersion: 3, + MinorVersion: 0, + Name: 'Test1.docx', + ServerRelativeUrl: '/sites/project-x/documents/Test1.docx', + TimeCreated: '2018-02-05T08:42:36Z', + TimeLastModified: '2018-02-05T08:44:03Z', + Title: '', + UIVersion: 1536, + UIVersionLabel: '3.0', + UniqueId: 'b2307a39-e878-458b-bc90-03bc578531d6' + }; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -58,7 +85,8 @@ describe(commands.FILE_ROLEINHERITANCE_RESET, () => { afterEach(() => { sinonUtil.restore([ cli.promptForConfirmation, - request.post + request.post, + spo.getFileById ]); }); @@ -135,15 +163,7 @@ describe(commands.FILE_ROLEINHERITANCE_RESET, () => { }); it('resets role inheritance on file by Id when prompt confirmed', async () => { - sinon.stub(cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { - if (command === spoFileGetCommand) { - return ({ - stdout: '{"LinkingUri": "https://contoso.sharepoint.com/sites/project-x/documents/Test1.docx?d=wc39926a80d2c4067afa6cff9902eb866","Name": "Test1.docx","ServerRelativeUrl": "/sites/project-x/documents/Test1.docx","UniqueId": "b2307a39-e878-458b-bc90-03bc578531d6"}' - }); - } - - throw new CommandError('Unknown case'); - }); + sinon.stub(spo, 'getFileById').resolves(fileResponse); sinon.stub(request, 'post').callsFake(async (opts) => { if (opts.url === `${webUrl}/_api/web/GetFileByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(fileUrl)}')/ListItemAllFields/resetroleinheritance`) { diff --git a/src/m365/spo/commands/file/file-roleinheritance-reset.ts b/src/m365/spo/commands/file/file-roleinheritance-reset.ts index 7e636fcfd63..a0c92656838 100644 --- a/src/m365/spo/commands/file/file-roleinheritance-reset.ts +++ b/src/m365/spo/commands/file/file-roleinheritance-reset.ts @@ -1,14 +1,13 @@ import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; -import Command from '../../../../Command.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { formatting } from '../../../../utils/formatting.js'; +import { spo } from '../../../../utils/spo.js'; import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import spoFileGetCommand, { Options as SpoFileGetCommandOptions } from './file-get.js'; interface CommandArgs { options: Options; @@ -99,7 +98,7 @@ class SpoFileRoleInheritanceResetCommand extends SpoCommand { await logger.logToStderr(`Resetting role inheritance for file ${args.options.fileId || args.options.fileUrl}`); } try { - const fileURL: string = await this.getFileURL(args); + const fileURL: string = await this.getFileURL(args, logger); const requestOptions: CliRequestOptions = { url: `${args.options.webUrl}/_api/web/GetFileByServerRelativePath(DecodedUrl='${formatting.encodeQueryParameter(fileURL)}')/ListItemAllFields/resetroleinheritance`, @@ -128,22 +127,13 @@ class SpoFileRoleInheritanceResetCommand extends SpoCommand { } } - private async getFileURL(args: CommandArgs): Promise { + private async getFileURL(args: CommandArgs, logger: Logger): Promise { if (args.options.fileUrl) { return urlUtil.getServerRelativePath(args.options.webUrl, args.options.fileUrl); } - const options: SpoFileGetCommandOptions = { - webUrl: args.options.webUrl, - id: args.options.fileId, - output: 'json', - debug: this.debug, - verbose: this.verbose - }; - - const output = await cli.executeCommandWithOutput(spoFileGetCommand as Command, { options: { ...options, _: [] } }); - const getFileOutput = JSON.parse(output.stdout); - return getFileOutput.ServerRelativeUrl; + const file = await spo.getFileById(args.options.webUrl, args.options.fileId!, logger, this.verbose); + return file.ServerRelativeUrl; } } diff --git a/src/m365/spo/commands/folder/folder-sharinglink-add.spec.ts b/src/m365/spo/commands/folder/folder-sharinglink-add.spec.ts new file mode 100644 index 00000000000..7698e93f50d --- /dev/null +++ b/src/m365/spo/commands/folder/folder-sharinglink-add.spec.ts @@ -0,0 +1,196 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandError } from '../../../../Command.js'; +import { telemetry } from '../../../../telemetry.js'; +import { Logger } from '../../../../cli/Logger.js'; +import request from '../../../../request.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { spo } from '../../../../utils/spo.js'; +import { drive } from '../../../../utils/drive.js'; +import { Drive } from '@microsoft/microsoft-graph-types'; +import commands from '../../commands.js'; +import command from './folder-sharinglink-add.js'; +import { settingsNames } from '../../../../settingsNames.js'; + +describe(commands.FOLDER_SHARINGLINK_ADD, () => { + let log: any[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + + const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; + const folderId = 'f09c4efe-b8c0-4e89-a166-03418661b89b'; + const folderUrl = '/sites/project-x/shared documents/folder1'; + const siteId = '0f9b8f4f-0e8e-4630-bb0a-501442db9b64'; + const driveId = '013TMHP6UOOSLON57HT5GLKEU7R5UGWZVK'; + const itemId = 'b!T4-bD44OMEa7ClAUQtubZID9tc40pGJKpguycvELod_Gx-lo4ZQiRJ7vylonTufG'; + + const driveDetails: Drive = { + id: driveId, + webUrl: `${webUrl}/Shared%20Documents` + }; + + const graphResponse = { + "id": "2a021f54-90a2-4016-b3b3-5f34d2e7d932", + "roles": [ + "read" + ], + "hasPassword": false, + "grantedToIdentitiesV2": [], + "grantedToIdentities": [], + "link": { + "scope": "anonymous", + "type": "view", + "webUrl": "https://contoso.sharepoint.com/:b:/s/pnpcoresdktestgroup/EY50lub3559MtRKfj2hrZqoBWnHOpGIcgi4gzw9XiWYJ-A", + "preventsDownload": false + } + }; + + const getStubs: any = (options: any) => { + sinon.stub(spo, 'getFolderServerRelativeUrl').resolves(options.folderUrl); + sinon.stub(spo, 'getSiteId').resolves(options.siteId); + sinon.stub(drive, 'getDriveByUrl').resolves(options.drive); + sinon.stub(drive, 'getDriveItemId').resolves(options.itemId); + }; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => settingName === settingsNames.prompt ? false : defaultValue); + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + }); + + afterEach(() => { + sinonUtil.restore([ + request.post, + spo.getSiteId, + spo.getFolderServerRelativeUrl, + drive.getDriveByUrl, + drive.getDriveItemId + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.FOLDER_SHARINGLINK_ADD); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { + const actual = await command.validate({ options: { webUrl: 'foo', folderId: folderId, type: "view" } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the folderId option is not a valid GUID', async () => { + const actual = await command.validate({ options: { webUrl: webUrl, folderId: 'invalid', type: "view" } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the expirationDateTime option is not a valid date', async () => { + const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderId: folderId, expirationDateTime: 'invalid date', type: 'view' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if invalid scope specified', async () => { + const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderId: folderId, scope: 'invalid scope', type: 'view' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if invalid type specified', async () => { + const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderId: folderId, type: 'invalid type' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if scope is users but recipients are not specified', async () => { + const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderId: folderId, type: 'view', scope: 'users' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if recipients option is not a valid UPN', async () => { + const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderId: folderId, type: 'view', scope: 'users', recipients: "invalid upn" } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation if options are valid', async () => { + const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', folderId: folderId, type: 'view' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('creates a sharing link to a folder specified by the id', async () => { + getStubs({ folderUrl: folderUrl, siteId: siteId, drive: driveDetails, itemId: itemId }); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/createLink`) { + return graphResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { webUrl: webUrl, folderId: folderId, type: 'view', scope: 'organization', verbose: true } } as any); + assert(loggerLogSpy.calledWith(graphResponse)); + }); + + it('creates a sharing link to a folder specified by the URL', async () => { + getStubs({ folderUrl: folderUrl, siteId: siteId, drive: driveDetails, itemId: itemId }); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/createLink`) { + return graphResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { webUrl: webUrl, folderUrl: folderUrl, type: 'view', scope: 'users', recipients: 'john@contoso.com', verbose: true } } as any); + assert(loggerLogSpy.calledWith(graphResponse)); + }); + + it('throws error when drive not found by url', async () => { + sinon.stub(spo, 'getFolderServerRelativeUrl').resolves(folderUrl); + sinon.stub(spo, 'getSiteId').resolves(siteId); + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/sites/${siteId}/drives?$select=webUrl,id`) { + return { + value: [] + }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, folderUrl: folderUrl, type: 'view' } } as any), + new CommandError(`Drive 'https://contoso.sharepoint.com/sites/project-x/shared%20documents/folder1' not found`)); + }); +}); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-sharinglink-add.ts b/src/m365/spo/commands/folder/folder-sharinglink-add.ts new file mode 100644 index 00000000000..23013bd4045 --- /dev/null +++ b/src/m365/spo/commands/folder/folder-sharinglink-add.ts @@ -0,0 +1,196 @@ +import { Logger } from '../../../../cli/Logger.js'; +import GlobalOptions from '../../../../GlobalOptions.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { spo } from '../../../../utils/spo.js'; +import { urlUtil } from '../../../../utils/urlUtil.js'; +import { drive } from '../../../../utils/drive.js'; +import { validation } from '../../../../utils/validation.js'; +import { formatting } from '../../../../utils/formatting.js'; +import SpoCommand from '../../../base/SpoCommand.js'; +import { Drive } from '@microsoft/microsoft-graph-types'; +import commands from '../../commands.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + webUrl: string; + folderUrl?: string; + folderId?: string; + type: string; + expirationDateTime?: string; + scope?: string; + retainInheritiedPermissions?: boolean; + recipients?: string; +} + +class SpoFolderSharingLinkAddCommand extends SpoCommand { + private readonly allowedTypes: string[] = ['view', 'edit']; + private readonly allowedScopes: string[] = ['anonymous', 'organization', 'users']; + + public get name(): string { + return commands.FOLDER_SHARINGLINK_ADD; + } + + public get description(): string { + return 'Creates a new sharing link to a folder'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + this.#initOptionSets(); + this.#initTypes(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + folderId: typeof args.options.folderId !== 'undefined', + folderUrl: typeof args.options.folderUrl !== 'undefined', + type: typeof args.options.type !== 'undefined', + expirationDateTime: typeof args.options.expirationDateTime !== 'undefined', + scope: typeof args.options.scope !== 'undefined', + retainInheritiedPermissions: !!args.options.retainInheritiedPermissions, + recipients: typeof args.options.recipients !== 'undefined' + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-u, --webUrl ' + }, + { + option: '--folderId [folderId]' + }, + { + option: '--folderUrl [folderUrl]' + }, + { + option: '--type ', + autocomplete: this.allowedTypes + }, + { + option: '--expirationDateTime [expirationDateTime]' + }, + { + option: '--scope [scope]', + autocomplete: this.allowedScopes + }, + { + option: '--retainInheritedPermissions [retainInheritedPermissions]' + }, + { + option: '--recipients [recipients]' + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.webUrl); + if (isValidSharePointUrl !== true) { + return isValidSharePointUrl; + } + + if (args.options.folderId && !validation.isValidGuid(args.options.folderId)) { + return `${args.options.folderId} is not a valid GUID`; + } + + if (args.options.type && !this.allowedTypes.some(type => type === args.options.type)) { + return `'${args.options.type}' is not a valid type. Allowed values are: ${this.allowedTypes.join(',')}`; + } + + if (args.options.scope) { + if (!this.allowedScopes.includes(args.options.scope)) { + return `'${args.options.scope}' is not a valid scope. Allowed values are: ${this.allowedScopes.join(', ')}.`; + } + } + + if (args.options.expirationDateTime && !validation.isValidISODateTime(args.options.expirationDateTime)) { + return `${args.options.expirationDateTime} is not a valid ISO date string.`; + } + + if (args.options.recipients) { + const isValidUPNArrayResult = validation.isValidUserPrincipalNameArray(args.options.recipients); + if (isValidUPNArrayResult !== true) { + return `The following user principal names are invalid for the option 'recipients': ${isValidUPNArrayResult}.`; + } + } + + return true; + } + ); + } + + #initOptionSets(): void { + this.optionSets.push( + { options: ['folderId', 'folderUrl'] }, + { + options: ['recipients'], + runsWhen: (args) => args.options.scope !== undefined && args.options.scope === 'users' + } + ); + } + + #initTypes(): void { + this.types.string.push('webUrl', 'folderId', 'folderUrl', 'type', 'expirationDateTime', 'scope', 'recipients'); + this.types.boolean.push('retainInheritiedPermissions'); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + if (this.verbose) { + await logger.logToStderr(`Creating a sharing link to a folder ${args.options.folderId || args.options.folderUrl}...`); + } + + try { + const relFolderUrl: string = await spo.getFolderServerRelativeUrl(args.options.webUrl, args.options.folderUrl, args.options.folderId, logger, args.options.verbose); + const absoluteFolderUrl: string = urlUtil.getAbsoluteUrl(args.options.webUrl, relFolderUrl); + const folderUrl: URL = new URL(absoluteFolderUrl); + + const siteId: string = await spo.getSiteId(args.options.webUrl); + const driveDetails: Drive = await drive.getDriveByUrl(siteId, folderUrl, logger, args.options.verbose); + const itemId: string = await drive.getDriveItemId(driveDetails, folderUrl, logger, args.options.verbose); + + const requestOptions: CliRequestOptions = { + url: `https://graph.microsoft.com/v1.0/drives/${driveDetails.id}/items/${itemId}/createLink`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: { + type: args.options.type, + expirationDateTime: args.options.expirationDateTime, + scope: args.options.scope, + retainInheritedPermissions: !!args.options.retainInheritiedPermissions + } + }; + + if (args.options.scope === 'users' && args.options.recipients) { + const recipients = formatting.splitAndTrim(args.options.recipients).map(email => ({ email })); + requestOptions.data.recipients = recipients; + } + + const sharingLink = await request.post(requestOptions); + + // remove grantedToIdentities from the sharing link object + if (sharingLink.grantedToIdentities) { + delete sharingLink.grantedToIdentities; + } + + await logger.log(sharingLink); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new SpoFolderSharingLinkAddCommand(); diff --git a/src/m365/spo/commands/folder/folder-sharinglink-clear.spec.ts b/src/m365/spo/commands/folder/folder-sharinglink-clear.spec.ts new file mode 100644 index 00000000000..fd0c5abf6e6 --- /dev/null +++ b/src/m365/spo/commands/folder/folder-sharinglink-clear.spec.ts @@ -0,0 +1,253 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandError } from '../../../../Command.js'; +import { telemetry } from '../../../../telemetry.js'; +import { Logger } from '../../../../cli/Logger.js'; +import request from '../../../../request.js'; +import { odata } from '../../../../utils/odata.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import commands from '../../commands.js'; +import command from './folder-sharinglink-clear.js'; +import { spo } from '../../../../utils/spo.js'; +import { drive } from '../../../../utils/drive.js'; +import { Drive } from '@microsoft/microsoft-graph-types'; + +describe(commands.FOLDER_SHARINGLINK_CLEAR, () => { + let log: any[]; + let logger: Logger; + let commandInfo: CommandInfo; + let promptIssued: boolean = false; + + const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; + const folderId = 'f09c4efe-b8c0-4e89-a166-03418661b89b'; + const folderUrl = '/sites/project-x/shared documents/folder1'; + const siteId = '0f9b8f4f-0e8e-4630-bb0a-501442db9b64'; + const driveId = '013TMHP6UOOSLON57HT5GLKEU7R5UGWZVK'; + const itemId = 'b!T4-bD44OMEa7ClAUQtubZID9tc40pGJKpguycvELod_Gx-lo4ZQiRJ7vylonTufG'; + + const graphResponse = { + value: [ + { + "id": "2a021f54-90a2-4016-b3b3-5f34d2e7d932", + "roles": [ + "read" + ], + "shareId": "u!aHR0cHM6Ly83NTY2YXZhLnNoYXJlcG9pbnQuY29tLzpmOi9nL0V2QVFpdnpLV2ZoT3ZJOHJiNm1UVEhjQnZ4SFBWWW10aGRJNUpYdG51cGhOeUE", + "hasPassword": false, + "grantedToIdentitiesV2": [], + "link": { + "scope": "anonymous", + "type": "view", + "webUrl": "https://contoso.sharepoint.com/:b:/s/pnpcoresdktestgroup/EY50lub3559MtRKfj2hrZqoBWnHOpGIcgi4gzw9XiWYJ-A", + "preventsDownload": false + } + } + ] + }; + + const driveDetails: Drive = { + id: driveId, + webUrl: `${webUrl}/Shared%20Documents` + }; + + const getStubs: any = (options: any) => { + sinon.stub(spo, 'getFolderServerRelativeUrl').resolves(options.folderUrl); + sinon.stub(spo, 'getSiteId').resolves(options.siteId); + sinon.stub(drive, 'getDriveByUrl').resolves(options.drive); + sinon.stub(drive, 'getDriveItemId').resolves(options.itemId); + }; + + const stubOdataResponse: any = (graphResponse: any = null) => { + return sinon.stub(odata, 'getAllItems').callsFake(async (url: string) => { + if (url === `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/permissions?$filter=Link ne null`) { + return graphResponse.value; + } + throw 'Invalid request'; + }); + }; + + const stubOdataScopeResponse: any = (scope: any = null, graphResponse: any = null) => { + return sinon.stub(odata, 'getAllItems').callsFake(async (url: string) => { + if (url === `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/permissions?$filter=Link ne null and Link/Scope eq '${scope}'`) { + return graphResponse.value.filter((x: any) => x.link.scope === scope); + } + throw 'Invalid request'; + }); + }; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + + sinon.stub(cli, 'promptForConfirmation').callsFake(() => { + promptIssued = true; + return Promise.resolve(false); + }); + + promptIssued = false; + }); + + afterEach(() => { + sinonUtil.restore([ + request.delete, + cli.promptForConfirmation, + odata.getAllItems, + spo.getSiteId, + spo.getFolderServerRelativeUrl, + drive.getDriveByUrl, + drive.getDriveItemId + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.FOLDER_SHARINGLINK_CLEAR); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { + const actual = await command.validate({ options: { webUrl: 'foo', folderId: folderId } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the folderId option is not a valid GUID', async () => { + const actual = await command.validate({ options: { webUrl: webUrl, folderId: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if invalid scope specified', async () => { + const actual = await command.validate({ options: { webUrl: webUrl, folderId: folderId, scope: 'invalid scope' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation if options are valid', async () => { + const actual = await command.validate({ options: { webUrl: webUrl, folderId: folderId, scope: 'organization' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('prompts before clearing the sharing links from a folder when force option not passed', async () => { + await command.action(logger, { + options: { + webUrl: webUrl, + folderId: folderId + } + }); + + assert(promptIssued); + }); + + it('aborts clearing the sharing links from a folder when force option not passed and prompt not confirmed', async () => { + const deleteSpy = sinon.spy(request, 'delete'); + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(false); + + await command.action(logger, { + options: { + webUrl: webUrl, + folderUrl: folderUrl + } + }); + + assert(deleteSpy.notCalled); + }); + + it('clears sharing links from folder by id for the specified scope', async () => { + const scope = 'anonymous'; + getStubs({ folderUrl: folderUrl, siteId: siteId, drive: driveDetails, itemId: itemId }); + stubOdataScopeResponse(scope, graphResponse); + + const requestDeleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/permissions/2a021f54-90a2-4016-b3b3-5f34d2e7d932`) { + return; + } + + throw 'Invalid request'; + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { + options: { + verbose: true, + webUrl: webUrl, + folderId: folderId, + scope: scope + } + }); + + assert(requestDeleteStub.called); + }); + + it('clears sharing links from folder by URL for the all scopes', async () => { + getStubs({ folderUrl: folderUrl, siteId: siteId, drive: driveDetails, itemId: itemId }); + stubOdataResponse(graphResponse); + + const requestDeleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/permissions/2a021f54-90a2-4016-b3b3-5f34d2e7d932`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + verbose: true, + webUrl: webUrl, + folderUrl: folderUrl, + force: true + } + }); + + assert(requestDeleteStub.called); + }); + + it('throws error when drive not found by url', async () => { + sinon.stub(spo, 'getFolderServerRelativeUrl').resolves(folderUrl); + sinon.stub(spo, 'getSiteId').resolves(siteId); + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/sites/${siteId}/drives?$select=webUrl,id`) { + return { + value: [] + }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, folderUrl: folderUrl, force: true } } as any), + new CommandError(`Drive 'https://contoso.sharepoint.com/sites/project-x/shared%20documents/folder1' not found`)); + }); +}); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-sharinglink-clear.ts b/src/m365/spo/commands/folder/folder-sharinglink-clear.ts new file mode 100644 index 00000000000..ebf5940c356 --- /dev/null +++ b/src/m365/spo/commands/folder/folder-sharinglink-clear.ts @@ -0,0 +1,155 @@ +import { cli } from '../../../../cli/cli.js'; +import { Logger } from '../../../../cli/Logger.js'; +import GlobalOptions from '../../../../GlobalOptions.js'; +import { spo } from '../../../../utils/spo.js'; +import { odata } from '../../../../utils/odata.js'; +import { urlUtil } from '../../../../utils/urlUtil.js'; +import { drive } from '../../../../utils/drive.js'; +import { validation } from '../../../../utils/validation.js'; +import SpoCommand from '../../../base/SpoCommand.js'; +import { Drive } from '@microsoft/microsoft-graph-types'; +import request, { CliRequestOptions } from '../../../../request.js'; +import commands from '../../commands.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + webUrl: string; + folderUrl?: string; + folderId?: string; + scope?: string; + force?: boolean; +} + +class SpoFolderSharingLinkClearCommand extends SpoCommand { + private readonly allowedScopes: string[] = ['anonymous', 'users', 'organization']; + + public get name(): string { + return commands.FOLDER_SHARINGLINK_CLEAR; + } + + public get description(): string { + return 'Removes all sharing links of a folder'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initOptionSets(); + this.#initValidators(); + this.#initTypes(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + webUrl: typeof args.options.webUrl !== 'undefined', + folderUrl: typeof args.options.folderUrl !== 'undefined', + folderId: typeof args.options.folderId !== 'undefined', + scope: typeof args.options.scope !== 'undefined', + force: !!args.options.force + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { option: '-u, --webUrl ' }, + { option: '--folderUrl [folderUrl]' }, + { option: '--folderId [folderId]' }, + { + option: '--scope [scope]', + autocomplete: this.allowedScopes + }, + { option: '-f, --force' } + ); + } + + #initOptionSets(): void { + this.optionSets.push( + { options: ['folderUrl', 'folderId'] } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.webUrl); + if (isValidSharePointUrl !== true) { + return isValidSharePointUrl; + } + + if (args.options.folderId && !validation.isValidGuid(args.options.folderId)) { + return `${args.options.folderId} is not a valid GUID`; + } + + if (args.options.scope && !this.allowedScopes.some(scope => scope === args.options.scope)) { + return `'${args.options.scope}' is not a valid scope. Allowed values are: ${this.allowedScopes.join(',')}`; + } + + return true; + } + ); + } + + #initTypes(): void { + this.types.string.push('webUrl', 'folderUrl', 'folderId', 'scope'); + this.types.boolean.push('force'); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + const clearSharingLinks = async (): Promise => { + if (this.verbose) { + await logger.logToStderr(`Clearing sharing links from folder ${args.options.folderId || args.options.folderUrl} for ${args.options.scope ? `${args.options.scope} scope` : 'all scopes'}`); + } + + try { + const relFolderUrl: string = await spo.getFolderServerRelativeUrl(args.options.webUrl, args.options.folderUrl, args.options.folderId, logger, args.options.verbose); + const absoluteFolderUrl: string = urlUtil.getAbsoluteUrl(args.options.webUrl, relFolderUrl); + const folderUrl: URL = new URL(absoluteFolderUrl); + + const siteId: string = await spo.getSiteId(args.options.webUrl); + const driveDetails: Drive = await drive.getDriveByUrl(siteId, folderUrl, logger, args.options.verbose); + const itemId: string = await drive.getDriveItemId(driveDetails, folderUrl, logger, args.options.verbose); + + let requestUrl = `https://graph.microsoft.com/v1.0/drives/${driveDetails.id}/items/${itemId}/permissions?$filter=Link ne null`; + if (args.options.scope) { + requestUrl += ` and Link/Scope eq '${args.options.scope}'`; + } + + const sharingLinks = await odata.getAllItems(requestUrl); + + for (const sharingLink of sharingLinks) { + const requestOptions: CliRequestOptions = { + url: `https://graph.microsoft.com/v1.0/drives/${driveDetails.id}/items/${itemId}/permissions/${sharingLink.id}`, + headers: { + accept: 'application/json;odata.metadata=none' + } + }; + await request.delete(requestOptions); + } + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + }; + + if (args.options.force) { + await clearSharingLinks(); + } + else { + const result = await cli.promptForConfirmation({ message: `Are you sure you want to clear sharing links from folder ${args.options.folderUrl || args.options.folderId}? for ${args.options.scope ? `${args.options.scope} scope` : 'all scopes'}` }); + + if (result) { + await clearSharingLinks(); + } + } + } + +} + +export default new SpoFolderSharingLinkClearCommand(); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-sharinglink-remove.spec.ts b/src/m365/spo/commands/folder/folder-sharinglink-remove.spec.ts new file mode 100644 index 00000000000..a0608d19f5f --- /dev/null +++ b/src/m365/spo/commands/folder/folder-sharinglink-remove.spec.ts @@ -0,0 +1,210 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandError } from '../../../../Command.js'; +import { telemetry } from '../../../../telemetry.js'; +import { Logger } from '../../../../cli/Logger.js'; +import request from '../../../../request.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import commands from '../../commands.js'; +import command from './folder-sharinglink-remove.js'; +import { spo } from '../../../../utils/spo.js'; +import { drive } from '../../../../utils/drive.js'; +import { Drive } from '@microsoft/microsoft-graph-types'; + +describe(commands.FOLDER_SHARINGLINK_REMOVE, () => { + let log: any[]; + let logger: Logger; + let commandInfo: CommandInfo; + let promptIssued: boolean = false; + + const webUrl = 'https://contoso.sharepoint.com/sites/project-x'; + const folderId = 'f09c4efe-b8c0-4e89-a166-03418661b89b'; + const folderUrl = '/sites/project-x/shared documents/folder1'; + const siteId = '0f9b8f4f-0e8e-4630-bb0a-501442db9b64'; + const driveId = '013TMHP6UOOSLON57HT5GLKEU7R5UGWZVK'; + const itemId = 'b!T4-bD44OMEa7ClAUQtubZID9tc40pGJKpguycvELod_Gx-lo4ZQiRJ7vylonTufG'; + const id = 'ef1cddaa-b74a-4aae-8a7a-5c16b4da67f2'; + + const driveDetails: Drive = { + id: driveId, + webUrl: `${webUrl}/Shared%20Documents` + }; + + const getStubs: any = (options: any) => { + sinon.stub(spo, 'getFolderServerRelativeUrl').resolves(options.folderUrl); + sinon.stub(spo, 'getSiteId').resolves(options.siteId); + sinon.stub(drive, 'getDriveByUrl').resolves(options.drive); + sinon.stub(drive, 'getDriveItemId').resolves(options.itemId); + }; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + + sinon.stub(cli, 'promptForConfirmation').callsFake(async () => { + promptIssued = true; + return false; + }); + + promptIssued = false; + }); + + afterEach(() => { + sinonUtil.restore([ + request.delete, + cli.promptForConfirmation, + spo.getSiteId, + spo.getFolderServerRelativeUrl, + drive.getDriveByUrl, + drive.getDriveItemId + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.FOLDER_SHARINGLINK_REMOVE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if the webUrl option is not a valid SharePoint site URL', async () => { + const actual = await command.validate({ options: { webUrl: 'foo', folderId: folderId, id: id } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the folderId option is not a valid GUID', async () => { + const actual = await command.validate({ options: { webUrl: webUrl, folderId: 'invalid', id: id } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation if options are valid', async () => { + const actual = await command.validate({ options: { webUrl: webUrl, folderId: folderId, id: id } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('prompts before removing the specified sharing link to a folder when force option not passed', async () => { + await command.action(logger, { + options: { + webUrl: webUrl, + folderId: folderId, + id: id + } + }); + + assert(promptIssued); + }); + + it('aborts removing the specified sharing link to a folder when force option not passed and prompt not confirmed', async () => { + const deleteSpy = sinon.spy(request, 'delete'); + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(false); + + await command.action(logger, { + options: { + webUrl: webUrl, + folderUrl: folderUrl, + id: id + } + }); + + assert(deleteSpy.notCalled); + }); + + it('removes specified sharing link to a folder by folderId when prompt confirmed', async () => { + getStubs({ folderUrl: folderUrl, siteId: siteId, drive: driveDetails, itemId: itemId }); + + const requestDeleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/permissions/${id}`) { + return; + } + + throw 'Invalid request'; + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { + options: { + verbose: true, + webUrl: webUrl, + folderId: folderId, + id: id + } + }); + assert(requestDeleteStub.called); + }); + + it('removes specified sharing link to a folder by folderUrl when prompt confirmed', async () => { + getStubs({ folderUrl: folderUrl, siteId: siteId, drive: driveDetails, itemId: itemId }); + + const requestDeleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/permissions/${id}`) { + return; + } + + throw 'Invalid request'; + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { + options: { + verbose: true, + webUrl: webUrl, + folderUrl: folderUrl, + id: id, + force: true + } + }); + assert(requestDeleteStub.called); + }); + + it('throws error when drive not found by url', async () => { + sinon.stub(spo, 'getFolderServerRelativeUrl').resolves(folderUrl); + sinon.stub(spo, 'getSiteId').resolves(siteId); + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/sites/${siteId}/drives?$select=webUrl,id`) { + return { + value: [] + }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { webUrl: webUrl, folderUrl: folderUrl, force: true } } as any), + new CommandError(`Drive 'https://contoso.sharepoint.com/sites/project-x/shared%20documents/folder1' not found`)); + }); +}); \ No newline at end of file diff --git a/src/m365/spo/commands/folder/folder-sharinglink-remove.ts b/src/m365/spo/commands/folder/folder-sharinglink-remove.ts new file mode 100644 index 00000000000..aff35bc1e86 --- /dev/null +++ b/src/m365/spo/commands/folder/folder-sharinglink-remove.ts @@ -0,0 +1,135 @@ +import { cli } from '../../../../cli/cli.js'; +import { Logger } from '../../../../cli/Logger.js'; +import GlobalOptions from '../../../../GlobalOptions.js'; +import { spo } from '../../../../utils/spo.js'; +import { urlUtil } from '../../../../utils/urlUtil.js'; +import { drive } from '../../../../utils/drive.js'; +import { validation } from '../../../../utils/validation.js'; +import SpoCommand from '../../../base/SpoCommand.js'; +import { Drive } from '@microsoft/microsoft-graph-types'; +import request, { CliRequestOptions } from '../../../../request.js'; +import commands from '../../commands.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + webUrl: string; + folderUrl?: string; + folderId?: string; + id: string; + force?: boolean; +} + +class SpoFolderSharingLinkRemoveCommand extends SpoCommand { + public get name(): string { + return commands.FOLDER_SHARINGLINK_REMOVE; + } + + public get description(): string { + return 'Removes a sharing link from a folder'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initOptionSets(); + this.#initValidators(); + this.#initTypes(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + folderUrl: typeof args.options.folderUrl !== 'undefined', + folderId: typeof args.options.folderId !== 'undefined', + force: !!args.options.force + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { option: '-u, --webUrl ' }, + { option: '--folderUrl [folderUrl]' }, + { option: '--folderId [folderId]' }, + { option: '-i, --id ' }, + { option: '-f, --force' } + ); + } + + #initOptionSets(): void { + this.optionSets.push( + { options: ['folderUrl', 'folderId'] } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.webUrl); + if (isValidSharePointUrl !== true) { + return isValidSharePointUrl; + } + + if (args.options.folderId && !validation.isValidGuid(args.options.folderId)) { + return `${args.options.folderId} is not a valid GUID`; + } + + return true; + } + ); + } + + #initTypes(): void { + this.types.string.push('webUrl', 'folderUrl', 'folderId', 'id'); + this.types.boolean.push('force'); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + const removeSharingLink = async (): Promise => { + if (this.verbose) { + await logger.logToStderr(`Removing sharing link of folder ${args.options.folderId || args.options.folderUrl} with id ${args.options.id}...`); + } + + try { + const relFolderUrl: string = await spo.getFolderServerRelativeUrl(args.options.webUrl, args.options.folderUrl, args.options.folderId, logger, args.options.verbose); + const absoluteFolderUrl: string = urlUtil.getAbsoluteUrl(args.options.webUrl, relFolderUrl); + const folderUrl: URL = new URL(absoluteFolderUrl); + + const siteId: string = await spo.getSiteId(args.options.webUrl); + const driveDetails: Drive = await drive.getDriveByUrl(siteId, folderUrl, logger, args.options.verbose); + const itemId: string = await drive.getDriveItemId(driveDetails, folderUrl, logger, args.options.verbose); + + const requestOptions: CliRequestOptions = { + url: `https://graph.microsoft.com/v1.0/drives/${driveDetails.id}/items/${itemId}/permissions/${args.options.id}`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + return request.delete(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + }; + + if (args.options.force) { + await removeSharingLink(); + } + else { + const result = await cli.promptForConfirmation({ message: `Are you sure you want to remove sharing link ${args.options.id} of folder ${args.options.folderUrl || args.options.folderId}?` }); + + if (result) { + await removeSharingLink(); + } + } + } +} + +export default new SpoFolderSharingLinkRemoveCommand(); diff --git a/src/m365/spo/commands/list/ListInstance.ts b/src/m365/spo/commands/list/ListInstance.ts index 8117172491e..8927045e21f 100644 --- a/src/m365/spo/commands/list/ListInstance.ts +++ b/src/m365/spo/commands/list/ListInstance.ts @@ -68,7 +68,7 @@ interface Member { PrincipalTypeString: string; } -interface VersionPolicy { +export interface VersionPolicy { DefaultExpireAfterDays: number; DefaultTrimMode: number; DefaultTrimModeValue?: string; diff --git a/src/m365/spo/commands/list/list-get.spec.ts b/src/m365/spo/commands/list/list-get.spec.ts index c72084ad976..ad2786adfd0 100644 --- a/src/m365/spo/commands/list/list-get.spec.ts +++ b/src/m365/spo/commands/list/list-get.spec.ts @@ -19,6 +19,65 @@ describe(commands.LIST_GET, () => { let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + const versionPolicies = { + VersionPolicies: { + DefaultExpireAfterDays: 0, + DefaultTrimMode: 0 + } + }; + const listResponse = { + AllowContentTypes: true, + BaseTemplate: 109, + BaseType: 1, + ContentTypesEnabled: false, + CrawlNonDefaultViews: false, + Created: null, + CurrentChangeToken: null, + CustomActionElements: null, + DefaultContentApprovalWorkflowId: "00000000-0000-0000-0000-000000000000", + DefaultItemOpenUseListSetting: false, + Description: "", + Direction: "none", + DocumentTemplateUrl: null, + DraftVersionVisibility: 0, + EnableAttachments: false, + EnableFolderCreation: true, + EnableMinorVersions: false, + EnableModeration: false, + EnableVersioning: false, + EntityTypeName: "Documents", + ExemptFromBlockDownloadOfNonViewableFiles: false, + FileSavePostProcessingEnabled: false, + ForceCheckout: false, + HasExternalDataSource: false, + Hidden: false, + Id: "14b2b6ed-0885-4814-bfd6-594737cc3ae3", + ImagePath: null, + ImageUrl: null, + IrmEnabled: false, + IrmExpire: false, + IrmReject: false, + IsApplicationList: false, + IsCatalog: false, + IsPrivate: false, + ItemCount: 69, + LastItemDeletedDate: null, + LastItemModifiedDate: null, + LastItemUserModifiedDate: null, + ListExperienceOptions: 0, + ListItemEntityTypeFullName: null, + MajorVersionLimit: 0, + MajorWithMinorVersionsLimit: 0, + MultipleDataList: false, + NoCrawl: false, + ParentWebPath: null, + ParentWebUrl: null, + ParserDisabled: false, + ServerTemplateCanCreateFolders: true, + TemplateFeatureId: null, + Title: "Documents" + }; + before(() => { sinon.stub(auth, 'restoreAuth').resolves(); sinon.stub(telemetry, 'trackEvent').returns(); @@ -63,129 +122,49 @@ describe(commands.LIST_GET, () => { assert.notStrictEqual(command.description, null); }); - it('retrieves and prints all details of list if title option is passed', async () => { - sinon.stub(request, 'get').resolves({ - "VersionPolicies": { - "DefaultExpireAfterDays": 0, - "DefaultTrimMode": 0 - }, - "AllowContentTypes": true, - "BaseTemplate": 109, - "BaseType": 1, - "ContentTypesEnabled": false, - "CrawlNonDefaultViews": false, - "Created": null, - "CurrentChangeToken": null, - "CustomActionElements": null, - "DefaultContentApprovalWorkflowId": "00000000-0000-0000-0000-000000000000", - "DefaultItemOpenUseListSetting": false, - "Description": "", - "Direction": "none", - "DocumentTemplateUrl": null, - "DraftVersionVisibility": 0, - "EnableAttachments": false, - "EnableFolderCreation": true, - "EnableMinorVersions": false, - "EnableModeration": false, - "EnableVersioning": false, - "EntityTypeName": "Documents", - "ExemptFromBlockDownloadOfNonViewableFiles": false, - "FileSavePostProcessingEnabled": false, - "ForceCheckout": false, - "HasExternalDataSource": false, - "Hidden": false, - "Id": "14b2b6ed-0885-4814-bfd6-594737cc3ae3", - "ImagePath": null, - "ImageUrl": null, - "IrmEnabled": false, - "IrmExpire": false, - "IrmReject": false, - "IsApplicationList": false, - "IsCatalog": false, - "IsPrivate": false, - "ItemCount": 69, - "LastItemDeletedDate": null, - "LastItemModifiedDate": null, - "LastItemUserModifiedDate": null, - "ListExperienceOptions": 0, - "ListItemEntityTypeFullName": null, - "MajorVersionLimit": 0, - "MajorWithMinorVersionsLimit": 0, - "MultipleDataList": false, - "NoCrawl": false, - "ParentWebPath": null, - "ParentWebUrl": null, - "ParserDisabled": false, - "ServerTemplateCanCreateFolders": true, - "TemplateFeatureId": null, - "Title": "Documents" + it('retrieves and prints all details of the list if the title option is passed and retrieves version policies for a document library', async () => { + const webUrl = 'https://contoso.sharepoint.com'; + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${webUrl}/_api/web/lists/GetByTitle('${listResponse.Title}')`) { + return listResponse; + } + if (opts.url === `${webUrl}/_api/web/lists/GetByTitle('${listResponse.Title}')?$select=VersionPolicies&$expand=VersionPolicies`) { + return versionPolicies; + } + throw 'Invalid request'; }); await command.action(logger, { options: { debug: true, - title: 'Documents', - webUrl: 'https://contoso.sharepoint.com' + title: listResponse.Title, + webUrl: webUrl } }); - assert(loggerLogSpy.calledWith({ - VersionPolicies: { - DefaultExpireAfterDays: 0, - DefaultTrimMode: 0, - DefaultTrimModeValue: 'NoExpiration' - }, - AllowContentTypes: true, - BaseTemplate: 109, - BaseType: 1, - ContentTypesEnabled: false, - CrawlNonDefaultViews: false, - Created: null, - CurrentChangeToken: null, - CustomActionElements: null, - DefaultContentApprovalWorkflowId: '00000000-0000-0000-0000-000000000000', - DefaultItemOpenUseListSetting: false, - Description: '', - Direction: 'none', - DocumentTemplateUrl: null, - DraftVersionVisibility: 0, - EnableAttachments: false, - EnableFolderCreation: true, - EnableMinorVersions: false, - EnableModeration: false, - EnableVersioning: false, - EntityTypeName: 'Documents', - ExemptFromBlockDownloadOfNonViewableFiles: false, - FileSavePostProcessingEnabled: false, - ForceCheckout: false, - HasExternalDataSource: false, - Hidden: false, - Id: '14b2b6ed-0885-4814-bfd6-594737cc3ae3', - ImagePath: null, - ImageUrl: null, - IrmEnabled: false, - IrmExpire: false, - IrmReject: false, - IsApplicationList: false, - IsCatalog: false, - IsPrivate: false, - ItemCount: 69, - LastItemDeletedDate: null, - LastItemModifiedDate: null, - LastItemUserModifiedDate: null, - ListExperienceOptions: 0, - ListItemEntityTypeFullName: null, - MajorVersionLimit: 0, - MajorWithMinorVersionsLimit: 0, - MultipleDataList: false, - NoCrawl: false, - ParentWebPath: null, - ParentWebUrl: null, - ParserDisabled: false, - ServerTemplateCanCreateFolders: true, - TemplateFeatureId: null, - Title: 'Documents' - })); + assert(loggerLogSpy.calledWith({ ...listResponse, ...versionPolicies })); + }); + + it('retrieves and prints all details of the list if the title option is passed and does not retrieve version policies for a generic list', async () => { + const webUrl = 'https://contoso.sharepoint.com'; + const listResponseGeneric = { ...listResponse, BaseTemplate: 100 }; + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${webUrl}/_api/web/lists/GetByTitle('${listResponse.Title}')`) { + return listResponseGeneric; + } + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + debug: true, + title: listResponse.Title, + webUrl: webUrl + } + }); + + assert(loggerLogSpy.calledOnceWithExactly(listResponseGeneric)); }); it('retrieves details of list if title and properties option is passed (debug)', async () => { diff --git a/src/m365/spo/commands/list/list-get.ts b/src/m365/spo/commands/list/list-get.ts index 6bde5e18419..53476a01f56 100644 --- a/src/m365/spo/commands/list/list-get.ts +++ b/src/m365/spo/commands/list/list-get.ts @@ -6,7 +6,7 @@ import { urlUtil } from '../../../../utils/urlUtil.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import { DefaultTrimModeType, ListInstance } from "./ListInstance.js"; +import { DefaultTrimModeType, ListInstance, VersionPolicy } from "./ListInstance.js"; import { ListPrincipalType } from './ListPrincipalType.js'; interface Properties { @@ -28,6 +28,8 @@ interface Options extends GlobalOptions { } class SpoListGetCommand extends SpoCommand { + private supportedBaseTemplates = [101, 109, 110, 111, 113, 114, 115, 116, 117, 119, 121, 122, 123, 126, 130, 175]; + public get name(): string { return commands.LIST_GET; } @@ -114,7 +116,7 @@ class SpoListGetCommand extends SpoCommand { requestUrl += `lists(guid'${formatting.encodeQueryParameter(args.options.id)}')`; } else if (args.options.title) { - requestUrl += `lists/GetByTitle('${formatting.encodeQueryParameter(args.options.title as string)}')`; + requestUrl += `lists/GetByTitle('${formatting.encodeQueryParameter(args.options.title)}')`; } else if (args.options.url) { const listServerRelativeUrl: string = urlUtil.getServerRelativePath(args.options.webUrl, args.options.url); @@ -132,12 +134,8 @@ class SpoListGetCommand extends SpoCommand { queryParams.push(`$expand=${fieldsProperties.expandProperties.join(',')}`); } - if (queryParams.length === 0) { - queryParams.push(`$expand=VersionPolicies`); - } - const requestOptions: CliRequestOptions = { - url: `${requestUrl}?${queryParams.join('&')}`, + url: `${requestUrl}${queryParams.length > 0 ? `?${queryParams.join('&')}` : ''}`, headers: { 'accept': 'application/json;odata=nometadata' }, @@ -152,6 +150,10 @@ class SpoListGetCommand extends SpoCommand { }); } + if (this.supportedBaseTemplates.some(template => template === listInstance.BaseTemplate)) { + await this.retrieveVersionPolicies(requestUrl, listInstance); + } + if (listInstance.VersionPolicies) { listInstance.VersionPolicies.DefaultTrimModeValue = DefaultTrimModeType[listInstance.VersionPolicies.DefaultTrimMode]; } @@ -186,6 +188,19 @@ class SpoListGetCommand extends SpoCommand { expandProperties: [...new Set(expandProperties)] }; } + + private async retrieveVersionPolicies(requestUrl: string, listInstance: ListInstance): Promise { + const requestOptions: CliRequestOptions = { + url: `${requestUrl}?$select=VersionPolicies&$expand=VersionPolicies`, + headers: { + 'accept': 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + const { VersionPolicies } = await request.get<{ VersionPolicies: VersionPolicy }>(requestOptions); + listInstance.VersionPolicies = VersionPolicies; + listInstance.VersionPolicies.DefaultTrimModeValue = DefaultTrimModeType[listInstance.VersionPolicies.DefaultTrimMode]; + } } export default new SpoListGetCommand(); \ No newline at end of file diff --git a/src/m365/spo/commands/page/canvasContent.ts b/src/m365/spo/commands/page/canvasContent.ts index 1d43bf0569b..634da0c6b92 100644 --- a/src/m365/spo/commands/page/canvasContent.ts +++ b/src/m365/spo/commands/page/canvasContent.ts @@ -1,13 +1,49 @@ export interface Control { controlType?: number; displayMode: number; - emphasis: any; + emphasis?: { zoneEmphasis?: number }; id?: string; position: ControlPosition; reservedHeight?: number; reservedWidth?: number; webPartData?: any; webPartId?: string; + zoneGroupMetadata?: ZoneGroupMetadata; +} + +export interface BackgroundControl { + controlType: number; + position?: any; + webPartData: { + properties: { + zoneBackground: { + [key: string]: { + type: string; + gradient?: string; + imageData?: { + source: number; + fileName: string; + height: number; + width: number; + }; + useLightText: boolean; + overlay: { + color: string; + opacity: number; + } + } + } + }, + serverProcessedContent: { + htmlStrings: any, + searchablePlainTexts: any, + imageSources?: { + [key: string]: string + }, + links: any + }, + dataVersion: string; + } } interface ControlPosition { @@ -17,4 +53,12 @@ interface ControlPosition { sectionIndex: number; zoneIndex: number; isLayoutReflowOnTop?: boolean; + zoneId?: string; +} + +interface ZoneGroupMetadata { + type: number; + isExpanded: boolean; + showDividerLine: boolean; + iconAlignment: string; } \ No newline at end of file diff --git a/src/m365/spo/commands/page/page-section-add.spec.ts b/src/m365/spo/commands/page/page-section-add.spec.ts index e4ab03a2720..c7c6d3cb30b 100644 --- a/src/m365/spo/commands/page/page-section-add.spec.ts +++ b/src/m365/spo/commands/page/page-section-add.spec.ts @@ -62,7 +62,7 @@ describe(commands.PAGE_SECTION_ADD, () => { it('checks out page if not checked out by the current user', async () => { let checkedOut = false; sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { + if ((opts.url as string).includes(`_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { return { "IsPageCheckedOutToCurrentUser": false, "CanvasContent1": "[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" @@ -73,12 +73,12 @@ describe(commands.PAGE_SECTION_ADD, () => { }); sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/checkoutpage`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/checkoutpage`)) { checkedOut = true; return {}; } - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { return {}; } @@ -99,7 +99,7 @@ describe(commands.PAGE_SECTION_ADD, () => { it('doesn\'t check out page if not checked out by the current user', async () => { let checkingOut = false; sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { return { "IsPageCheckedOutToCurrentUser": true, "CanvasContent1": "[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" @@ -110,12 +110,12 @@ describe(commands.PAGE_SECTION_ADD, () => { }); sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/checkoutpage`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/checkoutpage`)) { checkingOut = true; return {}; } - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { return {}; } @@ -134,7 +134,7 @@ describe(commands.PAGE_SECTION_ADD, () => { it('adds a first section to an uncustomized page', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { return { "IsPageCheckedOutToCurrentUser": true, "CanvasContent1": "[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" @@ -146,7 +146,7 @@ describe(commands.PAGE_SECTION_ADD, () => { let data: string = ''; sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { data = JSON.stringify(opts.data); return {}; } @@ -167,7 +167,7 @@ describe(commands.PAGE_SECTION_ADD, () => { it('adds a first section to an uncustomized page with order set to 1', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { return { "IsPageCheckedOutToCurrentUser": true, "CanvasContent1": "[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" @@ -179,7 +179,7 @@ describe(commands.PAGE_SECTION_ADD, () => { let data: string = ''; sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { data = JSON.stringify(opts.data); return {}; } @@ -200,25 +200,25 @@ describe(commands.PAGE_SECTION_ADD, () => { }); it('adds a first section to an uncustomized page correctly even when CanvasContent1 of returned page is null', async () => { - sinon.stub(request, 'get').callsFake((opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { - return Promise.resolve({ + sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { + return { "IsPageCheckedOutToCurrentUser": true, "CanvasContent1": null - }); + }; } - return Promise.reject('Invalid request'); + throw 'Invalid request'; }); let data: string = ''; - sinon.stub(request, 'post').callsFake((opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { data = JSON.stringify(opts.data); - return Promise.resolve({}); + return {}; } - return Promise.reject('Invalid request'); + throw 'Invalid request'; }); await command.action(logger, { @@ -234,7 +234,7 @@ describe(commands.PAGE_SECTION_ADD, () => { it('adds a first section to the page if no order specified', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { return { "IsPageCheckedOutToCurrentUser": true, "CanvasContent1": "[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" @@ -246,7 +246,7 @@ describe(commands.PAGE_SECTION_ADD, () => { let data: string = ''; sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { data = JSON.stringify(opts.data); return {}; } @@ -267,7 +267,7 @@ describe(commands.PAGE_SECTION_ADD, () => { it('adds a first section to the page if order 1 specified', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { return { "IsPageCheckedOutToCurrentUser": true, "CanvasContent1": "[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" @@ -279,7 +279,7 @@ describe(commands.PAGE_SECTION_ADD, () => { let data: string = ''; sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { data = JSON.stringify(opts.data); return {}; } @@ -301,7 +301,7 @@ describe(commands.PAGE_SECTION_ADD, () => { it('adds a section to the beginning of the page', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { return { "IsPageCheckedOutToCurrentUser": true, "CanvasContent1": "[{\"displayMode\":2,\"position\":{\"zoneIndex\":1,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":1},\"emphasis\":{}},{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" @@ -313,7 +313,7 @@ describe(commands.PAGE_SECTION_ADD, () => { let data: string = ''; sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { data = JSON.stringify(opts.data); return {}; } @@ -335,7 +335,7 @@ describe(commands.PAGE_SECTION_ADD, () => { it('adds a section to the end of the page when order not specified', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { return { "IsPageCheckedOutToCurrentUser": true, "CanvasContent1": "[{\"displayMode\":2,\"position\":{\"zoneIndex\":1,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":1},\"emphasis\":{}},{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" @@ -347,7 +347,7 @@ describe(commands.PAGE_SECTION_ADD, () => { let data: string = ''; sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { data = JSON.stringify(opts.data); return {}; } @@ -368,7 +368,7 @@ describe(commands.PAGE_SECTION_ADD, () => { it('adds a section to the end of the page when order set to last section', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { return { "IsPageCheckedOutToCurrentUser": true, "CanvasContent1": "[{\"displayMode\":2,\"position\":{\"zoneIndex\":1,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":1},\"emphasis\":{}},{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" @@ -380,7 +380,7 @@ describe(commands.PAGE_SECTION_ADD, () => { let data: string = ''; sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { data = JSON.stringify(opts.data); return {}; } @@ -402,7 +402,7 @@ describe(commands.PAGE_SECTION_ADD, () => { it('adds a section to the end of the page when order is larger than the last section', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { return { "IsPageCheckedOutToCurrentUser": true, "CanvasContent1": "[{\"displayMode\":2,\"position\":{\"zoneIndex\":1,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":1},\"emphasis\":{}},{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" @@ -414,7 +414,7 @@ describe(commands.PAGE_SECTION_ADD, () => { let data: string = ''; sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { data = JSON.stringify(opts.data); return {}; } @@ -436,7 +436,7 @@ describe(commands.PAGE_SECTION_ADD, () => { it('adds a section between two other sections', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { return { "IsPageCheckedOutToCurrentUser": true, "CanvasContent1": "[{\"displayMode\":2,\"position\":{\"zoneIndex\":1,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":1},\"emphasis\":{}},{\"displayMode\":2,\"position\":{\"zoneIndex\":2,\"sectionIndex\":1,\"sectionFactor\":4,\"layoutIndex\":1},\"emphasis\":{}},{\"displayMode\":2,\"position\":{\"zoneIndex\":2,\"sectionIndex\":2,\"sectionFactor\":8,\"layoutIndex\":1},\"emphasis\":{}},{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" @@ -448,7 +448,7 @@ describe(commands.PAGE_SECTION_ADD, () => { let data: string = ''; sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { data = JSON.stringify(opts.data); return {}; } @@ -470,7 +470,7 @@ describe(commands.PAGE_SECTION_ADD, () => { it('adds a section between two other sections (2)', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { return { "IsPageCheckedOutToCurrentUser": true, "CanvasContent1": "[{\"displayMode\":2,\"position\":{\"zoneIndex\":0.5,\"sectionIndex\":1,\"sectionFactor\":6,\"layoutIndex\":1},\"emphasis\":{}},{\"displayMode\":2,\"position\":{\"zoneIndex\":0.5,\"sectionIndex\":2,\"sectionFactor\":6,\"layoutIndex\":1},\"emphasis\":{}},{\"displayMode\":2,\"position\":{\"zoneIndex\":1,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":1},\"emphasis\":{}},{\"displayMode\":2,\"position\":{\"zoneIndex\":1.5,\"sectionIndex\":1,\"sectionFactor\":4,\"layoutIndex\":1},\"emphasis\":{}},{\"displayMode\":2,\"position\":{\"zoneIndex\":1.5,\"sectionIndex\":2,\"sectionFactor\":4,\"layoutIndex\":1},\"emphasis\":{}},{\"displayMode\":2,\"position\":{\"zoneIndex\":1.5,\"sectionIndex\":3,\"sectionFactor\":4,\"layoutIndex\":1},\"emphasis\":{}},{\"displayMode\":2,\"position\":{\"zoneIndex\":2,\"sectionIndex\":1,\"sectionFactor\":4,\"layoutIndex\":1},\"emphasis\":{}},{\"displayMode\":2,\"position\":{\"zoneIndex\":2,\"sectionIndex\":2,\"sectionFactor\":8,\"layoutIndex\":1},\"emphasis\":{}},{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" @@ -482,7 +482,7 @@ describe(commands.PAGE_SECTION_ADD, () => { let data: string = ''; sinon.stub(request, 'post').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { data = JSON.stringify(opts.data); return {}; } @@ -503,25 +503,25 @@ describe(commands.PAGE_SECTION_ADD, () => { }); it('adds a Vertical section at the end to an uncustomized page', async () => { - sinon.stub(request, 'get').callsFake((opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { - return Promise.resolve({ + sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { + return { "IsPageCheckedOutToCurrentUser": true, "CanvasContent1": "[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" - }); + }; } - return Promise.reject('Invalid request'); + throw 'Invalid request'; }); let data: string = ''; - sinon.stub(request, 'post').callsFake((opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { data = JSON.stringify(opts.data); - return Promise.resolve({}); + return {}; } - return Promise.reject('Invalid request'); + throw 'Invalid request'; }); await command.action(logger, { @@ -535,27 +535,26 @@ describe(commands.PAGE_SECTION_ADD, () => { assert.strictEqual(data, JSON.stringify({ "CanvasContent1": "[{\"displayMode\":2,\"position\":{\"zoneIndex\":1,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":2,\"isLayoutReflowOnTop\":false,\"controlIndex\":1},\"emphasis\":{}},{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" })); }); - it('adds a Vertical section at the end with correct zoneEmphasisValue to an uncustomized page', async () => { - sinon.stub(request, 'get').callsFake((opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { - return Promise.resolve({ + sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { + return { "IsPageCheckedOutToCurrentUser": true, "CanvasContent1": "[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" - }); + }; } - return Promise.reject('Invalid request'); + throw 'Invalid request'; }); let data: string = ''; - sinon.stub(request, 'post').callsFake((opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { data = JSON.stringify(opts.data); - return Promise.resolve({}); + return {}; } - return Promise.reject('Invalid request'); + throw 'Invalid request'; }); await command.action(logger, { @@ -571,25 +570,25 @@ describe(commands.PAGE_SECTION_ADD, () => { }); it('adds a Vertical section at the end with correct zoneEmphasisValue and isLayoutReflowOnTop values to an uncustomized page', async () => { - sinon.stub(request, 'get').callsFake((opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`) > -1) { - return Promise.resolve({ + sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { + return { "IsPageCheckedOutToCurrentUser": true, "CanvasContent1": "[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" - }); + }; } - return Promise.reject('Invalid request'); + throw 'Invalid request'; }); let data: string = ''; - sinon.stub(request, 'post').callsFake((opts) => { - if ((opts.url as string).indexOf(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`) > -1) { + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { data = JSON.stringify(opts.data); - return Promise.resolve({}); + return {}; } - return Promise.reject('Invalid request'); + throw 'Invalid request'; }); await command.action(logger, { @@ -605,6 +604,306 @@ describe(commands.PAGE_SECTION_ADD, () => { assert.strictEqual(data, JSON.stringify({ "CanvasContent1": "[{\"displayMode\":2,\"position\":{\"zoneIndex\":1,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":2,\"isLayoutReflowOnTop\":true,\"controlIndex\":1},\"emphasis\":{\"zoneEmphasis\":1}},{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" })); }); + it('adds a OneColumn section at the end to an uncustomized page with Image zoneEmphasis', async () => { + let newZoneId = ''; + sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { + return { + "IsPageCheckedOutToCurrentUser": true, + "CanvasContent1": "[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" + }; + } + + throw 'Invalid request'; + }); + + let data: string = ''; + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { + newZoneId = JSON.parse(opts.data.CanvasContent1)[1].position.zoneId; + data = JSON.stringify(opts.data); + return {}; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: + { + pageName: 'home.aspx', + webUrl: 'https://contoso.sharepoint.com/sites/newsletter', + sectionTemplate: 'OneColumn', + zoneEmphasis: 'Image', + imageUrl: 'https://contoso.com/image.jpg' + } + }); + assert.strictEqual(data, JSON.stringify({ "CanvasContent1": `[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}},{\"displayMode\":2,\"position\":{\"zoneIndex\":1,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":1,\"zoneId\":\"${newZoneId}\"},\"emphasis\":{}},{\"controlType\":14,\"webPartData\":{\"properties\":{\"zoneBackground\":{\"${newZoneId}\":{\"type\":\"image\",\"imageData\":{\"source\":2,\"fileName\":\"sectionbackground.jpg\",\"height\":955,\"width\":555},\"fillMode\":0,\"useLightText\":false,\"overlay\":{\"color\":\"#FFFFFF\",\"opacity\":60}}}},\"serverProcessedContent\":{\"htmlStrings\":{},\"searchablePlainTexts\":{},\"imageSources\":{\"zoneBackground.${newZoneId}.imageData.url\":\"https://contoso.com/image.jpg\"},\"links\":{}},\"dataVersion\":\"1.0\"}}]` })); + }); + + it('adds a OneColumn section at the end to an uncustomized page with Gradient zoneEmphasis', async () => { + let newZoneId = ''; + sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { + return { + "IsPageCheckedOutToCurrentUser": true, + "CanvasContent1": "[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" + }; + } + + throw 'Invalid request'; + }); + + let data: string = ''; + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { + newZoneId = JSON.parse(opts.data.CanvasContent1)[1].position.zoneId; + data = JSON.stringify(opts.data); + return {}; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: + { + pageName: 'home.aspx', + webUrl: 'https://contoso.sharepoint.com/sites/newsletter', + sectionTemplate: 'OneColumn', + zoneEmphasis: 'Gradient', + gradientText: 'test gradient' + } + }); + assert.strictEqual(data, JSON.stringify({ "CanvasContent1": `[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}},{\"displayMode\":2,\"position\":{\"zoneIndex\":1,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":1,\"zoneId\":\"${newZoneId}\"},\"emphasis\":{}},{\"controlType\":14,\"webPartData\":{\"properties\":{\"zoneBackground\":{\"${newZoneId}\":{\"type\":\"gradient\",\"gradient\":\"test gradient\",\"useLightText\":false,\"overlay\":{\"color\":\"#FFFFFF\",\"opacity\":60}}}},\"serverProcessedContent\":{\"htmlStrings\":{},\"searchablePlainTexts\":{},\"imageSources\":{},\"links\":{}},\"dataVersion\":\"1.0\"}}]` })); + }); + + it('adds a OneColumn section at the end to an uncustomized page with Image zoneEmphasis and all options available', async () => { + let newZoneId = ''; + sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { + return { + "IsPageCheckedOutToCurrentUser": true, + "CanvasContent1": "[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" + }; + } + + throw 'Invalid request'; + }); + + let data: string = ''; + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { + newZoneId = JSON.parse(opts.data.CanvasContent1)[1].position.zoneId; + data = JSON.stringify(opts.data); + return {}; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: + { + pageName: 'home.aspx', + webUrl: 'https://contoso.sharepoint.com/sites/newsletter', + sectionTemplate: 'OneColumn', + zoneEmphasis: 'Image', + imageUrl: 'https://contoso.com/image.jpg', + imageHeight: 100, + imageWidth: 200, + fillMode: 'ScaleToFill', + useLightText: true, + overlayColor: '#FF00FF', + overlayOpacity: 50 + } + }); + assert.strictEqual(data, JSON.stringify({ "CanvasContent1": `[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}},{\"displayMode\":2,\"position\":{\"zoneIndex\":1,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":1,\"zoneId\":\"${newZoneId}\"},\"emphasis\":{}},{\"controlType\":14,\"webPartData\":{\"properties\":{\"zoneBackground\":{\"${newZoneId}\":{\"type\":\"image\",\"imageData\":{\"source\":2,\"fileName\":\"sectionbackground.jpg\",\"height\":100,\"width\":200},\"fillMode\":0,\"useLightText\":true,\"overlay\":{\"color\":\"#FF00FF\",\"opacity\":50}}}},\"serverProcessedContent\":{\"htmlStrings\":{},\"searchablePlainTexts\":{},\"imageSources\":{\"zoneBackground.${newZoneId}.imageData.url\":\"https://contoso.com/image.jpg\"},\"links\":{}},\"dataVersion\":\"1.0\"}}]` })); + }); + + it('adds a OneColumn section at the end to an uncustomized page with Gradient zoneEmphasis and all options available', async () => { + let newZoneId = ''; + sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { + return { + "IsPageCheckedOutToCurrentUser": true, + "CanvasContent1": "[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" + }; + } + + throw 'Invalid request'; + }); + + let data: string = ''; + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { + newZoneId = JSON.parse(opts.data.CanvasContent1)[1].position.zoneId; + data = JSON.stringify(opts.data); + return {}; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: + { + pageName: 'home.aspx', + webUrl: 'https://contoso.sharepoint.com/sites/newsletter', + sectionTemplate: 'OneColumn', + zoneEmphasis: 'Gradient', + gradientText: 'test gradient', + overlayColor: '#FF00FF', + overlayOpacity: 50 + } + }); + assert.strictEqual(data, JSON.stringify({ "CanvasContent1": `[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}},{\"displayMode\":2,\"position\":{\"zoneIndex\":1,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":1,\"zoneId\":\"${newZoneId}\"},\"emphasis\":{}},{\"controlType\":14,\"webPartData\":{\"properties\":{\"zoneBackground\":{\"${newZoneId}\":{\"type\":\"gradient\",\"gradient\":\"test gradient\",\"useLightText\":false,\"overlay\":{\"color\":\"#FF00FF\",\"opacity\":50}}}},\"serverProcessedContent\":{\"htmlStrings\":{},\"searchablePlainTexts\":{},\"imageSources\":{},\"links\":{}},\"dataVersion\":\"1.0\"}}]` })); + }); + + it('adds a OneColumn section at the end to a page with background section added with Image zoneEmphasis', async () => { + let newZoneId = ''; + sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { + return { + "IsPageCheckedOutToCurrentUser": true, + "CanvasContent1": "[{\"position\":{\"layoutIndex\":1,\"zoneIndex\":2,\"sectionIndex\":1,\"controlIndex\":1,\"sectionFactor\":6,\"zoneId\":\"0158a0e8-20ad-4d8d-9cdc-6e1fde815a35\"},\"id\":\"emptySection\",\"addedFromPersistedData\":true},{\"position\":{\"layoutIndex\":1,\"zoneIndex\":2,\"sectionIndex\":2,\"controlIndex\":1,\"sectionFactor\":6,\"zoneId\":\"0158a0e8-20ad-4d8d-9cdc-6e1fde815a35\"},\"id\":\"emptySection\",\"addedFromPersistedData\":true},{\"position\":{\"layoutIndex\":1,\"zoneIndex\":3,\"sectionIndex\":1,\"controlIndex\":1,\"sectionFactor\":12,\"zoneId\":\"931e6d64-c667-4e2e-b678-eab508d511c8\"},\"id\":\"emptySection\",\"addedFromPersistedData\":true},{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true,\"globalRichTextStylingVersion\":0,\"rtePageSettings\":{\"contentVersion\":4},\"isEmailReady\":false}},{\"controlType\":14,\"webPartData\":{\"properties\":{\"zoneBackground\":{\"0158a0e8-20ad-4d8d-9cdc-6e1fde815a35\":{\"type\":\"gradient\",\"gradient\":\"radial-gradient(53.89% 99.37% at 39.45% -6.02%, rgba(4, 110, 212, 0.8) 0%, rgba(4, 110, 212, 0) 100%),\\n radial-gradient(47.01% 82.21% at 104.3% 15.51%, rgba(118, 5, 180, 0.5) 0%, rgba(118, 5, 180, 0) 100%),\\n radial-gradient(56.12% 58.33% at 50% 131.71%, #7605B4 34.7%, rgba(118, 5, 180, 0) 100%),\\n linear-gradient(0deg, #110739, #110739)\",\"useLightText\":true,\"overlay\":{\"color\":\"#000000\",\"opacity\":35}},\"931e6d64-c667-4e2e-b678-eab508d511c8\":{\"type\":\"image\",\"imageData\":{\"source\":1,\"fileName\":\"sectionbackgroundimagedark3.jpg\",\"height\":955,\"width\":555},\"overlay\":{\"color\":\"#000000\",\"opacity\":60},\"useLightText\":true}}},\"serverProcessedContent\":{\"htmlStrings\":{},\"searchablePlainTexts\":{},\"imageSources\":{\"zoneBackground.931e6d64-c667-4e2e-b678-eab508d511c8.imageData.url\":\"/_layouts/15/images/sectionbackgroundimagedark3.jpg\"},\"links\":{}},\"dataVersion\":\"1.0\"}}]" + }; + } + + throw 'Invalid request'; + }); + + let data: string = ''; + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { + newZoneId = JSON.parse(opts.data.CanvasContent1)[4].position.zoneId; + data = JSON.stringify(opts.data); + return {}; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: + { + pageName: 'home.aspx', + webUrl: 'https://contoso.sharepoint.com/sites/newsletter', + sectionTemplate: 'OneColumn', + zoneEmphasis: 'Image', + imageUrl: 'https://contoso.com/image.jpg' + } + }); + assert.strictEqual(data, JSON.stringify({ "CanvasContent1": `[{\"position\":{\"layoutIndex\":1,\"zoneIndex\":2,\"sectionIndex\":1,\"controlIndex\":1,\"sectionFactor\":6,\"zoneId\":\"0158a0e8-20ad-4d8d-9cdc-6e1fde815a35\"},\"id\":\"emptySection\",\"addedFromPersistedData\":true},{\"position\":{\"layoutIndex\":1,\"zoneIndex\":2,\"sectionIndex\":2,\"controlIndex\":1,\"sectionFactor\":6,\"zoneId\":\"0158a0e8-20ad-4d8d-9cdc-6e1fde815a35\"},\"id\":\"emptySection\",\"addedFromPersistedData\":true},{\"position\":{\"layoutIndex\":1,\"zoneIndex\":3,\"sectionIndex\":1,\"controlIndex\":1,\"sectionFactor\":12,\"zoneId\":\"931e6d64-c667-4e2e-b678-eab508d511c8\"},\"id\":\"emptySection\",\"addedFromPersistedData\":true},{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true,\"globalRichTextStylingVersion\":0,\"rtePageSettings\":{\"contentVersion\":4},\"isEmailReady\":false}},{\"displayMode\":2,\"position\":{\"zoneIndex\":6,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":1,\"zoneId\":\"${newZoneId}\"},\"emphasis\":{}},{\"controlType\":14,\"webPartData\":{\"properties\":{\"zoneBackground\":{\"0158a0e8-20ad-4d8d-9cdc-6e1fde815a35\":{\"type\":\"gradient\",\"gradient\":\"radial-gradient(53.89% 99.37% at 39.45% -6.02%, rgba(4, 110, 212, 0.8) 0%, rgba(4, 110, 212, 0) 100%),\\n radial-gradient(47.01% 82.21% at 104.3% 15.51%, rgba(118, 5, 180, 0.5) 0%, rgba(118, 5, 180, 0) 100%),\\n radial-gradient(56.12% 58.33% at 50% 131.71%, #7605B4 34.7%, rgba(118, 5, 180, 0) 100%),\\n linear-gradient(0deg, #110739, #110739)\",\"useLightText\":true,\"overlay\":{\"color\":\"#000000\",\"opacity\":35}},\"931e6d64-c667-4e2e-b678-eab508d511c8\":{\"type\":\"image\",\"imageData\":{\"source\":1,\"fileName\":\"sectionbackgroundimagedark3.jpg\",\"height\":955,\"width\":555},\"overlay\":{\"color\":\"#000000\",\"opacity\":60},\"useLightText\":true},\"${newZoneId}\":{\"type\":\"image\",\"imageData\":{\"source\":2,\"fileName\":\"sectionbackground.jpg\",\"height\":955,\"width\":555},\"fillMode\":0,\"useLightText\":false,\"overlay\":{\"color\":\"#FFFFFF\",\"opacity\":60}}}},\"serverProcessedContent\":{\"htmlStrings\":{},\"searchablePlainTexts\":{},\"imageSources\":{\"zoneBackground.931e6d64-c667-4e2e-b678-eab508d511c8.imageData.url\":\"/_layouts/15/images/sectionbackgroundimagedark3.jpg\",\"zoneBackground.${newZoneId}.imageData.url\":\"https://contoso.com/image.jpg\"},\"links\":{}},\"dataVersion\":\"1.0\"}}]` })); + }); + + it('adds a OneColumn section at the end to a page with background section added with Gradient zoneEmphasis', async () => { + let newZoneId = ''; + sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { + return { + "IsPageCheckedOutToCurrentUser": true, + "CanvasContent1": "[{\"position\":{\"layoutIndex\":1,\"zoneIndex\":2,\"sectionIndex\":1,\"controlIndex\":1,\"sectionFactor\":6,\"zoneId\":\"0158a0e8-20ad-4d8d-9cdc-6e1fde815a35\"},\"id\":\"emptySection\",\"addedFromPersistedData\":true},{\"position\":{\"layoutIndex\":1,\"zoneIndex\":2,\"sectionIndex\":2,\"controlIndex\":1,\"sectionFactor\":6,\"zoneId\":\"0158a0e8-20ad-4d8d-9cdc-6e1fde815a35\"},\"id\":\"emptySection\",\"addedFromPersistedData\":true},{\"position\":{\"layoutIndex\":1,\"zoneIndex\":3,\"sectionIndex\":1,\"controlIndex\":1,\"sectionFactor\":12,\"zoneId\":\"931e6d64-c667-4e2e-b678-eab508d511c8\"},\"id\":\"emptySection\",\"addedFromPersistedData\":true},{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true,\"globalRichTextStylingVersion\":0,\"rtePageSettings\":{\"contentVersion\":4},\"isEmailReady\":false}},{\"controlType\":14,\"webPartData\":{\"properties\":{\"zoneBackground\":{\"0158a0e8-20ad-4d8d-9cdc-6e1fde815a35\":{\"type\":\"gradient\",\"gradient\":\"radial-gradient(53.89% 99.37% at 39.45% -6.02%, rgba(4, 110, 212, 0.8) 0%, rgba(4, 110, 212, 0) 100%),\\n radial-gradient(47.01% 82.21% at 104.3% 15.51%, rgba(118, 5, 180, 0.5) 0%, rgba(118, 5, 180, 0) 100%),\\n radial-gradient(56.12% 58.33% at 50% 131.71%, #7605B4 34.7%, rgba(118, 5, 180, 0) 100%),\\n linear-gradient(0deg, #110739, #110739)\",\"useLightText\":true,\"overlay\":{\"color\":\"#000000\",\"opacity\":35}},\"931e6d64-c667-4e2e-b678-eab508d511c8\":{\"type\":\"image\",\"imageData\":{\"source\":1,\"fileName\":\"sectionbackgroundimagedark3.jpg\",\"height\":955,\"width\":555},\"overlay\":{\"color\":\"#000000\",\"opacity\":60},\"useLightText\":true}}},\"serverProcessedContent\":{\"htmlStrings\":{},\"searchablePlainTexts\":{},\"imageSources\":{\"zoneBackground.931e6d64-c667-4e2e-b678-eab508d511c8.imageData.url\":\"/_layouts/15/images/sectionbackgroundimagedark3.jpg\"},\"links\":{}},\"dataVersion\":\"1.0\"}}]" + }; + } + + throw 'Invalid request'; + }); + + let data: string = ''; + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { + newZoneId = JSON.parse(opts.data.CanvasContent1)[4].position.zoneId; + data = JSON.stringify(opts.data); + return {}; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: + { + pageName: 'home.aspx', + webUrl: 'https://contoso.sharepoint.com/sites/newsletter', + sectionTemplate: 'OneColumn', + zoneEmphasis: 'Gradient', + gradientText: 'test gradient' + } + }); + assert.strictEqual(data, JSON.stringify({ "CanvasContent1": `[{\"position\":{\"layoutIndex\":1,\"zoneIndex\":2,\"sectionIndex\":1,\"controlIndex\":1,\"sectionFactor\":6,\"zoneId\":\"0158a0e8-20ad-4d8d-9cdc-6e1fde815a35\"},\"id\":\"emptySection\",\"addedFromPersistedData\":true},{\"position\":{\"layoutIndex\":1,\"zoneIndex\":2,\"sectionIndex\":2,\"controlIndex\":1,\"sectionFactor\":6,\"zoneId\":\"0158a0e8-20ad-4d8d-9cdc-6e1fde815a35\"},\"id\":\"emptySection\",\"addedFromPersistedData\":true},{\"position\":{\"layoutIndex\":1,\"zoneIndex\":3,\"sectionIndex\":1,\"controlIndex\":1,\"sectionFactor\":12,\"zoneId\":\"931e6d64-c667-4e2e-b678-eab508d511c8\"},\"id\":\"emptySection\",\"addedFromPersistedData\":true},{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true,\"globalRichTextStylingVersion\":0,\"rtePageSettings\":{\"contentVersion\":4},\"isEmailReady\":false}},{\"displayMode\":2,\"position\":{\"zoneIndex\":6,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":1,\"zoneId\":\"${newZoneId}\"},\"emphasis\":{}},{\"controlType\":14,\"webPartData\":{\"properties\":{\"zoneBackground\":{\"0158a0e8-20ad-4d8d-9cdc-6e1fde815a35\":{\"type\":\"gradient\",\"gradient\":\"radial-gradient(53.89% 99.37% at 39.45% -6.02%, rgba(4, 110, 212, 0.8) 0%, rgba(4, 110, 212, 0) 100%),\\n radial-gradient(47.01% 82.21% at 104.3% 15.51%, rgba(118, 5, 180, 0.5) 0%, rgba(118, 5, 180, 0) 100%),\\n radial-gradient(56.12% 58.33% at 50% 131.71%, #7605B4 34.7%, rgba(118, 5, 180, 0) 100%),\\n linear-gradient(0deg, #110739, #110739)\",\"useLightText\":true,\"overlay\":{\"color\":\"#000000\",\"opacity\":35}},\"931e6d64-c667-4e2e-b678-eab508d511c8\":{\"type\":\"image\",\"imageData\":{\"source\":1,\"fileName\":\"sectionbackgroundimagedark3.jpg\",\"height\":955,\"width\":555},\"overlay\":{\"color\":\"#000000\",\"opacity\":60},\"useLightText\":true},\"${newZoneId}\":{\"type\":\"gradient\",\"gradient\":\"test gradient\",\"useLightText\":false,\"overlay\":{\"color\":\"#FFFFFF\",\"opacity\":60}}}},\"serverProcessedContent\":{\"htmlStrings\":{},\"searchablePlainTexts\":{},\"imageSources\":{\"zoneBackground.931e6d64-c667-4e2e-b678-eab508d511c8.imageData.url\":\"/_layouts/15/images/sectionbackgroundimagedark3.jpg\"},\"links\":{}},\"dataVersion\":\"1.0\"}}]` })); + }); + + it('adds a OneColumn section at the end to an uncustomized page with collapsible setting', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { + return { + "IsPageCheckedOutToCurrentUser": true, + "CanvasContent1": "[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" + }; + } + + throw 'Invalid request'; + }); + + let data: string = ''; + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { + data = JSON.stringify(opts.data); + return {}; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: + { + pageName: 'home.aspx', + webUrl: 'https://contoso.sharepoint.com/sites/newsletter', + sectionTemplate: 'OneColumn', + isCollapsibleSection: true, + iconAlignment: 'Right' + } + }); + assert.strictEqual(data, JSON.stringify({ "CanvasContent1": "[{\"displayMode\":2,\"position\":{\"zoneIndex\":1,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":1},\"emphasis\":{},\"zoneGroupMetadata\":{\"type\":1,\"isExpanded\":false,\"showDividerLine\":false,\"iconAlignment\":\"right\"}},{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" })); + }); + + it('adds a OneColumn section at the end to an uncustomized page with collapsible setting and left iconAlignment', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')?$select=CanvasContent1,IsPageCheckedOutToCurrentUser`)) { + return { + "IsPageCheckedOutToCurrentUser": true, + "CanvasContent1": "[{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" + }; + } + + throw 'Invalid request'; + }); + + let data: string = ''; + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url as string).includes(`/_api/sitepages/pages/GetByUrl('sitepages/home.aspx')/savepage`)) { + data = JSON.stringify(opts.data); + return {}; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: + { + pageName: 'home.aspx', + webUrl: 'https://contoso.sharepoint.com/sites/newsletter', + sectionTemplate: 'OneColumn', + isCollapsibleSection: true, + iconAlignment: 'Left' + } + }); + assert.strictEqual(data, JSON.stringify({ "CanvasContent1": "[{\"displayMode\":2,\"position\":{\"zoneIndex\":1,\"sectionIndex\":1,\"sectionFactor\":12,\"layoutIndex\":1},\"emphasis\":{},\"zoneGroupMetadata\":{\"type\":1,\"isExpanded\":false,\"showDividerLine\":false,\"iconAlignment\":\"left\"}},{\"controlType\":0,\"pageSettingsSlice\":{\"isDefaultDescription\":true,\"isDefaultThumbnail\":true}}]" })); + }); + it('correctly handles random API error', async () => { sinon.stub(request, 'get').callsFake(() => { throw 'An error has occurred'; @@ -681,7 +980,6 @@ describe(commands.PAGE_SECTION_ADD, () => { assert.notStrictEqual(actual, true); }); - it('fails validation if isLayoutReflowOnTop is valid but sectionTemplate is not Vertical', async () => { const actual = await command.validate({ options: { @@ -694,6 +992,123 @@ describe(commands.PAGE_SECTION_ADD, () => { assert.notStrictEqual(actual, true); }); + it('fails validation if iconAlignment is not valid', async () => { + const actual = await command.validate({ + options: { + pageName: 'page.aspx', + webUrl: 'https://contoso.sharepoint.com', + sectionTemplate: 'OneColumn', + zoneEmphasis: 'Image', + imageUrl: 'https://contoso.com/image.jpg', + iconAlignment: 'Invalid' + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if fillMode is not valid', async () => { + const actual = await command.validate({ + options: { + pageName: 'page.aspx', + webUrl: 'https://contoso.sharepoint.com', + sectionTemplate: 'OneColumn', + zoneEmphasis: 'Image', + imageUrl: 'https://contoso.com/image.jpg', + fillMode: 'Invalid' + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if imageUrl is specified but zoneEmphasis is not specified', async () => { + const actual = await command.validate({ + options: { + pageName: 'page.aspx', + webUrl: 'https://contoso.sharepoint.com', + sectionTemplate: 'OneColumn', + imageUrl: 'test.png' + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if gradientText is specified but zoneEmphasis is not specified', async () => { + const actual = await command.validate({ + options: { + pageName: 'page.aspx', + webUrl: 'https://contoso.sharepoint.com', + sectionTemplate: 'OneColumn', + gradientText: 'test gradient' + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if overlayOpacity is not valid', async () => { + const actual = await command.validate({ + options: { + pageName: 'page.aspx', + webUrl: 'https://contoso.sharepoint.com', + sectionTemplate: 'OneColumn', + zoneEmphasis: 'Image', + imageUrl: 'https://contoso.com/image.jpg', + overlayOpacity: 100001 + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if overlayColor is not valid', async () => { + const actual = await command.validate({ + options: { + pageName: 'page.aspx', + webUrl: 'https://contoso.sharepoint.com', + sectionTemplate: 'OneColumn', + zoneEmphasis: 'Image', + imageUrl: 'https://contoso.com/image.jpg', + overlayColor: "InvalidColor" + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if overlayColor is specified but is not Image or Gradient zoneEmphasis', async () => { + const actual = await command.validate({ + options: { + pageName: 'page.aspx', + webUrl: 'https://contoso.sharepoint.com', + sectionTemplate: 'OneColumn', + zoneEmphasis: 'Strong', + overlayColor: "#FFFFFF" + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if zoneEmphasis is Image and imageUrl is not defined', async () => { + const actual = await command.validate({ + options: { + pageName: 'page.aspx', + webUrl: 'https://contoso.sharepoint.com', + sectionTemplate: 'OneColumn', + zoneEmphasis: 'Image' + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if zoneEmphasis is Gradient and gradientText is not defined', async () => { + const actual = await command.validate({ + options: { + pageName: 'page.aspx', + webUrl: 'https://contoso.sharepoint.com', + sectionTemplate: 'OneColumn', + zoneEmphasis: 'Gradient' + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + it('passes validation if all the parameters are specified for a regular Section', async () => { const actual = await command.validate({ options: { @@ -788,7 +1203,7 @@ describe(commands.PAGE_SECTION_ADD, () => { const options = command.options; let containsOption = false; options.forEach((o) => { - if (o.option.indexOf('--pageName') > -1) { + if (o.option.indexOf('--pageName')) { containsOption = true; } }); @@ -799,7 +1214,7 @@ describe(commands.PAGE_SECTION_ADD, () => { const options = command.options; let containsOption = false; options.forEach((o) => { - if (o.option.indexOf('--webUrl') > -1) { + if (o.option.indexOf('--webUrl')) { containsOption = true; } }); @@ -810,7 +1225,7 @@ describe(commands.PAGE_SECTION_ADD, () => { const options = command.options; let containsOption = false; options.forEach((o) => { - if (o.option.indexOf('--sectionTemplate') > -1) { + if (o.option.indexOf('--sectionTemplate')) { containsOption = true; } }); @@ -821,7 +1236,7 @@ describe(commands.PAGE_SECTION_ADD, () => { const options = command.options; let containsOption = false; options.forEach((o) => { - if (o.option.indexOf('--order') > -1) { + if (o.option.indexOf('--order')) { containsOption = true; } }); @@ -832,7 +1247,7 @@ describe(commands.PAGE_SECTION_ADD, () => { const options = command.options; let containsOption = false; options.forEach((o) => { - if (o.option.indexOf('--zoneEmphasis') > -1) { + if (o.option.indexOf('--zoneEmphasis')) { containsOption = true; } }); @@ -843,7 +1258,7 @@ describe(commands.PAGE_SECTION_ADD, () => { const options = command.options; let containsOption = false; options.forEach((o) => { - if (o.option.indexOf('--isLayoutReflowOnTop') > -1) { + if (o.option.indexOf('--isLayoutReflowOnTop')) { containsOption = true; } }); diff --git a/src/m365/spo/commands/page/page-section-add.ts b/src/m365/spo/commands/page/page-section-add.ts index dd040acb12d..8d3e9fe2d99 100644 --- a/src/m365/spo/commands/page/page-section-add.ts +++ b/src/m365/spo/commands/page/page-section-add.ts @@ -1,3 +1,4 @@ +import { v4 } from 'uuid'; import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request from '../../../../request.js'; @@ -5,8 +6,8 @@ import { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; -import { Control } from './canvasContent.js'; -import { CanvasSectionTemplate, ZoneEmphasis } from './clientsidepages.js'; +import { BackgroundControl, Control } from './canvasContent.js'; +import { CanvasSectionTemplate } from './clientsidepages.js'; interface CommandArgs { options: Options; @@ -19,11 +20,25 @@ interface Options extends GlobalOptions { order?: number; zoneEmphasis?: string; isLayoutReflowOnTop?: boolean; + isCollapsibleSection?: boolean; + showDivider?: boolean; + iconAlignment?: string; + isExpanded?: boolean; + gradientText?: string; + imageUrl?: string; + imageHeight?: number; + imageWidth?: number; + fillMode?: string; + useLightText?: boolean; + overlayColor?: string; + overlayOpacity?: number; } class SpoPageSectionAddCommand extends SpoCommand { - public static readonly SectionTemplate: string[] = ['OneColumn', 'OneColumnFullWidth', 'TwoColumn', 'ThreeColumn', 'TwoColumnLeft', 'TwoColumnRight', 'Vertical']; - public static readonly ZoneEmphasis: string[] = ['None', 'Neutral', 'Soft', 'Strong']; + public readonly sectionTemplate: string[] = ['OneColumn', 'OneColumnFullWidth', 'TwoColumn', 'ThreeColumn', 'TwoColumnLeft', 'TwoColumnRight', 'Vertical']; + public readonly zoneEmphasis: string[] = ['None', 'Neutral', 'Soft', 'Strong', 'Image', 'Gradient']; + public readonly iconAlignment: string[] = ['Left', 'Right']; + public readonly fillMode: string[] = ['ScaleToFill', 'ScaleToFit', 'Tile', 'OriginalSize']; public get name(): string { return commands.PAGE_SECTION_ADD; @@ -39,6 +54,7 @@ class SpoPageSectionAddCommand extends SpoCommand { this.#initTelemetry(); this.#initOptions(); this.#initValidators(); + this.#initTypes(); } #initTelemetry(): void { @@ -46,7 +62,19 @@ class SpoPageSectionAddCommand extends SpoCommand { Object.assign(this.telemetryProperties, { order: typeof args.options.order !== 'undefined', zoneEmphasis: typeof args.options.zoneEmphasis !== 'undefined', - isLayoutReflowOnTop: !!args.options.isLayoutReflowOnTop + isLayoutReflowOnTop: !!args.options.isLayoutReflowOnTop, + isCollapsibleSection: !!args.options.isCollapsibleSection, + showDivider: !!typeof args.options.showDivider, + iconAlignment: typeof args.options.iconAlignment !== 'undefined', + isExpanded: !!args.options.isExpanded, + gradientText: typeof args.options.gradientText !== 'undefined', + imageUrl: typeof args.options.imageUrl !== 'undefined', + imageHeight: typeof args.options.imageHeight !== 'undefined', + imageWidth: typeof args.options.imageWidth !== 'undefined', + fillMode: typeof args.options.fillMode !== 'undefined', + useLightText: !!args.options.useLightText, + overlayColor: typeof args.options.overlayColor !== 'undefined', + overlayOpacity: typeof args.options.overlayOpacity !== 'undefined' }); }); } @@ -61,17 +89,55 @@ class SpoPageSectionAddCommand extends SpoCommand { }, { option: '-t, --sectionTemplate ', - autocomplete: SpoPageSectionAddCommand.SectionTemplate + autocomplete: this.sectionTemplate }, { option: '--order [order]' }, { option: '--zoneEmphasis [zoneEmphasis]', - autocomplete: SpoPageSectionAddCommand.ZoneEmphasis + autocomplete: this.zoneEmphasis }, { option: '--isLayoutReflowOnTop' + }, + { + option: '--isCollapsibleSection' + }, + { + option: '--showDivider' + }, + { + option: '--iconAlignment [iconAlignment]', + autocomplete: this.iconAlignment + }, + { + option: '--isExpanded' + }, + { + option: '--gradientText [gradientText]' + }, + { + option: '--imageUrl [imageUrl]' + }, + { + option: '--imageHeight [imageHeight]' + }, + { + option: '--imageWidth [imageWidth]' + }, + { + option: '--fillMode [fillMode]', + autocomplete: this.fillMode + }, + { + option: '--useLightText' + }, + { + option: '--overlayColor [overlayColor]' + }, + { + option: '--overlayOpacity [overlayOpacity]' } ); } @@ -90,8 +156,8 @@ class SpoPageSectionAddCommand extends SpoCommand { } if (typeof args.options.zoneEmphasis !== 'undefined') { - if (!(args.options.zoneEmphasis in ZoneEmphasis)) { - return 'The value of parameter zoneEmphasis must be None|Neutral|Soft|Strong'; + if (!this.zoneEmphasis.some(zoneEmphasisValue => zoneEmphasisValue.toLocaleLowerCase() === args.options.zoneEmphasis?.toLowerCase())) { + return `The value of parameter zoneEmphasis must be ${this.zoneEmphasis.join(', ')}`; } } @@ -101,17 +167,64 @@ class SpoPageSectionAddCommand extends SpoCommand { } } + if (typeof args.options.iconAlignment !== 'undefined') { + if (!this.iconAlignment.some(iconAlignmentValue => iconAlignmentValue.toLocaleLowerCase() === args.options.iconAlignment?.toLowerCase())) { + return `The value of parameter iconAlignment must be ${this.iconAlignment.join(', ')}`; + } + } + + if (typeof args.options.fillMode !== 'undefined') { + if (!this.fillMode.some(fillModeValue => fillModeValue.toLocaleLowerCase() === args.options.fillMode?.toLowerCase())) { + return `The value of parameter fillMode must be ${this.fillMode.join(', ')}`; + } + } + + if (args.options.zoneEmphasis?.toLocaleLowerCase() !== 'image' && (args.options.imageUrl || args.options.imageWidth || + args.options.imageHeight || args.options.fillMode)) { + return 'Specify imageUrl, imageWidth, imageHeight or fillMode only when zoneEmphasis is set to Image'; + } + + if (args.options.zoneEmphasis?.toLocaleLowerCase() === 'image' && !args.options.imageUrl) { + return 'Specify imageUrl when zoneEmphasis is set to Image'; + } + + if (args.options.zoneEmphasis?.toLowerCase() !== 'gradient' && args.options.gradientText) { + return 'Specify gradientText only when zoneEmphasis is set to Gradient'; + } + + if (args.options.zoneEmphasis?.toLowerCase() === 'gradient' && !args.options.gradientText) { + return 'Specify gradientText when zoneEmphasis is set to Gradient'; + } + + if (args.options.overlayOpacity && (args.options.overlayOpacity < 0 || args.options.overlayOpacity > 100)) { + return 'The value of parameter overlayOpacity must be between 0 and 100'; + } + + if (args.options.overlayColor && !/^#[0-9a-f]{6}$/i.test(args.options.overlayColor)) { + return 'The value of parameter overlayColor must be a valid hex color'; + } + + if (!(args.options.zoneEmphasis && ['image', 'gradient'].includes(args.options.zoneEmphasis.toLowerCase())) && (args.options.overlayColor || args.options.overlayOpacity || args.options.useLightText)) { + return 'Specify overlayColor or overlayOpacity only when zoneEmphasis is set to Image or Gradient'; + } + return validation.isValidSharePointUrl(args.options.webUrl); } ); } + #initTypes(): void { + this.types.string = ['pageName', 'webUrl', 'sectionTemplate', 'zoneEmphasis', 'iconAlignment', 'gradientText', 'imageUrl', 'fillMode', 'overlayColor']; + this.types.boolean = ['isLayoutReflowOnTop', 'isCollapsibleSection', 'showDivider', 'isExpanded', 'useLightText']; + } + public async commandAction(logger: Logger, args: CommandArgs): Promise { let pageFullName: string = args.options.pageName.toLowerCase(); - if (pageFullName.indexOf('.aspx') < 0) { + if (!pageFullName.endsWith('.aspx')) { pageFullName += '.aspx'; } - let canvasContent: Control[]; + + let canvasContent: (Control | BackgroundControl)[]; if (this.verbose) { await logger.logToStderr(`Retrieving page information...`); @@ -142,7 +255,7 @@ class SpoPageSectionAddCommand extends SpoCommand { } // get columns - const columns: Control[] = canvasContent + const columns: (Control | BackgroundControl)[] = canvasContent .filter(c => typeof c.controlType === 'undefined'); // get unique zoneIndex values given each section can have 1 or more // columns each assigned to the zoneIndex of the corresponding section @@ -154,11 +267,27 @@ class SpoPageSectionAddCommand extends SpoCommand { .sort(); // zoneIndex for the new section to add const zoneIndex: number = this.getSectionIndex(zoneIndices, args.options.order); + let zoneId: string | undefined; + + let backgroundControlToAdd: BackgroundControl | undefined = undefined; + + if (args.options.zoneEmphasis && ['image', 'gradient'].includes(args.options.zoneEmphasis.toLowerCase())) { + zoneId = v4(); + + // get background control based on control type 14 + const backgroundControl = canvasContent.find(c => c.controlType === 14) as BackgroundControl; + backgroundControlToAdd = this.setBackgroundControl(zoneId, backgroundControl, args); + + if (!backgroundControl) { + canvasContent.push(backgroundControlToAdd); + } + } + // get the list of columns to insert based on the selected template - const columnsToAdd: Control[] = this.getColumns(zoneIndex, args.options.sectionTemplate, args.options.zoneEmphasis, args.options.isLayoutReflowOnTop); + const columnsToAdd: Control[] = this.getColumns(zoneIndex, args, zoneId); // insert the column in the right place in the array so that // it stays sorted ascending by zoneIndex - let pos: number = canvasContent.findIndex(c => typeof c.controlType === 'undefined' && c.position.zoneIndex > zoneIndex); + let pos: number = canvasContent.findIndex(c => typeof c.controlType === 'undefined' && c.position && c.position.zoneIndex > zoneIndex); if (pos === -1) { pos = canvasContent.length - 1; } @@ -201,72 +330,145 @@ class SpoPageSectionAddCommand extends SpoCommand { return zoneIndices[order - 2] + ((zoneIndices[order - 1] - zoneIndices[order - 2]) / 2); } - private getColumns(zoneIndex: number, sectionTemplate: string, zoneEmphasis?: string, isLayoutReflowOnTop?: boolean): Control[] { + private getColumns(zoneIndex: number, args: CommandArgs, zoneId?: string): Control[] { const columns: Control[] = []; let sectionIndex: number = 1; - switch (sectionTemplate) { + switch (args.options.sectionTemplate) { case 'OneColumnFullWidth': - columns.push(this.getColumn(zoneIndex, sectionIndex++, 0, zoneEmphasis)); + columns.push(this.getColumn(zoneIndex, sectionIndex++, 0, args, zoneId)); break; case 'TwoColumn': - columns.push(this.getColumn(zoneIndex, sectionIndex++, 6, zoneEmphasis)); - columns.push(this.getColumn(zoneIndex, sectionIndex++, 6, zoneEmphasis)); + columns.push(this.getColumn(zoneIndex, sectionIndex++, 6, args, zoneId)); + columns.push(this.getColumn(zoneIndex, sectionIndex++, 6, args, zoneId)); break; case 'ThreeColumn': - columns.push(this.getColumn(zoneIndex, sectionIndex++, 4, zoneEmphasis)); - columns.push(this.getColumn(zoneIndex, sectionIndex++, 4, zoneEmphasis)); - columns.push(this.getColumn(zoneIndex, sectionIndex++, 4, zoneEmphasis)); + columns.push(this.getColumn(zoneIndex, sectionIndex++, 4, args, zoneId)); + columns.push(this.getColumn(zoneIndex, sectionIndex++, 4, args, zoneId)); + columns.push(this.getColumn(zoneIndex, sectionIndex++, 4, args, zoneId)); break; case 'TwoColumnLeft': - columns.push(this.getColumn(zoneIndex, sectionIndex++, 8, zoneEmphasis)); - columns.push(this.getColumn(zoneIndex, sectionIndex++, 4, zoneEmphasis)); + columns.push(this.getColumn(zoneIndex, sectionIndex++, 8, args, zoneId)); + columns.push(this.getColumn(zoneIndex, sectionIndex++, 4, args, zoneId)); break; case 'TwoColumnRight': - columns.push(this.getColumn(zoneIndex, sectionIndex++, 4, zoneEmphasis)); - columns.push(this.getColumn(zoneIndex, sectionIndex++, 8, zoneEmphasis)); + columns.push(this.getColumn(zoneIndex, sectionIndex++, 4, args, zoneId)); + columns.push(this.getColumn(zoneIndex, sectionIndex++, 8, args, zoneId)); break; case 'Vertical': - columns.push(this.getVerticalColumn(zoneEmphasis, isLayoutReflowOnTop)); + columns.push(this.getVerticalColumn(args, zoneId)); break; case 'OneColumn': default: - columns.push(this.getColumn(zoneIndex, sectionIndex++, 12, zoneEmphasis)); + columns.push(this.getColumn(zoneIndex, sectionIndex++, 12, args, zoneId)); break; } return columns; } - private getColumn(zoneIndex: number, sectionIndex: number, sectionFactor: number, zoneEmphasis?: string): Control { + private getColumn(zoneIndex: number, sectionIndex: number, sectionFactor: number, args: CommandArgs, zoneId?: string): Control { + const { zoneEmphasis, isCollapsibleSection, isExpanded, showDivider, iconAlignment } = args.options; const columnValue: Control = { displayMode: 2, position: { zoneIndex: zoneIndex, sectionIndex: sectionIndex, sectionFactor: sectionFactor, - layoutIndex: 1 + layoutIndex: 1, + zoneId: zoneId }, emphasis: { } }; - if (zoneEmphasis) { - const zoneEmphasisValue: number = ZoneEmphasis[zoneEmphasis as keyof typeof ZoneEmphasis]; + if (zoneEmphasis && ['none', 'neutral', 'soft', 'strong'].includes(zoneEmphasis?.toLocaleLowerCase())) { + // Just these zoneEmphasis values should be added to column emphasis + const zoneEmphasisValue: number = ['none', 'neutral', 'soft', 'strong'].indexOf(zoneEmphasis.toLocaleLowerCase()); columnValue.emphasis = { zoneEmphasis: zoneEmphasisValue }; } + if (isCollapsibleSection) { + columnValue.zoneGroupMetadata = { + type: 1, + isExpanded: !!isExpanded, + showDividerLine: !!showDivider, + iconAlignment: iconAlignment && iconAlignment.toLocaleLowerCase() === "right" ? "right" : "left" + }; + } + return columnValue; } - private getVerticalColumn(zoneEmphasis?: string, isLayoutReflowOnTop?: boolean): Control { - const columnValue: Control = this.getColumn(1, 1, 12, zoneEmphasis); - columnValue.position.isLayoutReflowOnTop = isLayoutReflowOnTop !== undefined ? true : false; + private getVerticalColumn(args: CommandArgs, zoneId?: string): Control { + const columnValue: Control = this.getColumn(1, 1, 12, args, zoneId); + columnValue.position.isLayoutReflowOnTop = args.options.isLayoutReflowOnTop !== undefined; columnValue.position.layoutIndex = 2; columnValue.position.controlIndex = 1; return columnValue; } + + private setBackgroundControl(zoneId: string, backgroundControl: BackgroundControl, args: CommandArgs): BackgroundControl { + const { overlayColor, overlayOpacity, useLightText, imageUrl } = args.options; + const backgroundDetails = this.getBackgroundDetails(args); + + if (!backgroundControl) { + backgroundControl = { + controlType: 14, + webPartData: { + properties: { + zoneBackground: { + } + }, + serverProcessedContent: { + htmlStrings: {}, + searchablePlainTexts: {}, + imageSources: {}, + links: {} + }, + dataVersion: "1.0" + } + }; + } + + backgroundControl.webPartData.properties.zoneBackground[zoneId] = { + ...backgroundDetails, + useLightText: !!useLightText, + overlay: { + color: overlayColor ? overlayColor : "#FFFFFF", + opacity: overlayOpacity ? overlayOpacity : 60 + } + }; + + if (imageUrl && backgroundControl.webPartData.serverProcessedContent.imageSources) { + backgroundControl.webPartData.serverProcessedContent.imageSources[`zoneBackground.${zoneId}.imageData.url`] = imageUrl; + } + return backgroundControl; + } + + private getBackgroundDetails(args: CommandArgs): any { + const { gradientText, imageUrl, imageHeight, imageWidth, fillMode } = args.options; + const backgroundDetails: any = {}; + + if (gradientText) { + backgroundDetails.type = "gradient"; + backgroundDetails.gradient = gradientText; + } + + if (imageUrl) { + backgroundDetails.type = "image"; + backgroundDetails.imageData = { + source: 2, + fileName: "sectionbackground.jpg", + height: imageHeight ? imageHeight : 955, + width: imageWidth ? imageWidth : 555 + }; + backgroundDetails.fillMode = fillMode ? this.fillMode.indexOf(fillMode) : 0; + } + + return backgroundDetails; + } } export default new SpoPageSectionAddCommand(); \ No newline at end of file diff --git a/src/m365/spo/commands/site/SiteAdmin.ts b/src/m365/spo/commands/site/SiteAdmin.ts new file mode 100644 index 00000000000..e11823751e4 --- /dev/null +++ b/src/m365/spo/commands/site/SiteAdmin.ts @@ -0,0 +1,49 @@ +export interface AdminUserResult { + email: string; + loginName: string; + name: string; + userPrincipalName: string; +} + +export interface AdminResult { + value: AdminUserResult[]; +} + +export interface SiteUserResult { + Email: string; + Id: number; + IsSiteAdmin: boolean; + LoginName: string; + PrincipalType: number; + Title: string; +} + +export interface SiteResult { + value: SiteUserResult[]; +} + +export interface AdminCommandResultItem { + Id: number | null; + Email: string; + IsPrimaryAdmin: boolean; + LoginName: string; + Title: string; + PrincipalType: number | null; + PrincipalTypeString: string | null; +} + +export interface IGraphUser { + userPrincipalName: string; +} + +export interface ISiteOwner { + LoginName: string; +} + +export interface ISPSite { + Id: string; +} + +export interface ISiteUser { + Id: number; +} diff --git a/src/m365/spo/commands/site/SiteProperties.ts b/src/m365/spo/commands/site/SiteProperties.ts index 481b10be257..fb37898a84b 100644 --- a/src/m365/spo/commands/site/SiteProperties.ts +++ b/src/m365/spo/commands/site/SiteProperties.ts @@ -2,5 +2,5 @@ export interface SiteProperties { Status: string; Title: string; Url: string; - SiteId: string; + SiteId?: string; } \ No newline at end of file diff --git a/src/m365/spo/commands/site/site-admin-add.spec.ts b/src/m365/spo/commands/site/site-admin-add.spec.ts new file mode 100644 index 00000000000..84e09a726ca --- /dev/null +++ b/src/m365/spo/commands/site/site-admin-add.spec.ts @@ -0,0 +1,558 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { cli } from '../../../../cli/cli.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command from './site-admin-add.js'; +import { spo } from '../../../../utils/spo.js'; +import { CommandError } from '../../../../Command.js'; +import config from '../../../../config.js'; +import { entraUser } from '../../../../utils/entraUser.js'; +import { entraGroup } from '../../../../utils/entraGroup.js'; + +describe(commands.SITE_ADMIN_ADD, () => { + let log: any[]; + let logger: Logger; + let commandInfo: CommandInfo; + + const listOfAdminsFromAdminSource = [ + { + email: 'user1Email@email.com', + loginName: 'i:0#.f|membership|user1loginName@email.com', + name: 'user1DisplayName', + userPrincipalName: 'user1loginName' + }, + { + email: 'user2Email@email.com', + loginName: 'i:0#.f|membership|user2loginName@email.com', + name: 'user2DisplayName', + userPrincipalName: 'user2loginName' + } + ]; + const rootUrl = 'https://contoso.sharepoint.com'; + const adminUrl = 'https://contoso-admin.sharepoint.com'; + const siteUrl = 'https://contoso.sharepoint.com/sites/site'; + const siteId = '00000000-0000-0000-0000-000000000010'; + const adminToAddId = '10000000-1000-0000-0000-000000000000'; + const adminToAddUPN = 'user3loginName@email.com'; + const primaryAdminLoginName = 'i:0#.f|membership|userPrimaryAdminEmail@email.com'; + const groupId = '00000000-1000-0000-0000-000000000000'; + const groupName = 'TestGroupName'; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + sinon.stub(spo, 'getRequestDigest').resolves({ + FormDigestValue: 'abc', + FormDigestTimeoutSeconds: 1800, + FormDigestExpiresAt: new Date(), + WebFullUrl: 'https://contoso.sharepoint.com' + }); + auth.connection.active = true; + auth.connection.spoUrl = 'https://contoso.sharepoint.com'; + commandInfo = cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + }); + + afterEach(() => { + sinonUtil.restore([ + request.get, + request.post, + request.patch, + entraGroup.getGroupById, + entraGroup.getGroupByDisplayName, + entraUser.getUpnByUserId + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + auth.connection.spoUrl = undefined; + }); + + it('fails validation if siteUrl is not a valid SharePoint URL', async () => { + const actual = await command.validate({ options: { siteUrl: 'foo', userId: adminToAddId } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if userId is not a valid GUID', async () => { + const actual = await command.validate({ options: { siteUrl: siteUrl, userId: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if userName is not a valid UPN', async () => { + const actual = await command.validate({ options: { siteUrl: siteUrl, userName: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if groupId is not a valid GUID', async () => { + const actual = await command.validate({ options: { siteUrl: siteUrl, groupId: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation when the siteUrl is a valid SharePoint URL and userId is a valid GUID', async () => { + const actual = await command.validate({ options: { siteUrl: siteUrl, userId: adminToAddId } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when the siteUrl is a valid SharePoint URL and userName is a valid UPN', async () => { + const actual = await command.validate({ options: { siteUrl: siteUrl, userName: adminToAddUPN } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('adds a user to site collection admins by userId as admin', async () => { + sinon.stub(entraUser, 'getUpnByUserId').resolves(adminToAddUPN); + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/sites/root?$select=webUrl`) { + return { res: { webUrl: rootUrl } }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/site?$select=id`) { + return { id: `contoso.sharepoint.com,${siteId},fb0a066f-c10f-4734-94d1-f896de4aa484` }; + } + + throw 'Invalid request: ' + opts.url; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/GetSiteAdministrators?siteId='${siteId}'`) { + return JSON.stringify({ + value: listOfAdminsFromAdminSource + }); + } + + if (opts.url === `${adminUrl}/_api/SPOInternalUseOnly.Tenant/SetSiteSecondaryAdministrators`) { + return; + } + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { siteUrl: siteUrl, userId: adminToAddId, asAdmin: true, verbose: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { + secondaryAdministratorsFieldsData: { + siteId: siteId, secondaryAdministratorLoginNames: + ['i:0#.f|membership|user1loginName@email.com', 'i:0#.f|membership|user2loginName@email.com', `i:0#.f|membership|${adminToAddUPN}`] + } + }); + }); + + it('adds a user as primary site collection admins by userName as admin', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/sites/root?$select=webUrl`) { + return { res: { webUrl: rootUrl } }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/users('user3loginName%40email.com')`) { + return { userPrincipalName: adminToAddUPN }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/site?$select=id`) { + return { id: `contoso.sharepoint.com,${siteId},fb0a066f-c10f-4734-94d1-f896de4aa484` }; + } + + throw 'Invalid request: ' + opts.url; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/GetSiteAdministrators?siteId='${siteId}'`) { + return JSON.stringify({ + value: listOfAdminsFromAdminSource + }); + } + + if (opts.url === `${adminUrl}/_api/SPOInternalUseOnly.Tenant/SetSiteSecondaryAdministrators`) { + return; + } + + throw 'Invalid request: ' + opts.url; + }); + + const patchStub = sinon.stub(request, 'patch').callsFake(async opts => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites('${siteId}')`) { + return; + } + + throw `Invalid PATCH request: ${JSON.stringify(opts, null, 2)}`; + }); + + await command.action(logger, { options: { siteUrl: siteUrl, userName: adminToAddUPN, asAdmin: true, primary: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { + secondaryAdministratorsFieldsData: { + siteId: siteId, secondaryAdministratorLoginNames: + ['i:0#.f|membership|user1loginName@email.com', 'i:0#.f|membership|user2loginName@email.com', `i:0#.f|membership|${adminToAddUPN}`] + } + }); + assert.deepStrictEqual(patchStub.lastCall.args[0].data, { + Owner: `i:0#.f|membership|${adminToAddUPN}`, + SetOwnerWithoutUpdatingSecondaryAdmin: true + }); + }); + + it('adds a group to site collection admin by groupId as admin - for M365 Group', async () => { + sinon.stub(entraGroup, 'getGroupById').resolves({ + mail: 'mail', + id: groupId + }); + + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/sites/root?$select=webUrl`) { + return { res: { webUrl: rootUrl } }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/site?$select=id`) { + return { id: `contoso.sharepoint.com,${siteId},fb0a066f-c10f-4734-94d1-f896de4aa484` }; + } + + throw 'Invalid request: ' + opts.url; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/GetSiteAdministrators?siteId='${siteId}'`) { + return JSON.stringify({ + value: listOfAdminsFromAdminSource + }); + } + + if (opts.url === `${adminUrl}/_api/SPOInternalUseOnly.Tenant/SetSiteSecondaryAdministrators`) { + return; + } + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { siteUrl: siteUrl, groupId: groupId, asAdmin: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { + secondaryAdministratorsFieldsData: { + siteId: siteId, secondaryAdministratorLoginNames: + ['i:0#.f|membership|user1loginName@email.com', 'i:0#.f|membership|user2loginName@email.com', `c:0o.c|federateddirectoryclaimprovider|${groupId}`] + } + }); + }); + + it('adds a group to site collection admin by groupId as admin - for Security Group', async () => { + sinon.stub(entraGroup, 'getGroupById').resolves({ + mail: undefined, + id: groupId + }); + + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/sites/root?$select=webUrl`) { + return { res: { webUrl: rootUrl } }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/site?$select=id`) { + return { id: `contoso.sharepoint.com,${siteId},fb0a066f-c10f-4734-94d1-f896de4aa484` }; + } + + throw 'Invalid request: ' + opts.url; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/GetSiteAdministrators?siteId='${siteId}'`) { + return JSON.stringify({ + value: listOfAdminsFromAdminSource + }); + } + + if (opts.url === `${adminUrl}/_api/SPOInternalUseOnly.Tenant/SetSiteSecondaryAdministrators`) { + return; + } + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { siteUrl: siteUrl, groupId: groupId, asAdmin: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { + secondaryAdministratorsFieldsData: { + siteId: siteId, secondaryAdministratorLoginNames: + ['i:0#.f|membership|user1loginName@email.com', 'i:0#.f|membership|user2loginName@email.com', `c:0t.c|tenant|${groupId}`] + } + }); + }); + + it('adds a group to site collection admin by groupName as admin', async () => { + sinon.stub(entraGroup, 'getGroupByDisplayName').resolves( + { + mail: undefined, + id: groupId + } + ); + + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/sites/root?$select=webUrl`) { + return { res: { webUrl: rootUrl } }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/site?$select=id`) { + return { id: `contoso.sharepoint.com,${siteId},fb0a066f-c10f-4734-94d1-f896de4aa484` }; + } + + throw 'Invalid request: ' + opts.url; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/GetSiteAdministrators?siteId='${siteId}'`) { + return JSON.stringify({ + value: listOfAdminsFromAdminSource + }); + } + + if (opts.url === `${adminUrl}/_api/SPOInternalUseOnly.Tenant/SetSiteSecondaryAdministrators`) { + return; + } + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { siteUrl: siteUrl, groupName: groupName, asAdmin: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { + secondaryAdministratorsFieldsData: { + siteId: siteId, secondaryAdministratorLoginNames: + ['i:0#.f|membership|user1loginName@email.com', 'i:0#.f|membership|user2loginName@email.com', `c:0t.c|tenant|${groupId}`] + } + }); + }); + + it('adds a group as primary site collection admins by userName as admin', async () => { + sinon.stub(entraGroup, 'getGroupByDisplayName').resolves( + { + mail: undefined, + id: groupId + } + ); + + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/sites/root?$select=webUrl`) { + return { res: { webUrl: rootUrl } }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/site?$select=id`) { + return { id: `contoso.sharepoint.com,${siteId},fb0a066f-c10f-4734-94d1-f896de4aa484` }; + } + + throw 'Invalid request: ' + opts.url; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/GetSiteAdministrators?siteId='${siteId}'`) { + return JSON.stringify({ + value: listOfAdminsFromAdminSource + }); + } + + if (opts.url === `${adminUrl}/_api/SPOInternalUseOnly.Tenant/SetSiteSecondaryAdministrators`) { + return; + } + + throw 'Invalid request: ' + opts.url; + }); + + const patchStub = sinon.stub(request, 'patch').callsFake(async opts => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites('${siteId}')`) { + return; + } + + throw `Invalid PATCH request: ${JSON.stringify(opts, null, 2)}`; + }); + + await command.action(logger, { options: { siteUrl: siteUrl, groupName: groupName, asAdmin: true, primary: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { + secondaryAdministratorsFieldsData: { + siteId: siteId, secondaryAdministratorLoginNames: + ['i:0#.f|membership|user1loginName@email.com', 'i:0#.f|membership|user2loginName@email.com', `c:0t.c|tenant|${groupId}`] + } + }); + assert.deepStrictEqual(patchStub.lastCall.args[0].data, { + Owner: `c:0t.c|tenant|${groupId}`, + SetOwnerWithoutUpdatingSecondaryAdmin: true + }); + }); + + it('adds a user to site collection admins by userId', async () => { + sinon.stub(entraUser, 'getUpnByUserId').resolves(adminToAddUPN); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `${siteUrl}/_api/web/siteusers('i%3A0%23.f%7Cmembership%7Cuser3loginName%40email.com')`) { + return; + } + + if (opts.url === `${siteUrl}/_api/web/ensureuser` && opts.data.logonName === `i:0#.f|membership|${adminToAddUPN}`) { + return { LoginName: `i:0#.f|membership|${adminToAddUPN}` }; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { siteUrl: siteUrl, userId: adminToAddId, verbose: true } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { IsSiteAdmin: true }); + }); + + it('adds a user to site collection admins by userName', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('user3loginName%40email.com')`) { + return { userPrincipalName: adminToAddUPN }; + } + + throw 'Invalid request: ' + opts.url; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `${siteUrl}/_api/web/siteusers('i%3A0%23.f%7Cmembership%7Cuser3loginName%40email.com')`) { + return; + } + + if (opts.url === `${siteUrl}/_api/web/ensureuser` && opts.data.logonName === `i:0#.f|membership|${adminToAddUPN}`) { + return { LoginName: `i:0#.f|membership|${adminToAddUPN}` }; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { siteUrl: siteUrl, userName: adminToAddUPN } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { IsSiteAdmin: true }); + }); + + it('adds a group to site collection admin by groupId', async () => { + sinon.stub(entraGroup, 'getGroupById').resolves({ + mail: 'mail', + id: groupId + }); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `${siteUrl}/_api/web/siteusers('c%3A0o.c%7Cfederateddirectoryclaimprovider%7C${groupId}')`) { + return; + } + + if (opts.url === `${siteUrl}/_api/web/ensureuser` && opts.data.logonName === `c:0o.c|federateddirectoryclaimprovider|${groupId}`) { + return { LoginName: `c:0o.c|federateddirectoryclaimprovider|${groupId}` }; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { siteUrl: siteUrl, groupId: groupId } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { IsSiteAdmin: true }); + }); + + it('adds a group to site collection admin by groupName', async () => { + sinon.stub(entraGroup, 'getGroupByDisplayName').resolves( + { + mail: undefined, + id: groupId + } + ); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + if (opts.url === `${siteUrl}/_api/web/siteusers('c%3A0t.c%7Ctenant%7C00000000-1000-0000-0000-000000000000')`) { + return; + } + + if (opts.url === `${siteUrl}/_api/web/ensureuser` && opts.data.logonName === `c:0t.c|tenant|${groupId}`) { + return { LoginName: `c:0t.c|tenant|${groupId}` }; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { siteUrl: siteUrl, groupName: groupName } }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { IsSiteAdmin: true }); + }); + + it('adds a user as primary site collection admins by userId', async () => { + sinon.stub(entraUser, 'getUpnByUserId').resolves(adminToAddUPN); + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `${siteUrl}/_api/site?$select=Id`) { + return { Id: siteId }; + } + + if (opts.url === `${siteUrl}/_api/site/owner?$select=LoginName`) { + return { LoginName: primaryAdminLoginName }; + } + + throw 'Invalid request: ' + opts.url; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async opts => { + const userId = 5; + if (opts.url === `${siteUrl}/_api/web/siteusers('i%3A0%23.f%7Cmembership%7Cuser3loginName%40email.com')`) { + return; + } + + if (opts.url === `${siteUrl}/_api/web/siteusers('i%3A0%23.f%7Cmembership%7CuserPrimaryAdminEmail%40email.com')`) { + return; + } + + if (opts.url === `${siteUrl}/_api/web/ensureuser` && opts.data.logonName === `i:0#.f|membership|${adminToAddUPN}`) { + return { LoginName: `i:0#.f|membership|${adminToAddUPN}`, Id: userId }; + } + + if (opts.url === `${siteUrl}/_vti_bin/client.svc/ProcessQuery` && + opts.data === `` + ) { + return; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { siteUrl: siteUrl, userId: adminToAddId, primary: true } }); + assert.deepStrictEqual(postStub.getCall(postStub.callCount - 1).args[0].data, { IsSiteAdmin: true }); + assert.deepStrictEqual(postStub.lastCall.args[0].data, { IsSiteAdmin: true }); + }); + + it('correctly handles error when site id is not found for specified site URL in admin mode', async () => { + sinon.stub(entraUser, 'getUpnByUserId').resolves(adminToAddUPN); + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/sites/root?$select=webUrl`) { + return { res: { webUrl: rootUrl } }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/site?$select=id`) { + return { id: 'Incorrect ID' }; + } + + throw 'Invalid request: ' + opts.url; + }); + + await assert.rejects(command.action(logger, { options: { siteUrl: siteUrl, userId: adminToAddId, asAdmin: true } }), + new CommandError(`Site with URL ${siteUrl} not found`)); + }); + + it('correctly handles error when user is not found userId admin mode', async () => { + sinon.stub(entraUser, 'getUpnByUserId').resolves(undefined); + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/sites/root?$select=webUrl`) { + return { res: { webUrl: rootUrl } }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/site?$select=id`) { + return { id: 'Incorrect ID' }; + } + + throw 'Invalid request: ' + opts.url; + }); + + await assert.rejects(command.action(logger, { options: { siteUrl: siteUrl, userId: adminToAddId, asAdmin: true } }), new CommandError(`User not found.`)); + }); +}); \ No newline at end of file diff --git a/src/m365/spo/commands/site/site-admin-add.ts b/src/m365/spo/commands/site/site-admin-add.ts new file mode 100644 index 00000000000..8121b7d4f9b --- /dev/null +++ b/src/m365/spo/commands/site/site-admin-add.ts @@ -0,0 +1,317 @@ +import { Logger } from '../../../../cli/Logger.js'; +import config from '../../../../config.js'; +import GlobalOptions from '../../../../GlobalOptions.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { entraGroup } from '../../../../utils/entraGroup.js'; +import { entraUser } from '../../../../utils/entraUser.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { FormDigestInfo, spo } from '../../../../utils/spo.js'; +import { validation } from '../../../../utils/validation.js'; +import SpoCommand from '../../../base/SpoCommand.js'; +import commands from '../../commands.js'; +import { AdminResult, AdminUserResult, ISiteOwner, ISiteUser, ISPSite } from './SiteAdmin.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + siteUrl: string; + userId?: string; + userName?: string; + groupId?: string; + groupName?: string; + primary?: boolean; + asAdmin?: boolean; +} + +class SpoSiteAdminAddCommand extends SpoCommand { + public get name(): string { + return commands.SITE_ADMIN_ADD; + } + + public get description(): string { + return 'Adds a user or group as a site collection administrator'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + this.#initOptionSets(); + this.#initTypes(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + userId: typeof args.options.userId !== 'undefined', + userName: typeof args.options.userName !== 'undefined', + groupId: typeof args.options.groupId !== 'undefined', + groupName: typeof args.options.groupName !== 'undefined', + primary: !!args.options.primary, + asAdmin: !!args.options.asAdmin + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-u, --siteUrl ' + }, + { + option: '--userId [userId]' + }, + { + option: '--userName [userName]' + }, + { + option: '--groupId [groupId]' + }, + { + option: '--groupName [groupName]' + }, + { + option: '--primary' + }, + { + option: '--asAdmin' + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.userId && + !validation.isValidGuid(args.options.userId)) { + return `'${args.options.userId}' is not a valid GUID for option 'userId'`; + } + + if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { + return `'${args.options.userName}' is not a valid 'userName'`; + } + + if (args.options.groupId && + !validation.isValidGuid(args.options.groupId)) { + return `'${args.options.groupId}' is not a valid GUID for option 'groupId'`; + } + + return validation.isValidSharePointUrl(args.options.siteUrl); + } + ); + } + + #initOptionSets(): void { + this.optionSets.push({ options: ['userId', 'userName', 'groupId', 'groupName'] }); + } + + #initTypes(): void { + this.types.string.push('siteUrl', 'userId', 'userName', 'groupId', 'groupName'); + this.types.boolean.push('primary', 'asAdmin'); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + const loginNameToAdd = await this.getCorrectLoginName(args.options); + if (args.options.asAdmin) { + await this.callActionAsAdmin(logger, args, loginNameToAdd); + return; + } + + await this.callAction(logger, args, loginNameToAdd); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private async callActionAsAdmin(logger: Logger, args: CommandArgs, loginNameToAdd: string): Promise { + if (this.verbose) { + await logger.logToStderr('Adding site administrator as an administrator...'); + } + + const adminUrl: string = await spo.getSpoAdminUrl(logger, this.debug); + const siteId = await this.getSiteIdBasedOnUrl(args.options.siteUrl, logger); + const siteAdmins = (await this.getSiteAdmins(adminUrl, siteId)).map(u => u.loginName); + siteAdmins.push(loginNameToAdd); + await this.setSiteAdminsAsAdmin(adminUrl, siteId, siteAdmins); + if (args.options.primary) { + await this.setPrimaryAdminAsAdmin(adminUrl, siteId, loginNameToAdd); + } + } + + private async getSiteIdBasedOnUrl(siteUrl: string, logger: Logger): Promise { + const siteGraphId = await spo.getSiteId(siteUrl, logger, this.verbose); + const match = siteGraphId.match(/,([a-f0-9\-]{36}),/i); + if (!match) { + throw `Site with URL ${siteUrl} not found`; + } + + return match[1]; + } + + private async getSiteAdmins(adminUrl: string, siteId: string): Promise { + const requestOptions: CliRequestOptions = { + url: `${adminUrl}/_api/SPO.Tenant/GetSiteAdministrators?siteId='${siteId}'`, + headers: { + accept: 'application/json;odata=nometadata', + 'content-type': 'application/json;charset=utf-8' + } + }; + + const response: string = await request.post(requestOptions); + const responseContent: AdminResult = JSON.parse(response); + return responseContent.value; + } + + private async getCorrectLoginName(options: Options): Promise { + if (options.userId || options.userName) { + const userPrincipalName = options.userName ? options.userName : await entraUser.getUpnByUserId(options.userId!); + + if (userPrincipalName) { + return `i:0#.f|membership|${userPrincipalName}`; + } + + throw 'User not found.'; + } + else { + const group = options.groupId ? await entraGroup.getGroupById(options.groupId) : await entraGroup.getGroupByDisplayName(options.groupName!); + //for entra groups, M365 groups have an associated email and security groups don't + if (group?.mail) { + //M365 group is prefixed with c:0o.c|federateddirectoryclaimprovider + return `c:0o.c|federateddirectoryclaimprovider|${group.id}`; + } + else { + //security group is prefixed with c:0t.c|tenant + return `c:0t.c|tenant|${group?.id}`; + } + } + } + + private async setSiteAdminsAsAdmin(adminUrl: string, siteId: string, admins: string[]): Promise { + const requestOptions: CliRequestOptions = { + url: `${adminUrl}/_api/SPOInternalUseOnly.Tenant/SetSiteSecondaryAdministrators`, + headers: { + accept: 'application/json;odata=nometadata', + 'content-type': 'application/json;charset=utf-8' + }, + data: { + secondaryAdministratorsFieldsData: { + siteId: siteId, + secondaryAdministratorLoginNames: + admins + } + } + }; + + return request.post(requestOptions); + } + + private async setPrimaryAdminAsAdmin(adminUrl: string, siteId: string, adminLogin: string): Promise { + const requestOptions: CliRequestOptions = { + url: `${adminUrl}/_api/SPO.Tenant/sites('${siteId}')`, + headers: { + accept: 'application/json;odata=nometadata', + 'content-type': 'application/json;charset=utf-8' + }, + data: { + Owner: adminLogin, + SetOwnerWithoutUpdatingSecondaryAdmin: true + } + }; + + return request.patch(requestOptions); + } + + private async callAction(logger: Logger, args: CommandArgs, loginNameToAdd: string): Promise { + if (this.verbose) { + await logger.logToStderr('Adding site administrator...'); + } + + const ensuredUserData = await this.ensureUser(args, loginNameToAdd); + await this.setSiteAdmin(args.options.siteUrl, loginNameToAdd); + + if (args.options.primary) { + const siteId = await this.getSiteId(args.options.siteUrl); + const previousPrimaryOwner = await this.getSiteOwnerLoginName(args.options.siteUrl); + await this.setPrimaryOwnerLoginFromSite(logger, args.options.siteUrl, siteId, ensuredUserData); + await this.setSiteAdmin(args.options.siteUrl, previousPrimaryOwner); + } + } + + private async ensureUser(args: CommandArgs, loginName: string): Promise { + const requestOptions: CliRequestOptions = { + url: `${args.options.siteUrl}/_api/web/ensureuser`, + headers: { + accept: 'application/json;odata=nometadata' + }, + data: { + logonName: loginName + }, + responseType: 'json' + }; + + return request.post(requestOptions); + } + + private async setSiteAdmin(siteUrl: string, loginName: string): Promise { + const requestOptions: CliRequestOptions = { + url: `${siteUrl}/_api/web/siteusers('${formatting.encodeQueryParameter(loginName)}')`, + headers: { + 'accept': 'application/json', + 'X-Http-Method': 'MERGE', + 'If-Match': '*' + }, + data: { IsSiteAdmin: true }, + responseType: 'json' + }; + return request.post(requestOptions); + } + + private async getSiteId(siteUrl: string): Promise { + const requestOptions: CliRequestOptions = { + url: `${siteUrl}/_api/site?$select=Id`, + headers: { + accept: 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + const response = await request.get(requestOptions); + return response.Id; + } + + private async getSiteOwnerLoginName(siteUrl: string): Promise { + const requestOptions: CliRequestOptions = { + url: `${siteUrl}/_api/site/owner?$select=LoginName`, + headers: { + accept: 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + const response = await request.get(requestOptions); + return response.LoginName; + } + + private async setPrimaryOwnerLoginFromSite(logger: Logger, siteUrl: string, siteId: string, loginName: ISiteUser): Promise { + const res: FormDigestInfo = await spo.ensureFormDigest(siteUrl, logger, undefined, this.debug); + const body = ``; + + const requestOptions: CliRequestOptions = { + url: `${siteUrl}/_vti_bin/client.svc/ProcessQuery`, + headers: { + 'X-RequestDigest': res.FormDigestValue + }, + data: body + }; + + return request.post(requestOptions); + } +} + +export default new SpoSiteAdminAddCommand(); \ No newline at end of file diff --git a/src/m365/spo/commands/site/site-admin-list.spec.ts b/src/m365/spo/commands/site/site-admin-list.spec.ts index 705dcd0573c..475cbb601e9 100644 --- a/src/m365/spo/commands/site/site-admin-list.spec.ts +++ b/src/m365/spo/commands/site/site-admin-list.spec.ts @@ -140,7 +140,9 @@ describe(commands.SITE_ADMIN_LIST, () => { sinonUtil.restore([ request.get, request.post, - cli.getSettingWithDefaultValue + cli.getSettingWithDefaultValue, + spo.getPrimaryAdminLoginNameAsAdmin, + spo.getPrimaryOwnerLoginFromSite ]); }); @@ -159,15 +161,12 @@ describe(commands.SITE_ADMIN_LIST, () => { }); it('gets site collection admins in regular mode', async () => { + sinon.stub(spo, 'getPrimaryOwnerLoginFromSite').resolves(primaryAdminLoginName); sinon.stub(request, 'get').callsFake(async opts => { if (opts.url === `${siteUrl}/_api/web/siteusers?$filter=IsSiteAdmin eq true`) { return { value: listOfAdminsFromSiteSource }; } - if (opts.url === `${siteUrl}/_api/site/owner`) { - return { LoginName: primaryAdminLoginName }; - } - throw 'Invalid request: ' + opts.url; }); @@ -176,6 +175,7 @@ describe(commands.SITE_ADMIN_LIST, () => { }); it('gets site collection admins in admin mode', async () => { + sinon.stub(spo, 'getPrimaryAdminLoginNameAsAdmin').resolves(primaryAdminLoginName); sinon.stub(request, 'get').callsFake(async opts => { if (opts.url === `https://graph.microsoft.com/v1.0/sites/root?$select=webUrl`) { return { res: { webUrl: rootUrl } }; @@ -187,10 +187,6 @@ describe(commands.SITE_ADMIN_LIST, () => { }); } - if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites('${siteId}')?$select=OwnerLoginName`) { - return JSON.stringify({ OwnerLoginName: primaryAdminLoginName }); - } - if (opts.url === `https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/site?$select=id`) { return { id: `contoso.sharepoint.com,${siteId},fb0a066f-c10f-4734-94d1-f896de4aa484` }; } @@ -213,15 +209,12 @@ describe(commands.SITE_ADMIN_LIST, () => { }); it('correctly handles empty list of site collection admins from API in regular mode', async () => { + sinon.stub(spo, 'getPrimaryOwnerLoginFromSite').resolves(undefined); sinon.stub(request, 'get').callsFake(async opts => { if (opts.url === `${siteUrl}/_api/web/siteusers?$filter=IsSiteAdmin eq true`) { return { value: [] }; } - if (opts.url === `${siteUrl}/_api/site/owner`) { - return null; - } - throw 'Invalid request: ' + opts.url; }); @@ -236,6 +229,7 @@ describe(commands.SITE_ADMIN_LIST, () => { }); it('correctly handles empty list of site collection admins from API in admin mode', async () => { + sinon.stub(spo, 'getPrimaryAdminLoginNameAsAdmin').resolves(''); sinon.stub(request, 'get').callsFake(async opts => { if (opts.url === `https://graph.microsoft.com/v1.0/sites/root?$select=webUrl`) { return { res: { webUrl: rootUrl } }; @@ -247,10 +241,6 @@ describe(commands.SITE_ADMIN_LIST, () => { }); } - if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites('${siteId}')?$select=OwnerLoginName`) { - return JSON.stringify({ OwnerLoginName: '' }); - } - if (opts.url === `https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/site?$select=id`) { return { id: `contoso.sharepoint.com,${siteId},fb0a066f-c10f-4734-94d1-f896de4aa484` }; } @@ -273,22 +263,20 @@ describe(commands.SITE_ADMIN_LIST, () => { }); it('handles error when primary admin API returns error in regular mode', async () => { + sinon.stub(spo, 'getPrimaryOwnerLoginFromSite').throws("Invalid request"); sinon.stub(request, 'get').callsFake(async opts => { if (opts.url === `${siteUrl}/_api/web/siteusers?$filter=IsSiteAdmin eq true`) { return { value: listOfAdminsFromSiteSource }; } - if (opts.url === `${siteUrl}/_api/site/owner`) { - throw "Invalid request"; - } - throw 'Invalid request: ' + opts.url; }); - await assert.rejects(command.action(logger, { options: { siteUrl: siteUrl } }), new CommandError('Invalid request')); + await assert.rejects(command.action(logger, { options: { siteUrl: siteUrl } }), new CommandError('Sinon-provided Invalid request')); }); it('handles error when primary admin API returns error in admin mode', async () => { + sinon.stub(spo, 'getPrimaryAdminLoginNameAsAdmin').throws("Invalid request"); sinon.stub(request, 'get').callsFake(async opts => { if (opts.url === `https://graph.microsoft.com/v1.0/sites/root?$select=webUrl`) { return { res: { webUrl: rootUrl } }; @@ -300,10 +288,6 @@ describe(commands.SITE_ADMIN_LIST, () => { }); } - if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites('${siteId}')?$select=OwnerLoginName`) { - throw "Invalid request"; - } - if (opts.url === `https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/site?$select=id`) { return { id: `contoso.sharepoint.com,${siteId},fb0a066f-c10f-4734-94d1-f896de4aa484` }; } @@ -321,10 +305,11 @@ describe(commands.SITE_ADMIN_LIST, () => { throw 'Invalid request: ' + opts.url; }); - await assert.rejects(command.action(logger, { options: { siteUrl: siteUrl, asAdmin: true } }), new CommandError('Invalid request')); + await assert.rejects(command.action(logger, { options: { siteUrl: siteUrl, asAdmin: true } }), new CommandError('Sinon-provided Invalid request')); }); it('handles error when returned siteId is incorrect in admin mode', async () => { + sinon.stub(spo, 'getPrimaryAdminLoginNameAsAdmin').resolves("Invalid request"); sinon.stub(request, 'get').callsFake(async opts => { if (opts.url === `https://graph.microsoft.com/v1.0/sites/root?$select=webUrl`) { return { res: { webUrl: rootUrl } }; @@ -336,10 +321,6 @@ describe(commands.SITE_ADMIN_LIST, () => { }); } - if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites('${siteId}')?$select=OwnerLoginName`) { - throw "Invalid request"; - } - if (opts.url === `https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/site?$select=id`) { return { id: 'Incorrect ID' }; } @@ -404,15 +385,12 @@ describe(commands.SITE_ADMIN_LIST, () => { }); it('get additional log when verbose parameter is set in regular mode', async () => { + sinon.stub(spo, 'getPrimaryOwnerLoginFromSite').resolves(primaryAdminLoginName); sinon.stub(request, 'get').callsFake(async opts => { if (opts.url === `${siteUrl}/_api/web/siteusers?$filter=IsSiteAdmin eq true`) { return { value: listOfAdminsFromSiteSource }; } - if (opts.url === `${siteUrl}/_api/site/owner`) { - return { LoginName: primaryAdminLoginName }; - } - throw 'Invalid request: ' + opts.url; }); @@ -421,6 +399,7 @@ describe(commands.SITE_ADMIN_LIST, () => { }); it('get additional log when verbose parameter is set in admin mode', async () => { + sinon.stub(spo, 'getPrimaryAdminLoginNameAsAdmin').resolves(primaryAdminLoginName); sinon.stub(request, 'get').callsFake(async opts => { if (opts.url === `https://graph.microsoft.com/v1.0/sites/root?$select=webUrl`) { return { res: { webUrl: rootUrl } }; @@ -432,10 +411,6 @@ describe(commands.SITE_ADMIN_LIST, () => { }); } - if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites('${siteId}')?$select=OwnerLoginName`) { - return JSON.stringify({ OwnerLoginName: primaryAdminLoginName }); - } - if (opts.url === `https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com:/sites/site?$select=id`) { return { id: `contoso.sharepoint.com,${siteId},fb0a066f-c10f-4734-94d1-f896de4aa484` }; } diff --git a/src/m365/spo/commands/site/site-admin-list.ts b/src/m365/spo/commands/site/site-admin-list.ts index 773bc7ea248..c97b37ca0a6 100644 --- a/src/m365/spo/commands/site/site-admin-list.ts +++ b/src/m365/spo/commands/site/site-admin-list.ts @@ -6,45 +6,12 @@ import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; import { ListPrincipalType } from '../list/ListPrincipalType.js'; +import { AdminResult, AdminUserResult, AdminCommandResultItem, SiteResult, SiteUserResult } from './SiteAdmin.js'; interface CommandArgs { options: Options; } -interface AdminUserResult { - email: string; - loginName: string; - name: string; - userPrincipalName: string; -} - -interface AdminResult { - value: AdminUserResult[]; -} - -interface SiteUserResult { - Email: string; - Id: number; - IsSiteAdmin: boolean; - LoginName: string; - PrincipalType: number; - Title: string; -} - -interface SiteResult { - value: SiteUserResult[]; -} - -interface CommandResultItem { - Id: number | null; - Email: string; - IsPrimaryAdmin: boolean; - LoginName: string; - Title: string; - PrincipalType: number | null; - PrincipalTypeString: string | null; -} - interface Options extends GlobalOptions { siteUrl: string; asAdmin?: boolean; @@ -71,7 +38,6 @@ class SpoSiteAdminListCommand extends SpoCommand { #initTelemetry(): void { this.telemetry.push((args: CommandArgs) => { Object.assign(this.telemetryProperties, { - siteUrl: typeof args.options.siteUrl !== 'undefined', asAdmin: !!args.options.asAdmin }); }); @@ -129,9 +95,9 @@ class SpoSiteAdminListCommand extends SpoCommand { const response: string = await request.post(requestOptions); const responseContent: AdminResult = JSON.parse(response); - const primaryAdminLoginName = await this.getPrimaryAdminLoginNameFromAdmin(adminUrl, siteId); + const primaryAdminLoginName = await spo.getPrimaryAdminLoginNameAsAdmin(adminUrl, siteId, logger, this.verbose); - const mappedResult = responseContent.value.map((u: AdminUserResult): CommandResultItem => ({ + const mappedResult = responseContent.value.map((u: AdminUserResult): AdminCommandResultItem => ({ Id: null, Email: u.email, LoginName: u.loginName, @@ -153,20 +119,6 @@ class SpoSiteAdminListCommand extends SpoCommand { return match[1]; } - private async getPrimaryAdminLoginNameFromAdmin(adminUrl: string, siteId: string): Promise { - const requestOptions: CliRequestOptions = { - url: `${adminUrl}/_api/SPO.Tenant/sites('${siteId}')?$select=OwnerLoginName`, - headers: { - accept: 'application/json;odata=nometadata', - 'content-type': 'application/json;charset=utf-8' - } - }; - - const response: string = await request.get(requestOptions); - const responseContent = JSON.parse(response); - return responseContent.OwnerLoginName; - } - private async callAction(logger: Logger, args: CommandArgs): Promise { if (this.verbose) { await logger.logToStderr('Retrieving site administrators...'); @@ -182,8 +134,8 @@ class SpoSiteAdminListCommand extends SpoCommand { }; const responseContent: SiteResult = await request.get(requestOptions); - const primaryOwnerLogin = await this.getPrimaryOwnerLoginFromSite(args.options.siteUrl); - const mappedResult = responseContent.value.map((u: SiteUserResult): CommandResultItem => ({ + const primaryOwnerLogin = await spo.getPrimaryOwnerLoginFromSite(args.options.siteUrl, logger, this.verbose); + const mappedResult = responseContent.value.map((u: SiteUserResult): AdminCommandResultItem => ({ Id: u.Id, LoginName: u.LoginName, Title: u.Title, @@ -194,20 +146,6 @@ class SpoSiteAdminListCommand extends SpoCommand { })); await logger.log(mappedResult); } - - private async getPrimaryOwnerLoginFromSite(siteUrl: string): Promise { - const requestOptions: CliRequestOptions = { - url: `${siteUrl}/_api/site/owner`, - method: 'GET', - headers: { - 'accept': 'application/json;odata=nometadata' - }, - responseType: 'json' - }; - - const responseContent = await request.get<{ LoginName: string }>(requestOptions); - return responseContent?.LoginName ?? null; - } } export default new SpoSiteAdminListCommand(); \ No newline at end of file diff --git a/src/m365/spo/commands/site/site-get.spec.ts b/src/m365/spo/commands/site/site-get.spec.ts index ac12e938a20..9505a70a2bd 100644 --- a/src/m365/spo/commands/site/site-get.spec.ts +++ b/src/m365/spo/commands/site/site-get.spec.ts @@ -1,5 +1,6 @@ import assert from 'assert'; import sinon from 'sinon'; +import { z } from 'zod'; import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; @@ -18,6 +19,7 @@ describe(commands.SITE_GET, () => { let logger: Logger; let loggerLogSpy: sinon.SinonSpy; let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; before(() => { sinon.stub(auth, 'restoreAuth').resolves(); @@ -26,6 +28,7 @@ describe(commands.SITE_GET, () => { sinon.stub(session, 'getId').returns(''); auth.connection.active = true; commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; }); beforeEach(() => { @@ -193,24 +196,13 @@ describe(commands.SITE_GET, () => { await assert.rejects(command.action(logger, { options: { debug: true, url: 'https://contoso.sharepoint.com/sites/project-x' } } as any), new CommandError('404 - "404 FILE NOT FOUND"')); }); - it('supports specifying URL', () => { - const options = command.options; - let containsTypeOption = false; - options.forEach(o => { - if (o.option.indexOf('') > -1) { - containsTypeOption = true; - } - }); - assert(containsTypeOption); - }); - it('fails validation if the url option is not a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { url: 'foo' } }, commandInfo); + const actual = commandOptionsSchema.safeParse({ url: 'foo' }); assert.notStrictEqual(actual, true); }); it('passes validation if the url option is a valid SharePoint site URL', async () => { - const actual = await command.validate({ options: { url: 'https://contoso.sharepoint.com' } }, commandInfo); - assert.strictEqual(actual, true); + const actual = commandOptionsSchema.safeParse({ url: 'https://contoso.sharepoint.com' }); + assert.strictEqual(actual.success, true); }); }); diff --git a/src/m365/spo/commands/site/site-get.ts b/src/m365/spo/commands/site/site-get.ts index 19f2b36ab49..cfea71cc08b 100644 --- a/src/m365/spo/commands/site/site-get.ts +++ b/src/m365/spo/commands/site/site-get.ts @@ -1,18 +1,25 @@ +import { z } from 'zod'; import { Logger } from '../../../../cli/Logger.js'; -import GlobalOptions from '../../../../GlobalOptions.js'; +import { globalOptionsZod } from '../../../../Command.js'; import request from '../../../../request.js'; import { validation } from '../../../../utils/validation.js'; +import { zod } from '../../../../utils/zod.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +const options = globalOptionsZod + .extend({ + url: zod.alias('u', z.string().refine(url => validation.isValidSharePointUrl(url) === true, { + message: 'Specify a valid SharePoint site URL' + })) + }) + .strict(); +declare type Options = z.infer; + interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { - url: string; -} - class SpoSiteGetCommand extends SpoCommand { public get name(): string { return commands.SITE_GET; @@ -22,23 +29,8 @@ class SpoSiteGetCommand extends SpoCommand { return 'Gets information about the specific site collection'; } - constructor() { - super(); - - this.#initOptions(); - this.#initValidators(); - } - - #initOptions(): void { - this.options.unshift( - { option: '-u, --url ' } - ); - } - - #initValidators(): void { - this.validators.push( - async (args) => validation.isValidSharePointUrl(args.options.url) - ); + public get schema(): z.ZodTypeAny | undefined { + return options; } public async commandAction(logger: Logger, args: CommandArgs): Promise { diff --git a/src/m365/spo/commands/user/user-get.spec.ts b/src/m365/spo/commands/user/user-get.spec.ts index 9e1b4ab7f30..a7fb780077e 100644 --- a/src/m365/spo/commands/user/user-get.spec.ts +++ b/src/m365/spo/commands/user/user-get.spec.ts @@ -13,8 +13,98 @@ import { sinonUtil } from '../../../../utils/sinonUtil.js'; import commands from '../../commands.js'; import command from './user-get.js'; import { settingsNames } from '../../../../settingsNames.js'; +import { formatting } from '../../../../utils/formatting.js'; describe(commands.USER_GET, () => { + const validUserName = 'john.doe_hotmail.com#ext#@contoso.onmicrosoft.com'; + const validEmail = 'john.doe@contoso.onmicrosoft.com'; + const validEntraGroupId = '2056d2f6-3257-4253-8cfc-b73393e414e5'; + const validEntraGroupName = 'Finance'; + const validEntraSecurityGroupName = 'EntraGroupTest'; + const validLoginName = `i:0#.f|membership|${validUserName}`; + const validWebUrl = 'https://contoso.sharepoint.com/subsite'; + + const userResponse = { + "Id": 10, + "IsHiddenInUI": false, + "LoginName": validLoginName, + "Title": "John Doe", + "PrincipalType": 1, + "Email": validEmail, + "Expiration": "", + "IsEmailAuthenticationGuestUser": false, + "IsShareByEmailGuestUser": false, + "IsSiteAdmin": false, + "UserId": { "NameId": "10010001b0c19a2", "NameIdIssuer": "urn:federation:microsoftonline" }, + "UserPrincipalName": validUserName + }; + + const groupM365Response = { + value: [{ + "id": "2056d2f6-3257-4253-8cfc-b73393e414e5", + "deletedDateTime": null, + "classification": null, + "createdDateTime": "2017-11-29T03:27:05Z", + "description": "This is the Contoso Finance Group. Please come here and check out the latest news, posts, files, and more.", + "displayName": "Finance", + "groupTypes": [ + "Unified" + ], + "mail": "finance@contoso.onmicrosoft.com", + "mailEnabled": true, + "mailNickname": "finance", + "onPremisesLastSyncDateTime": null, + "onPremisesProvisioningErrors": [], + "onPremisesSecurityIdentifier": null, + "onPremisesSyncEnabled": null, + "preferredDataLocation": null, + "proxyAddresses": [ + "SMTP:finance@contoso.onmicrosoft.com" + ], + "renewedDateTime": "2017-11-29T03:27:05Z", + "securityEnabled": false, + "visibility": "Public" + }] + }; + + const groupSecurityResponse = { + value: [{ + "id": "2056d2f6-3257-4253-8cfc-b73393e414e5", + "deletedDateTime": null, + "classification": null, + "createdDateTime": "2024-01-27T16:02:56Z", + "creationOptions": [], + "description": "Entra Group Test", + "displayName": "EntraGroupTest", + "expirationDateTime": null, + "groupTypes": [], + "isAssignableToRole": true, + "mail": null, + "mailEnabled": false, + "mailNickname": "f45205a2-d", + "membershipRule": null, + "membershipRuleProcessingState": null, + "onPremisesDomainName": null, + "onPremisesLastSyncDateTime": null, + "onPremisesNetBiosName": null, + "onPremisesSamAccountName": null, + "onPremisesSecurityIdentifier": null, + "onPremisesSyncEnabled": null, + "preferredDataLocation": null, + "preferredLanguage": null, + "proxyAddresses": [], + "renewedDateTime": "2024-01-27T16:02:56Z", + "resourceBehaviorOptions": [], + "resourceProvisioningOptions": [], + "securityEnabled": true, + "securityIdentifier": "S-1-12-1-1968173404-1154184881-1694549896-3083850660", + "theme": null, + "visibility": "Private", + "onPremisesProvisioningErrors": [], + "serviceProvisioningErrors": [] + }] + }; + let log: any[]; let logger: Logger; let loggerLogSpy: sinon.SinonSpy; @@ -65,26 +155,81 @@ describe(commands.USER_GET, () => { assert.notStrictEqual(command.description, null); }); - it('retrieves user by id with output option json', async () => { + it('retrieves user by id', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${validWebUrl}/_api/web/siteusers/GetById('10')`) { + return userResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + output: 'json', + debug: true, + webUrl: validWebUrl, + id: 10 + } + }); + + assert(loggerLogSpy.calledWith(userResponse)); + }); + + it('retrieves user by email', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${validWebUrl}/_api/web/siteusers/GetByEmail('${formatting.encodeQueryParameter(validEmail)}')`) { + return userResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + output: 'json', + debug: true, + webUrl: validWebUrl, + email: validEmail + } + }); + + assert(loggerLogSpy.calledWith(userResponse)); + }); + + it('retrieves user by loginName', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf('/_api/web/siteusers/GetById') > -1) { + if (opts.url === `${validWebUrl}/_api/web/siteusers/GetByLoginName('${formatting.encodeQueryParameter(validLoginName)}')`) { + return userResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + output: 'json', + debug: true, + webUrl: validWebUrl, + loginName: validLoginName + } + }); + + assert(loggerLogSpy.calledWith(userResponse)); + }); + + it('retrieves user by userName', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${validWebUrl}/_api/web/siteusers?$filter=UserPrincipalName eq ('${formatting.encodeQueryParameter(validUserName)}')`) { return { - "value": [{ - "Id": 6, - "IsHiddenInUI": false, - "LoginName": "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com", - "Title": "John Doe", - "PrincipalType": 1, - "Email": "john.deo@mytenant.onmicrosoft.com", - "Expiration": "", - "IsEmailAuthenticationGuestUser": false, - "IsShareByEmailGuestUser": false, - "IsSiteAdmin": false, - "UserId": { "NameId": "10010001b0c19a2", "NameIdIssuer": "urn:federation:microsoftonline" }, - "UserPrincipalName": "john.deo@mytenant.onmicrosoft.com" - }] + "value": [userResponse] }; } + + if (opts.url === `${validWebUrl}/_api/web/siteusers/GetById('10')`) { + return userResponse; + } + throw 'Invalid request'; }); @@ -92,48 +237,37 @@ describe(commands.USER_GET, () => { options: { output: 'json', debug: true, - webUrl: 'https://contoso.sharepoint.com', - id: 1 + webUrl: validWebUrl, + userName: validUserName } }); - assert(loggerLogSpy.calledWith({ - value: [{ - Id: 6, - IsHiddenInUI: false, - LoginName: "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com", - Title: "John Doe", - PrincipalType: 1, - Email: "john.deo@mytenant.onmicrosoft.com", - Expiration: "", - IsEmailAuthenticationGuestUser: false, - IsShareByEmailGuestUser: false, - IsSiteAdmin: false, - UserId: { NameId: "10010001b0c19a2", NameIdIssuer: "urn:federation:microsoftonline" }, - UserPrincipalName: "john.deo@mytenant.onmicrosoft.com" - }] - })); + + assert(loggerLogSpy.calledWith(userResponse)); }); - it('retrieves user by email with output option json', async () => { + it('retrieves m365 group by entraGroupId for mail enabled group', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf('/_api/web/siteusers/GetByEmail') > -1) { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${validEntraGroupId}`) { + return groupM365Response.value[0]; + } + + if (opts.url === `${validWebUrl}/_api/web/siteusers/GetByEmail('finance%40contoso.onmicrosoft.com')`) { return { - "value": [{ - "Id": 6, - "IsHiddenInUI": false, - "LoginName": "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com", - "Title": "John Doe", - "PrincipalType": 1, - "Email": "john.deo@mytenant.onmicrosoft.com", - "Expiration": "", - "IsEmailAuthenticationGuestUser": false, - "IsShareByEmailGuestUser": false, - "IsSiteAdmin": false, - "UserId": { "NameId": "10010001b0c19a2", "NameIdIssuer": "urn:federation:microsoftonline" }, - "UserPrincipalName": "john.deo@mytenant.onmicrosoft.com" - }] + "Id": 45, + "IsHiddenInUI": false, + "LoginName": "c:0o.c|federateddirectoryclaimprovider|2056d2f6-3257-4253-8cfc-b73393e414e5", + "Title": "Finance", + "PrincipalType": 4, + "Email": "finance@contoso.onmicrosoft.com", + "Expiration": "", + "IsEmailAuthenticationGuestUser": false, + "IsShareByEmailGuestUser": false, + "IsSiteAdmin": false, + "UserId": null, + "UserPrincipalName": null }; } + throw 'Invalid request'; }); @@ -141,98 +275,96 @@ describe(commands.USER_GET, () => { options: { output: 'json', debug: true, - webUrl: 'https://contoso.sharepoint.com', - email: "john.deo@mytenant.onmicrosoft.com" + webUrl: validWebUrl, + entraGroupId: validEntraGroupId } }); + assert(loggerLogSpy.calledWith({ - value: [{ - Id: 6, - IsHiddenInUI: false, - LoginName: "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com", - Title: "John Doe", - PrincipalType: 1, - Email: "john.deo@mytenant.onmicrosoft.com", - Expiration: "", - IsEmailAuthenticationGuestUser: false, - IsShareByEmailGuestUser: false, - IsSiteAdmin: false, - UserId: { NameId: "10010001b0c19a2", NameIdIssuer: "urn:federation:microsoftonline" }, - UserPrincipalName: "john.deo@mytenant.onmicrosoft.com" - }] + "Id": 45, + "IsHiddenInUI": false, + "LoginName": "c:0o.c|federateddirectoryclaimprovider|2056d2f6-3257-4253-8cfc-b73393e414e5", + "Title": "Finance", + "PrincipalType": 4, + "Email": "finance@contoso.onmicrosoft.com", + "Expiration": "", + "IsEmailAuthenticationGuestUser": false, + "IsShareByEmailGuestUser": false, + "IsSiteAdmin": false, + "UserId": null, + "UserPrincipalName": null })); }); - it('retrieves user by loginName with output option json', async () => { + it('retrieves security group by entraGroupName', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { - if ((opts.url as string).indexOf('/_api/web/siteusers/GetByLoginName') > -1) { + if (opts.url === `https://graph.microsoft.com/v1.0/groups?$filter=displayName eq '${validEntraSecurityGroupName}'`) { + return groupSecurityResponse; + } + + if (opts.url === `${validWebUrl}/_api/web/siteusers/GetByLoginName('c:0t.c|tenant|${validEntraGroupId}')`) { return { - "value": [{ - "Id": 6, - "IsHiddenInUI": false, - "LoginName": "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com", - "Title": "John Doe", - "PrincipalType": 1, - "Email": "john.deo@mytenant.onmicrosoft.com", - "Expiration": "", - "IsEmailAuthenticationGuestUser": false, - "IsShareByEmailGuestUser": false, - "IsSiteAdmin": false, - "UserId": { "NameId": "10010001b0c19a2", "NameIdIssuer": "urn:federation:microsoftonline" }, - "UserPrincipalName": "john.deo@mytenant.onmicrosoft.com" - }] + "Id": 31, + "IsHiddenInUI": false, + "LoginName": "c:0t.c|tenant|2056d2f6-3257-4253-8cfc-b73393e414e5", + "Title": "EntraGroupTest", + "PrincipalType": 4, + "Email": "", + "Expiration": "", + "IsEmailAuthenticationGuestUser": false, + "IsShareByEmailGuestUser": false, + "IsSiteAdmin": false, + "UserId": null, + "UserPrincipalName": null }; } + throw 'Invalid request'; }); await command.action(logger, { options: { output: 'json', - debug: true, - webUrl: 'https://contoso.sharepoint.com', - loginName: "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com" + webUrl: validWebUrl, + entraGroupName: validEntraSecurityGroupName } - }); + } as any); + assert(loggerLogSpy.calledWith({ - value: [{ - Id: 6, - IsHiddenInUI: false, - LoginName: "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com", - Title: "John Doe", - PrincipalType: 1, - Email: "john.deo@mytenant.onmicrosoft.com", - Expiration: "", - IsEmailAuthenticationGuestUser: false, - IsShareByEmailGuestUser: false, - IsSiteAdmin: false, - UserId: { NameId: "10010001b0c19a2", NameIdIssuer: "urn:federation:microsoftonline" }, - UserPrincipalName: "john.deo@mytenant.onmicrosoft.com" - }] + "Id": 31, + "IsHiddenInUI": false, + "LoginName": "c:0t.c|tenant|2056d2f6-3257-4253-8cfc-b73393e414e5", + "Title": "EntraGroupTest", + "PrincipalType": 4, + "Email": "", + "Expiration": "", + "IsEmailAuthenticationGuestUser": false, + "IsShareByEmailGuestUser": false, + "IsSiteAdmin": false, + "UserId": null, + "UserPrincipalName": null })); }); - it('retrieves current logged-in user', async () => { sinon.stub(request, 'get').callsFake(async (opts) => { if (opts.url === 'https://contoso.sharepoint.com/_api/web/currentuser') { return { - "value": [{ - "Id": 6, - "IsHiddenInUI": false, - "LoginName": "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com", - "Title": "John Doe", - "PrincipalType": 1, - "Email": "john.deo@mytenant.onmicrosoft.com", - "Expiration": "", - "IsEmailAuthenticationGuestUser": false, - "IsShareByEmailGuestUser": false, - "IsSiteAdmin": false, - "UserId": { "NameId": "10010001b0c19a2", "NameIdIssuer": "urn:federation:microsoftonline" }, - "UserPrincipalName": "john.deo@mytenant.onmicrosoft.com" - }] + "Id": 6, + "IsHiddenInUI": false, + "LoginName": "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com", + "Title": "John Doe", + "PrincipalType": 1, + "Email": "john.doe@mytenant.onmicrosoft.com", + "Expiration": "", + "IsEmailAuthenticationGuestUser": false, + "IsShareByEmailGuestUser": false, + "IsSiteAdmin": false, + "UserId": { "NameId": "10010001b0c19a2", "NameIdIssuer": "urn:federation:microsoftonline" }, + "UserPrincipalName": "john.doe@mytenant.onmicrosoft.com" }; } + throw 'Invalid request'; }); @@ -241,24 +373,42 @@ describe(commands.USER_GET, () => { webUrl: 'https://contoso.sharepoint.com' } }); + assert(loggerLogSpy.calledWith({ - value: [{ - Id: 6, - IsHiddenInUI: false, - LoginName: "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com", - Title: "John Doe", - PrincipalType: 1, - Email: "john.deo@mytenant.onmicrosoft.com", - Expiration: "", - IsEmailAuthenticationGuestUser: false, - IsShareByEmailGuestUser: false, - IsSiteAdmin: false, - UserId: { NameId: "10010001b0c19a2", NameIdIssuer: "urn:federation:microsoftonline" }, - UserPrincipalName: "john.deo@mytenant.onmicrosoft.com" - }] + Id: 6, + IsHiddenInUI: false, + LoginName: "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com", + Title: "John Doe", + PrincipalType: 1, + Email: "john.doe@mytenant.onmicrosoft.com", + Expiration: "", + IsEmailAuthenticationGuestUser: false, + IsShareByEmailGuestUser: false, + IsSiteAdmin: false, + UserId: { NameId: "10010001b0c19a2", NameIdIssuer: "urn:federation:microsoftonline" }, + UserPrincipalName: "john.doe@mytenant.onmicrosoft.com" })); }); + it('handles generic error when user not found when username is passed', async () => { + const err = `User not found: ${validUserName}`; + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${validWebUrl}/_api/web/siteusers?$filter=UserPrincipalName eq ('${formatting.encodeQueryParameter(validUserName)}')`) { + return { "value": [] }; + } + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { + options: { + debug: true, + webUrl: validWebUrl, + userName: validUserName + } + }), new CommandError(err)); + }); + it('handles error correctly', async () => { sinon.stub(request, 'get').callsFake(() => { throw 'An error has occurred'; @@ -288,47 +438,27 @@ describe(commands.USER_GET, () => { assert.notStrictEqual(actual, true); }); - - it('fails validation if id, email and loginName options are passed (multiple options)', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', id: 1, email: "jonh.deo@mytenant.com", loginName: "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com" } }, commandInfo); + it('fails validation if entraGroupId is not a valid id', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupId: 'invalid' } }, commandInfo); assert.notStrictEqual(actual, true); }); - it('fails validation if id and email both are passed (multiple options)', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); - - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', id: 1, email: "jonh.deo@mytenant.com" } }, commandInfo); + it('fails validation if id is not a valid number', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, id: 'invalid' } }, commandInfo); assert.notStrictEqual(actual, true); }); - it('fails validation if id and loginName options are passed (multiple options)', async () => { - sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { - if (settingName === settingsNames.prompt) { - return false; - } - - return defaultValue; - }); + it('fails validation if userName is not a valid user principal name', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, userName: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', id: 1, loginName: "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com" } }, commandInfo); + it('fails validation if email is not a valid user principal name', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, email: 'invalid' } }, commandInfo); assert.notStrictEqual(actual, true); }); - it('fails validation if email and loginName options are passed (multiple options)', async () => { + it('fails validation if id, email, loginName, userName, entraGroupId, and entraGroupName options are passed (multiple options)', async () => { sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { if (settingName === settingsNames.prompt) { return false; @@ -337,32 +467,37 @@ describe(commands.USER_GET, () => { return defaultValue; }); - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', email: "jonh.deo@mytenant.com", loginName: "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com" } }, commandInfo); - assert.notStrictEqual(actual, true); - }); - - it('fails validation if specified id is not a number', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', id: 'a' } }, commandInfo); + const actual = await command.validate({ options: { webUrl: validWebUrl, id: 1, email: validEmail, loginName: validLoginName, userName: validUserName, entraGroupId: validEntraGroupId, entraGroupName: validEntraGroupName } }, commandInfo); assert.notStrictEqual(actual, true); }); it('passes validation url is valid and id is passed', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', id: 1 } }, commandInfo); + const actual = await command.validate({ options: { webUrl: validWebUrl, id: 1 } }, commandInfo); assert.strictEqual(actual, true); }); it('passes validation if the url is valid and email is passed', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', email: "jonh.deo@mytenant.com" } }, commandInfo); + const actual = await command.validate({ options: { webUrl: validWebUrl, email: validEmail } }, commandInfo); assert.strictEqual(actual, true); }); it('passes validation if the url is valid and loginName is passed', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', loginName: "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com" } }, commandInfo); + const actual = await command.validate({ options: { webUrl: validWebUrl, loginName: validLoginName } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation if the url is valid and userName is passed', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, userName: validUserName } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation if the url is valid and entraGroupName is passed', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupName: validEntraGroupName } }, commandInfo); assert.strictEqual(actual, true); }); - it('passes validation if the url is valid and no other options are provided', async () => { - const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com' } }, commandInfo); + it('passes validation if the url is valid and entraGroupId is passed', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupId: validEntraGroupId } }, commandInfo); assert.strictEqual(actual, true); }); -}); +}); \ No newline at end of file diff --git a/src/m365/spo/commands/user/user-get.ts b/src/m365/spo/commands/user/user-get.ts index 6b1aa52920f..b1787fe388a 100644 --- a/src/m365/spo/commands/user/user-get.ts +++ b/src/m365/spo/commands/user/user-get.ts @@ -1,20 +1,43 @@ import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; +import { Group } from '@microsoft/microsoft-graph-types'; +import { entraGroup } from '../../../../utils/entraGroup.js'; import { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +interface SpoUser { + Id: number; + IsHiddenInUI: boolean; + Title: string; + PrincipalType: number; + Email: string; + Expiration: string; + IsEmailAuthenticationGuestUser: boolean; + IsShareByEmailGuestUser: boolean; + IsSiteAdmin: boolean; + UserId: { + NameId: string; + NameIdIssuer: string; + urn: string; + }; + UserPrincipalName: string; +} + interface CommandArgs { options: Options; } export interface Options extends GlobalOptions { webUrl: string; + id?: string; email?: string; - id?: number; loginName?: string; + userName?: string; + entraGroupId?: string; + entraGroupName?: string; } class SpoUserGetCommand extends SpoCommand { @@ -33,6 +56,7 @@ class SpoUserGetCommand extends SpoCommand { this.#initOptions(); this.#initValidators(); this.#initOptionSets(); + this.#initTypes(); } #initTelemetry(): void { @@ -40,7 +64,10 @@ class SpoUserGetCommand extends SpoCommand { Object.assign(this.telemetryProperties, { id: typeof args.options.id !== 'undefined', email: typeof args.options.email !== 'undefined', - loginName: typeof args.options.loginName !== 'undefined' + loginName: typeof args.options.loginName !== 'undefined', + userName: typeof args.options.userName !== 'undefined', + entraGroupId: typeof args.options.entraGroupId !== 'undefined', + entraGroupName: typeof args.options.entraGroupName !== 'undefined' }); }); } @@ -58,10 +85,23 @@ class SpoUserGetCommand extends SpoCommand { }, { option: '--loginName [loginName]' + }, + { + option: '--userName [userName]' + }, + { + option: '--entraGroupId [entraGroupId]' + }, + { + option: '--entraGroupName [entraGroupName]' } ); } + #initTypes(): void { + this.types.string.push('webUrl', 'id', 'email', 'loginName', 'userName', 'entraGroupId', 'entraGroupName'); + } + #initValidators(): void { this.validators.push( async (args: CommandArgs) => { @@ -70,6 +110,18 @@ class SpoUserGetCommand extends SpoCommand { return `Specified id ${args.options.id} is not a number`; } + if (args.options.entraGroupId && !validation.isValidGuid(args.options.entraGroupId)) { + return `${args.options.entraGroupId} is not a valid GUID.`; + } + + if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { + return `${args.options.userName} is not a valid userName.`; + } + + if (args.options.email && !validation.isValidUserPrincipalName(args.options.email)) { + return `${args.options.email} is not a valid email.`; + } + return validation.isValidSharePointUrl(args.options.webUrl); } ); @@ -77,8 +129,8 @@ class SpoUserGetCommand extends SpoCommand { #initOptionSets(): void { this.optionSets.push({ - options: ['id', 'email', 'loginName'], - runsWhen: (args) => args.options.id || args.options.loginName || args.options.email + options: ['id', 'email', 'loginName', 'userName', 'entraGroupId', 'entraGroupName'], + runsWhen: (args) => args.options.id || args.options.email || args.options.loginName || args.options.userName || args.options.entraGroupId || args.options.entraGroupName }); } @@ -87,19 +139,34 @@ class SpoUserGetCommand extends SpoCommand { await logger.logToStderr(`Retrieving information for user in site '${args.options.webUrl}'...`); } - let requestUrl: string = ''; + let requestUrl: string = `${args.options.webUrl}/_api/web/`; if (args.options.id) { - requestUrl = `${args.options.webUrl}/_api/web/siteusers/GetById('${formatting.encodeQueryParameter(args.options.id.toString())}')`; + requestUrl += `siteusers/GetById('${formatting.encodeQueryParameter(args.options.id.toString())}')`; } else if (args.options.email) { - requestUrl = `${args.options.webUrl}/_api/web/siteusers/GetByEmail('${formatting.encodeQueryParameter(args.options.email)}')`; + requestUrl += `siteusers/GetByEmail('${formatting.encodeQueryParameter(args.options.email)}')`; } else if (args.options.loginName) { - requestUrl = `${args.options.webUrl}/_api/web/siteusers/GetByLoginName('${formatting.encodeQueryParameter(args.options.loginName)}')`; + requestUrl += `siteusers/GetByLoginName('${formatting.encodeQueryParameter(args.options.loginName)}')`; + } + else if (args.options.userName) { + const user = await this.getUser(requestUrl, args.options.userName); + requestUrl += `siteusers/GetById('${formatting.encodeQueryParameter(user.Id.toString())}')`; + } + else if (args.options.entraGroupId || args.options.entraGroupName) { + const entraGroup = await this.getEntraGroup(args.options.entraGroupId!, args.options.entraGroupName!); + + // For entra groups, M365 groups have an associated email and security groups don't + if (entraGroup?.mail) { + requestUrl += `siteusers/GetByEmail('${formatting.encodeQueryParameter(entraGroup.mail)}')`; + } + else { + requestUrl += `siteusers/GetByLoginName('c:0t.c|tenant|${entraGroup?.id}')`; + } } else { - requestUrl = `${args.options.webUrl}/_api/web/currentuser`; + requestUrl += `currentuser`; } const requestOptions: CliRequestOptions = { @@ -119,6 +186,36 @@ class SpoUserGetCommand extends SpoCommand { this.handleRejectedODataJsonPromise(err); } } + + private async getUser(baseUrl: string, userName: string): Promise { + const requestUrl: string = `${baseUrl}siteusers?$filter=UserPrincipalName eq ('${formatting.encodeQueryParameter(userName)}')`; + const requestOptions: CliRequestOptions = { + url: requestUrl, + headers: { + accept: 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + const userInstance = await request.get(requestOptions); + const userInstanceValue = (userInstance as { + value: SpoUser[]; + }).value[0]; + + if (!userInstanceValue) { + throw `User not found: ${userName}`; + } + + return userInstanceValue; + } + + private async getEntraGroup(entraGroupId: string, entraGroupName: string): Promise { + if (entraGroupId) { + return entraGroup.getGroupById(entraGroupId); + } + + return entraGroup.getGroupByDisplayName(entraGroupName); + } } -export default new SpoUserGetCommand(); +export default new SpoUserGetCommand(); \ No newline at end of file diff --git a/src/m365/spp/commands.ts b/src/m365/spp/commands.ts new file mode 100644 index 00000000000..d3309979bb6 --- /dev/null +++ b/src/m365/spp/commands.ts @@ -0,0 +1,5 @@ +const prefix: string = 'spp'; + +export default { + CONTENTCENTER_LIST: `${prefix} contentcenter list` +}; \ No newline at end of file diff --git a/src/m365/spp/commands/contentcenter/contentcenter-list.spec.ts b/src/m365/spp/commands/contentcenter/contentcenter-list.spec.ts new file mode 100644 index 00000000000..2fe8249ce0c --- /dev/null +++ b/src/m365/spp/commands/contentcenter/contentcenter-list.spec.ts @@ -0,0 +1,204 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { CommandError } from '../../../../Command.js'; +import config from '../../../../config.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import { spo } from '../../../../utils/spo.js'; +import commands from '../../commands.js'; +import command from './contentcenter-list.js'; + +describe(commands.CONTENTCENTER_LIST, () => { + let log: string[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + sinon.stub(spo, 'getRequestDigest').resolves({ FormDigestValue: 'abc', FormDigestTimeoutSeconds: 1800, FormDigestExpiresAt: new Date(), WebFullUrl: 'https://contoso.sharepoint.com' }); + sinon.stub(spo, 'ensureFormDigest').resolves({ FormDigestValue: 'abc', FormDigestTimeoutSeconds: 1800, FormDigestExpiresAt: new Date(), WebFullUrl: 'https://contoso.sharepoint.com' }); + auth.connection.active = true; + auth.connection.spoUrl = 'https://contoso.sharepoint.com'; + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + }); + + afterEach(() => { + sinonUtil.restore([ + request.post + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + auth.connection.spoUrl = undefined; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.CONTENTCENTER_LIST); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('defines correct properties for the default output', () => { + assert.deepStrictEqual(command.defaultProperties(), ['Title', 'Url']); + }); + + it('retrieves list of content centers', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url = `https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery`)) { + if (opts.headers && + opts.headers['X-RequestDigest'] && + opts.headers['X-RequestDigest'] === 'abc' && + opts.data === `false00CONTENTCTR#0`) { + return JSON.stringify([ + { + "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7206.1204", "ErrorInfo": null, "TraceCorrelationId": "487c379e-80f8-4000-80be-1d37a4995717" + }, 2, { + "IsNull": false + }, 4, { + "IsNull": false + }, 5, { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SPOSitePropertiesEnumerable", "NextStartIndex": -1, "NextStartIndexFromSharePoint": null, "_Child_Items_": [ + { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SiteProperties", "_ObjectIdentity_": "487c379e-80f8-4000-80be-1d37a4995717|908bed80-a04a-4433-b4a0-883d9847d110:67753f63-bc14-4012-869e-f808a43fe023\nSiteProperties\nhttps%3a%2f%2fcontoso.sharepoint.com%2fsites%2fctest_101", "AllowDownloadingNonWebViewableFiles": false, "AllowEditing": false, "AllowSelfServiceUpgrade": true, "AverageResourceUsage": 0, "CommentsOnSitePagesDisabled": false, "CompatibilityLevel": 15, "ConditionalAccessPolicy": 0, "CurrentResourceUsage": 0, "DenyAddAndCustomizePages": 2, "DisableAppViews": 0, "DisableCompanyWideSharingLinks": 0, "DisableFlows": 0, "HasHolds": false, "LastContentModifiedDate": "\/Date(2017,11,17,4,12,28,997)\/", "Lcid": 1033, "LockIssue": null, "LockState": "Unlock", "NewUrl": "", "Owner": "", "OwnerEmail": null, "PWAEnabled": 0, "RestrictedToRegion": 3, "SandboxedCodeActivationCapability": 0, "SharingAllowedDomainList": null, "SharingBlockedDomainList": null, "SharingCapability": 1, "SharingDomainRestrictionMode": 0, "ShowPeoplePickerSuggestionsForGuestUsers": false, "SiteDefinedSharingCapability": 0, "Status": "Active", "StorageMaximumLevel": 26214400, "StorageQuotaType": null, "StorageUsage": 1, "StorageWarningLevel": 25574400, "Template": "CONTENTCTR#0", "TimeZoneId": 13, "Title": "Content Center 101", "Url": "https:\u002f\u002fcontoso.sharepoint.com\u002fsites\u002fctest_101", "UserCodeMaximumLevel": 300, "UserCodeWarningLevel": 200, "WebsCount": 0 + }, { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SiteProperties", "_ObjectIdentity_": "487c379e-80f8-4000-80be-1d37a4995717|908bed80-a04a-4433-b4a0-883d9847d110:67753f63-bc14-4012-869e-f808a43fe023\nSiteProperties\nhttps%3a%2f%2fcontoso.sharepoint.com%2fsites%2fctest_1010", "AllowDownloadingNonWebViewableFiles": false, "AllowEditing": false, "AllowSelfServiceUpgrade": true, "AverageResourceUsage": 0, "CommentsOnSitePagesDisabled": false, "CompatibilityLevel": 15, "ConditionalAccessPolicy": 0, "CurrentResourceUsage": 0, "DenyAddAndCustomizePages": 2, "DisableAppViews": 0, "DisableCompanyWideSharingLinks": 0, "DisableFlows": 0, "HasHolds": false, "LastContentModifiedDate": "\/Date(2017,11,17,17,46,0,910)\/", "Lcid": 1033, "LockIssue": null, "LockState": "Unlock", "NewUrl": "", "Owner": "", "OwnerEmail": null, "PWAEnabled": 0, "RestrictedToRegion": 3, "SandboxedCodeActivationCapability": 0, "SharingAllowedDomainList": null, "SharingBlockedDomainList": null, "SharingCapability": 1, "SharingDomainRestrictionMode": 0, "ShowPeoplePickerSuggestionsForGuestUsers": false, "SiteDefinedSharingCapability": 0, "Status": "Active", "StorageMaximumLevel": 1048576, "StorageQuotaType": null, "StorageUsage": 1, "StorageWarningLevel": 1022361, "Template": "CONTENTCTR#0", "TimeZoneId": 13, "Title": "Content Center 1010", "Url": "https:\u002f\u002fcontoso.sharepoint.com\u002fsites\u002fctest_1010", "UserCodeMaximumLevel": 300, "UserCodeWarningLevel": 200, "WebsCount": 0 + } + ] + } + ]); + } + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: {} }); + assert(loggerLogSpy.calledOnceWithExactly([ + { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SiteProperties", "_ObjectIdentity_": "487c379e-80f8-4000-80be-1d37a4995717|908bed80-a04a-4433-b4a0-883d9847d110:67753f63-bc14-4012-869e-f808a43fe023\nSiteProperties\nhttps%3a%2f%2fcontoso.sharepoint.com%2fsites%2fctest_101", "AllowDownloadingNonWebViewableFiles": false, "AllowEditing": false, "AllowSelfServiceUpgrade": true, "AverageResourceUsage": 0, "CommentsOnSitePagesDisabled": false, "CompatibilityLevel": 15, "ConditionalAccessPolicy": 0, "CurrentResourceUsage": 0, "DenyAddAndCustomizePages": 2, "DisableAppViews": 0, "DisableCompanyWideSharingLinks": 0, "DisableFlows": 0, "HasHolds": false, "LastContentModifiedDate": "\/Date(2017,11,17,4,12,28,997)\/", "Lcid": 1033, "LockIssue": null, "LockState": "Unlock", "NewUrl": "", "Owner": "", "OwnerEmail": null, "PWAEnabled": 0, "RestrictedToRegion": 3, "SandboxedCodeActivationCapability": 0, "SharingAllowedDomainList": null, "SharingBlockedDomainList": null, "SharingCapability": 1, "SharingDomainRestrictionMode": 0, "ShowPeoplePickerSuggestionsForGuestUsers": false, "SiteDefinedSharingCapability": 0, "Status": "Active", "StorageMaximumLevel": 26214400, "StorageQuotaType": null, "StorageUsage": 1, "StorageWarningLevel": 25574400, "Template": "CONTENTCTR#0", "TimeZoneId": 13, "Title": "Content Center 101", "Url": "https:\u002f\u002fcontoso.sharepoint.com\u002fsites\u002fctest_101", "UserCodeMaximumLevel": 300, "UserCodeWarningLevel": 200, "WebsCount": 0 + }, { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SiteProperties", "_ObjectIdentity_": "487c379e-80f8-4000-80be-1d37a4995717|908bed80-a04a-4433-b4a0-883d9847d110:67753f63-bc14-4012-869e-f808a43fe023\nSiteProperties\nhttps%3a%2f%2fcontoso.sharepoint.com%2fsites%2fctest_1010", "AllowDownloadingNonWebViewableFiles": false, "AllowEditing": false, "AllowSelfServiceUpgrade": true, "AverageResourceUsage": 0, "CommentsOnSitePagesDisabled": false, "CompatibilityLevel": 15, "ConditionalAccessPolicy": 0, "CurrentResourceUsage": 0, "DenyAddAndCustomizePages": 2, "DisableAppViews": 0, "DisableCompanyWideSharingLinks": 0, "DisableFlows": 0, "HasHolds": false, "LastContentModifiedDate": "\/Date(2017,11,17,17,46,0,910)\/", "Lcid": 1033, "LockIssue": null, "LockState": "Unlock", "NewUrl": "", "Owner": "", "OwnerEmail": null, "PWAEnabled": 0, "RestrictedToRegion": 3, "SandboxedCodeActivationCapability": 0, "SharingAllowedDomainList": null, "SharingBlockedDomainList": null, "SharingCapability": 1, "SharingDomainRestrictionMode": 0, "ShowPeoplePickerSuggestionsForGuestUsers": false, "SiteDefinedSharingCapability": 0, "Status": "Active", "StorageMaximumLevel": 1048576, "StorageQuotaType": null, "StorageUsage": 1, "StorageWarningLevel": 1022361, "Template": "CONTENTCTR#0", "TimeZoneId": 13, "Title": "Content Center 1010", "Url": "https:\u002f\u002fcontoso.sharepoint.com\u002fsites\u002fctest_1010", "UserCodeMaximumLevel": 300, "UserCodeWarningLevel": 200, "WebsCount": 0 + } + ])); + }); + + it('retrieves list of all content centers when results returned in multiple pages', async () => { + const postStub = sinon.stub(request, 'post'); + postStub.onFirstCall().callsFake(async (opts) => { + if ((opts.url = `https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery`)) { + if (opts.data === `false00CONTENTCTR#0`) { + return JSON.stringify([ + { + "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7206.1204", "ErrorInfo": null, "TraceCorrelationId": "487c379e-80f8-4000-80be-1d37a4995717" + }, 2, { + "IsNull": false + }, 4, { + "IsNull": false + }, 5, { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SPOSitePropertiesEnumerable", "NextStartIndex": -1, "NextStartIndexFromSharePoint": "SPSiteQuery,841cb9d7-61a2-4029-b405-8cef77f591e2,924a239d-6416-49ff-86e2-0283b03bc4aa,0f820ed9-1927-4d48-8f88-94f863949574", "_Child_Items_": [ + { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SiteProperties", "_ObjectIdentity_": "487c379e-80f8-4000-80be-1d37a4995717|908bed80-a04a-4433-b4a0-883d9847d110:67753f63-bc14-4012-869e-f808a43fe023\nSiteProperties\nhttps%3a%2f%2fcontoso.sharepoint.com%2fsites%2fctest_101", "AllowDownloadingNonWebViewableFiles": false, "AllowEditing": false, "AllowSelfServiceUpgrade": true, "AverageResourceUsage": 0, "CommentsOnSitePagesDisabled": false, "CompatibilityLevel": 15, "ConditionalAccessPolicy": 0, "CurrentResourceUsage": 0, "DenyAddAndCustomizePages": 2, "DisableAppViews": 0, "DisableCompanyWideSharingLinks": 0, "DisableFlows": 0, "HasHolds": false, "LastContentModifiedDate": "\/Date(2017,11,17,4,12,28,997)\/", "Lcid": 1033, "LockIssue": null, "LockState": "Unlock", "NewUrl": "", "Owner": "", "OwnerEmail": null, "PWAEnabled": 0, "RestrictedToRegion": 3, "SandboxedCodeActivationCapability": 0, "SharingAllowedDomainList": null, "SharingBlockedDomainList": null, "SharingCapability": 1, "SharingDomainRestrictionMode": 0, "ShowPeoplePickerSuggestionsForGuestUsers": false, "SiteDefinedSharingCapability": 0, "Status": "Active", "StorageMaximumLevel": 26214400, "StorageQuotaType": null, "StorageUsage": 1, "StorageWarningLevel": 25574400, "Template": "CONTENTCTR#0", "TimeZoneId": 13, "Title": "Content Center 101", "Url": "https:\u002f\u002fcontoso.sharepoint.com\u002fsites\u002fctest_101", "UserCodeMaximumLevel": 300, "UserCodeWarningLevel": 200, "WebsCount": 0 + }, { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SiteProperties", "_ObjectIdentity_": "487c379e-80f8-4000-80be-1d37a4995717|908bed80-a04a-4433-b4a0-883d9847d110:67753f63-bc14-4012-869e-f808a43fe023\nSiteProperties\nhttps%3a%2f%2fcontoso.sharepoint.com%2fsites%2fctest_1010", "AllowDownloadingNonWebViewableFiles": false, "AllowEditing": false, "AllowSelfServiceUpgrade": true, "AverageResourceUsage": 0, "CommentsOnSitePagesDisabled": false, "CompatibilityLevel": 15, "ConditionalAccessPolicy": 0, "CurrentResourceUsage": 0, "DenyAddAndCustomizePages": 2, "DisableAppViews": 0, "DisableCompanyWideSharingLinks": 0, "DisableFlows": 0, "HasHolds": false, "LastContentModifiedDate": "\/Date(2017,11,17,17,46,0,910)\/", "Lcid": 1033, "LockIssue": null, "LockState": "Unlock", "NewUrl": "", "Owner": "", "OwnerEmail": null, "PWAEnabled": 0, "RestrictedToRegion": 3, "SandboxedCodeActivationCapability": 0, "SharingAllowedDomainList": null, "SharingBlockedDomainList": null, "SharingCapability": 1, "SharingDomainRestrictionMode": 0, "ShowPeoplePickerSuggestionsForGuestUsers": false, "SiteDefinedSharingCapability": 0, "Status": "Active", "StorageMaximumLevel": 1048576, "StorageQuotaType": null, "StorageUsage": 1, "StorageWarningLevel": 1022361, "Template": "CONTENTCTR#0", "TimeZoneId": 13, "Title": "Content Center 1010", "Url": "https:\u002f\u002fcontoso.sharepoint.com\u002fsites\u002fctest_1010", "UserCodeMaximumLevel": 300, "UserCodeWarningLevel": 200, "WebsCount": 0 + } + ] + } + ]); + } + } + + throw 'Invalid request'; + }); + + postStub.onSecondCall().callsFake(async (opts) => { + if (opts.data === `false0SPSiteQuery,841cb9d7-61a2-4029-b405-8cef77f591e2,924a239d-6416-49ff-86e2-0283b03bc4aa,0f820ed9-1927-4d48-8f88-94f863949574CONTENTCTR#0`) { + return JSON.stringify([ + { + "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7206.1204", "ErrorInfo": null, "TraceCorrelationId": "487c379e-80f8-4000-80be-1d37a4995717" + }, 2, { + "IsNull": false + }, 4, { + "IsNull": false + }, 5, { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SPOSitePropertiesEnumerable", "NextStartIndex": -1, "NextStartIndexFromSharePoint": null, "_Child_Items_": [ + { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SiteProperties", "_ObjectIdentity_": "487c379e-80f8-4000-80be-1d37a4995717|908bed80-a04a-4433-b4a0-883d9847d110:67753f63-bc14-4012-869e-f808a43fe023\nSiteProperties\nhttps%3a%2f%2fcontoso.sharepoint.com%2fsites%2fctest_101", "AllowDownloadingNonWebViewableFiles": false, "AllowEditing": false, "AllowSelfServiceUpgrade": true, "AverageResourceUsage": 0, "CommentsOnSitePagesDisabled": false, "CompatibilityLevel": 15, "ConditionalAccessPolicy": 0, "CurrentResourceUsage": 0, "DenyAddAndCustomizePages": 2, "DisableAppViews": 0, "DisableCompanyWideSharingLinks": 0, "DisableFlows": 0, "HasHolds": false, "LastContentModifiedDate": "\/Date(2017,11,17,4,12,28,997)\/", "Lcid": 1033, "LockIssue": null, "LockState": "Unlock", "NewUrl": "", "Owner": "", "OwnerEmail": null, "PWAEnabled": 0, "RestrictedToRegion": 3, "SandboxedCodeActivationCapability": 0, "SharingAllowedDomainList": null, "SharingBlockedDomainList": null, "SharingCapability": 1, "SharingDomainRestrictionMode": 0, "ShowPeoplePickerSuggestionsForGuestUsers": false, "SiteDefinedSharingCapability": 0, "Status": "Active", "StorageMaximumLevel": 26214400, "StorageQuotaType": null, "StorageUsage": 1, "StorageWarningLevel": 25574400, "Template": "CONTENTCTR#0", "TimeZoneId": 13, "Title": "Content Center 101", "Url": "https:\u002f\u002fcontoso.sharepoint.com\u002fsites\u002fctest_101", "UserCodeMaximumLevel": 300, "UserCodeWarningLevel": 200, "WebsCount": 0 + }, { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SiteProperties", "_ObjectIdentity_": "487c379e-80f8-4000-80be-1d37a4995717|908bed80-a04a-4433-b4a0-883d9847d110:67753f63-bc14-4012-869e-f808a43fe023\nSiteProperties\nhttps%3a%2f%2fcontoso.sharepoint.com%2fsites%2fctest_1010", "AllowDownloadingNonWebViewableFiles": false, "AllowEditing": false, "AllowSelfServiceUpgrade": true, "AverageResourceUsage": 0, "CommentsOnSitePagesDisabled": false, "CompatibilityLevel": 15, "ConditionalAccessPolicy": 0, "CurrentResourceUsage": 0, "DenyAddAndCustomizePages": 2, "DisableAppViews": 0, "DisableCompanyWideSharingLinks": 0, "DisableFlows": 0, "HasHolds": false, "LastContentModifiedDate": "\/Date(2017,11,17,17,46,0,910)\/", "Lcid": 1033, "LockIssue": null, "LockState": "Unlock", "NewUrl": "", "Owner": "", "OwnerEmail": null, "PWAEnabled": 0, "RestrictedToRegion": 3, "SandboxedCodeActivationCapability": 0, "SharingAllowedDomainList": null, "SharingBlockedDomainList": null, "SharingCapability": 1, "SharingDomainRestrictionMode": 0, "ShowPeoplePickerSuggestionsForGuestUsers": false, "SiteDefinedSharingCapability": 0, "Status": "Active", "StorageMaximumLevel": 1048576, "StorageQuotaType": null, "StorageUsage": 1, "StorageWarningLevel": 1022361, "Template": "CONTENTCTR#0", "TimeZoneId": 13, "Title": "Content Center 1010", "Url": "https:\u002f\u002fcontoso.sharepoint.com\u002fsites\u002fctest_1010", "UserCodeMaximumLevel": 300, "UserCodeWarningLevel": 200, "WebsCount": 0 + } + ] + } + ]); + } + throw 'Invalid request'; + }); + + await command.action(logger, { options: {} }); + assert(loggerLogSpy.calledOnceWith([ + { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SiteProperties", "_ObjectIdentity_": "487c379e-80f8-4000-80be-1d37a4995717|908bed80-a04a-4433-b4a0-883d9847d110:67753f63-bc14-4012-869e-f808a43fe023\nSiteProperties\nhttps%3a%2f%2fcontoso.sharepoint.com%2fsites%2fctest_101", "AllowDownloadingNonWebViewableFiles": false, "AllowEditing": false, "AllowSelfServiceUpgrade": true, "AverageResourceUsage": 0, "CommentsOnSitePagesDisabled": false, "CompatibilityLevel": 15, "ConditionalAccessPolicy": 0, "CurrentResourceUsage": 0, "DenyAddAndCustomizePages": 2, "DisableAppViews": 0, "DisableCompanyWideSharingLinks": 0, "DisableFlows": 0, "HasHolds": false, "LastContentModifiedDate": "\/Date(2017,11,17,4,12,28,997)\/", "Lcid": 1033, "LockIssue": null, "LockState": "Unlock", "NewUrl": "", "Owner": "", "OwnerEmail": null, "PWAEnabled": 0, "RestrictedToRegion": 3, "SandboxedCodeActivationCapability": 0, "SharingAllowedDomainList": null, "SharingBlockedDomainList": null, "SharingCapability": 1, "SharingDomainRestrictionMode": 0, "ShowPeoplePickerSuggestionsForGuestUsers": false, "SiteDefinedSharingCapability": 0, "Status": "Active", "StorageMaximumLevel": 26214400, "StorageQuotaType": null, "StorageUsage": 1, "StorageWarningLevel": 25574400, "Template": "CONTENTCTR#0", "TimeZoneId": 13, "Title": "Content Center 101", "Url": "https:\u002f\u002fcontoso.sharepoint.com\u002fsites\u002fctest_101", "UserCodeMaximumLevel": 300, "UserCodeWarningLevel": 200, "WebsCount": 0 + }, { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SiteProperties", "_ObjectIdentity_": "487c379e-80f8-4000-80be-1d37a4995717|908bed80-a04a-4433-b4a0-883d9847d110:67753f63-bc14-4012-869e-f808a43fe023\nSiteProperties\nhttps%3a%2f%2fcontoso.sharepoint.com%2fsites%2fctest_1010", "AllowDownloadingNonWebViewableFiles": false, "AllowEditing": false, "AllowSelfServiceUpgrade": true, "AverageResourceUsage": 0, "CommentsOnSitePagesDisabled": false, "CompatibilityLevel": 15, "ConditionalAccessPolicy": 0, "CurrentResourceUsage": 0, "DenyAddAndCustomizePages": 2, "DisableAppViews": 0, "DisableCompanyWideSharingLinks": 0, "DisableFlows": 0, "HasHolds": false, "LastContentModifiedDate": "\/Date(2017,11,17,17,46,0,910)\/", "Lcid": 1033, "LockIssue": null, "LockState": "Unlock", "NewUrl": "", "Owner": "", "OwnerEmail": null, "PWAEnabled": 0, "RestrictedToRegion": 3, "SandboxedCodeActivationCapability": 0, "SharingAllowedDomainList": null, "SharingBlockedDomainList": null, "SharingCapability": 1, "SharingDomainRestrictionMode": 0, "ShowPeoplePickerSuggestionsForGuestUsers": false, "SiteDefinedSharingCapability": 0, "Status": "Active", "StorageMaximumLevel": 1048576, "StorageQuotaType": null, "StorageUsage": 1, "StorageWarningLevel": 1022361, "Template": "CONTENTCTR#0", "TimeZoneId": 13, "Title": "Content Center 1010", "Url": "https:\u002f\u002fcontoso.sharepoint.com\u002fsites\u002fctest_1010", "UserCodeMaximumLevel": 300, "UserCodeWarningLevel": 200, "WebsCount": 0 + }, + { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SiteProperties", "_ObjectIdentity_": "487c379e-80f8-4000-80be-1d37a4995717|908bed80-a04a-4433-b4a0-883d9847d110:67753f63-bc14-4012-869e-f808a43fe023\nSiteProperties\nhttps%3a%2f%2fcontoso.sharepoint.com%2fsites%2fctest_101", "AllowDownloadingNonWebViewableFiles": false, "AllowEditing": false, "AllowSelfServiceUpgrade": true, "AverageResourceUsage": 0, "CommentsOnSitePagesDisabled": false, "CompatibilityLevel": 15, "ConditionalAccessPolicy": 0, "CurrentResourceUsage": 0, "DenyAddAndCustomizePages": 2, "DisableAppViews": 0, "DisableCompanyWideSharingLinks": 0, "DisableFlows": 0, "HasHolds": false, "LastContentModifiedDate": "\/Date(2017,11,17,4,12,28,997)\/", "Lcid": 1033, "LockIssue": null, "LockState": "Unlock", "NewUrl": "", "Owner": "", "OwnerEmail": null, "PWAEnabled": 0, "RestrictedToRegion": 3, "SandboxedCodeActivationCapability": 0, "SharingAllowedDomainList": null, "SharingBlockedDomainList": null, "SharingCapability": 1, "SharingDomainRestrictionMode": 0, "ShowPeoplePickerSuggestionsForGuestUsers": false, "SiteDefinedSharingCapability": 0, "Status": "Active", "StorageMaximumLevel": 26214400, "StorageQuotaType": null, "StorageUsage": 1, "StorageWarningLevel": 25574400, "Template": "CONTENTCTR#0", "TimeZoneId": 13, "Title": "Content Center 101", "Url": "https:\u002f\u002fcontoso.sharepoint.com\u002fsites\u002fctest_101", "UserCodeMaximumLevel": 300, "UserCodeWarningLevel": 200, "WebsCount": 0 + }, { + "_ObjectType_": "Microsoft.Online.SharePoint.TenantAdministration.SiteProperties", "_ObjectIdentity_": "487c379e-80f8-4000-80be-1d37a4995717|908bed80-a04a-4433-b4a0-883d9847d110:67753f63-bc14-4012-869e-f808a43fe023\nSiteProperties\nhttps%3a%2f%2fcontoso.sharepoint.com%2fsites%2fctest_1010", "AllowDownloadingNonWebViewableFiles": false, "AllowEditing": false, "AllowSelfServiceUpgrade": true, "AverageResourceUsage": 0, "CommentsOnSitePagesDisabled": false, "CompatibilityLevel": 15, "ConditionalAccessPolicy": 0, "CurrentResourceUsage": 0, "DenyAddAndCustomizePages": 2, "DisableAppViews": 0, "DisableCompanyWideSharingLinks": 0, "DisableFlows": 0, "HasHolds": false, "LastContentModifiedDate": "\/Date(2017,11,17,17,46,0,910)\/", "Lcid": 1033, "LockIssue": null, "LockState": "Unlock", "NewUrl": "", "Owner": "", "OwnerEmail": null, "PWAEnabled": 0, "RestrictedToRegion": 3, "SandboxedCodeActivationCapability": 0, "SharingAllowedDomainList": null, "SharingBlockedDomainList": null, "SharingCapability": 1, "SharingDomainRestrictionMode": 0, "ShowPeoplePickerSuggestionsForGuestUsers": false, "SiteDefinedSharingCapability": 0, "Status": "Active", "StorageMaximumLevel": 1048576, "StorageQuotaType": null, "StorageUsage": 1, "StorageWarningLevel": 1022361, "Template": "CONTENTCTR#0", "TimeZoneId": 13, "Title": "Content Center 1010", "Url": "https:\u002f\u002fcontoso.sharepoint.com\u002fsites\u002fctest_1010", "UserCodeMaximumLevel": 300, "UserCodeWarningLevel": 200, "WebsCount": 0 + } + ])); + }); + + it('correctly handles error when retrieving sites', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if ((opts.url = `https://contoso-admin.sharepoint.com/_vti_bin/client.svc/ProcessQuery`)) { + if (opts.data === `false00CONTENTCTR#0`) { + + return JSON.stringify([ + { + "SchemaVersion": "15.0.0.0", "LibraryVersion": "16.0.7206.1204", "ErrorInfo": { + "ErrorMessage": "Syntax error in the filter expression 'Url like 'test''.", "ErrorValue": null, "TraceCorrelationId": "3984379e-3011-4000-8240-a1114b993cad", "ErrorCode": -2147024809, "ErrorTypeName": "System.ArgumentException" + }, "TraceCorrelationId": "3984379e-3011-4000-8240-a1114b993cad" + } + ]); + } + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { debug: true } } as any), new CommandError("Syntax error in the filter expression 'Url like 'test''.")); + }); + + it('correctly handles random API error', async () => { + sinon.stub(request, 'post').rejects(new Error('An error has occurred')); + + await assert.rejects(command.action(logger, { options: { debug: true } } as any), new CommandError('An error has occurred')); + }); +}); diff --git a/src/m365/spp/commands/contentcenter/contentcenter-list.ts b/src/m365/spp/commands/contentcenter/contentcenter-list.ts new file mode 100644 index 00000000000..71d48a49049 --- /dev/null +++ b/src/m365/spp/commands/contentcenter/contentcenter-list.ts @@ -0,0 +1,74 @@ +import { Logger } from '../../../../cli/Logger.js'; +import config from '../../../../config.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { ClientSvcResponse, ClientSvcResponseContents, FormDigestInfo, spo } from '../../../../utils/spo.js'; +import SpoCommand from '../../../base/SpoCommand.js'; +import { SiteProperties } from '../../../spo/commands/site/SiteProperties.js'; +import { SPOSitePropertiesEnumerable } from '../../../spo/commands/site/SPOSitePropertiesEnumerable.js'; +import commands from '../../commands.js'; + +class SppContentCenterListCommand extends SpoCommand { + public get name(): string { + return commands.CONTENTCENTER_LIST; + } + + public get description(): string { + return 'Gets information about the SharePoint Premium content centers'; + } + + public defaultProperties(): string[] | undefined { + return ['Title', 'Url']; + } + + public async commandAction(logger: Logger): Promise { + try { + if (this.verbose) { + await logger.logToStderr(`Retrieving list of content centers...`); + } + + const spoAdminUrl: string = await spo.getSpoAdminUrl(logger, this.debug); + const allContentCenters = await this.getContentCenters(spoAdminUrl, logger); + await logger.log(allContentCenters); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private async getContentCenters(spoAdminUrl: string, logger: Logger): Promise { + const allSites: SiteProperties[] = []; + let currentStartIndex = '0'; + + const res: FormDigestInfo = await spo.ensureFormDigest(spoAdminUrl, logger, undefined, this.debug); + + do { + const requestBody: string = `false0${currentStartIndex}CONTENTCTR#0`; + const requestOptions: CliRequestOptions = { + url: `${spoAdminUrl}/_vti_bin/client.svc/ProcessQuery`, + headers: { + 'X-RequestDigest': res.FormDigestValue + }, + data: requestBody + }; + + const response: string = await request.post(requestOptions); + const json: ClientSvcResponse = JSON.parse(response); + const responseContent: ClientSvcResponseContents = json[0]; + + if (responseContent.ErrorInfo) { + throw responseContent.ErrorInfo.ErrorMessage; + } + + const sites: SPOSitePropertiesEnumerable = json[json.length - 1]; + allSites.push(...sites._Child_Items_); + + currentStartIndex = sites.NextStartIndexFromSharePoint; + + } while (currentStartIndex); + + return allSites; + } + +} + +export default new SppContentCenterListCommand(); \ No newline at end of file diff --git a/src/m365/teams/commands.ts b/src/m365/teams/commands.ts index d3de34a558c..7ef0ea0c6d7 100644 --- a/src/m365/teams/commands.ts +++ b/src/m365/teams/commands.ts @@ -40,6 +40,7 @@ export default { MESSAGE_LIST: `${prefix} message list`, MESSAGE_REMOVE: `${prefix} message remove`, MESSAGE_REPLY_LIST: `${prefix} message reply list`, + MESSAGE_RESTORE: `${prefix} message restore`, MESSAGE_SEND: `${prefix} message send`, MESSAGINGSETTINGS_LIST: `${prefix} messagingsettings list`, MESSAGINGSETTINGS_SET: `${prefix} messagingsettings set`, diff --git a/src/m365/teams/commands/message/message-restore.spec.ts b/src/m365/teams/commands/message/message-restore.spec.ts new file mode 100644 index 00000000000..9b8ae346e51 --- /dev/null +++ b/src/m365/teams/commands/message/message-restore.spec.ts @@ -0,0 +1,232 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { CommandError } from '../../../../Command.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command from './message-restore.js'; +import { settingsNames } from '../../../../settingsNames.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import { teams } from '../../../../utils/teams.js'; + +describe(commands.MESSAGE_RESTORE, () => { + const messageId = '1540911392778'; + const teamId = '5f5d7b71-1161-44d8-bcc1-3da710eb4171'; + const channelId = '19:00000000000000000000000000000000@thread.skype'; + const teamName = 'Team Name'; + const channelName = 'Channel Name'; + + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + sinon.stub(accessToken, 'assertDelegatedAccessToken').returns(); + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => settingName === settingsNames.prompt ? false : defaultValue); + }); + + afterEach(() => { + sinonUtil.restore([ + request.post, + cli.getSettingWithDefaultValue, + accessToken.isAppOnlyAccessToken + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.MESSAGE_RESTORE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if teamId or teamName options are not passed', async () => { + const actual = await command.validate({ + options: { + id: messageId, + channelId: channelId + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if teamId and teamName options are both passed', async () => { + const actual = await command.validate({ + options: { + id: messageId, + teamId: teamId, + teamName: teamName, + channelId: channelId + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if channelId or channelName options are not passed', async () => { + const actual = await command.validate({ + options: { + id: messageId, + teamId: teamId + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if channelId and channelName options are both passed', async () => { + const actual = await command.validate({ + options: { + id: messageId, + teamId: teamId, + channelName: channelName, + channelId: channelId + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the teamId is not a valid guid', async () => { + const actual = await command.validate({ + options: { + teamId: "5f5d7b71-1161-44", + channelId: channelId, + id: messageId + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('validates for a correct input', async () => { + const actual = await command.validate({ + options: { + teamId: teamId, + channelId: channelId, + id: messageId + } + }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation for an incorrect channelId missing leading 19:.', async () => { + const actual = await command.validate({ + options: { + teamId: teamId, + channelId: '552b7125655c46d5b5b86db02ee7bfdf@thread.skype', + id: messageId + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation for an incorrect channelId missing trailing @thread.skype.', async () => { + const actual = await command.validate({ + options: { + teamId: teamId, + channelId: '19:552b7125655c46d5b5b86db02ee7bfdf@thread', + id: messageId + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('restores the specified message', async () => { + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/teams/${teamId}/channels/${channelId}/messages/${messageId}/undoSoftDelete`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + teamId: teamId, + channelId: channelId, + id: messageId + } + }); + + assert(postStub.calledOnce); + }); + + it('restores the specified message by team name and channel name', async () => { + sinon.stub(teams, 'getChannelIdByDisplayName').withArgs(teamId, channelName).resolves(channelId); + sinon.stub(teams, 'getTeamIdByDisplayName').withArgs(teamName).resolves(teamId); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/teams/${teamId}/channels/${channelId}/messages/${messageId}/undoSoftDelete`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + verbose: true, + teamName: teamName, + channelName: channelName, + id: messageId + } + }); + + assert(postStub.calledOnce); + }); + + it('correctly handles error when retrieving a message', async () => { + const error = { + "error": { + "code": "UnknownError", + "message": "An error has occurred", + "innerError": { + "date": "2022-02-14T13:27:37", + "request-id": "77e0ed26-8b57-48d6-a502-aca6211d6e7c", + "client-request-id": "77e0ed26-8b57-48d6-a502-aca6211d6e7c" + } + } + }; + + sinon.stub(request, 'post').rejects(error); + + await assert.rejects(command.action(logger, { + options: { + teamId: teamId, + channelId: channelId, + id: messageId + } + }), new CommandError('An error has occurred')); + }); +}); \ No newline at end of file diff --git a/src/m365/teams/commands/message/message-restore.ts b/src/m365/teams/commands/message/message-restore.ts new file mode 100644 index 00000000000..b56a76dd805 --- /dev/null +++ b/src/m365/teams/commands/message/message-restore.ts @@ -0,0 +1,146 @@ +import { Logger } from '../../../../cli/Logger.js'; +import GlobalOptions from '../../../../GlobalOptions.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { validation } from '../../../../utils/validation.js'; +import commands from '../../commands.js'; +import DelegatedGraphCommand from '../../../base/DelegatedGraphCommand.js'; +import { teams } from '../../../../utils/teams.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + teamId?: string; + teamName?: string; + channelId?: string; + channelName?: string; + id: string; +} + +class TeamsMessageRestoreCommand extends DelegatedGraphCommand { + public get name(): string { + return commands.MESSAGE_RESTORE; + } + + public get description(): string { + return 'Restores a deleted message from a channel in a Microsoft Teams team'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + this.#initOptionSets(); + this.#initTypes(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + teamId: typeof args.options.teamId !== 'undefined', + teamName: typeof args.options.teamName !== 'undefined', + channelId: typeof args.options.channelId !== 'undefined', + channelName: typeof args.options.channelName !== 'undefined' + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '--teamId [teamId]' + }, + { + option: '--teamName [teamName]' + }, + { + option: '--channelId [channelId]' + }, + { + option: '--channelName [channelName]' + }, + { + option: '-i, --id ' + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.teamId && !validation.isValidGuid(args.options.teamId)) { + return `'${args.options.teamId}' is not a valid GUID for 'teamId'.`; + } + + if (args.options.channelId && !validation.isValidTeamsChannelId(args.options.channelId)) { + return `'${args.options.channelId}' is not a valid ID for 'channelId'.`; + } + + return true; + } + ); + } + + #initOptionSets(): void { + this.optionSets.push({ options: ['teamId', 'teamName'] }, { options: ['channelId', 'channelName'] }); + } + + #initTypes(): void { + this.types.string.push('teamId', 'teamName', 'channelId', 'channelName', 'id'); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + if (this.verbose) { + await logger.logToStderr(`Restoring deleted message '${args.options.id}' from channel '${args.options.channelId || args.options.channelName}' in the Microsoft Teams team '${args.options.teamId || args.options.teamName}'.`); + } + + const teamId: string = await this.getTeamId(args.options, logger); + const channelId: string = await this.getChannelId(args.options, teamId, logger); + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/teams/${teamId}/channels/${channelId}/messages/${args.options.id}/undoSoftDelete`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + await request.post(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private async getTeamId(options: Options, logger: Logger): Promise { + if (options.teamId) { + return options.teamId; + } + + if (this.verbose) { + await logger.logToStderr(`Getting the Team ID.`); + } + + const groupId = await teams.getTeamIdByDisplayName(options.teamName!); + + return groupId; + } + + private async getChannelId(options: Options, teamId: string, logger: Logger): Promise { + if (options.channelId) { + return options.channelId; + } + + if (this.verbose) { + await logger.logToStderr(`Getting the channel ID.`); + } + + const channelId = await teams.getChannelIdByDisplayName(teamId, options.channelName!); + return channelId; + } +} + +export default new TeamsMessageRestoreCommand(); \ No newline at end of file diff --git a/src/settingsNames.ts b/src/settingsNames.ts index 6f19cee7ef1..ff6ba887e4c 100644 --- a/src/settingsNames.ts +++ b/src/settingsNames.ts @@ -1,6 +1,11 @@ const settingsNames = { authType: 'authType', autoOpenLinksInBrowser: 'autoOpenLinksInBrowser', + clientId: 'clientId', + clientSecret: 'clientSecret', + clientCertificateFile: 'clientCertificateFile', + clientCertificateBase64Encoded: 'clientCertificateBase64Encoded', + clientCertificatePassword: 'clientCertificatePassword', copyDeviceCodeToClipboard: 'copyDeviceCodeToClipboard', csvEscape: 'csvEscape', csvHeader: 'csvHeader', @@ -16,7 +21,8 @@ const settingsNames = { prompt: 'prompt', promptListPageSize: 'promptListPageSize', showHelpOnFailure: 'showHelpOnFailure', - showSpinner: 'showSpinner' + showSpinner: 'showSpinner', + tenantId: 'tenantId' }; export { settingsNames }; diff --git a/src/utils/entraApp.ts b/src/utils/entraApp.ts new file mode 100644 index 00000000000..d017fcf3349 --- /dev/null +++ b/src/utils/entraApp.ts @@ -0,0 +1,414 @@ +import { RequiredResourceAccess, ResourceAccess } from '@microsoft/microsoft-graph-types'; +import fs from 'fs'; +import { Logger } from '../cli/Logger.js'; +import request, { CliRequestOptions } from '../request.js'; +import { odata } from './odata.js'; + +export interface AppInfo { + appId: string; + // objectId + id: string; + tenantId: string; + secrets?: { + displayName: string; + value: string; + }[]; + requiredResourceAccess: RequiredResourceAccess[]; +} + +export interface ServicePrincipalInfo { + appId: string; + appRoles: { id: string; value: string; }[]; + id: string; + oauth2PermissionScopes: { id: string; value: string; }[]; + servicePrincipalNames: string[]; +} + +export interface AppCreationOptions { + apisApplication?: string; + apisDelegated?: string; + implicitFlow: boolean; + multitenant: boolean; + name?: string; + platform?: string; + redirectUris?: string; + certificateFile?: string; + certificateBase64Encoded?: string; + certificateDisplayName?: string; + allowPublicClientFlows?: boolean; +} + +export interface AppPermissions { + resourceId: string; + resourceAccess: ResourceAccess[]; + scope: string[]; +} + +async function getCertificateBase64Encoded({ options, logger, debug }: { + options: AppCreationOptions, + logger: Logger, + debug: boolean +}): Promise { + if (options.certificateBase64Encoded) { + return options.certificateBase64Encoded; + } + + if (debug) { + await logger.logToStderr(`Reading existing ${options.certificateFile}...`); + } + + try { + return fs.readFileSync(options.certificateFile as string, { encoding: 'base64' }); + } + catch (e) { + throw new Error(`Error reading certificate file: ${e}. Please add the certificate using base64 option '--certificateBase64Encoded'.`); + } +} + +async function createServicePrincipal(appId: string): Promise { + const requestOptions: CliRequestOptions = { + url: `https://graph.microsoft.com/v1.0/myorganization/servicePrincipals`, + headers: { + 'content-type': 'application/json' + }, + data: { + appId: appId + }, + responseType: 'json' + }; + + return request.post(requestOptions); +} + +async function grantOAuth2Permission({ appId, resourceId, scopeName }: { + appId: string, + resourceId: string, + scopeName: string +}): Promise { + const grantAdminConsentApplicationRequestOptions: CliRequestOptions = { + url: `https://graph.microsoft.com/v1.0/myorganization/oauth2PermissionGrants`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: { + clientId: appId, + consentType: "AllPrincipals", + principalId: null, + resourceId: resourceId, + scope: scopeName + } + }; + + return request.post(grantAdminConsentApplicationRequestOptions); +} + +async function addRoleToServicePrincipal({ objectId, resourceId, appRoleId }: { + objectId: string, + resourceId: string, + appRoleId: string +}): Promise { + const requestOptions: CliRequestOptions = { + url: `https://graph.microsoft.com/v1.0/myorganization/servicePrincipals/${objectId}/appRoleAssignments`, + headers: { + 'Content-Type': 'application/json' + }, + responseType: 'json', + data: { + appRoleId: appRoleId, + principalId: objectId, + resourceId: resourceId + } + }; + + return request.post(requestOptions); +} + +async function getRequiredResourceAccessForApis({ servicePrincipals, apis, scopeType, logger, debug }: { + servicePrincipals: ServicePrincipalInfo[], + apis: string | undefined, + scopeType: string, + logger: Logger, + debug: boolean +}): Promise { + if (!apis) { + return []; + } + + const resolvedApis: RequiredResourceAccess[] = []; + const requestedApis: string[] = apis!.split(',').map(a => a.trim()); + for (const api of requestedApis) { + const pos: number = api.lastIndexOf('/'); + const permissionName: string = api.substring(pos + 1); + const servicePrincipalName: string = api.substring(0, pos); + if (debug) { + await logger.logToStderr(`Resolving ${api}...`); + await logger.logToStderr(`Permission name: ${permissionName}`); + await logger.logToStderr(`Service principal name: ${servicePrincipalName}`); + } + const servicePrincipal = servicePrincipals.find(sp => ( + sp.servicePrincipalNames.indexOf(servicePrincipalName) > -1 || + sp.servicePrincipalNames.indexOf(`${servicePrincipalName}/`) > -1)); + if (!servicePrincipal) { + throw `Service principal ${servicePrincipalName} not found`; + } + + const scopesOfType = scopeType === 'Scope' ? servicePrincipal.oauth2PermissionScopes : servicePrincipal.appRoles; + const permission = scopesOfType.find(scope => scope.value === permissionName); + if (!permission) { + throw `Permission ${permissionName} for service principal ${servicePrincipalName} not found`; + } + + let resolvedApi = resolvedApis.find(a => a.resourceAppId === servicePrincipal.appId); + if (!resolvedApi) { + resolvedApi = { + resourceAppId: servicePrincipal.appId, + resourceAccess: [] + }; + resolvedApis.push(resolvedApi); + } + + const resourceAccessPermission = { + id: permission.id, + type: scopeType + }; + + resolvedApi.resourceAccess!.push(resourceAccessPermission); + + updateAppPermissions({ + spId: servicePrincipal.id, + resourceAccessPermission, + oAuth2PermissionValue: permission.value + }); + } + + return resolvedApis; +} + +function updateAppPermissions({ spId, resourceAccessPermission, oAuth2PermissionValue }: { + spId: string, + resourceAccessPermission: ResourceAccess, + oAuth2PermissionValue?: string +}): void { + // During API resolution, we store globally both app role assignments and oauth2permissions + // So that we'll be able to parse them during the admin consent process + let existingPermission = entraApp.appPermissions.find(oauth => oauth.resourceId === spId); + if (!existingPermission) { + existingPermission = { + resourceId: spId, + resourceAccess: [], + scope: [] + }; + + entraApp.appPermissions.push(existingPermission); + } + + if (resourceAccessPermission.type === 'Scope' && oAuth2PermissionValue && !existingPermission.scope.find(scp => scp === oAuth2PermissionValue)) { + existingPermission.scope.push(oAuth2PermissionValue); + } + + if (!existingPermission.resourceAccess.find(res => res.id === resourceAccessPermission.id)) { + existingPermission.resourceAccess.push(resourceAccessPermission); + } +} + +export const entraApp = { + appPermissions: [] as AppPermissions[], + createAppRegistration: async ({ options, apis, logger, verbose, debug }: { + options: AppCreationOptions, + apis: RequiredResourceAccess[], + logger: Logger, + verbose: boolean, + debug: boolean + }): Promise => { + const applicationInfo: any = { + displayName: options.name, + signInAudience: options.multitenant ? 'AzureADMultipleOrgs' : 'AzureADMyOrg' + }; + + if (apis.length > 0) { + applicationInfo.requiredResourceAccess = apis; + } + + if (options.redirectUris) { + applicationInfo[options.platform!] = { + redirectUris: options.redirectUris.split(',').map(u => u.trim()) + }; + } + + if (options.implicitFlow) { + if (!applicationInfo.web) { + applicationInfo.web = {}; + } + applicationInfo.web.implicitGrantSettings = { + enableAccessTokenIssuance: true, + enableIdTokenIssuance: true + }; + } + + if (options.certificateFile || options.certificateBase64Encoded) { + const certificateBase64Encoded = await getCertificateBase64Encoded({ options, logger, debug }); + + const newKeyCredential = { + type: 'AsymmetricX509Cert', + usage: 'Verify', + displayName: options.certificateDisplayName, + key: certificateBase64Encoded + } as any; + + applicationInfo.keyCredentials = [newKeyCredential]; + } + + if (options.allowPublicClientFlows) { + applicationInfo.isFallbackPublicClient = true; + } + + if (verbose) { + await logger.logToStderr(`Creating Microsoft Entra app registration...`); + } + + const createApplicationRequestOptions: CliRequestOptions = { + url: `https://graph.microsoft.com/v1.0/myorganization/applications`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: applicationInfo + }; + + return request.post(createApplicationRequestOptions); + }, + grantAdminConsent: async ({ appInfo, appPermissions, adminConsent, logger, debug }: { + appInfo: AppInfo, + adminConsent: boolean | undefined, + appPermissions: AppPermissions[], + logger: Logger, + debug: boolean + }): Promise => { + if (!adminConsent || appPermissions.length === 0) { + return appInfo; + } + + const sp = await createServicePrincipal(appInfo.appId); + if (debug) { + await logger.logToStderr("Service principal created, returned object id: " + sp.id); + } + + const tasks: Promise[] = []; + + appPermissions.forEach(async (permission) => { + if (permission.scope.length > 0) { + tasks.push(grantOAuth2Permission({ + appId: sp.id, + resourceId: permission.resourceId, + scopeName: permission.scope.join(' ') + })); + + if (debug) { + await logger.logToStderr(`Admin consent granted for following resource ${permission.resourceId}, with delegated permissions: ${permission.scope.join(',')}`); + } + } + + permission.resourceAccess.filter(access => access.type === "Role").forEach(async (access: ResourceAccess) => { + tasks.push(addRoleToServicePrincipal({ + objectId: sp.id, + resourceId: permission.resourceId, + appRoleId: access.id! + })); + + if (debug) { + await logger.logToStderr(`Admin consent granted for following resource ${permission.resourceId}, with application permission: ${access.id}`); + } + }); + }); + + await Promise.all(tasks); + return appInfo; + }, + resolveApis: async ({ options, manifest, logger, verbose, debug }: { + options: AppCreationOptions, + manifest?: any, + logger: Logger, + verbose: boolean, + debug: boolean + }): Promise => { + if (!options.apisDelegated && !options.apisApplication + && (typeof manifest?.requiredResourceAccess === 'undefined' || manifest.requiredResourceAccess.length === 0)) { + return []; + } + + if (verbose) { + await logger.logToStderr('Resolving requested APIs...'); + } + + const servicePrincipals = await odata.getAllItems(`https://graph.microsoft.com/v1.0/myorganization/servicePrincipals?$select=appId,appRoles,id,oauth2PermissionScopes,servicePrincipalNames`); + + let resolvedApis: RequiredResourceAccess[] = []; + + if (options.apisDelegated || options.apisApplication) { + resolvedApis = await getRequiredResourceAccessForApis({ + servicePrincipals, + apis: options.apisDelegated, + scopeType: 'Scope', + logger, + debug + }); + if (verbose) { + await logger.logToStderr(`Resolved delegated permissions: ${JSON.stringify(resolvedApis, null, 2)}`); + } + const resolvedApplicationApis = await getRequiredResourceAccessForApis({ + servicePrincipals, + apis: options.apisApplication, + scopeType: 'Role', + logger, + debug + }); + if (verbose) { + await logger.logToStderr(`Resolved application permissions: ${JSON.stringify(resolvedApplicationApis, null, 2)}`); + } + // merge resolved application APIs onto resolved delegated APIs + resolvedApplicationApis.forEach(resolvedRequiredResource => { + const requiredResource = resolvedApis.find(api => api.resourceAppId === resolvedRequiredResource.resourceAppId); + if (requiredResource) { + requiredResource.resourceAccess!.push(...resolvedRequiredResource.resourceAccess!); + } + else { + resolvedApis.push(resolvedRequiredResource); + } + }); + } + else { + const manifestApis = (manifest.requiredResourceAccess as RequiredResourceAccess[]); + + manifestApis.forEach(manifestApi => { + resolvedApis.push(manifestApi); + + const app = servicePrincipals.find(servicePrincipals => servicePrincipals.appId === manifestApi.resourceAppId); + + if (app) { + manifestApi.resourceAccess!.forEach((res => { + const resourceAccessPermission = { + id: res.id, + type: res.type + }; + + const oAuthValue = app.oauth2PermissionScopes.find(scp => scp.id === res.id)?.value; + updateAppPermissions({ + spId: app.id, + resourceAccessPermission, + oAuth2PermissionValue: oAuthValue + }); + })); + } + }); + } + + if (verbose) { + await logger.logToStderr(`Merged delegated and application permissions: ${JSON.stringify(resolvedApis, null, 2)}`); + await logger.logToStderr(`App role assignments: ${JSON.stringify(entraApp.appPermissions.flatMap(permission => permission.resourceAccess.filter(access => access.type === "Role")), null, 2)}`); + await logger.logToStderr(`OAuth2 permissions: ${JSON.stringify(entraApp.appPermissions.flatMap(permission => permission.scope), null, 2)}`); + } + + return resolvedApis; + } +}; \ No newline at end of file diff --git a/src/utils/spo.spec.ts b/src/utils/spo.spec.ts index 112244c65e8..689d9d9a758 100644 --- a/src/utils/spo.spec.ts +++ b/src/utils/spo.spec.ts @@ -2580,4 +2580,93 @@ describe('utils/spo', () => { assert.deepStrictEqual(ex, `File Not Found`); } }); + + it(`gets primary admin loginName from admin site`, async () => { + const adminUrl = 'https://contoso-admin.sharepoint.com'; + const siteId = '0ead8b78-89e5-427f-b1bc-6e5a77ac191c'; + const primaryAdminLoginName = 'user1loginName'; + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites('${siteId}')?$select=OwnerLoginName`) { + return JSON.stringify({ OwnerLoginName: primaryAdminLoginName }); + } + + throw 'Invalid request'; + }); + + const result = await spo.getPrimaryAdminLoginNameAsAdmin(adminUrl, siteId, logger, true); + assert.strictEqual(result, primaryAdminLoginName); + }); + + it(`gets primary admin loginName from site`, async () => { + const siteUrl = 'https://contoso.sharepoint.com'; + const primaryAdminLoginName = 'user1loginName'; + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/site/owner`) { + return { LoginName: primaryAdminLoginName }; + } + + throw 'Invalid request'; + }); + + const result = await spo.getPrimaryOwnerLoginFromSite(siteUrl, logger, true); + assert.strictEqual(result, primaryAdminLoginName); + }); + + it(`retrieves a file with its properties sucessfully`, async () => { + const id = 'b2307a39-e878-458b-bc90-03bc578531d6'; + const fileResponse = { + ListItemAllFields: { + FileSystemObjectType: 0, + Id: 4, + ServerRedirectedEmbedUri: 'https://contoso.sharepoint.com/sites/project-x/_layouts/15/WopiFrame.aspx?sourcedoc={b2307a39-e878-458b-bc90-03bc578531d6}&action=interactivepreview', + ServerRedirectedEmbedUrl: 'https://contoso.sharepoint.com/sites/project-x/_layouts/15/WopiFrame.aspx?sourcedoc={b2307a39-e878-458b-bc90-03bc578531d6}&action=interactivepreview', + ContentTypeId: '0x0101008E462E3ACE8DB844B3BEBF9473311889', + ComplianceAssetId: null, + Title: null, + ID: 4, + Created: '2018-02-05T09:42:36', + AuthorId: 1, + Modified: '2018-02-05T09:44:03', + EditorId: 1, + 'OData__CopySource': null, + CheckoutUserId: null, + 'OData__UIVersionString': '3.0', + GUID: '2054f49e-0f76-46d4-ac55-50e1c057941c' + }, + CheckInComment: '', + CheckOutType: 2, + ContentTag: '{F09C4EFE-B8C0-4E89-A166-03418661B89B},9,12', + CustomizedPageStatus: 0, + ETag: '\'{F09C4EFE-B8C0-4E89-A166-03418661B89B},9\'', + Exists: true, + IrmEnabled: false, + Length: '331673', + Level: 1, + LinkingUri: 'https://contoso.sharepoint.com/sites/project-x/Documents/Test1.docx?d=wf09c4efeb8c04e89a16603418661b89b', + LinkingUrl: 'https://contoso.sharepoint.com/sites/project-x/Documents/Test1.docx?d=wf09c4efeb8c04e89a16603418661b89b', + MajorVersion: 3, + MinorVersion: 0, + Name: 'Opendag maart 2018.docx', + ServerRelativeUrl: '/sites/project-x/Documents/Test1.docx', + TimeCreated: '2018-02-05T08:42:36Z', + TimeLastModified: '2018-02-05T08:44:03Z', + Title: '', + UIVersion: 1536, + UIVersionLabel: '3.0', + UniqueId: 'b2307a39-e878-458b-bc90-03bc578531d6' + }; + + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/sales/_api/web/GetFileById('${formatting.encodeQueryParameter(id)}')`) { + return fileResponse; + } + + throw 'Invalid request'; + }); + + const group = await spo.getFileById(webUrl, id, logger, true); + assert.deepEqual(group, fileResponse); + }); }); \ No newline at end of file diff --git a/src/utils/spo.ts b/src/utils/spo.ts index 32b03d345a1..2c415f9dd4f 100644 --- a/src/utils/spo.ts +++ b/src/utils/spo.ts @@ -653,10 +653,10 @@ export const spo = { * @param webUrl Web url * @param email The email of the user * @param logger the Logger object - * @param verbose set if verbose logging should be logged + * @param verbose set for verbose logging */ - async getUserByEmail(webUrl: string, email: string, logger: Logger, verbose?: boolean): Promise { - if (verbose) { + async getUserByEmail(webUrl: string, email: string, logger?: Logger, verbose?: boolean): Promise { + if (verbose && logger) { await logger.logToStderr(`Retrieving the spo user by email ${email}`); } const requestUrl = `${webUrl}/_api/web/siteusers/GetByEmail('${formatting.encodeQueryParameter(email)}')`; @@ -737,10 +737,10 @@ export const spo = { * @param webUrl Web url * @param name The name of the group * @param logger the Logger object - * @param verbose set if verbose logging should be logged + * @param verbose set for verbose logging */ - async getGroupByName(webUrl: string, name: string, logger: Logger, verbose?: boolean): Promise { - if (verbose) { + async getGroupByName(webUrl: string, name: string, logger?: Logger, verbose?: boolean): Promise { + if (verbose && logger) { await logger.logToStderr(`Retrieving the group by name ${name}`); } const requestUrl = `${webUrl}/_api/web/sitegroups/GetByName('${formatting.encodeQueryParameter(name)}')`; @@ -763,10 +763,10 @@ export const spo = { * @param webUrl Web url * @param name the name of the role definition * @param logger the Logger object - * @param debug set if debug logging should be logged + * @param verbose set for verbose logging */ - async getRoleDefinitionByName(webUrl: string, name: string, logger: Logger, debug?: boolean): Promise { - if (debug) { + async getRoleDefinitionByName(webUrl: string, name: string, logger?: Logger, verbose?: boolean): Promise { + if (verbose && logger) { await logger.logToStderr(`Retrieving the role definitions for ${name}`); } @@ -1860,5 +1860,84 @@ export const spo = { const itemsResponse = await request.get(requestOptionsItems); return (itemsResponse); + }, + + /** + * Retrieves the file by id. + * Returns a FileProperties object + * @param webUrl Web url + * @param id the id of the file + * @param logger the Logger object + * @param verbose set for verbose logging + */ + async getFileById(webUrl: string, id: string, logger?: Logger, verbose?: boolean): Promise { + if (verbose && logger) { + await logger.logToStderr(`Retrieving the file with id ${id}`); + } + const requestUrl = `${webUrl}/_api/web/GetFileById('${formatting.encodeQueryParameter(id)}')`; + + const requestOptions: CliRequestOptions = { + url: requestUrl, + headers: { + 'accept': 'application/json;odata=nometadata' + }, + + responseType: 'json' + }; + + const file: FileProperties = await request.get(requestOptions); + + return file; + }, + + /** + * Gets the site collection URL for a given web URL using SP Admin site. + * @param adminUrl The SharePoint admin URL + * @param siteId The site ID + * @param logger The logger object + * @param verbose If in verbose mode + * @returns Owner login name + */ + async getPrimaryAdminLoginNameAsAdmin(adminUrl: string, siteId: string, logger: Logger, verbose: boolean): Promise { + if (verbose) { + await logger.logToStderr('Getting the primary admin login name...'); + } + + const requestOptions: CliRequestOptions = { + url: `${adminUrl}/_api/SPO.Tenant/sites('${siteId}')?$select=OwnerLoginName`, + headers: { + accept: 'application/json;odata=nometadata', + 'content-type': 'application/json;charset=utf-8' + } + }; + + const response: string = await request.get(requestOptions); + const responseContent = JSON.parse(response); + return responseContent.OwnerLoginName; + }, + + /** + * Gets the primary owner login from a site. + * @param siteUrl The site URL + * @param logger The logger object + * @param verbose If in verbose mode + * @returns Owner login name + */ + async getPrimaryOwnerLoginFromSite(siteUrl: string, logger: Logger, verbose: boolean): Promise { + if (verbose) { + await logger.logToStderr('Getting the primary admin login name...'); + } + + const requestOptions: CliRequestOptions = { + url: `${siteUrl}/_api/site/owner`, + method: 'GET', + headers: { + 'accept': 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + const responseContent = await request.get<{ LoginName: string }>(requestOptions); + return responseContent?.LoginName; } }; \ No newline at end of file diff --git a/src/utils/zod.ts b/src/utils/zod.ts index f7c021910ce..fb55d1513e9 100644 --- a/src/utils/zod.ts +++ b/src/utils/zod.ts @@ -41,7 +41,7 @@ function parseString(_def: z.ZodStringDef, _options: CommandOptionInfo[], curren if (currentOption) { currentOption.type = 'string'; } - + return; } @@ -49,7 +49,7 @@ function parseNumber(_def: z.ZodNumberDef, _options: CommandOptionInfo[], curren if (currentOption) { currentOption.type = 'number'; } - + return; } @@ -57,7 +57,7 @@ function parseBoolean(_def: z.ZodBooleanDef, _options: CommandOptionInfo[], curr if (currentOption) { currentOption.type = 'boolean'; } - + return; } @@ -80,7 +80,7 @@ function parseDefault(def: z.ZodDefaultDef, _options: CommandOptionInfo[], curre function parseEnum(def: z.ZodEnumDef, _options: CommandOptionInfo[], currentOption?: CommandOptionInfo): z.ZodTypeDef | undefined { if (currentOption) { currentOption.type = 'string'; - currentOption.autocomplete = def.values; + currentOption.autocomplete = [...def.values]; } return;