diff --git a/packages/cli/src/commands/models/permissionGrant.ts b/packages/cli/src/commands/models/permissionGrant.ts index 3c9b8660a06..d8c172e8c6d 100644 --- a/packages/cli/src/commands/models/permissionGrant.ts +++ b/packages/cli/src/commands/models/permissionGrant.ts @@ -1,7 +1,20 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { CLICommand, InputsWithProjectPath, err, ok } from "@microsoft/teamsfx-api"; -import { PermissionGrantInputs, PermissionGrantOptions } from "@microsoft/teamsfx-core"; +import { + CLICommand, + CLICommandOption, + err, + InputsWithProjectPath, + ok, +} from "@microsoft/teamsfx-api"; +import { + CollaborationConstants, + featureFlagManager, + FeatureFlags, + PermissionGrantInputs, + PermissionGrantOptions, + QuestionNames, +} from "@microsoft/teamsfx-core"; import { getFxCore } from "../../activate"; import { logger } from "../../commonlib/logger"; import { MissingRequiredOptionError } from "../../error"; @@ -20,10 +33,21 @@ export const spfxMessage = "Manage site admins using SharePoint admin center: " + "https://docs.microsoft.com/en-us/sharepoint/manage-site-collection-administrators"; +export const agentOwnerOption: CLICommandOption = { + name: "agent", + type: "boolean", + default: false, + description: "Whether share the ownership of agent.", +}; + export const permissionGrantCommand: CLICommand = { name: "grant", description: commands["collaborator.grant"].description, - options: [...PermissionGrantOptions, ProjectFolderOption], + options: [ + ...PermissionGrantOptions, + ProjectFolderOption, + ...(featureFlagManager.getBooleanValue(FeatureFlags.ShareEnabled) ? [agentOwnerOption] : []), + ], telemetry: { event: TelemetryEvent.GrantPermission, }, @@ -32,18 +56,34 @@ export const permissionGrantCommand: CLICommand = { command: `${process.env.TEAMSFX_CLI_BIN_NAME} collaborator grant -i false --manifest-file ./appPackage/manifest.json --env dev --email other@email.com`, description: "Grant permission for another Microsoft 365 account to collaborate on the app.", }, + ...(featureFlagManager.getBooleanValue(FeatureFlags.ShareEnabled) + ? [ + { + command: `${process.env.TEAMSFX_CLI_BIN_NAME} collaborator grant -i false --agent true --env dev --email other@email.com`, + description: + "Grant permission for another Microsoft 365 account as owner of the agent.", + }, + ] + : []), ], handler: async (ctx) => { const inputs = ctx.optionValues as PermissionGrantInputs & InputsWithProjectPath; // print necessary messages logger.info(azureMessage); logger.info(spfxMessage); + if (ctx.optionValues["agent"]) { + inputs[QuestionNames.collaborationAppType] = [CollaborationConstants.AgentOptionId]; + } if (!ctx.globalOptionValues.interactive) { - if (!inputs["manifest-file-path"] && !inputs["manifest-path"]) { + if ( + !ctx.optionValues["agent"] && + !inputs["entra-app-manifest-file"] && + !inputs["manifest-path"] + ) { return err( new MissingRequiredOptionError( ctx.command.fullName, - "--manifest-file-path or --manifest-path" + "--entra-app-manifest-file or --manifest-path" ) ); } diff --git a/packages/cli/src/commands/models/permissionStatus.ts b/packages/cli/src/commands/models/permissionStatus.ts index 1fca548c393..d52e6208523 100644 --- a/packages/cli/src/commands/models/permissionStatus.ts +++ b/packages/cli/src/commands/models/permissionStatus.ts @@ -1,13 +1,20 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { CLICommand, InputsWithProjectPath, err, ok } from "@microsoft/teamsfx-api"; -import { PermissionListInputs, PermissionListOptions } from "@microsoft/teamsfx-core"; +import { CLICommand, err, InputsWithProjectPath, ok } from "@microsoft/teamsfx-api"; +import { + CollaborationConstants, + featureFlagManager, + FeatureFlags, + PermissionListInputs, + PermissionListOptions, + QuestionNames, +} from "@microsoft/teamsfx-core"; import { getFxCore } from "../../activate"; import { logger } from "../../commonlib/logger"; import { commands } from "../../resource"; import { TelemetryEvent } from "../../telemetry/cliTelemetryEvents"; import { ProjectFolderOption } from "../common"; -import { azureMessage, spfxMessage } from "./permissionGrant"; +import { agentOwnerOption, azureMessage, spfxMessage } from "./permissionGrant"; export const permissionStatusCommand: CLICommand = { name: "status", @@ -21,6 +28,7 @@ export const permissionStatusCommand: CLICommand = { type: "boolean", required: false, }, + ...(featureFlagManager.getBooleanValue(FeatureFlags.ShareEnabled) ? [agentOwnerOption] : []), ProjectFolderOption, ], telemetry: { @@ -34,6 +42,9 @@ export const permissionStatusCommand: CLICommand = { // print necessary messages logger.info(azureMessage); logger.info(spfxMessage); + if (ctx.optionValues["agent"]) { + inputs[QuestionNames.collaborationAppType] = [CollaborationConstants.AgentOptionId]; + } const result = listAll ? await core.listCollaborator(inputs) : await core.checkPermission(inputs); diff --git a/packages/cli/src/commands/models/root.ts b/packages/cli/src/commands/models/root.ts index 97538b4acc2..6e0ed68a73e 100644 --- a/packages/cli/src/commands/models/root.ts +++ b/packages/cli/src/commands/models/root.ts @@ -1,8 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { CLICommand, ok } from "@microsoft/teamsfx-api"; +import { featureFlagManager, FeatureFlags } from "@microsoft/teamsfx-core"; import { logger } from "../../commonlib/logger"; import { FooterText } from "../../constants"; +import { commands } from "../../resource"; import { TelemetryEvent } from "../../telemetry/cliTelemetryEvents"; import { getVersion } from "../../utils"; import { helper } from "../helper"; @@ -19,17 +21,15 @@ import { m365UnacquireCommand } from "./m365Unacquire"; import { permissionCommand } from "./permission"; import { previewCommand } from "./preview"; import { provisionCommand } from "./provision"; +import { regenerateCommand } from "./regnereate"; +import { setCommand } from "./set"; +import { shareCommand } from "./share"; import { teamsappDoctorCommand } from "./teamsapp/doctor"; import { teamsappPackageCommand } from "./teamsapp/package"; import { teamsappPublishCommand } from "./teamsapp/publish"; import { teamsappUpdateCommand } from "./teamsapp/update"; import { teamsappValidateCommand } from "./teamsapp/validate"; import { upgradeCommand } from "./upgrade"; -import { commands } from "../../resource"; -import { shareCommand } from "./share"; -import { setCommand } from "./set"; -import { featureFlagManager, FeatureFlags } from "@microsoft/teamsfx-core"; -import { regenerateCommand } from "./regnereate"; export const helpCommand: CLICommand = { name: "help", diff --git a/packages/cli/src/commands/models/share.ts b/packages/cli/src/commands/models/share.ts index 712764af1bf..519623e4059 100644 --- a/packages/cli/src/commands/models/share.ts +++ b/packages/cli/src/commands/models/share.ts @@ -44,10 +44,6 @@ export const shareCommand: CLICommand = { command: `${process.env.TEAMSFX_CLI_BIN_NAME} share --scope users --email 'a@example.com,b@example.com' -i false`, description: "Share the agent with specific users", }, - { - command: `${process.env.TEAMSFX_CLI_BIN_NAME} share --scope owners --email 'a@example.com,b@example.com' -i false`, - description: "Share the ownership of agent with selected users", - }, ], commands: [shareRemoveCommand], }; diff --git a/packages/cli/tests/unit/commands.tests.ts b/packages/cli/tests/unit/commands.tests.ts index c51609d296d..bae597334f5 100644 --- a/packages/cli/tests/unit/commands.tests.ts +++ b/packages/cli/tests/unit/commands.tests.ts @@ -1,5 +1,6 @@ import { CLIContext, SystemError, err, ok, signedIn, signedOut } from "@microsoft/teamsfx-api"; import { + CollaborationConstants, CollaborationStateResult, FuncToolChecker, FxCore, @@ -7,7 +8,10 @@ import { LocalCertificateManager, LtsNodeChecker, PackageService, + PermissionGrantInputs, + PermissionListInputs, PermissionsResult, + QuestionNames, UserCancelError, envUtil, featureFlagManager, @@ -482,13 +486,77 @@ describe("CLI commands", () => { }); }); describe("permissionGrantCommand", async () => { + afterEach(() => { + sandbox.restore(); + }); + + it("success with agent option", async () => { + sandbox.stub(featureFlagManager, "getBooleanValue").returns(true); + sandbox + .stub(FxCore.prototype, "grantPermission") + .resolves(ok({ state: "OK" } as PermissionsResult)); + const ctx: CLIContext = { + command: { ...permissionGrantCommand, fullName: "teamsfx" }, + optionValues: { agent: true, email: "email", env: "dev" }, + globalOptionValues: { interactive: false }, + argumentValues: [], + telemetryProperties: {}, + }; + const res = await permissionGrantCommand.handler!(ctx); + assert.isTrue(res.isOk()); + const inputs = ctx.optionValues as PermissionGrantInputs; + assert.deepEqual(inputs[QuestionNames.collaborationAppType], [ + CollaborationConstants.AgentOptionId, + ]); + }); + + it("success with agent option in interactive mode", async () => { + sandbox.stub(featureFlagManager, "getBooleanValue").returns(true); + sandbox + .stub(FxCore.prototype, "grantPermission") + .resolves(ok({ state: "OK" } as PermissionsResult)); + const ctx: CLIContext = { + command: { ...permissionGrantCommand, fullName: "teamsfx" }, + optionValues: { agent: true }, + globalOptionValues: { interactive: true }, + argumentValues: [], + telemetryProperties: {}, + }; + const res = await permissionGrantCommand.handler!(ctx); + assert.isTrue(res.isOk()); + const inputs = ctx.optionValues as PermissionGrantInputs; + assert.deepEqual(inputs[QuestionNames.collaborationAppType], [ + CollaborationConstants.AgentOptionId, + ]); + }); + + it("missing manifest options with agent = false", async () => { + sandbox.stub(featureFlagManager, "getBooleanValue").returns(true); + sandbox + .stub(FxCore.prototype, "grantPermission") + .resolves(ok({ state: "OK" } as PermissionsResult)); + const ctx: CLIContext = { + command: { ...permissionGrantCommand, fullName: "teamsfx" }, + optionValues: { env: "dev", email: "email", agent: false }, + globalOptionValues: { interactive: false }, + argumentValues: [], + telemetryProperties: {}, + }; + const res = await permissionGrantCommand.handler!(ctx); + assert.isTrue(res.isErr()); + if (res.isErr()) { + assert.isTrue(res.error instanceof MissingRequiredOptionError); + } + }); + it("success interactive = false", async () => { + sandbox.stub(featureFlagManager, "getBooleanValue").returns(false); sandbox .stub(FxCore.prototype, "grantPermission") .resolves(ok({ state: "OK" } as PermissionsResult)); const ctx: CLIContext = { command: { ...permissionGrantCommand, fullName: "teamsfx" }, - optionValues: { "manifest-file-path": "abc" }, + optionValues: { "manifest-path": "abc" }, globalOptionValues: { interactive: false }, argumentValues: [], telemetryProperties: {}, @@ -496,6 +564,7 @@ describe("CLI commands", () => { const res = await permissionGrantCommand.handler!(ctx); assert.isTrue(res.isOk()); }); + it("success interactive = true", async () => { sandbox .stub(FxCore.prototype, "grantPermission") @@ -526,7 +595,52 @@ describe("CLI commands", () => { }); }); describe("permissionStatusCommand", async () => { + afterEach(() => { + sandbox.restore(); + }); + + it("listCollaborator with agent option", async () => { + sandbox.stub(featureFlagManager, "getBooleanValue").returns(true); + sandbox + .stub(FxCore.prototype, "listCollaborator") + .resolves(ok({ state: "OK" } as ListCollaboratorResult)); + const ctx: CLIContext = { + command: { ...permissionStatusCommand, fullName: "teamsfx" }, + optionValues: { all: true, agent: true }, + globalOptionValues: {}, + argumentValues: [], + telemetryProperties: {}, + }; + const res = await permissionStatusCommand.handler!(ctx); + assert.isTrue(res.isOk()); + const inputs = ctx.optionValues as PermissionListInputs; + assert.deepEqual(inputs[QuestionNames.collaborationAppType], [ + CollaborationConstants.AgentOptionId, + ]); + }); + + it("checkPermission with agent option", async () => { + sandbox.stub(featureFlagManager, "getBooleanValue").returns(true); + sandbox + .stub(FxCore.prototype, "checkPermission") + .resolves(ok({ state: "OK" } as CollaborationStateResult)); + const ctx: CLIContext = { + command: { ...permissionStatusCommand, fullName: "teamsfx" }, + optionValues: { all: false, agent: true }, + globalOptionValues: {}, + argumentValues: [], + telemetryProperties: {}, + }; + const res = await permissionStatusCommand.handler!(ctx); + assert.isTrue(res.isOk()); + const inputs = ctx.optionValues as PermissionListInputs; + assert.deepEqual(inputs[QuestionNames.collaborationAppType], [ + CollaborationConstants.AgentOptionId, + ]); + }); + it("listCollaborator", async () => { + sandbox.stub(featureFlagManager, "getBooleanValue").returns(false); sandbox .stub(FxCore.prototype, "listCollaborator") .resolves(ok({ state: "OK" } as ListCollaboratorResult)); @@ -540,6 +654,7 @@ describe("CLI commands", () => { const res = await permissionStatusCommand.handler!(ctx); assert.isTrue(res.isOk()); }); + it("checkPermission", async () => { sandbox .stub(FxCore.prototype, "checkPermission") diff --git a/packages/fx-core/resource/package.nls.json b/packages/fx-core/resource/package.nls.json index 77f03ce5bcb..769a2245c2e 100644 --- a/packages/fx-core/resource/package.nls.json +++ b/packages/fx-core/resource/package.nls.json @@ -64,15 +64,20 @@ "core.collaboration.AccountUsedToCheck": "Account used to check: ", "core.collaboration.StartingListAllTeamsAppOwners": "\nStarting to list all app owners for environment: ", "core.collaboration.StartingListAllAadAppOwners": "\nStarting to list all Microsoft Entra app owners for environment: ", + "core.collaboration.StartingListAllAgentOwners": "\nStarting to list all agent owners for environment: ", "core.collaboration.M365TeamsAppId": "App (ID: ", "core.collaboration.SsoAadAppId": "SSO Microsoft Entra App (ID: ", + "core.collaboration.AgentTitleId": "Agent (ID: ", "core.collaboration.TeamsAppOwner": "App Owner: ", "core.collaboration.AadAppOwner": "Microsoft Entra App Owner: ", + "core.collaboration.AgentOwner": "Agent Owner: ", "core.collaboration.StaringCheckPermission": "Starting to check permission for environment: ", "core.collaboration.CheckPermissionResourceId": "Resource ID: ", "core.collaboration.Undefined": "undefined", "core.collaboration.ResourceName": ", Resource Name: ", "core.collaboration.Permission": ", Permission: ", + "core.collaboration.agent.label": "Agent", + "core.collaboration.agent.description": "Microsoft 365 Agent", "core.developerPortal.scaffold.CannotFindManifest": "Manifest not found from the downloaded package for app %s.", "plugins.spfx.questions.framework.title": "Framework", "plugins.spfx.questions.webpartName": "Name for SharePoint Framework Web Part", @@ -641,7 +646,6 @@ "core.common.ReceiveApiResponse": "Received API response: %s.", "core.common.shareWithTenant.success": "Agent successfully shared with tenant.", "core.common.shareWithUser.success": "Agent successfully shared with users: %s.", - "core.common.shareWithOwner.success": "Agent successfully shared with owners: %s.", "core.common.removeOwnership.success": "Shared agent ownership removed from the users: %s.", "core.common.removeShareAccess.success": "Shared access successfully removed from users: %s.", "core.envFunc.unsupportedFile.errorLog": "\"%s\" is an invalid file. Supported format: %s.", @@ -702,11 +706,10 @@ "core.share.removeAccess.operator": "Cannot remove permission of the operator. Email: %s.", "core.shareOperationQuestion.option.removeShareAccessFromUsers": "Remove access for selected user(s)", "core.shareOperationQuestion.option.shareWithUsers": "Share access with selected user(s)", - "core.shareOptionQuestion.option.shareWithOwners": "Share agent ownership with selected user(s)", "core.shareOptionQuestion.placeholder": "Select how to share the agent", "core.shareOptionQuestion.title": "Share the agent", - "core.shareOptionQuestion.unshare.emails.title": "Email addresses of users for agent access removal", - "core.shareScopeQuestion.emails.title": "Email addresses of users for agent sharing", + "core.shareOptionQuestion.unshare.emails.title": "Email addresses of users or groups for agent access removal", + "core.shareScopeQuestion.emails.title": "Email addresses of users or groups for agent sharing", "core.shareScopeQuestion.option.shareWithTenant": "Share the agent with all tenant users", "core.shareScopeQuestion.option.shareWithUsers": "Share the agent with selected user(s)", "core.shareScopeQuestion.placeholder": "Select a sharing scope", diff --git a/packages/fx-core/src/client/graphClient.ts b/packages/fx-core/src/client/graphClient.ts index 2aef4893d5c..ccee4f723ad 100644 --- a/packages/fx-core/src/client/graphClient.ts +++ b/packages/fx-core/src/client/graphClient.ts @@ -3,19 +3,17 @@ import { hooks } from "@feathersjs/hooks"; import { - M365TokenProvider, - LogProvider, - ok, err, FxError, + LogProvider, + M365TokenProvider, + ok, Result, - SystemError, SensitivityLabel, signedIn, + SystemError, } from "@microsoft/teamsfx-api"; import { AxiosInstance } from "axios"; -import { ErrorContextMW } from "../common/globalVars"; -import { GetTeamsAppSettingsResponse } from "./interfaces/GetTeamsAppSettingsResponse"; import { GraphTeamsAppSettingsReadScopes, GraphTeamsChannelCreateScopes, @@ -23,19 +21,24 @@ import { GraphTeamsInstallAppScopes, GraphTeamsTeamCreateScopes, GraphTeamsTeamReadScopes, + GroupSearchScopes, ListSensitivityLabelScope, + UserReadScopes, } from "../common/constants"; -import { GetJoinedTeamsResponse } from "./interfaces/GetJoinedTeamsResponse"; -import { GetChannelResponse } from "./interfaces/GetChannelResponse"; +import { globalStateGet, globalStateUpdate } from "../common/globalState"; +import { ErrorContextMW } from "../common/globalVars"; +import { getDefaultString, getLocalizedString } from "../common/localizeUtils"; +import { waitSeconds } from "../common/utils"; import { WrappedAxiosClient } from "../common/wrappedAxiosClient"; import { CreateChannelResponse } from "./interfaces/CreateChannelResponse"; import { CreateTeamAndChannelResponse } from "./interfaces/CreateTeamAndChannelResponse"; -import { ListSensitivityCacheValue } from "./interfaces/ListSensitivityCacheValue"; -import { waitSeconds } from "../common/utils"; -import { getLocalizedString } from "../common/localizeUtils"; import { GetAppInstallationResponse } from "./interfaces/GetAppInstallationResponse"; -import { globalStateGet, globalStateUpdate } from "../common/globalState"; -import { getDefaultString } from "../common/localizeUtils"; +import { GetChannelResponse } from "./interfaces/GetChannelResponse"; +import { Group } from "./interfaces/GetGroupResponse"; +import { GetJoinedTeamsResponse } from "./interfaces/GetJoinedTeamsResponse"; +import { GetTeamsAppSettingsResponse } from "./interfaces/GetTeamsAppSettingsResponse"; +import { User } from "./interfaces/GetUserResponse"; +import { ListSensitivityCacheValue } from "./interfaces/ListSensitivityCacheValue"; const listSensitivityLabelAPIPath = "/me/informationProtection/sensitivityLabels"; const errorSourceName = "GraphAPI"; @@ -440,4 +443,40 @@ export class GraphClient { } return [accountUniqueName, tenantId]; } + + public async getUserInfoFromId(id: string): Promise { + const tokenResponse = await this.tokenProvider.getAccessToken({ scopes: UserReadScopes }); + if (tokenResponse.isErr()) { + throw tokenResponse.error; + } + const requester = this.createRequesterWithToken(tokenResponse.value); + const response = await requester.get(`/users/${id}`); + if (!response || !response.data) { + return undefined; + } + + return response.data; + } + + public async getGroupInfo(email: string): Promise { + const tokenResponse = await this.tokenProvider.getAccessToken({ scopes: GroupSearchScopes }); + if (tokenResponse.isErr()) { + throw tokenResponse.error; + } + const requester = this.createRequesterWithToken(tokenResponse.value); + const res = await requester.get(`/groups?$filter=startsWith(mail,'${email}')`); + if (!res || !res.data || !res.data.value) { + return undefined; + } + + const group = res.data.value.find( + (group: any) => group.mail?.toLowerCase() === email.toLowerCase() + ); + + if (!group) { + return undefined; + } + + return group; + } } diff --git a/packages/fx-core/src/client/interfaces/GetGroupResponse.ts b/packages/fx-core/src/client/interfaces/GetGroupResponse.ts new file mode 100644 index 00000000000..568c28229d7 --- /dev/null +++ b/packages/fx-core/src/client/interfaces/GetGroupResponse.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export interface Group { + id: string; + displayName: string; + mail: string; +} diff --git a/packages/fx-core/src/client/interfaces/GetUserResponse.ts b/packages/fx-core/src/client/interfaces/GetUserResponse.ts new file mode 100644 index 00000000000..88b91a420a8 --- /dev/null +++ b/packages/fx-core/src/client/interfaces/GetUserResponse.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +export interface User { + id: string; + displayName: string; + userPrincipalName: string; + mail: string; +} diff --git a/packages/fx-core/src/common/constants.ts b/packages/fx-core/src/common/constants.ts index e28e14a8a99..6606acc1790 100644 --- a/packages/fx-core/src/common/constants.ts +++ b/packages/fx-core/src/common/constants.ts @@ -73,6 +73,7 @@ export function getAppStudioEndpoint(): string { export const AuthSvcScopes = ["https://api.spaces.skype.com/Region.ReadWrite"]; export const GraphScopes = ["Application.ReadWrite.All", "TeamsAppInstallation.ReadForUser"]; +export const UserReadScopes = ["User.Read.All"]; export const GroupSearchScopes = ["GroupMember.Read.All"]; export const GCScopes = ["ExternalConnection.Read.All"]; export const GraphReadUserScopes = ["https://graph.microsoft.com/User.ReadBasic.All"]; diff --git a/packages/fx-core/src/common/permissionInterface.ts b/packages/fx-core/src/common/permissionInterface.ts index 537c7392dcf..44fe069ad43 100644 --- a/packages/fx-core/src/common/permissionInterface.ts +++ b/packages/fx-core/src/common/permissionInterface.ts @@ -57,6 +57,13 @@ export interface TeamsAppAdmin { userPrincipalName: string; } +export interface AgentOwner { + userObjectId: string; + resourceId: string; + displayName: string; + userPrincipalName: string; +} + export interface AppIds { teamsAppId?: string; aadObjectId?: string; diff --git a/packages/fx-core/src/component/feature/collaboration.ts b/packages/fx-core/src/component/feature/collaboration.ts index 5ed9e314f36..20f5ee7aae6 100644 --- a/packages/fx-core/src/component/feature/collaboration.ts +++ b/packages/fx-core/src/component/feature/collaboration.ts @@ -13,10 +13,18 @@ import { } from "@microsoft/teamsfx-api"; import axios from "axios"; import { Service } from "typedi"; +import { GraphClient } from "../../client/graphClient"; import { teamsDevPortalClient } from "../../client/teamsDevPortalClient"; import { AppStudioScopes } from "../../common/constants"; import { ErrorContextMW } from "../../common/globalVars"; -import { AadOwner, ResourcePermission, TeamsAppAdmin } from "../../common/permissionInterface"; +import { + AadOwner, + AgentOwner, + ResourcePermission, + TeamsAppAdmin, +} from "../../common/permissionInterface"; +import { AgentPermission, PackageService } from "../../component/m365/packageService"; +import { MosServiceScope } from "../../component/m365/serviceConstant"; import { HttpClientError, HttpServerError, assembleError } from "../../error/common"; import { AppIdNotExist } from "../../error/teamsApp"; import { AadAppClient } from "../driver/aad/utility/aadAppClient"; @@ -32,6 +40,7 @@ const EventName = { }; const componentNameAad = "fx-resource-aad-app-for-teams"; const componentNameTeams = "AppStudioPlugin"; +const componentNameAgent = "AgentPlugin"; @Service("aad-collaboration") export class AadCollaboration { @@ -258,3 +267,82 @@ export class TeamsCollaboration { return assembleError(error as Error, componentNameTeams); } } + +@Service("agent-collaboration") +export class AgentCollaboration { + private readonly tokenProvider: M365TokenProvider; + + constructor(m365TokenProvider: M365TokenProvider) { + this.tokenProvider = m365TokenProvider; + } + + @hooks([ + ErrorContextMW({ source: "M365", component: "AgentCollaboration" }), + addStartAndEndTelemetry(EventName.grantPermission, componentNameAgent), + ]) + public async grantPermission( + ctx: Context, + titleId: string, + userInfo: AppUser + ): Promise> { + const mosTokenRes = await this.tokenProvider.getAccessToken({ + scopes: [MosServiceScope], + }); + if (mosTokenRes.isErr()) { + return err(mosTokenRes.error); + } + const mosToken = mosTokenRes.value; + // grant owner permission + const res = await PackageService.GetSharedInstance().addOwner(mosToken, titleId, userInfo); + if (res.isErr()) { + return err(res.error); + } + const result: ResourcePermission[] = [ + { + name: AgentPermission.name, + roles: [AgentPermission.owner], + type: AgentPermission.type, + resourceId: titleId, + }, + ]; + return ok(result); + } + + @hooks([ + ErrorContextMW({ source: "M365", component: "AgentCollaboration" }), + addStartAndEndTelemetry(EventName.listCollaborator, componentNameAgent), + ]) + public async listCollaborator( + ctx: Context, + titleId: string + ): Promise> { + const mosTokenRes = await this.tokenProvider.getAccessToken({ + scopes: [MosServiceScope], + }); + if (mosTokenRes.isErr()) { + return err(mosTokenRes.error); + } + const mosToken = mosTokenRes.value; + + const res = await PackageService.GetSharedInstance().previewApp(mosToken, titleId); + if (res.isErr()) { + return err(res.error); + } + const owners = res.value.owners; + const agentOwners: AgentOwner[] = []; + const graphClient = new GraphClient(this.tokenProvider); + for (const owner of owners) { + const userInfo = await graphClient.getUserInfoFromId(owner.entityId); + if (userInfo) { + agentOwners.push({ + userObjectId: owner.entityId, + displayName: userInfo.displayName, + userPrincipalName: userInfo.userPrincipalName, + resourceId: titleId, + }); + } + } + + return ok(agentOwners); + } +} diff --git a/packages/fx-core/src/component/m365/packageService.ts b/packages/fx-core/src/component/m365/packageService.ts index 9b691bde4c6..0d0747a2aa4 100644 --- a/packages/fx-core/src/component/m365/packageService.ts +++ b/packages/fx-core/src/component/m365/packageService.ts @@ -44,6 +44,12 @@ export enum AppScope { Tenant = "Tenant", } +export const AgentPermission = { + name: "Agent", + owner: "Owner", + type: "M365", +}; + // Call m365 service for package CRUD export class PackageService { private static sharedInstance: PackageService; @@ -554,7 +560,7 @@ export class PackageService { } @hooks([ErrorContextMW({ source: M365ErrorSource, component: M365ErrorComponent })]) - public async grantPermission( + public async addOwner( token: string, titleId: string, user: AppUser diff --git a/packages/fx-core/src/core/FxCore.ts b/packages/fx-core/src/core/FxCore.ts index d4afbaaa2c3..09034bc4190 100644 --- a/packages/fx-core/src/core/FxCore.ts +++ b/packages/fx-core/src/core/FxCore.ts @@ -949,7 +949,6 @@ export class FxCore { if ( scope === ShareScopeOption.ShareAppWithSpecificUsers || - scope === ShareScopeOption.ShareAppWithOwners || operation === ShareOperationOption.RemoveShareAccessFromUsers ) { emails = (inputs[QuestionNames.UserEmail] as string).split(",").map((e) => e.trim()); @@ -977,41 +976,6 @@ export class FxCore { return shareWithTenant(mosToken, sharedTitleId); } else if (scope === ShareScopeOption.ShareAppWithSpecificUsers) { return addSharedUsers(mosToken, sharedTitleId, emails); - } else if (scope === ShareScopeOption.ShareAppWithOwners) { - const tokenProvider = TOOLS.tokenProvider.m365TokenProvider; - const appStudioTokenRes = await tokenProvider.getAccessToken({ - scopes: AppStudioScopes, - }); - if (appStudioTokenRes.isErr()) { - return err(appStudioTokenRes.error); - } - const appStudioToken = appStudioTokenRes.value; - for (const email of emails) { - const userInfo = await CollaborationUtil.getUserInfo(tokenProvider, email); - if (!userInfo) { - return err(new InputValidationError("shareWithOwner", `Invalid user: ${email}`)); - } - - // 1. grant TDP permission - await teamsDevPortalClient.grantPermission( - appStudioToken, - parseRes.value.teamsappId, - userInfo - ); - - // 2. grant mos permission - const res = await PackageService.GetSharedInstance().grantPermission( - mosToken, - sharedTitleId, - userInfo - ); - if (res.isErr()) { - return err(res.error); - } - } - const msg = getLocalizedString("core.common.shareWithOwner.success", emails); - TOOLS.ui?.showMessage("info", msg, false); - return ok(undefined); } else { return err(new InputValidationError("shareOption", "Invalid share option")); } diff --git a/packages/fx-core/src/core/collaborator.ts b/packages/fx-core/src/core/collaborator.ts index 9815cbd8320..a29cf7f72c3 100644 --- a/packages/fx-core/src/core/collaborator.ts +++ b/packages/fx-core/src/core/collaborator.ts @@ -23,6 +23,7 @@ import { GraphScopes, VSCodeExtensionCommand } from "../common/constants"; import { getDefaultString, getLocalizedString } from "../common/localizeUtils"; import { AadOwner, + AgentOwner, AppIds, CollaborationState, ListCollaboratorResult, @@ -30,8 +31,13 @@ import { ResourcePermission, } from "../common/permissionInterface"; import { SolutionError, SolutionSource, SolutionTelemetryProperty } from "../component/constants"; +import { parseShareAppActionYamlConfig } from "../component/driver/share/utils"; import { AppUser } from "../component/driver/teamsApp/interfaces/appdefinitions/appUser"; -import { AadCollaboration, TeamsCollaboration } from "../component/feature/collaboration"; +import { + AadCollaboration, + AgentCollaboration, + TeamsCollaboration, +} from "../component/feature/collaboration"; import { FailedToLoadManifestId, FileNotFoundError } from "../error/common"; import { QuestionNames } from "../question/constants"; @@ -50,6 +56,7 @@ export class CollaborationConstants { static readonly AppType = "collaborationType"; static readonly TeamsAppQuestionId = "teamsApp"; static readonly AadAppQuestionId = "aadApp"; + static readonly AgentOptionId = "agent"; static readonly placeholderRegex = /\$\{\{ *[a-zA-Z0-9_.-]* *\}\}/g; } @@ -128,36 +135,6 @@ export class CollaborationUtil { }; } - // static async getGroupInfo( - // email: string, - // m365TokenProvider?: M365TokenProvider - // ): Promise { - // const graphTokenRes = await m365TokenProvider?.getAccessToken({ scopes: GroupSearchScopes }); - // const graphToken = graphTokenRes?.isOk() ? graphTokenRes.value : undefined; - // const instance = axios.create({ - // baseURL: "https://graph.microsoft.com/v1.0", - // }); - // instance.defaults.headers.common["Authorization"] = `Bearer ${graphToken as string}`; - // const res = await instance.get(`/groups?$filter=startsWith(mail,'${email}')`); - // if (!res || !res.data || !res.data.value) { - // return undefined; - // } - - // const group = res.data.value.find( - // (group: any) => group.mail?.toLowerCase() === email.toLowerCase() - // ); - - // if (!group) { - // return undefined; - // } - - // return { - // id: group.id, - // displayName: group.displayName, - // email: group.mail, - // }; - // } - static async loadDotEnvFile( dotEnvFilePath: string ): Promise> { @@ -327,24 +304,44 @@ export async function listCollaborator( return err(getAppIdsResult.error); } const appIds = getAppIdsResult.value; + let agentTitleId: string | undefined; + if ( + inputs[QuestionNames.collaborationAppType] && + inputs[QuestionNames.collaborationAppType].indexOf(CollaborationConstants.AgentOptionId) > -1 + ) { + const parseRes = await parseShareAppActionYamlConfig(inputs.projectPath); + if (parseRes.isErr()) { + return err(parseRes.error); + } + agentTitleId = parseRes.value.titleId; + } const hasAad = appIds.aadObjectId != undefined; const hasTeams = appIds.teamsAppId != undefined; const teamsCollaboration = new TeamsCollaboration(tokenProvider.m365TokenProvider); const aadCollaboration = new AadCollaboration(tokenProvider.m365TokenProvider, ctx.logProvider); - const appStudioRes = hasTeams - ? await teamsCollaboration.listCollaborator(ctx, appIds.teamsAppId!) - : ok([]); + const agentCollaboration = new AgentCollaboration(tokenProvider.m365TokenProvider); + const appStudioRes = + appIds.teamsAppId != undefined + ? await teamsCollaboration.listCollaborator(ctx, appIds.teamsAppId) + : ok([]); if (appStudioRes.isErr()) return err(appStudioRes.error); const teamsAppOwners = appStudioRes.value; - const aadRes = hasAad - ? await aadCollaboration.listCollaborator(ctx, appIds.aadObjectId!) - : ok([]); + const aadRes = + appIds.aadObjectId != undefined + ? await aadCollaboration.listCollaborator(ctx, appIds.aadObjectId) + : ok([]); if (aadRes.isErr()) return err(aadRes.error); const aadOwners: AadOwner[] = aadRes.value; const teamsAppId: string = teamsAppOwners[0]?.resourceId ?? ""; const aadAppId: string = aadOwners[0]?.resourceId ?? ""; const aadAppTenantId = user.tenantId; + const agentOwners: AgentOwner[] = []; + if (agentTitleId) { + const result = await agentCollaboration.listCollaborator(ctx, agentTitleId); + if (result.isErr()) return err(result.error); + agentOwners.push(...result.value); + } if (inputs.platform === Platform.CLI || inputs.platform === Platform.VSCode) { const message = [ @@ -416,6 +413,35 @@ export async function listCollaborator( } } + if (agentTitleId) { + message.push( + ...getPrintEnvMessage( + inputs.env, + getLocalizedString("core.collaboration.StartingListAllAgentOwners") + ), + { + content: getLocalizedString("core.collaboration.AgentTitleId"), + color: Colors.BRIGHT_WHITE, + }, + { content: agentTitleId, color: Colors.BRIGHT_MAGENTA }, + { content: `)\n`, color: Colors.BRIGHT_WHITE } + ); + + for (const agentOwner of agentOwners) { + message.push( + { + content: getLocalizedString("core.collaboration.AgentOwner"), + color: Colors.BRIGHT_WHITE, + }, + { + content: agentOwner.userPrincipalName ?? agentOwner.displayName, + color: Colors.BRIGHT_MAGENTA, + }, + { content: `.\n`, color: Colors.BRIGHT_WHITE } + ); + } + } + if (inputs.platform === Platform.CLI) { void ctx.userInteraction.showMessage("info", message, false); } else if (inputs.platform === Platform.VSCode) { @@ -596,6 +622,17 @@ export async function grantPermission( return err(getAppIdsResult.error); } const appIds = getAppIdsResult.value; + let agentTitleId: string | undefined; + if ( + inputs[QuestionNames.collaborationAppType] && + inputs[QuestionNames.collaborationAppType].indexOf(CollaborationConstants.AgentOptionId) > -1 + ) { + const parseRes = await parseShareAppActionYamlConfig(inputs.projectPath); + if (parseRes.isErr()) { + return err(parseRes.error); + } + agentTitleId = parseRes.value.titleId; + } if (inputs.platform === Platform.CLI) { // TODO: get tenant id from .env @@ -620,6 +657,7 @@ export async function grantPermission( const isTeamsActivated = appIds.teamsAppId != undefined; const teamsCollaboration = new TeamsCollaboration(tokenProvider.m365TokenProvider); const aadCollaboration = new AadCollaboration(tokenProvider.m365TokenProvider, ctx.logProvider); + const agentCollaboration = new AgentCollaboration(tokenProvider.m365TokenProvider); const appStudioRes = isTeamsActivated ? await teamsCollaboration.grantPermission(ctx, appIds.teamsAppId!, userInfo) : ok([] as ResourcePermission[]); @@ -638,6 +676,13 @@ export async function grantPermission( permissions.push(r); }); } + if (agentTitleId) { + const result = await agentCollaboration.grantPermission(ctx, agentTitleId, userInfo); + if (result.isErr()) return err(result.error); + result.value.forEach((r: ResourcePermission) => { + permissions.push(r); + }); + } if (inputs.platform === Platform.CLI) { for (const permission of permissions) { const message = [ diff --git a/packages/fx-core/src/core/share.ts b/packages/fx-core/src/core/share.ts index 7b494965a4a..d88fc83af76 100644 --- a/packages/fx-core/src/core/share.ts +++ b/packages/fx-core/src/core/share.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. import { FxError, Result, err, ok } from "@microsoft/teamsfx-api"; import "reflect-metadata"; +import { GraphClient } from "../client/graphClient"; import { TOOLS } from "../common/globalVars"; import { getLocalizedString } from "../common/localizeUtils"; import "../component/driver/index"; @@ -48,10 +49,18 @@ export async function addSharedUsers( }); } + const graphClient = new GraphClient(tokenProvider); for (const email of emails) { const userInfo = await CollaborationUtil.getUserInfo(tokenProvider, email); if (!userInfo) { - return err(new InputValidationError("shareWithUser", `Invalid user: ${email}`)); + const groupInfo = await graphClient.getGroupInfo(email); + if (!groupInfo) { + return err(new InputValidationError("shareWithUser", `Invalid user or group: ${email}`)); + } + entities.add({ + entityId: groupInfo.id, + entityType: M365EntityType.Group, + }); } else { entities.add({ entityId: userInfo.aadId, diff --git a/packages/fx-core/src/question/collaborator.ts b/packages/fx-core/src/question/collaborator.ts new file mode 100644 index 00000000000..78cf38f8bbb --- /dev/null +++ b/packages/fx-core/src/question/collaborator.ts @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + DynamicPlatforms, + Inputs, + IQTreeNode, + MultiSelectQuestion, + OptionItem, + Platform, +} from "@microsoft/teamsfx-api"; +import * as path from "path"; +import { featureFlagManager, FeatureFlags } from "../common/featureFlags"; +import { getLocalizedString } from "../common/localizeUtils"; +import { CollaborationConstants, CollaborationUtil } from "../core/collaborator"; +import { QuestionNames } from "./constants"; +import { + confirmManifestQuestion, + inputUserEmailQuestion, + selectAadManifestQuestion, + selectTargetEnvQuestion, + selectTeamsAppManifestQuestionNode, +} from "./other"; + +export function grantPermissionQuestionNode(): IQTreeNode { + const selectAppManifestNode = selectTeamsAppManifestQuestionNode(); + selectAppManifestNode.condition = { + contains: CollaborationConstants.TeamsAppQuestionId, + }; + selectAppManifestNode.children?.push({ + condition: envQuestionCondition, + data: selectTargetEnvQuestion(QuestionNames.Env, false, false, ""), + }); + const selectAadAppNode = selectAadAppManifestQuestionNode(); + selectAadAppNode.condition = { contains: CollaborationConstants.AadAppQuestionId }; + selectAadAppNode.children?.push({ + condition: envQuestionCondition, + data: selectTargetEnvQuestion(QuestionNames.Env, false, false, ""), + }); + return { + data: { type: "group" }, + children: [ + { + condition: (inputs: Inputs) => DynamicPlatforms.includes(inputs.platform), + data: selectAppTypeQuestion(), + cliOptionDisabled: "self", + inputsDisabled: "self", + children: [ + selectAppManifestNode, + selectAadAppNode, + { + data: inputUserEmailQuestion( + getLocalizedString("core.getUserEmailQuestion.title"), + "Email address of the collaborator.", + true + ), + }, + ], + }, + ], + }; +} + +export function listCollaboratorQuestionNode(): IQTreeNode { + const selectAppManifestNode = selectTeamsAppManifestQuestionNode(); + selectAppManifestNode.condition = { + contains: CollaborationConstants.TeamsAppQuestionId, + }; + selectAppManifestNode.children?.push({ + condition: envQuestionCondition, + data: selectTargetEnvQuestion(QuestionNames.Env, false, false, ""), + }); + + const selectAadAppNode = selectAadAppManifestQuestionNode(); + selectAadAppNode.condition = { contains: CollaborationConstants.AadAppQuestionId }; + selectAadAppNode.children?.push({ + condition: envQuestionCondition, + data: selectTargetEnvQuestion(QuestionNames.Env, false, false, ""), + }); + return { + data: { type: "group" }, + children: [ + { + condition: (inputs: Inputs) => DynamicPlatforms.includes(inputs.platform), + data: selectAppTypeQuestion(), + cliOptionDisabled: "self", + inputsDisabled: "self", + children: [selectAppManifestNode, selectAadAppNode], + }, + ], + }; +} + +export async function envQuestionCondition(inputs: Inputs): Promise { + const appType = inputs[CollaborationConstants.AppType] as string[]; + const requireAad = appType?.includes(CollaborationConstants.AadAppQuestionId); + const requireTeams = appType?.includes(CollaborationConstants.TeamsAppQuestionId); + const aadManifestPath = inputs[QuestionNames.AadAppManifestFilePath]; + const teamsManifestPath = inputs[QuestionNames.TeamsAppManifestFilePath]; + + // When both is selected, only show the question once at the end + if ((requireAad && !aadManifestPath) || (requireTeams && !teamsManifestPath)) { + return false; + } + + // Only show env question when manifest id is referencing value from .env file + let requireEnv = false; + if (requireTeams && teamsManifestPath) { + const teamsAppIdRes = await CollaborationUtil.loadManifestId(teamsManifestPath); + if (teamsAppIdRes.isOk()) { + requireEnv = CollaborationUtil.requireEnvQuestion(teamsAppIdRes.value); + if (requireEnv) { + return true; + } + } else { + return false; + } + } + + if (requireAad && aadManifestPath) { + const aadAppIdRes = await CollaborationUtil.loadManifestId(aadManifestPath); + if (aadAppIdRes.isOk()) { + requireEnv = CollaborationUtil.requireEnvQuestion(aadAppIdRes.value); + if (requireEnv) { + return true; + } + } else { + return false; + } + } + + return false; +} + +export function selectAadAppManifestQuestionNode(): IQTreeNode { + return { + data: selectAadManifestQuestion(), + children: [ + { + condition: (inputs: Inputs) => + inputs.platform === Platform.VSCode && // confirm question only works for VSC + inputs.projectPath && + inputs[QuestionNames.AadAppManifestFilePath] && + path.resolve(inputs[QuestionNames.AadAppManifestFilePath]) !== + path.join(inputs.projectPath, "aad.manifest.json"), + data: confirmManifestQuestion(false, false), + cliOptionDisabled: "self", + inputsDisabled: "self", + }, + ], + }; +} + +function selectAppTypeQuestion(): MultiSelectQuestion { + const options: MultiSelectQuestion = { + name: QuestionNames.collaborationAppType, + title: getLocalizedString("core.selectCollaborationAppTypeQuestion.title"), + type: "multiSelect", + staticOptions: [ + { + id: CollaborationConstants.AadAppQuestionId, + label: getLocalizedString("core.aadAppQuestion.label"), + description: getLocalizedString("core.aadAppQuestion.description"), + }, + { + id: CollaborationConstants.TeamsAppQuestionId, + label: getLocalizedString("core.teamsAppQuestion.label"), + description: getLocalizedString("core.teamsAppQuestion.description"), + }, + ], + validation: { minItems: 1 }, + validationHelp: "Please select at least one app type.", + }; + if (featureFlagManager.getBooleanValue(FeatureFlags.ShareEnabled)) { + (options.staticOptions as OptionItem[]).push({ + id: CollaborationConstants.AgentOptionId, + label: getLocalizedString("core.collaboration.agent.label"), + description: getLocalizedString("core.collaboration.agent.description"), + }); + } + return options; +} diff --git a/packages/fx-core/src/question/index.ts b/packages/fx-core/src/question/index.ts index edcb1900f01..30e04c67611 100644 --- a/packages/fx-core/src/question/index.ts +++ b/packages/fx-core/src/question/index.ts @@ -2,6 +2,7 @@ // Licensed under the MIT license. import { IQTreeNode, Platform } from "@microsoft/teamsfx-api"; +import { grantPermissionQuestionNode, listCollaboratorQuestionNode } from "./collaborator"; import { createSampleProjectQuestionNode } from "./create"; import { addAuthActionQuestion, @@ -13,9 +14,7 @@ import { copilotPluginAddAPIQuestionNode, createNewEnvQuestionNode, deployAadManifestQuestionNode, - grantPermissionQuestionNode, kiotaRegenerateQuestion, - listCollaboratorQuestionNode, metaOSExtendToDAQuestionNode, oauthQuestion, previewWithTeamsAppManifestQuestionNode, diff --git a/packages/fx-core/src/question/inputs/ShareInputs.ts b/packages/fx-core/src/question/inputs/ShareInputs.ts index b4f868debd6..2f0605d0c4a 100644 --- a/packages/fx-core/src/question/inputs/ShareInputs.ts +++ b/packages/fx-core/src/question/inputs/ShareInputs.ts @@ -14,7 +14,7 @@ export interface ShareInputs extends Inputs { /** @description Share the agent */ "share-operation"?: "share" | "unshare"; /** @description Share the agent with users */ - scope?: "tenant" | "users" | "owners"; - /** @description Email addresses of users for agent sharing */ + scope?: "tenant" | "users"; + /** @description Email addresses of users or groups for agent sharing */ email?: string; } diff --git a/packages/fx-core/src/question/options/ShareOptions.ts b/packages/fx-core/src/question/options/ShareOptions.ts index 098914ad4d0..f8f62bdefa9 100644 --- a/packages/fx-core/src/question/options/ShareOptions.ts +++ b/packages/fx-core/src/question/options/ShareOptions.ts @@ -23,7 +23,7 @@ export const ShareOptions: CLICommandOption[] = [ type: "string", description: "Share the agent with users", default: "users", - choices: ["tenant", "users", "owners"], + choices: ["tenant", "users"], }, { name: "email", diff --git a/packages/fx-core/src/question/other.ts b/packages/fx-core/src/question/other.ts index 7b01c2b4324..e4cc564dbfc 100644 --- a/packages/fx-core/src/question/other.ts +++ b/packages/fx-core/src/question/other.ts @@ -29,7 +29,6 @@ import { getLocalizedString } from "../common/localizeUtils"; import { Constants } from "../component/driver/add/utility/constants"; import { manifestUtils } from "../component/driver/teamsApp/utils/ManifestUtils"; import { envUtil } from "../component/utils/envUtil"; -import { CollaborationConstants, CollaborationUtil } from "../core/collaborator"; import { environmentNameManager } from "../core/environmentName"; import { ActionStartOptions, @@ -66,74 +65,6 @@ import { import { UninstallInputs } from "./inputs"; import { inputOrSearchAPISpecNode } from "./scaffold/vsc/teamsProjectTypeNode"; -export function listCollaboratorQuestionNode(): IQTreeNode { - const selectAppManifestNode = selectTeamsAppManifestQuestionNode(); - selectAppManifestNode.condition = { - contains: CollaborationConstants.TeamsAppQuestionId, - }; - selectAppManifestNode.children!.push({ - condition: envQuestionCondition, - data: selectTargetEnvQuestion(QuestionNames.Env, false, false, ""), - }); - const selectAadAppNode = selectAadAppManifestQuestionNode(); - selectAadAppNode.condition = { contains: CollaborationConstants.AadAppQuestionId }; - selectAadAppNode.children!.push({ - condition: envQuestionCondition, - data: selectTargetEnvQuestion(QuestionNames.Env, false, false, ""), - }); - return { - data: { type: "group" }, - children: [ - { - condition: (inputs: Inputs) => DynamicPlatforms.includes(inputs.platform), - data: selectAppTypeQuestion(), - cliOptionDisabled: "self", - inputsDisabled: "self", - children: [selectAppManifestNode, selectAadAppNode], - }, - ], - }; -} - -export function grantPermissionQuestionNode(): IQTreeNode { - const selectAppManifestNode = selectTeamsAppManifestQuestionNode(); - selectAppManifestNode.condition = { - contains: CollaborationConstants.TeamsAppQuestionId, - }; - selectAppManifestNode.children!.push({ - condition: envQuestionCondition, - data: selectTargetEnvQuestion(QuestionNames.Env, false, false, ""), - }); - const selectAadAppNode = selectAadAppManifestQuestionNode(); - selectAadAppNode.condition = { contains: CollaborationConstants.AadAppQuestionId }; - selectAadAppNode.children!.push({ - condition: envQuestionCondition, - data: selectTargetEnvQuestion(QuestionNames.Env, false, false, ""), - }); - return { - data: { type: "group" }, - children: [ - { - condition: (inputs: Inputs) => DynamicPlatforms.includes(inputs.platform), - data: selectAppTypeQuestion(), - cliOptionDisabled: "self", - inputsDisabled: "self", - children: [ - selectAppManifestNode, - selectAadAppNode, - { - data: inputUserEmailQuestion( - getLocalizedString("core.getUserEmailQuestion.title"), - "Email address of the collaborator.", - true - ), - }, - ], - }, - ], - }; -} - export function convertAadToNewSchemaQuestionNode(): IQTreeNode { return { data: { type: "group" }, @@ -224,25 +155,6 @@ export function selectTeamsAppManifestQuestionNode(): IQTreeNode { }; } -export function selectAadAppManifestQuestionNode(): IQTreeNode { - return { - data: selectAadManifestQuestion(), - children: [ - { - condition: (inputs: Inputs) => - inputs.platform === Platform.VSCode && // confirm question only works for VSC - inputs.projectPath && - inputs[QuestionNames.AadAppManifestFilePath] && - path.resolve(inputs[QuestionNames.AadAppManifestFilePath]) !== - path.join(inputs.projectPath, "aad.manifest.json"), - data: confirmManifestQuestion(false, false), - cliOptionDisabled: "self", - inputsDisabled: "self", - }, - ], - }; -} - function confirmCondition(inputs: Inputs, isLocal: boolean): boolean { return ( inputs.platform === Platform.VSCode && // confirm question only works for VSC @@ -377,7 +289,7 @@ export function selectLocalTeamsAppManifestQuestion(): SingleFileQuestion { }; } -function confirmManifestQuestion(isTeamsApp = true, isLocal = false): SingleSelectQuestion { +export function confirmManifestQuestion(isTeamsApp = true, isLocal = false): SingleSelectQuestion { const map: Record = { true_true: QuestionNames.ConfirmLocalManifest, true_false: QuestionNames.ConfirmManifest, @@ -636,68 +548,6 @@ export function selectAadManifestQuestion(): SingleFileQuestion { }; } -function selectAppTypeQuestion(): MultiSelectQuestion { - return { - name: QuestionNames.collaborationAppType, - title: getLocalizedString("core.selectCollaborationAppTypeQuestion.title"), - type: "multiSelect", - staticOptions: [ - { - id: CollaborationConstants.AadAppQuestionId, - label: getLocalizedString("core.aadAppQuestion.label"), - description: getLocalizedString("core.aadAppQuestion.description"), - }, - { - id: CollaborationConstants.TeamsAppQuestionId, - label: getLocalizedString("core.teamsAppQuestion.label"), - description: getLocalizedString("core.teamsAppQuestion.description"), - }, - ], - validation: { minItems: 1 }, - validationHelp: "Please select at least one app type.", - }; -} - -export async function envQuestionCondition(inputs: Inputs): Promise { - const appType = inputs[CollaborationConstants.AppType] as string[]; - const requireAad = appType?.includes(CollaborationConstants.AadAppQuestionId); - const requireTeams = appType?.includes(CollaborationConstants.TeamsAppQuestionId); - const aadManifestPath = inputs[QuestionNames.AadAppManifestFilePath]; - const teamsManifestPath = inputs[QuestionNames.TeamsAppManifestFilePath]; - - // When both is selected, only show the question once at the end - if ((requireAad && !aadManifestPath) || (requireTeams && !teamsManifestPath)) { - return false; - } - - // Only show env question when manifest id is referencing value from .env file - let requireEnv = false; - if (requireTeams && teamsManifestPath) { - const teamsAppIdRes = await CollaborationUtil.loadManifestId(teamsManifestPath); - if (teamsAppIdRes.isOk()) { - requireEnv = CollaborationUtil.requireEnvQuestion(teamsAppIdRes.value); - if (requireEnv) { - return true; - } - } else { - return false; - } - } - - if (requireAad && aadManifestPath) { - const aadAppIdRes = await CollaborationUtil.loadManifestId(aadManifestPath); - if (aadAppIdRes.isOk()) { - requireEnv = CollaborationUtil.requireEnvQuestion(aadAppIdRes.value); - if (requireEnv) { - return true; - } - } else { - return false; - } - } - - return false; -} export async function newEnvNameValidation( input: string, inputs?: Inputs diff --git a/packages/fx-core/src/question/share.ts b/packages/fx-core/src/question/share.ts index d5457be0125..3675ee855bd 100644 --- a/packages/fx-core/src/question/share.ts +++ b/packages/fx-core/src/question/share.ts @@ -25,7 +25,6 @@ export enum ShareOperationOption { export enum ShareScopeOption { ShareAppWithTenantUsers = "tenant", ShareAppWithSpecificUsers = "users", - ShareAppWithOwners = "owners", } export const MAX_SHARE_EMAILS = 20; @@ -72,20 +71,13 @@ function shareScopeOptions(): IQTreeNode { name: QuestionNames.ShareScope, title: getLocalizedString("core.shareScopeQuestion.title"), placeholder: getLocalizedString("core.shareScopeQuestion.placeholder"), - staticOptions: [ - ShareScopeOptions.shareWithTenant(), - ShareScopeOptions.shareWithUsers(), - ShareScopeOptions.shareWithOwners(), - ], + staticOptions: [ShareScopeOptions.shareWithTenant(), ShareScopeOptions.shareWithUsers()], default: ShareScopeOptions.shareWithUsers().id, }, children: [ { condition: (inputs: Inputs) => { - return ( - inputs[QuestionNames.ShareScope] === ShareScopeOption.ShareAppWithOwners || - inputs[QuestionNames.ShareScope] === ShareScopeOption.ShareAppWithSpecificUsers - ); + return inputs[QuestionNames.ShareScope] === ShareScopeOption.ShareAppWithSpecificUsers; }, data: inputUserEmailQuestion( getLocalizedString("core.shareScopeQuestion.emails.title"), @@ -127,13 +119,6 @@ export class ShareScopeOptions { label: getLocalizedString("core.shareScopeQuestion.option.shareWithUsers"), }; } - - static shareWithOwners(): OptionItem { - return { - id: ShareScopeOption.ShareAppWithOwners, - label: getLocalizedString("core.shareOptionQuestion.option.shareWithOwners"), - }; - } } export function removeSharedAccessNode(): IQTreeNode { diff --git a/packages/fx-core/tests/client/graphClient.test.ts b/packages/fx-core/tests/client/graphClient.test.ts index 7a4cdbcb34f..9a112054dbc 100644 --- a/packages/fx-core/tests/client/graphClient.test.ts +++ b/packages/fx-core/tests/client/graphClient.test.ts @@ -1,16 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { expect } from "chai"; -import { createSandbox } from "sinon"; -import { RetryHandler } from "../../src/client/graphClient"; -import { GraphClient } from "../../src/client/graphClient"; -import { ok, err, SystemError, SensitivityLabel, signedIn } from "@microsoft/teamsfx-api"; +import { err, ok, SensitivityLabel, signedIn, SystemError } from "@microsoft/teamsfx-api"; import axios from "axios"; +import { expect } from "chai"; import "mocha"; -import { MockedM365Provider, MockTools } from "../core/utils"; +import { createSandbox } from "sinon"; +import { GraphClient, RetryHandler } from "../../src/client/graphClient"; import * as globalState from "../../src/common/globalState"; -import { setTools, TOOLS } from "../../src/common/globalVars"; +import { setTools } from "../../src/common/globalVars"; +import { MockedM365Provider, MockTools } from "../core/utils"; describe("GraphAPIClient Test", () => { const sandbox = createSandbox(); @@ -470,6 +469,170 @@ describe("GraphAPIClient Test", () => { }); }); + describe("getUserInfoFromId", () => { + const tokenProvider = new MockedM365Provider(); + const graphClient = new GraphClient(tokenProvider); + const sandbox = createSandbox(); + const fakeAxiosInstance = axios.create(); + + beforeEach(() => { + sandbox.stub(axios, "create").returns(fakeAxiosInstance); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should return user info when successful", async () => { + const userId = "test-user-id"; + const mockUser = { + id: userId, + displayName: "Test User", + userPrincipalName: "testuser@example.com", + }; + const mockResponse = { data: mockUser }; + + sandbox.stub(tokenProvider, "getAccessToken").resolves(ok("fake-token")); + sandbox.stub(fakeAxiosInstance, "get").resolves(mockResponse); + + const result = await graphClient.getUserInfoFromId(userId); + + expect(result).to.deep.equal(mockUser); + }); + + it("should return undefined when response is empty", async () => { + const userId = "test-user-id"; + sandbox.stub(tokenProvider, "getAccessToken").resolves(ok("fake-token")); + sandbox.stub(fakeAxiosInstance, "get").resolves({}); + + const result = await graphClient.getUserInfoFromId(userId); + + expect(result).to.be.undefined; + }); + + it("should throw error when token acquisition fails", async () => { + const userId = "test-user-id"; + const error = new SystemError({ + name: "TokenError", + message: "Token acquisition failed", + source: "GraphClient", + }); + sandbox.stub(tokenProvider, "getAccessToken").resolves(err(error)); + + try { + await graphClient.getUserInfoFromId(userId); + expect.fail("Should have thrown error"); + } catch (e) { + expect(e).to.equal(error); + } + }); + }); + + describe("getGroupInfo", () => { + const tokenProvider = new MockedM365Provider(); + const graphClient = new GraphClient(tokenProvider); + const sandbox = createSandbox(); + const fakeAxiosInstance = axios.create(); + + beforeEach(() => { + sandbox.stub(axios, "create").returns(fakeAxiosInstance); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should return group info when successful", async () => { + const email = "testgroup@example.com"; + const mockGroup = { + id: "test-group-id", + displayName: "Test Group", + mail: email, + }; + const mockResponse = { + data: { + value: [mockGroup], + }, + }; + + sandbox.stub(tokenProvider, "getAccessToken").resolves(ok("fake-token")); + sandbox.stub(fakeAxiosInstance, "get").resolves(mockResponse); + + const result = await graphClient.getGroupInfo(email); + + expect(result).to.deep.equal(mockGroup); + }); + + it("should return group info with case-insensitive email matching", async () => { + const email = "TestGroup@Example.com"; + const mockGroup = { + id: "test-group-id", + displayName: "Test Group", + mail: "testgroup@example.com", + }; + const mockResponse = { + data: { + value: [mockGroup], + }, + }; + + sandbox.stub(tokenProvider, "getAccessToken").resolves(ok("fake-token")); + sandbox.stub(fakeAxiosInstance, "get").resolves(mockResponse); + + const result = await graphClient.getGroupInfo(email); + + expect(result).to.deep.equal(mockGroup); + }); + + it("should return undefined when no matching group found", async () => { + const email = "testgroup@example.com"; + const mockResponse = { + data: { + value: [ + { + id: "other-group", + mail: "other@example.com", + }, + ], + }, + }; + + sandbox.stub(tokenProvider, "getAccessToken").resolves(ok("fake-token")); + sandbox.stub(fakeAxiosInstance, "get").resolves(mockResponse); + + const result = await graphClient.getGroupInfo(email); + + expect(result).to.be.undefined; + }); + + it("should return undefined when response is empty", async () => { + const email = "testgroup@example.com"; + sandbox.stub(tokenProvider, "getAccessToken").resolves(ok("fake-token")); + sandbox.stub(fakeAxiosInstance, "get").resolves({}); + + const result = await graphClient.getGroupInfo(email); + + expect(result).to.be.undefined; + }); + + it("should throw error when token acquisition fails", async () => { + const email = "testgroup@example.com"; + const error = new SystemError({ + name: "TokenError", + message: "Token acquisition failed", + source: "GraphClient", + }); + sandbox.stub(tokenProvider, "getAccessToken").resolves(err(error)); + + try { + await graphClient.getGroupInfo(email); + expect.fail("Should have thrown error"); + } catch (e) { + expect(e).to.equal(error); + } + }); + }); + describe("getCurrentUserInfo", () => { const tokenProvider = new MockedM365Provider(); const graphClient = new GraphClient(tokenProvider); diff --git a/packages/fx-core/tests/component/feature/collaboration.test.ts b/packages/fx-core/tests/component/feature/collaboration.test.ts index 5b3e1a0799d..1afb5f54e58 100644 --- a/packages/fx-core/tests/component/feature/collaboration.test.ts +++ b/packages/fx-core/tests/component/feature/collaboration.test.ts @@ -1,17 +1,27 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import "mocha"; -import * as sinon from "sinon"; +import { FxError } from "@microsoft/teamsfx-api"; +import axios from "axios"; import * as chai from "chai"; import chaiAsPromised from "chai-as-promised"; -import { AadCollaboration, TeamsCollaboration } from "../../../src/component/feature/collaboration"; -import { MockedLogProvider, MockedV2Context } from "../../plugins/solution/util"; +import "mocha"; +import { err, ok } from "neverthrow"; +import * as sinon from "sinon"; +import { GraphClient } from "../../../src/client/graphClient"; +import { teamsDevPortalClient } from "../../../src/client/teamsDevPortalClient"; +import { setTools } from "../../../src/common/globalVars"; import { AadAppClient } from "../../../src/component/driver/aad/utility/aadAppClient"; -import axios from "axios"; import { AppUser } from "../../../src/component/driver/teamsApp/interfaces/appdefinitions/appUser"; -import { teamsDevPortalClient } from "../../../src/client/teamsDevPortalClient"; -import { MockedM365Provider } from "../../core/utils"; +import { + AadCollaboration, + AgentCollaboration, + TeamsCollaboration, +} from "../../../src/component/feature/collaboration"; +import { M365AppDefinition } from "../../../src/component/m365/interface"; +import { PackageService } from "../../../src/component/m365/packageService"; +import { MockedM365Provider, MockTools } from "../../core/utils"; +import { MockedLogProvider, MockedV2Context } from "../../plugins/solution/util"; chai.use(chaiAsPromised); const expect = chai.expect; @@ -540,3 +550,133 @@ describe("TeamsCollaboration", async () => { expect(result.isErr() && result.error.name == "UnhandledError").to.be.true; }); }); + +describe("AgentCollaboration", async () => { + const context = new MockedV2Context(); + const m365TokenProvider = new MockedM365Provider(); + setTools(new MockTools()); + const agentCollaboration = new AgentCollaboration(m365TokenProvider); + const sandbox = sinon.createSandbox(); + const expectedTitleId = "test-title-id"; + const expectedUserId = "expectedUserId"; + const expectedUserInfo: AppUser = { + tenantId: "tenantId", + aadId: expectedUserId, + displayName: "displayName", + userPrincipalName: "userPrincipalName", + isAdministrator: true, + }; + + afterEach(() => { + sandbox.restore(); + }); + + it("grant permission: should add owner", async () => { + sandbox.stub(m365TokenProvider, "getAccessToken").resolves(ok("test-token")); + + const packageServiceStub = sandbox.stub(PackageService.GetSharedInstance(), "addOwner"); + packageServiceStub.resolves(ok(undefined)); + + const result = await agentCollaboration.grantPermission( + context, + expectedTitleId, + expectedUserInfo + ); + expect(result.isOk() && result.value[0].resourceId == expectedTitleId).to.be.true; + expect(result.isOk() && result.value[0].roles![0] == "Owner").to.be.true; + }); + + it("list collaborator: should return all owners", async () => { + sandbox.stub(m365TokenProvider, "getAccessToken").resolves(ok("test-token")); + + const packageServiceStub = sandbox.stub(PackageService.GetSharedInstance(), "previewApp"); + packageServiceStub.resolves( + ok({ + owners: [ + { + entityId: expectedUserId, + entityType: "User", + }, + ], + } as M365AppDefinition) + ); + + sandbox.stub(GraphClient.prototype, "getUserInfoFromId").resolves({ + id: expectedUserId, + displayName: "displayName", + userPrincipalName: "userPrincipalName", + mail: "test@mail.com", + }); + + const result = await agentCollaboration.listCollaborator(context, expectedTitleId); + expect(result.isOk() && result.value[0].resourceId == expectedTitleId).to.be.true; + expect(result.isOk() && result.value[0].userObjectId == expectedUserId).to.be.true; + }); + + it("grant permission errors: should return error from token provider", async () => { + const expectedError = new Error("token error"); + sandbox.stub(m365TokenProvider, "getAccessToken").resolves(err(expectedError as FxError)); + + const result = await agentCollaboration.grantPermission( + context, + expectedTitleId, + expectedUserInfo + ); + expect(result.isErr() && result.error === expectedError).to.be.true; + }); + + it("grant permission errors: should return error from package service", async () => { + sandbox.stub(m365TokenProvider, "getAccessToken").resolves(ok("test-token")); + + const expectedError = new Error("package service error"); + const packageServiceStub = sandbox.stub(PackageService.GetSharedInstance(), "addOwner"); + packageServiceStub.resolves(err(expectedError as FxError)); + + const result = await agentCollaboration.grantPermission( + context, + expectedTitleId, + expectedUserInfo + ); + expect(result.isErr() && result.error === expectedError).to.be.true; + }); + + it("list collaborator: should skip users when getUserInfoFromId returns undefined", async () => { + sandbox.stub(m365TokenProvider, "getAccessToken").resolves(ok("test-token")); + + const packageServiceStub = sandbox.stub(PackageService.GetSharedInstance(), "previewApp"); + packageServiceStub.resolves( + ok({ + owners: [ + { + entityId: expectedUserId, + entityType: "User", + }, + ], + } as M365AppDefinition) + ); + + sandbox.stub(GraphClient.prototype, "getUserInfoFromId").resolves(undefined); + + const result = await agentCollaboration.listCollaborator(context, expectedTitleId); + expect(result.isOk() && result.value.length === 0).to.be.true; + }); + + it("list collaborator errors: should return error from token provider", async () => { + const expectedError = new Error("token error"); + sandbox.stub(m365TokenProvider, "getAccessToken").resolves(err(expectedError as FxError)); + + const result = await agentCollaboration.listCollaborator(context, expectedTitleId); + expect(result.isErr() && result.error === expectedError).to.be.true; + }); + + it("list collaborator errors: should return error from package service", async () => { + sandbox.stub(m365TokenProvider, "getAccessToken").resolves(ok("test-token")); + + const expectedError = new Error("package service error"); + const packageServiceStub = sandbox.stub(PackageService.GetSharedInstance(), "previewApp"); + packageServiceStub.resolves(err(expectedError as FxError)); + + const result = await agentCollaboration.listCollaborator(context, expectedTitleId); + expect(result.isErr() && result.error === expectedError).to.be.true; + }); +}); diff --git a/packages/fx-core/tests/component/m365/packageService.test.ts b/packages/fx-core/tests/component/m365/packageService.test.ts index 403b7c27b2b..2f7c93ecacb 100644 --- a/packages/fx-core/tests/component/m365/packageService.test.ts +++ b/packages/fx-core/tests/component/m365/packageService.test.ts @@ -1457,7 +1457,7 @@ describe("Package Service", () => { }; const packageService = new PackageService("https://test-endpoint", logger); - const result = await packageService.grantPermission("test-token", "test-title-id", { + const result = await packageService.addOwner("test-token", "test-title-id", { aadId: "new-user", displayName: "New User", userPrincipalName: "newuser@test.com", @@ -1490,7 +1490,7 @@ describe("Package Service", () => { axiosPutResponses["/builder/v1/users/titles/test-title-id/owners?idType=TitleId"] = error; const packageService = new PackageService("https://test-endpoint", logger); - const result = await packageService.grantPermission("test-token", "test-title-id", { + const result = await packageService.addOwner("test-token", "test-title-id", { aadId: "new-user", displayName: "New User", userPrincipalName: "newuser@test.com", @@ -1522,7 +1522,7 @@ describe("Package Service", () => { // Don't need to mock put response since it won't be called for existing user const packageService = new PackageService("https://test-endpoint", logger); - const result = await packageService.grantPermission("test-token", "test-title-id", { + const result = await packageService.addOwner("test-token", "test-title-id", { aadId: "existing-user", displayName: "Existing User", userPrincipalName: "existinguser@test.com", @@ -1545,7 +1545,7 @@ describe("Package Service", () => { axiosGetResponses["/marketplace/v1/users/titles/test-title-id/preview?idType=TitleId"] = error; const packageService = new PackageService("https://test-endpoint", logger); - const result = await packageService.grantPermission("test-token", "test-title-id", { + const result = await packageService.addOwner("test-token", "test-title-id", { aadId: "new-user", displayName: "New User", userPrincipalName: "newuser@test.com", diff --git a/packages/fx-core/tests/core/FxCore.share.test.ts b/packages/fx-core/tests/core/FxCore.share.test.ts index 6a827825f89..505dbd1a61c 100644 --- a/packages/fx-core/tests/core/FxCore.share.test.ts +++ b/packages/fx-core/tests/core/FxCore.share.test.ts @@ -7,14 +7,12 @@ import chaiAsPromised from "chai-as-promised"; import fs from "fs-extra"; import "mocha"; import { createSandbox, match } from "sinon"; -import { InputValidationError, MAX_EMAIL_NUMBER, PackageService } from "../../src"; -import * as teamsDevPortalClient from "../../src/client/teamsDevPortalClient"; +import { InputValidationError, MAX_EMAIL_NUMBER } from "../../src"; import { ProjectModel } from "../../src/component/configManager/interface"; import * as shareUtils from "../../src/component/driver/share/utils"; import { envUtil } from "../../src/component/utils/envUtil"; import { metadataUtil } from "../../src/component/utils/metadataUtil"; import { pathUtils } from "../../src/component/utils/pathUtils"; -import * as collaboratorUtil from "../../src/core/collaborator"; import { FxCore } from "../../src/core/FxCore"; import * as shareCore from "../../src/core/share"; import { QuestionNames } from "../../src/question/questionNames"; @@ -249,132 +247,6 @@ describe("FxCore.shareApplication", () => { }); }); - describe("Share with owners", () => { - afterEach(() => { - sandbox.restore(); - }); - it("shares with owners successfully", async () => { - // Mock getUserInfo from CollaborationUtil - sandbox.stub(collaboratorUtil.CollaborationUtil, "getUserInfo").resolves({ - displayName: "Test User", - userPrincipalName: "testuser@example.com", - aadId: "mock-aad-id", - tenantId: "mock-tenant-id", - isAdministrator: false, - }); - - // Mock teams portal client - const grantPermissionStub = sandbox - .stub(teamsDevPortalClient.TeamsDevPortalClient.prototype, "grantPermission") - .resolves(); - - // Mock package service - sandbox.stub(PackageService, "GetSharedInstance").returns({ - grantPermission: () => ok(undefined), - } as any); - - // Setup common stubs - sandbox - .stub(shareUtils, "parseShareAppActionYamlConfig") - .resolves(ok({ teamsappId: "mockAppId", titleId: "mockTitleId", appId: "mockAppId" })); - sandbox.stub(metadataUtil, "parse").resolves(ok(mockProjectModel)); - sandbox.stub(envUtil, "listEnv").resolves(ok(["dev", "prod"])); - sandbox.stub(envUtil, "readEnv").resolves(ok({})); - sandbox.stub(envUtil, "writeEnv").resolves(ok(undefined)); - sandbox.stub(tools.ui, "selectOption").callsFake(async (config) => { - if (config.name === "env") { - return ok({ type: "success", result: "dev" }); - } else { - return ok({ type: "success", result: "" }); - } - }); - - // Mock token provider - sandbox - .stub(tools.tokenProvider.m365TokenProvider, "getAccessToken") - .resolves(ok("mock-token")); - - const progressStartStub = sandbox.stub(); - const progressEndStub = sandbox.stub(); - sandbox.stub(tools.ui, "createProgressBar").returns({ - start: progressStartStub, - end: progressEndStub, - } as any as IProgressHandler); - sandbox.stub(pathUtils, "getEnvFilePath").resolves(ok(".")); - sandbox.stub(pathUtils, "getYmlFilePath").returns("m365agents.yml"); - sandbox.stub(fs, "pathExistsSync").onFirstCall().returns(false).onSecondCall().returns(true); - - const emails = "owner@example.com"; - const inputs: Inputs = { - platform: Platform.VSCode, - projectPath: ".", - [QuestionNames.ShareOperation]: ShareOperationOption.ShareWithUsers, - [QuestionNames.ShareScope]: ShareScopeOption.ShareAppWithOwners, - [QuestionNames.UserEmail]: emails, - }; - - const fxCore = new FxCore(tools); - const res = await fxCore.shareApplication(inputs); - - chai.assert.isTrue(res.isOk()); - chai.assert.isTrue(grantPermissionStub.calledOnce); - }); - - it("returns error for invalid user when sharing with owners", async () => { - // Mock getUserInfo to return undefined (invalid user) - sandbox.stub(collaboratorUtil.CollaborationUtil, "getUserInfo").resolves(undefined); - - // Setup common stubs - sandbox - .stub(shareUtils, "parseShareAppActionYamlConfig") - .resolves(ok({ teamsappId: "mockAppId", titleId: "mockTitleId", appId: "mockAppId" })); - sandbox.stub(metadataUtil, "parse").resolves(ok(mockProjectModel)); - sandbox.stub(envUtil, "listEnv").resolves(ok(["dev", "prod"])); - sandbox.stub(envUtil, "readEnv").resolves(ok({})); - sandbox.stub(envUtil, "writeEnv").resolves(ok(undefined)); - sandbox.stub(tools.ui, "selectOption").callsFake(async (config) => { - if (config.name === "env") { - return ok({ type: "success", result: "dev" }); - } else { - return ok({ type: "success", result: "" }); - } - }); - - // Mock token provider - sandbox - .stub(tools.tokenProvider.m365TokenProvider, "getAccessToken") - .resolves(ok("mock-token")); - - const progressStartStub = sandbox.stub(); - const progressEndStub = sandbox.stub(); - sandbox.stub(tools.ui, "createProgressBar").returns({ - start: progressStartStub, - end: progressEndStub, - } as any as IProgressHandler); - sandbox.stub(pathUtils, "getEnvFilePath").resolves(ok(".")); - sandbox.stub(pathUtils, "getYmlFilePath").returns("m365agents.yml"); - sandbox.stub(fs, "pathExistsSync").onFirstCall().returns(false).onSecondCall().returns(true); - - const emails = "invalid@example.com"; - const inputs: Inputs = { - platform: Platform.VSCode, - projectPath: ".", - [QuestionNames.ShareOperation]: ShareOperationOption.ShareWithUsers, - [QuestionNames.ShareScope]: ShareScopeOption.ShareAppWithOwners, - [QuestionNames.UserEmail]: emails, - }; - - const fxCore = new FxCore(tools); - const res = await fxCore.shareApplication(inputs); - - chai.assert.isTrue(res.isErr()); - if (res.isErr()) { - chai.assert.instanceOf(res.error, InputValidationError); - chai.assert.isTrue(res.error.message.indexOf("Invalid user: invalid@example.com") > -1); - } - }); - }); - describe("Error cases", () => { afterEach(() => { sandbox.restore(); diff --git a/packages/fx-core/tests/core/collaborator.test.ts b/packages/fx-core/tests/core/collaborator.test.ts index 68f8a0d28f5..384ccfd04a4 100644 --- a/packages/fx-core/tests/core/collaborator.test.ts +++ b/packages/fx-core/tests/core/collaborator.test.ts @@ -13,13 +13,18 @@ import { import { assert } from "chai"; import fs from "fs-extra"; import "mocha"; -import mockedEnv, { RestoreFn } from "mocked-env"; +import mockedEnv from "mocked-env"; import os from "os"; import * as path from "path"; import sinon from "sinon"; import { CollaborationState } from "../../src/common/permissionInterface"; import { SolutionError } from "../../src/component/constants"; -import { AadCollaboration, TeamsCollaboration } from "../../src/component/feature/collaboration"; +import * as shareUtils from "../../src/component/driver/share/utils"; +import { + AadCollaboration, + AgentCollaboration, + TeamsCollaboration, +} from "../../src/component/feature/collaboration"; import { CollaborationConstants, CollaborationUtil, @@ -27,7 +32,7 @@ import { grantPermission, listCollaborator, } from "../../src/core/collaborator"; -import { QuestionNames } from "../../src/question"; +import { QuestionNames } from "../../src/question/constants"; import { MockedV2Context } from "../plugins/solution/util"; import { MockedAzureAccountProvider, MockedM365Provider, randomAppName } from "./utils"; @@ -49,6 +54,13 @@ describe("Collaborator APIs for V3", () => { }); describe("listCollaborator", () => { + let inputs: InputsWithProjectPath; + beforeEach(() => { + inputs = { + platform: Platform.VSCode, + projectPath: path.join(os.tmpdir(), randomAppName()), + }; + }); afterEach(() => { sandbox.restore(); }); @@ -162,6 +174,127 @@ describe("Collaborator APIs for V3", () => { const result = await listCollaborator(ctx, inputs, tokenProvider); assert.isTrue(result.isOk()); }); + + it("happy path with agent", async () => { + sandbox.stub(tokenProvider.m365TokenProvider, "getJsonObject").resolves( + ok({ + tid: "mock_project_tenant_id", + oid: "fake_oid", + unique_name: "fake_unique_name", + name: "fake_name", + }) + ); + const expectedTitleId = "test-agent-title"; + sandbox + .stub(shareUtils, "parseShareAppActionYamlConfig") + .resolves(ok({ titleId: expectedTitleId, teamsappId: "", appId: "" })); + sandbox.stub(AgentCollaboration.prototype, "listCollaborator").resolves( + ok([ + { + userObjectId: "fake-agent-user-id", + resourceId: expectedTitleId, + displayName: "fake-agent-display-name", + userPrincipalName: "fake-agent-upn", + }, + ]) + ); + inputs[QuestionNames.collaborationAppType] = [CollaborationConstants.AgentOptionId]; + const result = await listCollaborator(ctx, inputs, tokenProvider); + assert.isTrue(result.isOk()); + }); + + it("should handle failed agent config parse", async () => { + sandbox.stub(tokenProvider.m365TokenProvider, "getJsonObject").resolves( + ok({ + tid: "mock_project_tenant_id", + oid: "fake_oid", + unique_name: "fake_unique_name", + name: "fake_name", + }) + ); + inputs[QuestionNames.collaborationAppType] = [CollaborationConstants.AgentOptionId]; + sandbox + .stub(shareUtils, "parseShareAppActionYamlConfig") + .resolves(err(new UserError("source", "name", "Failed to parse config"))); + const result = await listCollaborator(ctx, inputs, tokenProvider); + assert.isTrue(result.isErr()); + }); + + it("happy path with agent", async () => { + sandbox.stub(tokenProvider.m365TokenProvider, "getJsonObject").resolves( + ok({ + tid: "mock_project_tenant_id", + oid: "fake_oid", + unique_name: "fake_unique_name", + name: "fake_name", + }) + ); + inputs[QuestionNames.collaborationAppType] = [CollaborationConstants.AgentOptionId]; + sandbox.stub(TeamsCollaboration.prototype, "listCollaborator").resolves( + ok([ + { + userObjectId: "fake-aad-user-object-id", + resourceId: "fake-resource-id", + displayName: "fake-display-name", + userPrincipalName: "fake-user-principal-name", + }, + ]) + ); + const expectedTitleId = "test-agent-title"; + sandbox + .stub(shareUtils, "parseShareAppActionYamlConfig") + .resolves(ok({ titleId: expectedTitleId, teamsappId: "", appId: "" })); + sandbox.stub(AgentCollaboration.prototype, "listCollaborator").resolves( + ok([ + { + userObjectId: "fake-agent-user-id", + resourceId: expectedTitleId, + displayName: "fake-agent-display-name", + userPrincipalName: "fake-agent-upn", + }, + ]) + ); + const result = await listCollaborator(ctx, inputs, tokenProvider); + assert.isTrue(result.isOk()); + }); + + it("should handle agent config parse error", async () => { + sandbox.stub(tokenProvider.m365TokenProvider, "getJsonObject").resolves( + ok({ + tid: "mock_project_tenant_id", + oid: "fake_oid", + unique_name: "fake_unique_name", + name: "fake_name", + }) + ); + inputs[QuestionNames.collaborationAppType] = [CollaborationConstants.AgentOptionId]; + sandbox + .stub(shareUtils, "parseShareAppActionYamlConfig") + .resolves(err(new UserError("source", "name", "Failed to parse agent config"))); + const result = await listCollaborator(ctx, inputs, tokenProvider); + assert.isTrue(result.isErr()); + }); + + it("should handle agent list collaborator error", async () => { + sandbox.stub(tokenProvider.m365TokenProvider, "getJsonObject").resolves( + ok({ + tid: "mock_project_tenant_id", + oid: "fake_oid", + unique_name: "fake_unique_name", + name: "fake_name", + }) + ); + inputs[QuestionNames.collaborationAppType] = [CollaborationConstants.AgentOptionId]; + const expectedTitleId = "test-agent-title"; + sandbox + .stub(shareUtils, "parseShareAppActionYamlConfig") + .resolves(ok({ titleId: expectedTitleId, teamsappId: "", appId: "" })); + sandbox + .stub(AgentCollaboration.prototype, "listCollaborator") + .resolves(err(new UserError("source", "name", "Failed to list agent collaborators"))); + const result = await listCollaborator(ctx, inputs, tokenProvider); + assert.isTrue(result.isErr()); + }); }); describe("checkPermission", () => { @@ -251,7 +384,15 @@ describe("Collaborator APIs for V3", () => { assert.isTrue(result.isOk()); }); }); + describe("grantPermission", () => { + let inputs: InputsWithProjectPath; + beforeEach(() => { + inputs = { + platform: Platform.VSCode, + projectPath: path.join(os.tmpdir(), randomAppName()), + }; + }); it("should return NotProvisioned state if Teamsfx project hasn't been provisioned", async () => { sandbox.stub(CollaborationUtil, "getUserInfo").resolves({ tenantId: "fake_tid", @@ -449,6 +590,85 @@ describe("Collaborator APIs for V3", () => { const result = await grantPermission(ctx, inputs, tokenProvider); assert.isTrue(result.isOk()); }); + + it("happy path with agent permission", async () => { + sandbox + .stub(CollaborationUtil, "getUserInfo") + .onCall(0) + .resolves({ + tenantId: "mock_project_tenant_id", + aadId: "aadId", + userPrincipalName: "userPrincipalName", + displayName: "displayName", + isAdministrator: true, + }) + .onCall(1) + .resolves({ + tenantId: "mock_project_tenant_id", + aadId: "aadId2", + userPrincipalName: "userPrincipalName2", + displayName: "displayName2", + isAdministrator: true, + }); + + const expectedTitleId = "test-agent-title"; + inputs.email = "your_collaborator@yourcompany.com"; + inputs.platform = Platform.CLI; + inputs[QuestionNames.collaborationAppType] = [CollaborationConstants.AgentOptionId]; + + sandbox + .stub(shareUtils, "parseShareAppActionYamlConfig") + .resolves(ok({ titleId: expectedTitleId, teamsappId: "", appId: "" })); + sandbox.stub(AgentCollaboration.prototype, "grantPermission").resolves( + ok([ + { + name: "agent_app", + resourceId: expectedTitleId, + roles: ["Owner"], + type: "M365", + }, + ]) + ); + + const result = await grantPermission(ctx, inputs, tokenProvider); + assert.isTrue(result.isOk()); + if (result.isOk()) { + const agentPermission = result.value.permissions?.find((p) => p.name === "agent_app"); + assert.isDefined(agentPermission); + assert.equal(agentPermission?.resourceId, expectedTitleId); + } + }); + + it("should handle agent config parse error in grant permission", async () => { + sandbox + .stub(CollaborationUtil, "getUserInfo") + .onCall(0) + .resolves({ + tenantId: "mock_project_tenant_id", + aadId: "aadId", + userPrincipalName: "userPrincipalName", + displayName: "displayName", + isAdministrator: true, + }) + .onCall(1) + .resolves({ + tenantId: "mock_project_tenant_id", + aadId: "aadId2", + userPrincipalName: "userPrincipalName2", + displayName: "displayName2", + isAdministrator: true, + }); + + inputs.email = "your_collaborator@yourcompany.com"; + inputs[QuestionNames.collaborationAppType] = [CollaborationConstants.AgentOptionId]; + + sandbox + .stub(shareUtils, "parseShareAppActionYamlConfig") + .resolves(err(new UserError("source", "name", "Failed to parse agent config"))); + + const result = await grantPermission(ctx, inputs, tokenProvider); + assert.isTrue(result.isErr()); + }); }); describe("loadDotEnvFile v3", () => { diff --git a/packages/fx-core/tests/core/share.test.ts b/packages/fx-core/tests/core/share.test.ts index 4733451d2ad..50016750b29 100644 --- a/packages/fx-core/tests/core/share.test.ts +++ b/packages/fx-core/tests/core/share.test.ts @@ -5,6 +5,7 @@ import { err, ok } from "@microsoft/teamsfx-api"; import { assert } from "chai"; import "mocha"; import sinon from "sinon"; +import { GraphClient } from "../../src/client/graphClient"; import { setTools } from "../../src/common/globalVars"; import { AppUser } from "../../src/component/driver/teamsApp/interfaces/appdefinitions/appUser"; import { M365AppEntity, M365EntityType } from "../../src/component/m365/interface"; @@ -133,6 +134,11 @@ describe("share", () => { getUserInfoStub.withArgs(sinon.match.any, mockEmails[0]).resolves(mockUserInfo1); getUserInfoStub.withArgs(sinon.match.any, mockEmails[1]).resolves(undefined); + // Mock GraphClient.getGroupInfo to also return undefined + const getGroupInfoStub = sandbox + .stub(GraphClient.prototype, "getGroupInfo") + .resolves(undefined); + // Act const result = await addSharedUsers(mockMosToken, mockTitleId, mockEmails); @@ -140,8 +146,76 @@ describe("share", () => { assert.isTrue(result.isErr()); if (result.isErr()) { assert.instanceOf(result.error, InputValidationError); - assert.include(result.error.message, "Invalid user: user2@example.com"); + assert.include(result.error.message, "Invalid user or group: user2@example.com"); } + assert.isTrue(getGroupInfoStub.calledOnce); + }); + + it("should add group when user info not found but group info is found", async () => { + // Arrange + const groupEmail = "group@example.com"; + const mockGroupInfo = { + id: "group-id", + displayName: "Test Group", + mail: groupEmail, + }; + const emailsWithGroup = [mockEmails[0], groupEmail]; + + sandbox.stub(mockSharedInstance, "getSharedUsers").resolves(ok(mockExistingEntities)); + + const shareWithUsersStub = sandbox + .stub(mockSharedInstance, "shareWithUsers") + .resolves(ok(undefined)); + + const getUserInfoStub = sandbox.stub(CollaborationUtil, "getUserInfo"); + getUserInfoStub.withArgs(sinon.match.any, mockEmails[0]).resolves(mockUserInfo1); + getUserInfoStub.withArgs(sinon.match.any, groupEmail).resolves(undefined); + + const getGroupInfoStub = sandbox.stub(GraphClient.prototype, "getGroupInfo"); + getGroupInfoStub.withArgs(groupEmail).resolves(mockGroupInfo); + + // Act + const result = await addSharedUsers(mockMosToken, mockTitleId, emailsWithGroup); + + // Assert + assert.isTrue(result.isOk()); + assert.isTrue(getUserInfoStub.calledTwice); + assert.isTrue(getGroupInfoStub.calledOnce); + assert.isTrue(shareWithUsersStub.calledOnce); + }); + + it("should handle mixed users and groups successfully", async () => { + // Arrange + const groupEmail = "group@example.com"; + const mockGroupInfo = { + id: "group-id", + displayName: "Test Group", + mail: groupEmail, + }; + const emailsWithMixed = [mockEmails[0], groupEmail, mockEmails[1]]; + + sandbox.stub(mockSharedInstance, "getSharedUsers").resolves(ok(mockExistingEntities)); + + const shareWithUsersStub = sandbox + .stub(mockSharedInstance, "shareWithUsers") + .resolves(ok(undefined)); + + const getUserInfoStub = sandbox.stub(CollaborationUtil, "getUserInfo"); + getUserInfoStub.withArgs(sinon.match.any, mockEmails[0]).resolves(mockUserInfo1); + getUserInfoStub.withArgs(sinon.match.any, groupEmail).resolves(undefined); + getUserInfoStub.withArgs(sinon.match.any, mockEmails[1]).resolves(mockUserInfo2); + + const getGroupInfoStub = sandbox.stub(GraphClient.prototype, "getGroupInfo"); + getGroupInfoStub.withArgs(groupEmail).resolves(mockGroupInfo); + + // Act + const result = await addSharedUsers(mockMosToken, mockTitleId, emailsWithMixed); + + // Assert + assert.isTrue(result.isOk()); + assert.isTrue(getUserInfoStub.calledThrice); + assert.isTrue(getGroupInfoStub.calledOnce); + assert.isTrue(shareWithUsersStub.calledOnce); }); it("should return error when getSharedUsers fails", async () => { diff --git a/packages/fx-core/tests/question/collaborator.test.ts b/packages/fx-core/tests/question/collaborator.test.ts new file mode 100644 index 00000000000..86020775b7e --- /dev/null +++ b/packages/fx-core/tests/question/collaborator.test.ts @@ -0,0 +1,322 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + Inputs, + MultiSelectQuestion, + Platform, + StringArrayValidation, + TextInputQuestion, + UserError, + err, + ok, +} from "@microsoft/teamsfx-api"; +import { assert } from "chai"; +import "mocha"; +import sinon from "sinon"; +import { featureFlagManager } from "../../src/common/featureFlags"; +import { CollaborationConstants, CollaborationUtil } from "../../src/core/collaborator"; +import { + envQuestionCondition, + grantPermissionQuestionNode, + listCollaboratorQuestionNode, +} from "../../src/question/collaborator"; +import { QuestionNames } from "../../src/question/constants"; + +describe("Collaboration Question Node Tests", () => { + const sandbox = sinon.createSandbox(); + + afterEach(() => { + sandbox.restore(); + }); + + describe("grantPermissionQuestionNode", () => { + it("should return question node with correct structure", () => { + const node = grantPermissionQuestionNode(); + + // Check root node structure + assert.equal(node.data.type, "group"); + assert.isDefined(node.children); + assert.lengthOf(node.children!, 1); + + // Check root child node + const rootChild = node.children![0]; + assert.isDefined(rootChild.condition); + assert.isDefined(rootChild.data); + assert.equal(rootChild.cliOptionDisabled, "self"); + assert.equal(rootChild.inputsDisabled, "self"); + assert.isDefined(rootChild.children); + assert.lengthOf(rootChild.children!, 3); // Teams app, AAD app, and email input + + // Check that the condition function works correctly + const conditionFn = rootChild.condition as (inputs: Inputs) => boolean; + assert.isTrue(conditionFn({ platform: Platform.VSCode })); + assert.isTrue(conditionFn({ platform: Platform.CLI })); + + // Check Teams app manifest node + const teamsAppNode = rootChild.children![0]; + assert.isDefined(teamsAppNode.condition); + assert.deepEqual(teamsAppNode.condition, { + contains: CollaborationConstants.TeamsAppQuestionId, + }); + + // Check AAD app manifest node + const aadAppNode = rootChild.children![1]; + assert.isDefined(aadAppNode.condition); + assert.deepEqual(aadAppNode.condition, { + contains: CollaborationConstants.AadAppQuestionId, + }); + + // Check email input node + const emailNode = rootChild.children![2]; + assert.isDefined(emailNode.data); + const emailQuestion = emailNode.data as TextInputQuestion; + assert.include(emailQuestion.title!, "Add owner to"); + }); + + it("should include agent option when ShareEnabled flag is true", () => { + sandbox.stub(featureFlagManager, "getBooleanValue").returns(true); + const node = grantPermissionQuestionNode(); + const appTypeQuestion = node.children![0].data as MultiSelectQuestion; + + assert.isDefined(appTypeQuestion.staticOptions); + const options = appTypeQuestion.staticOptions as { id: string }[]; + assert.lengthOf(options, 3); + assert.isTrue(options.some((o) => o.id === CollaborationConstants.AgentOptionId)); + }); + + it("should not include agent option when ShareEnabled flag is false", () => { + sandbox.stub(featureFlagManager, "getBooleanValue").returns(false); + const node = grantPermissionQuestionNode(); + const appTypeQuestion = node.children![0].data as MultiSelectQuestion; + + assert.isDefined(appTypeQuestion.staticOptions); + const options = appTypeQuestion.staticOptions as { id: string }[]; + assert.lengthOf(options, 2); + assert.isFalse(options.some((o) => o.id === CollaborationConstants.AgentOptionId)); + }); + + it("should require at least one app type selection", () => { + const node = grantPermissionQuestionNode(); + if (!node.children) return; + const appTypeQuestion = node.children[0].data as MultiSelectQuestion; + const validation = appTypeQuestion.validation as StringArrayValidation; + + assert.isDefined(validation); + assert.equal(validation.minItems, 1); + }); + }); + + describe("listCollaboratorQuestionNode", () => { + it("should return question node with correct structure", () => { + const node = listCollaboratorQuestionNode(); + + // Check root node structure + assert.equal(node.data.type, "group"); + assert.isDefined(node.children); + assert.lengthOf(node.children!, 1); + + // Check root child node + const rootChild = node.children![0]; + assert.isDefined(rootChild.condition); + assert.isDefined(rootChild.data); + assert.equal(rootChild.cliOptionDisabled, "self"); + assert.equal(rootChild.inputsDisabled, "self"); + assert.isDefined(rootChild.children); + assert.lengthOf(rootChild.children!, 2); // Teams app and AAD app, no email input + + // Check that the condition function works correctly + const conditionFn = rootChild.condition as (inputs: Inputs) => boolean; + assert.isTrue(conditionFn({ platform: Platform.VSCode })); + assert.isTrue(conditionFn({ platform: Platform.CLI })); + + // Check Teams app manifest node + const teamsAppNode = rootChild.children![0]; + assert.isDefined(teamsAppNode.condition); + assert.deepEqual(teamsAppNode.condition, { + contains: CollaborationConstants.TeamsAppQuestionId, + }); + + // Check AAD app manifest node + const aadAppNode = rootChild.children![1]; + assert.isDefined(aadAppNode.condition); + assert.deepEqual(aadAppNode.condition, { + contains: CollaborationConstants.AadAppQuestionId, + }); + }); + + it("should include agent option when ShareEnabled flag is true", () => { + sandbox.stub(featureFlagManager, "getBooleanValue").returns(true); + const node = listCollaboratorQuestionNode(); + const appTypeQuestion = node.children![0].data as MultiSelectQuestion; + + assert.isDefined(appTypeQuestion.staticOptions); + const options = appTypeQuestion.staticOptions as { id: string }[]; + assert.lengthOf(options, 3); + assert.isTrue(options.some((o) => o.id === CollaborationConstants.AgentOptionId)); + }); + + it("should not include agent option when ShareEnabled flag is false", () => { + sandbox.stub(featureFlagManager, "getBooleanValue").returns(false); + const node = listCollaboratorQuestionNode(); + const appTypeQuestion = node.children![0].data as MultiSelectQuestion; + + assert.isDefined(appTypeQuestion.staticOptions); + const options = appTypeQuestion.staticOptions as { id: string }[]; + assert.lengthOf(options, 2); + assert.isFalse(options.some((o) => o.id === CollaborationConstants.AgentOptionId)); + }); + + it("should require at least one app type selection", () => { + const node = listCollaboratorQuestionNode(); + if (!node.children) return; + const appTypeQuestion = node.children[0].data as MultiSelectQuestion; + const validation = appTypeQuestion.validation as StringArrayValidation; + + assert.isDefined(validation); + assert.equal(validation.minItems, 1); + }); + }); + + describe("envQuestionCondition", () => { + let inputs: Inputs; + + beforeEach(() => { + inputs = { + platform: Platform.VSCode, + [CollaborationConstants.AppType]: [], + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("should return false when required manifest paths are missing", async () => { + // Set app types but no manifest paths + inputs[CollaborationConstants.AppType] = [ + CollaborationConstants.TeamsAppQuestionId, + CollaborationConstants.AadAppQuestionId, + ]; + + const result = await envQuestionCondition(inputs); + assert.isFalse(result); + }); + + it("should return false when Teams manifest ID doesn't require env vars", async () => { + // Setup for Teams app only + inputs[CollaborationConstants.AppType] = [CollaborationConstants.TeamsAppQuestionId]; + inputs[QuestionNames.TeamsAppManifestFilePath] = "path/to/manifest.json"; + + const loadManifestStub = sandbox.stub(CollaborationUtil, "loadManifestId"); + loadManifestStub.resolves(ok("static-id")); + + const requireEnvStub = sandbox.stub(CollaborationUtil, "requireEnvQuestion"); + requireEnvStub.returns(false); + + const result = await envQuestionCondition(inputs); + assert.isFalse(result); + assert.isTrue(loadManifestStub.calledOnce); + assert.isTrue(requireEnvStub.calledOnce); + }); + + it("should return true when Teams manifest ID requires env vars", async () => { + // Setup for Teams app that requires env vars + inputs[CollaborationConstants.AppType] = [CollaborationConstants.TeamsAppQuestionId]; + inputs[QuestionNames.TeamsAppManifestFilePath] = "path/to/manifest.json"; + + const loadManifestStub = sandbox.stub(CollaborationUtil, "loadManifestId"); + loadManifestStub.resolves(ok("${{TEAMS_APP_ID}}")); + + const requireEnvStub = sandbox.stub(CollaborationUtil, "requireEnvQuestion"); + requireEnvStub.returns(true); + + const result = await envQuestionCondition(inputs); + assert.isTrue(result); + assert.isTrue(loadManifestStub.calledOnce); + assert.isTrue(requireEnvStub.calledOnce); + }); + + it("should return false when Teams manifest ID loading fails", async () => { + // Setup for Teams app with failed manifest loading + inputs[CollaborationConstants.AppType] = [CollaborationConstants.TeamsAppQuestionId]; + inputs[QuestionNames.TeamsAppManifestFilePath] = "path/to/manifest.json"; + + const loadManifestStub = sandbox.stub(CollaborationUtil, "loadManifestId"); + loadManifestStub.resolves( + err( + new UserError({ + name: "FailedToLoadManifestId", + message: "Failed to load manifest ID", + }) + ) + ); + + const result = await envQuestionCondition(inputs); + assert.isFalse(result); + assert.isTrue(loadManifestStub.calledOnce); + }); + + it("should return true when AAD manifest ID requires env vars", async () => { + // Setup for AAD app that requires env vars + inputs[CollaborationConstants.AppType] = [CollaborationConstants.AadAppQuestionId]; + inputs[QuestionNames.AadAppManifestFilePath] = "path/to/aad.manifest.json"; + + const loadManifestStub = sandbox.stub(CollaborationUtil, "loadManifestId"); + loadManifestStub.resolves(ok("${{AAD_APP_ID}}")); + + const requireEnvStub = sandbox.stub(CollaborationUtil, "requireEnvQuestion"); + requireEnvStub.returns(true); + + const result = await envQuestionCondition(inputs); + assert.isTrue(result); + assert.isTrue(loadManifestStub.calledOnce); + assert.isTrue(requireEnvStub.calledOnce); + }); + + it("should return false when AAD manifest ID loading fails", async () => { + // Setup for AAD app with failed manifest loading + inputs[CollaborationConstants.AppType] = [CollaborationConstants.AadAppQuestionId]; + inputs[QuestionNames.AadAppManifestFilePath] = "path/to/aad.manifest.json"; + + const loadManifestStub = sandbox.stub(CollaborationUtil, "loadManifestId"); + loadManifestStub.resolves( + err( + new UserError({ + name: "FailedToLoadManifestId", + message: "Failed to load manifest ID", + }) + ) + ); + + const result = await envQuestionCondition(inputs); + assert.isFalse(result); + assert.isTrue(loadManifestStub.calledOnce); + }); + + it("should check both manifest types when both app types are selected", async () => { + // Setup for both app types + inputs[CollaborationConstants.AppType] = [ + CollaborationConstants.TeamsAppQuestionId, + CollaborationConstants.AadAppQuestionId, + ]; + inputs[QuestionNames.TeamsAppManifestFilePath] = "path/to/manifest.json"; + inputs[QuestionNames.AadAppManifestFilePath] = "path/to/aad.manifest.json"; + + const loadManifestStub = sandbox.stub(CollaborationUtil, "loadManifestId"); + // First call for Teams app returns false for requiring env + loadManifestStub.onFirstCall().resolves(ok("static-id")); + // Second call for AAD app returns true for requiring env + loadManifestStub.onSecondCall().resolves(ok("${{AAD_APP_ID}}")); + + const requireEnvStub = sandbox.stub(CollaborationUtil, "requireEnvQuestion"); + requireEnvStub.onFirstCall().returns(false); + requireEnvStub.onSecondCall().returns(true); + + const result = await envQuestionCondition(inputs); + assert.isTrue(result); + assert.isTrue(loadManifestStub.calledTwice); + assert.isTrue(requireEnvStub.calledTwice); + }); + }); +}); diff --git a/packages/fx-core/tests/question/question.test.ts b/packages/fx-core/tests/question/question.test.ts index dff5fdd4d89..58a8ece7da0 100644 --- a/packages/fx-core/tests/question/question.test.ts +++ b/packages/fx-core/tests/question/question.test.ts @@ -23,6 +23,7 @@ import "mocha"; import mockedEnv, { RestoreFn } from "mocked-env"; import * as path from "path"; import sinon from "sinon"; +import { FeatureFlags, featureFlagManager } from "../../src"; import { setTools } from "../../src/common/globalVars"; import { manifestUtils } from "../../src/component/driver/teamsApp/utils/ManifestUtils"; import { @@ -41,30 +42,28 @@ import { SPFxImportFolderQuestion, questionNodes, } from "../../src/question"; +import { selectAadAppManifestQuestionNode } from "../../src/question/collaborator"; import { ActionStartOptions, + GCSelectOptions, + KnowledgeSearchTypeOptions, KnowledgeSourceOptions, QuestionNames, TeamsAppValidationOptions, - KnowledgeSearchTypeOptions, - GCSelectOptions, } from "../../src/question/constants"; import { addPluginQuestionNode, apiSpecApiKeyQuestion, createNewEnvQuestionNode, - envQuestionCondition, isAadMainifestContainsPlaceholder, newEnvNameValidation, oauthQuestion, - selectAadAppManifestQuestionNode, selectAadManifestQuestion, selectLocalTeamsAppManifestQuestion, selectTeamsAppManifestQuestion, } from "../../src/question/other"; import { QuestionTreeVisitor, traverse } from "../../src/ui/visitor"; import { MockTools, MockUserInteraction, MockedAzureAccountProvider } from "../core/utils"; -import { featureFlagManager, FeatureFlags } from "../../src"; const ui = new MockUserInteraction(); export async function callFuncs(question: Question, inputs: Inputs, answer?: string) { @@ -666,98 +665,6 @@ describe("deployAadManifest", async () => { }); }); -describe("envQuestionCondition", async () => { - const sandbox = sinon.createSandbox(); - - afterEach(async () => { - sandbox.restore(); - }); - - it("case 1", async () => { - const inputs: Inputs = { - platform: Platform.CLI_HELP, - projectPath: ".", - [QuestionNames.AadAppManifestFilePath]: "aadAppManifest", - [QuestionNames.TeamsAppManifestFilePath]: "teamsAppManifest", - [QuestionNames.collaborationAppType]: [ - CollaborationConstants.TeamsAppQuestionId, - CollaborationConstants.AadAppQuestionId, - ], - }; - sandbox.stub(CollaborationUtil, "loadManifestId").callsFake(async (manifestFilePath) => { - return manifestFilePath == "teamsAppManifest" ? ok("teamsAppId") : ok("aadAppId"); - }); - sandbox.stub(CollaborationUtil, "requireEnvQuestion").resolves(true); - const res = await envQuestionCondition(inputs); - assert.isTrue(res); - }); - - it("case 2", async () => { - const inputs: Inputs = { - platform: Platform.CLI_HELP, - projectPath: ".", - [QuestionNames.AadAppManifestFilePath]: "aadAppManifest", - [QuestionNames.TeamsAppManifestFilePath]: "teamsAppManifest", - [QuestionNames.collaborationAppType]: [ - CollaborationConstants.TeamsAppQuestionId, - CollaborationConstants.AadAppQuestionId, - ], - }; - sandbox.stub(CollaborationUtil, "loadManifestId").callsFake(async (manifestFilePath) => { - return manifestFilePath == "teamsAppManifest" ? ok("teamsAppId") : ok("aadAppId"); - }); - sandbox - .stub(CollaborationUtil, "requireEnvQuestion") - .onFirstCall() - .resolves(false) - .onSecondCall() - .resolves(true); - const res = await envQuestionCondition(inputs); - assert.isTrue(res); - }); - - it("case 3", async () => { - const inputs: Inputs = { - platform: Platform.CLI_HELP, - projectPath: ".", - [QuestionNames.AadAppManifestFilePath]: "aadAppManifest", - [QuestionNames.TeamsAppManifestFilePath]: "teamsAppManifest", - [QuestionNames.collaborationAppType]: [ - CollaborationConstants.TeamsAppQuestionId, - CollaborationConstants.AadAppQuestionId, - ], - }; - sandbox.stub(CollaborationUtil, "loadManifestId").resolves(err(new UserError({}))); - const res = await envQuestionCondition(inputs); - assert.isFalse(res); - }); - - it("case 4", async () => { - const inputs: Inputs = { - platform: Platform.CLI_HELP, - projectPath: ".", - [QuestionNames.AadAppManifestFilePath]: "aadAppManifest", - [QuestionNames.collaborationAppType]: [CollaborationConstants.AadAppQuestionId], - }; - sandbox.stub(CollaborationUtil, "loadManifestId").resolves(err(new UserError({}))); - const res = await envQuestionCondition(inputs); - assert.isFalse(res); - }); - - it("case 5", async () => { - const inputs: Inputs = { - platform: Platform.CLI_HELP, - projectPath: ".", - [QuestionNames.collaborationAppType]: [ - CollaborationConstants.TeamsAppQuestionId, - CollaborationConstants.AadAppQuestionId, - ], - }; - const res = await envQuestionCondition(inputs); - assert.isFalse(res); - }); -}); - describe("resourceGroupQuestionNode", async () => { const sandbox = sinon.createSandbox(); afterEach(() => { diff --git a/packages/fx-core/tests/question/share.test.ts b/packages/fx-core/tests/question/share.test.ts index ab582d665fe..87682f50e87 100644 --- a/packages/fx-core/tests/question/share.test.ts +++ b/packages/fx-core/tests/question/share.test.ts @@ -85,13 +85,12 @@ describe("shareNode", () => { assert.property(shareScopeQuestion, "type"); assert.equal(shareScopeQuestion.type, "singleSelect"); assert.isArray(shareScopeQuestion.staticOptions); - assert.lengthOf(shareScopeQuestion.staticOptions, 3); + assert.lengthOf(shareScopeQuestion.staticOptions, 2); // Check scope options - const [shareTenant, shareUsers, shareOwners] = shareScopeQuestion.staticOptions as OptionItem[]; + const [shareTenant, shareUsers] = shareScopeQuestion.staticOptions as OptionItem[]; assert.equal(shareTenant.id, ShareScopeOption.ShareAppWithTenantUsers); assert.equal(shareUsers.id, ShareScopeOption.ShareAppWithSpecificUsers); - assert.equal(shareOwners.id, ShareScopeOption.ShareAppWithOwners); }); it("email input should be shown when ShareScope is specific users or owners", () => { @@ -108,13 +107,11 @@ describe("shareNode", () => { const inputsSpecificUsers = { [QuestionNames.ShareScope]: ShareScopeOption.ShareAppWithSpecificUsers, }; - const inputsOwners = { [QuestionNames.ShareScope]: ShareScopeOption.ShareAppWithOwners }; const inputsTenant = { [QuestionNames.ShareScope]: ShareScopeOption.ShareAppWithTenantUsers }; // Call the condition function with different inputs const conditionFunc = emailInputNode.condition as ConditionFunc; assert.isTrue(conditionFunc(inputsSpecificUsers as unknown as Inputs)); - assert.isTrue(conditionFunc(inputsOwners as unknown as Inputs)); assert.isFalse(conditionFunc(inputsTenant as unknown as Inputs)); });