diff --git a/docs/docs/cmd/teams/team/team-app-list.md b/docs/docs/cmd/teams/team/team-app-list.md new file mode 100644 index 00000000000..d136f7441f8 --- /dev/null +++ b/docs/docs/cmd/teams/team/team-app-list.md @@ -0,0 +1,78 @@ +# teams team app list + +List apps installed in the specified team + +## Usage + +```sh +m365 teams team app list [options] +``` + +## Options + +`-i, --teamId [teamId]` +: The id of the Microsoft Teams team. Specify either `teamId` or `teamName` but not both. + +`-n, --teamName [teamName]` +: The name of the Microsoft Teams team. Specify either `teamId` or `teamName` but not both. + + +--8<-- "docs/cmd/_global.md" + +## Examples + +List applications installed in the specified Microsoft Teams team by id + +```sh +m365 teams team app list --teamId 2eaf7dcd-7e83-4c3a-94f7-932a1299c844 +``` + +List applications installed in the specified Microsoft Teams team by name + +```sh +m365 teams team app list --teamName "Team Name" +``` + +## Response + +=== "JSON" + + ```json + [ + { + "id": "MGFkNTViNWQtNmE3OS00NjdiLWFkMjEtZDRiZWY3OTQ4YTc5IyMxNGQ2OTYyZC02ZWViLTRmNDgtODg5MC1kZTU1NDU0YmIxMzY=", + "teamsApp": { + "id": "14d6962d-6eeb-4f48-8890-de55454bb136", + "externalId": null, + "displayName": "Activity", + "distributionMethod": "store" + }, + "teamsAppDefinition": { + "id": "MTRkNjk2MmQtNmVlYi00ZjQ4LTg4OTAtZGU1NTQ1NGJiMTM2IyMxLjAjI1B1Ymxpc2hlZA==", + "teamsAppId": "14d6962d-6eeb-4f48-8890-de55454bb136", + "displayName": "Activity", + "version": "1.0", + "publishingState": "published", + "shortDescription": "Activity app bar entry.", + "description": "Activity app bar entry.", + "lastModifiedDateTime": null, + "createdBy": null + } + } + ] + ``` + +=== "Text" + + ```text + id displayName distributionMethod + ---------------------------------------------------------------------------------------------------- ----------- ------------------ + MGFkNTViNWQtNmE3OS00NjdiLWFkMjEtZDRiZWY3OTQ4YTc5IyMxNGQ2OTYyZC02ZWViLTRmNDgtODg5MC1kZTU1NDU0YmIxMzY= Activity store + ``` + +=== "CSV" + + ```csv + id,displayName,distributionMethod + MGFkNTViNWQtNmE3OS00NjdiLWFkMjEtZDRiZWY3OTQ4YTc5IyMxNGQ2OTYyZC02ZWViLTRmNDgtODg5MC1kZTU1NDU0YmIxMzY=,Activity,store + ``` diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index e562131b92b..51c80a9a5cd 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -696,6 +696,7 @@ nav: - team remove: cmd/teams/team/team-remove.md - team set: cmd/teams/team/team-set.md - team unarchive: cmd/teams/team/team-unarchive.md + - team app list: cmd/teams/team/team-app-list.md - user: - user add: cmd/aad/o365group/o365group-user-add.md - user list: cmd/teams/user/user-list.md diff --git a/src/m365/teams/commands.ts b/src/m365/teams/commands.ts index b0a3aa2fbb2..c128a0dc619 100644 --- a/src/m365/teams/commands.ts +++ b/src/m365/teams/commands.ts @@ -48,6 +48,7 @@ export default { TAB_LIST: `${prefix} tab list`, TAB_REMOVE: `${prefix} tab remove`, TEAM_ADD: `${prefix} team add`, + TEAM_APP_LIST: `${prefix} team app list`, TEAM_ARCHIVE: `${prefix} team archive`, TEAM_CLONE: `${prefix} team clone`, TEAM_GET: `${prefix} team get`, diff --git a/src/m365/teams/commands/team/team-app-list.spec.ts b/src/m365/teams/commands/team/team-app-list.spec.ts new file mode 100644 index 00000000000..846a043593b --- /dev/null +++ b/src/m365/teams/commands/team/team-app-list.spec.ts @@ -0,0 +1,131 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import appInsights from '../../../../appInsights'; +import auth from '../../../../Auth'; +import { Cli } from '../../../../cli/Cli'; +import { CommandInfo } from '../../../../cli/CommandInfo'; +import { Logger } from '../../../../cli/Logger'; +import Command, { CommandError } from '../../../../Command'; +import { odata } from '../../../../utils/odata'; +import { pid } from '../../../../utils/pid'; +import { sinonUtil } from '../../../../utils/sinonUtil'; +import commands from '../../commands'; +import * as TeamGetCommand from './team-get'; +const command: Command = require('./team-app-list'); + +describe(commands.TEAM_APP_LIST, () => { + const teamId = '0ad55b5d-6a79-467b-ad21-d4bef7948a79'; + const teamName = 'Contoso Team'; + const jsonResponse = JSON.parse(`[{"id":"MGFkNTViNWQtNmE3OS00NjdiLWFkMjEtZDRiZWY3OTQ4YTc5IyMxNGQ2OTYyZC02ZWViLTRmNDgtODg5MC1kZTU1NDU0YmIxMzY=","teamsApp":{"id":"14d6962d-6eeb-4f48-8890-de55454bb136","externalId":null,"displayName":"Activity","distributionMethod":"store"},"teamsAppDefinition":{"id":"MTRkNjk2MmQtNmVlYi00ZjQ4LTg4OTAtZGU1NTQ1NGJiMTM2IyMxLjAjI1B1Ymxpc2hlZA==","teamsAppId":"14d6962d-6eeb-4f48-8890-de55454bb136","displayName":"Activity","version":"1.0","publishingState":"published","shortDescription":"Activity app bar entry.","description":"Activity app bar entry.","lastModifiedDateTime":null,"createdBy":null}},{"id":"MGFkNTViNWQtNmE3OS00NjdiLWFkMjEtZDRiZWY3OTQ4YTc5IyMyMGMzNDQwZC1jNjdlLTQ0MjAtOWY4MC0wZTUwYzM5NjkzZGY=","teamsApp":{"id":"20c3440d-c67e-4420-9f80-0e50c39693df","externalId":null,"displayName":"Calling","distributionMethod":"store"},"teamsAppDefinition":{"id":"MjBjMzQ0MGQtYzY3ZS00NDIwLTlmODAtMGU1MGMzOTY5M2RmIyMxLjAjI1B1Ymxpc2hlZA==","teamsAppId":"20c3440d-c67e-4420-9f80-0e50c39693df","displayName":"Calling","version":"1.0","publishingState":"published","shortDescription":"Calling app bar entry.","description":"Calling app bar entry.","lastModifiedDateTime":null,"createdBy":null}},{"id":"MGFkNTViNWQtNmE3OS00NjdiLWFkMjEtZDRiZWY3OTQ4YTc5IyMyYTg0OTE5Zi01OWQ4LTQ0NDEtYTk3NS0yYThjMjY0M2I3NDE=","teamsApp":{"id":"2a84919f-59d8-4441-a975-2a8c2643b741","externalId":null,"displayName":"Teams","distributionMethod":"store"},"teamsAppDefinition":{"id":"MmE4NDkxOWYtNTlkOC00NDQxLWE5NzUtMmE4YzI2NDNiNzQxIyMxLjAjI1B1Ymxpc2hlZA==","teamsAppId":"2a84919f-59d8-4441-a975-2a8c2643b741","displayName":"Teams","version":"1.0","publishingState":"published","shortDescription":"Teams app bar entry.","description":"Teams app bar entry.","lastModifiedDateTime":null,"createdBy":null}}]`); + const friendlyResponse = [{ "id": "MGFkNTViNWQtNmE3OS00NjdiLWFkMjEtZDRiZWY3OTQ4YTc5IyMxNGQ2OTYyZC02ZWViLTRmNDgtODg5MC1kZTU1NDU0YmIxMzY=", "displayName": "Activity", "distributionMethod": "store" }, { "id": "MGFkNTViNWQtNmE3OS00NjdiLWFkMjEtZDRiZWY3OTQ4YTc5IyMyMGMzNDQwZC1jNjdlLTQ0MjAtOWY4MC0wZTUwYzM5NjkzZGY=", "displayName": "Calling", "distributionMethod": "store" }, { "id": "MGFkNTViNWQtNmE3OS00NjdiLWFkMjEtZDRiZWY3OTQ4YTc5IyMyYTg0OTE5Zi01OWQ4LTQ0NDEtYTk3NS0yYThjMjY0M2I3NDE=", "displayName": "Teams", "distributionMethod": "store" }]; + + let log: string[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + + before(() => { + sinon.stub(auth, 'restoreAuth').callsFake(() => Promise.resolve()); + sinon.stub(appInsights, 'trackEvent').callsFake(() => { }); + sinon.stub(pid, 'getProcessName').callsFake(() => ''); + auth.service.connected = true; + commandInfo = Cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: (msg: string) => { + log.push(msg); + }, + logRaw: (msg: string) => { + log.push(msg); + }, + logToStderr: (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + (command as any).items = []; + }); + + afterEach(() => { + sinonUtil.restore([ + odata.getAllItems, + Cli.executeCommandWithOutput + ]); + }); + + after(() => { + sinonUtil.restore([ + auth.restoreAuth, + appInsights.trackEvent, + pid.getProcessName + ]); + auth.service.connected = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name.startsWith(commands.TEAM_APP_LIST), true); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if the teamId is not a valid GUID', async () => { + const actual = await command.validate({ options: { teamId: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation if the teamId is a valid GUID', async () => { + const actual = await command.validate({ options: { teamId: teamId } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails when team does not exist in tenant', async () => { + sinon.stub(Cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { + if (command === TeamGetCommand) { + throw 'The specified team does not exist in the Microsoft Teams'; + } + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { teamName: teamName, verbose: true } }), + new CommandError('The specified team does not exist in the Microsoft Teams')); + }); + + it('lists team apps for team specified by name with output json', async () => { + sinon.stub(Cli, 'executeCommandWithOutput').callsFake(async (command): Promise => { + if (command === TeamGetCommand) { + return { "stdout": JSON.stringify({ id: teamId }) }; + } + + throw 'Invalid request'; + }); + + sinon.stub(odata, 'getAllItems').callsFake(async (url: string): Promise => { + if (url === `https://graph.microsoft.com/v1.0/teams/${teamId}/installedApps?$expand=teamsApp,teamsAppDefinition`) { + return jsonResponse; + } + + throw 'Invalid response'; + }); + + await command.action(logger, { options: { teamName: teamName, verbose: true, output: 'json' } }); + assert(loggerLogSpy.calledWith(jsonResponse)); + }); + + it('lists team apps for team specified by id with output csv', async () => { + sinon.stub(odata, 'getAllItems').callsFake(async (url: string): Promise => { + if (url === `https://graph.microsoft.com/v1.0/teams/${teamId}/installedApps?$expand=teamsApp,teamsAppDefinition`) { + return jsonResponse; + } + + throw 'Invalid response'; + }); + + await command.action(logger, { options: { teamId: teamId, verbose: true, output: 'csv' } }); + assert(loggerLogSpy.calledWith(friendlyResponse)); + }); +}); \ No newline at end of file diff --git a/src/m365/teams/commands/team/team-app-list.ts b/src/m365/teams/commands/team/team-app-list.ts new file mode 100644 index 00000000000..2013efeaa98 --- /dev/null +++ b/src/m365/teams/commands/team/team-app-list.ts @@ -0,0 +1,124 @@ +import { Logger } from '../../../../cli/Logger'; +import GlobalOptions from '../../../../GlobalOptions'; +import { validation } from '../../../../utils/validation'; +import GraphCommand from '../../../base/GraphCommand'; +import commands from '../../commands'; +import { odata } from '../../../../utils/odata'; +import { Cli } from '../../../../cli/Cli'; +import { Options as TeamsTeamGetOptions } from './team-get'; +import * as TeamGetCommand from './team-get'; +import Command from '../../../../Command'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + teamId?: string; + teamName?: string; +} + +class TeamsTeamAppListCommand extends GraphCommand { + public get name(): string { + return commands.TEAM_APP_LIST; + } + + public get description(): string { + return 'List apps installed in the specified team'; + } + + public defaultProperties(): string[] | undefined { + return ['id', 'displayName', 'distributionMethod']; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + this.#initOptionSets(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + teamId: typeof args.options.teamId !== 'undefined', + teamName: typeof args.options.teamName !== 'undefined' + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-i, --teamId [teamId]' + }, + { + option: '-n, --teamName [teamName]' + } + ); + } + + #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`; + } + + return true; + } + ); + } + + #initOptionSets(): void { + this.optionSets.push({ options: ['teamId', 'teamName'] }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + if (this.verbose) { + logger.logToStderr(`Retrieving installed apps for team '${args.options.teamId || args.options.teamId}'`); + } + + const teamId: string = await this.getTeamId(args); + const res = await odata.getAllItems(`${this.resource}/v1.0/teams/${teamId}/installedApps?$expand=teamsApp,teamsAppDefinition`); + + if (args.options.output === 'json') { + logger.log(res); + } + else { + //converted to text friendly output + logger.log(res.map(i => { + return { + id: i.id, + displayName: i.teamsApp.displayName, + distributionMethod: i.teamsApp.distributionMethod + }; + })); + } + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private async getTeamId(args: CommandArgs): Promise { + if (args.options.teamId) { + return args.options.teamId; + } + + const teamGetOptions: TeamsTeamGetOptions = { + name: args.options.teamName, + debug: this.debug, + verbose: this.verbose + }; + + const commandOutput = await Cli.executeCommandWithOutput(TeamGetCommand as Command, { options: { ...teamGetOptions, _: [] } }); + const team = JSON.parse(commandOutput.stdout); + return team.id; + } +} + +module.exports = new TeamsTeamAppListCommand(); \ No newline at end of file diff --git a/src/m365/teams/commands/team/team-get.ts b/src/m365/teams/commands/team/team-get.ts index 365589a36ca..38ce81807d0 100644 --- a/src/m365/teams/commands/team/team-get.ts +++ b/src/m365/teams/commands/team/team-get.ts @@ -16,7 +16,7 @@ interface CommandArgs { options: Options; } -interface Options extends GlobalOptions { +export interface Options extends GlobalOptions { id?: string; name?: string; } @@ -101,7 +101,7 @@ class TeamsTeamGetCommand extends GraphCommand { }, responseType: 'json' }; - const res: Team = await request.get(requestOptions); + const res = await request.get(requestOptions); logger.log(res); } catch (err: any) {