diff --git a/.devproxy/api-specs/sharepoint.yaml b/.devproxy/api-specs/sharepoint.yaml index 6393d2cd60a..b3677ec4d43 100644 --- a/.devproxy/api-specs/sharepoint.yaml +++ b/.devproxy/api-specs/sharepoint.yaml @@ -113,6 +113,24 @@ paths: responses: 200: description: OK + /_api/web/Alerts/DeleteAlert({id}): + delete: + parameters: + - name: id + in: path + required: true + description: GUID of the alert to delete + schema: + type: string + example: "'f55e3c17-63ea-456a-8451-48d2839760f7'" + security: + - delegated: + - AllSites.FullControl + - application: + - Sites.FullControl.All + responses: + 200: + description: OK /_api/web/folders/addUsingPath(decodedUrl={folderPath}): post: parameters: diff --git a/docs/docs/cmd/spo/site/site-alert-remove.mdx b/docs/docs/cmd/spo/site/site-alert-remove.mdx new file mode 100644 index 00000000000..aea0b15dafa --- /dev/null +++ b/docs/docs/cmd/spo/site/site-alert-remove.mdx @@ -0,0 +1,65 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# spo site alert remove + +Removes an alert from a SharePoint list + +## Usage + +```sh +m365 spo site alert remove [options] +``` + +## Options + +```md definition-list +`-u, --webUrl ` +: The URL of the SharePoint site. + +`--id ` +: The ID of the alert. + +`-f, --force` +: Don't prompt for confirmation. +``` + + + +## Permissions + + + + + | Resource | Permissions | + |------------|----------------------| + | SharePoint | AllSites.FullControl | + + + + + | Resource | Permissions | + |------------|-----------------------| + | SharePoint | Sites.FullControl.All | + + + + +## Examples + +Remove an alert by ID + +```sh +m365 spo site alert remove --webUrl https://contoso.sharepoint.com/sites/Marketing --id 7cbb4c8d-8e4d-4d2e-9c6f-3f1d8b2e6a0e +``` + +Remove another alert without confirmation + +```sh +m365 spo site alert remove --webUrl https://contoso.sharepoint.com/sites/Marketing --id 2b6f1c8a-3e6a-4c7e-b8c0-7bf4c8e6d7f1 --force +``` + +## Response + +The command won't return a response on success. diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index b6c437fbe6a..c845f688aaf 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -3709,6 +3709,11 @@ const sidebars: SidebarsConfig = { label: 'site alert list', id: 'cmd/spo/site/site-alert-list' }, + { + type: 'doc', + label: 'site alert remove', + id: 'cmd/spo/site/site-alert-remove' + }, { type: 'doc', label: 'site appcatalog add', diff --git a/src/m365/spo/commands.ts b/src/m365/spo/commands.ts index c7497aa11ec..afac4f531be 100644 --- a/src/m365/spo/commands.ts +++ b/src/m365/spo/commands.ts @@ -259,6 +259,7 @@ export default { SITE_ADMIN_LIST: `${prefix} site admin list`, SITE_ADMIN_REMOVE: `${prefix} site admin remove`, SITE_ALERT_LIST: `${prefix} site alert list`, + SITE_ALERT_REMOVE: `${prefix} site alert remove`, SITE_APPCATALOG_ADD: `${prefix} site appcatalog add`, SITE_APPCATALOG_LIST: `${prefix} site appcatalog list`, SITE_APPCATALOG_REMOVE: `${prefix} site appcatalog remove`, diff --git a/src/m365/spo/commands/site/site-alert-remove.spec.ts b/src/m365/spo/commands/site/site-alert-remove.spec.ts new file mode 100644 index 00000000000..50dcd9929ba --- /dev/null +++ b/src/m365/spo/commands/site/site-alert-remove.spec.ts @@ -0,0 +1,130 @@ +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 { formatting } from '../../../../utils/formatting.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import { z } from 'zod'; +import commands from '../../commands.js'; +import command from './site-alert-remove.js'; + +describe(commands.SITE_ALERT_REMOVE, () => { + let log: any[]; + let logger: Logger; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; + let confirmationPromptStub: sinon.SinonStub; + + const webUrl = 'https://contoso.sharepoint.com/sites/marketing'; + const alertId = '39d9e102-9e8f-4e74-8f17-84a92f972fcf'; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').resolves(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').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); + } + }; + confirmationPromptStub = sinon.stub(cli, 'promptForConfirmation').resolves(false); + }); + + afterEach(() => { + sinonUtil.restore([ + request.delete, + cli.promptForConfirmation + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.SITE_ALERT_REMOVE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if webUrl is not a valid URL', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'foo', id: alertId }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if alertId is not a valid GUID', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: 'invalid' }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation when valid webUrl and alertId are provided', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: webUrl, id: alertId }); + assert.strictEqual(actual.success, true); + }); + + it('prompts before removing the alert', async () => { + await command.action(logger, { options: { webUrl: webUrl, id: alertId } }); + assert(confirmationPromptStub.calledOnce); + }); + + it('aborts removing the alert when prompt is not confirmed', async () => { + const deleteStub = sinon.stub(request, 'delete').resolves(); + + await command.action(logger, { options: { webUrl: webUrl, id: alertId } }); + assert(deleteStub.notCalled); + }); + + it('correctly removes the alert', async () => { + const deleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `${webUrl}/_api/web/Alerts/DeleteAlert('${formatting.encodeQueryParameter(alertId)}')`) { + return; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { webUrl: webUrl, id: alertId, force: true, verbose: true } }); + assert(deleteStub.calledOnce); + }); + + it('handles error correctly', async () => { + const error = { + error: { + 'odata.error': { + code: '-2146232832, Microsoft.SharePoint.SPException', + message: { + value: 'The alert you are trying to access does not exist or has just been deleted.' + } + } + } + }; + sinon.stub(request, 'delete').rejects(error); + + await assert.rejects(command.action(logger, { options: { force: true, webUrl: webUrl, id: alertId } }), + new CommandError(error.error['odata.error'].message.value)); + }); +}); \ No newline at end of file diff --git a/src/m365/spo/commands/site/site-alert-remove.ts b/src/m365/spo/commands/site/site-alert-remove.ts new file mode 100644 index 00000000000..2237c3b6731 --- /dev/null +++ b/src/m365/spo/commands/site/site-alert-remove.ts @@ -0,0 +1,75 @@ +import commands from '../../commands.js'; +import { Logger } from '../../../../cli/Logger.js'; +import SpoCommand from '../../../base/SpoCommand.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import { z } from 'zod'; +import { zod } from '../../../../utils/zod.js'; +import { validation } from '../../../../utils/validation.js'; +import { formatting } from '../../../../utils/formatting.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { cli } from '../../../../cli/cli.js'; + +const options = globalOptionsZod + .extend({ + webUrl: zod.alias('u', z.string() + .refine(url => validation.isValidSharePointUrl(url) === true, url => ({ + message: `'${url}' is not a valid SharePoint URL.` + }))), + id: z.string() + .refine(id => validation.isValidGuid(id), id => ({ + message: `'${id}' is not a valid GUID.` + })), + force: zod.alias('f', z.boolean().optional()) + }) + .strict(); + +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class SpoSiteAlertRemoveCommand extends SpoCommand { + public get name(): string { + return commands.SITE_ALERT_REMOVE; + } + + public get description(): string { + return 'Removes an alert from a SharePoint list'; + } + + public get schema(): z.ZodTypeAny | undefined { + return options; + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + if (!args.options.force) { + const result = await cli.promptForConfirmation({ message: `Are you sure you want to remove the alert with id '${args.options.id}' from site '${args.options.webUrl}'?` }); + + if (!result) { + return; + } + } + + try { + if (this.verbose) { + await logger.logToStderr(`Removing alert with ID '${args.options.id}' from site '${args.options.webUrl}'...`); + } + + const requestOptions: CliRequestOptions = { + url: `${args.options.webUrl}/_api/web/Alerts/DeleteAlert('${formatting.encodeQueryParameter(args.options.id)}')`, + headers: { + accept: 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + await request.delete(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new SpoSiteAlertRemoveCommand(); \ No newline at end of file