diff --git a/packages/fx-core/resource/package.nls.json b/packages/fx-core/resource/package.nls.json index 2012963b72..352ee42e65 100644 --- a/packages/fx-core/resource/package.nls.json +++ b/packages/fx-core/resource/package.nls.json @@ -293,6 +293,8 @@ "core.createProjectQuestion.apiSpecInputUrl.label": "Enter OpenAPI Description Document Location", "core.createProjectQuestion.OpenAIPluginDomain": "OpenAI Plugin Manifest", "core.createProjectQuestion.OpenAIPluginDomain.placeholder": "Enter your website domain or manifest URL", + "core.createaProjectQuestion.ApiKey": "Client secret for API key in OpenAPI specification", + "core.createProjectQuestion.invalidApiKey.message": "Client secret is invalid. The length of secret should be >= 10 and <= 128", "core.createProjectQuestion.invalidUrl.message": "Enter a valid HTTP URL without authentication to access your OpenAPI description document.", "core.createProjectQuestion.apiSpec.operation.title": "Select Operation(s) Teams Can Interact with", "core.createProjectQuestion.apiSpec.operation.placeholder": "GET/POST methods with at most one required parameter and no auth are listed", diff --git a/packages/fx-core/src/component/driver/apiKey/create.ts b/packages/fx-core/src/component/driver/apiKey/create.ts index f11c5aea42..70679f8d5f 100644 --- a/packages/fx-core/src/component/driver/apiKey/create.ts +++ b/packages/fx-core/src/component/driver/apiKey/create.ts @@ -25,6 +25,8 @@ import { InvalidActionInputError, UnhandledError } from "../../../error"; import { ApiKeyNameTooLongError } from "./error/apiKeyNameTooLong"; import { ApiKeyClientSecretInvalidError } from "./error/apiKeyClientSecretInvalid"; import { ApiKeyDomainInvalidError } from "./error/apiKeyDomainInvalid"; +import { QuestionMW } from "../../middleware/questionMW"; +import { QuestionNames } from "../../../question"; const actionName = "apiKey/create"; // DO NOT MODIFY the name const helpLink = "https://aka.ms/teamsfx-actions/apiKey-create"; @@ -34,7 +36,7 @@ export class CreateApiKeyDriver implements StepDriver { description = getLocalizedString("driver.apiKey.description.create"); readonly progressTitle = getLocalizedString("driver.aadApp.apiKey.title.create"); - @hooks([addStartAndEndTelemetry(actionName, actionName)]) + @hooks([QuestionMW("apiKey", true), addStartAndEndTelemetry(actionName, actionName)]) public async execute( args: CreateApiKeyArgs, context: DriverContext, @@ -77,6 +79,10 @@ export class CreateApiKeyDriver implements StepDriver { ); } } else { + const clientSecret = this.loadClientSecret(); + if (clientSecret) { + args.clientSecret = clientSecret; + } this.validateArgs(args); const apiKey = await this.mapArgsToApiSecretRegistration(context.m365TokenProvider, args); @@ -131,6 +137,11 @@ export class CreateApiKeyDriver implements StepDriver { return result; } + private loadClientSecret(): string | undefined { + const clientSecret = process.env[QuestionNames.ApiSpecApiKey]; + return clientSecret; + } + // Allowed secrets: secret or secret1, secret2 // Need to validate secrets outside of the function private parseSecret(apiKeyClientSecret: string): string[] { diff --git a/packages/fx-core/src/component/middleware/questionMW.ts b/packages/fx-core/src/component/middleware/questionMW.ts index a61da66b30..ce1a4ee241 100644 --- a/packages/fx-core/src/component/middleware/questionMW.ts +++ b/packages/fx-core/src/component/middleware/questionMW.ts @@ -7,9 +7,12 @@ import { TOOLS } from "../../core/globalVars"; import { QuestionNodes, questionNodes } from "../../question"; import { traverse } from "../../ui/visitor"; -export function QuestionMW(key: keyof QuestionNodes): Middleware { +export function QuestionMW(key: keyof QuestionNodes, fromAction = false): Middleware { return async (ctx: HookContext, next: NextFunction) => { const inputs = ctx.arguments[0] as Inputs; + if (fromAction) { + inputs.outputEnvVarNames = ctx.arguments[2]; + } const node = questionNodes[key](); const askQuestionRes = await traverse(node, inputs, TOOLS.ui, TOOLS.telemetryReporter); if (askQuestionRes.isErr()) { diff --git a/packages/fx-core/src/question/index.ts b/packages/fx-core/src/question/index.ts index b2bc150b06..f372155370 100644 --- a/packages/fx-core/src/question/index.ts +++ b/packages/fx-core/src/question/index.ts @@ -9,6 +9,7 @@ import { } from "./create"; import { addWebPartQuestionNode, + apiSpecApiKeyQuestion, copilotPluginAddAPIQuestionNode, createNewEnvQuestionNode, deployAadManifestQuestionNode, @@ -63,6 +64,9 @@ export class QuestionNodes { copilotPluginAddAPI(): IQTreeNode { return copilotPluginAddAPIQuestionNode(); } + apiKey(): IQTreeNode { + return apiSpecApiKeyQuestion(); + } } export const questionNodes = new QuestionNodes(); diff --git a/packages/fx-core/src/question/other.ts b/packages/fx-core/src/question/other.ts index f2d2e0334a..24325fe887 100644 --- a/packages/fx-core/src/question/other.ts +++ b/packages/fx-core/src/question/other.ts @@ -918,3 +918,44 @@ export function resourceGroupQuestionNode( ], }; } + +export function apiSpecApiKeyQuestion(): IQTreeNode { + return { + data: { + type: "text", + name: QuestionNames.ApiSpecApiKey, + cliShortName: "k", + title: getLocalizedString("core.createaProjectQuestion.ApiKey"), + cliDescription: "Api key for OpenAPI spec.", + forgetLastValue: true, + validation: { + validFunc: (input: string): string | undefined => { + const pattern = /^(\w){10,128}/g; + const match = pattern.test(input); + + const result = match + ? undefined + : getLocalizedString("core.createProjectQuestion.invalidApiKey.message"); + return result; + }, + }, + additionalValidationOnAccept: { + validFunc: (input: string, inputs?: Inputs): string | undefined => { + if (!inputs) { + throw new Error("inputs is undefined"); // should never happen + } + + process.env[QuestionNames.ApiSpecApiKey] = input; + return; + }, + }, + }, + condition: (inputs: Inputs) => { + return ( + inputs.outputEnvVarNames && + !process.env[inputs.outputEnvVarNames.get("registrationId")] && + !inputs.clientSecret + ); + }, + }; +} diff --git a/packages/fx-core/src/question/questionNames.ts b/packages/fx-core/src/question/questionNames.ts index 4d9177936e..5cc0b45e2a 100644 --- a/packages/fx-core/src/question/questionNames.ts +++ b/packages/fx-core/src/question/questionNames.ts @@ -36,6 +36,7 @@ export enum QuestionNames { OpenAIPluginManifest = "openai-plugin-manifest", ApiOperation = "api-operation", MeArchitectureType = "me-architecture", + ApiSpecApiKey = "api-key", Features = "features", Env = "env", diff --git a/packages/fx-core/tests/component/driver/apiKey/create.test.ts b/packages/fx-core/tests/component/driver/apiKey/create.test.ts index b747be684a..ce2e83d7bd 100644 --- a/packages/fx-core/tests/component/driver/apiKey/create.test.ts +++ b/packages/fx-core/tests/component/driver/apiKey/create.test.ts @@ -7,6 +7,7 @@ import * as chai from "chai"; import chaiAsPromised from "chai-as-promised"; import mockedEnv, { RestoreFn } from "mocked-env"; import { + MockedAzureAccountProvider, MockedLogProvider, MockedM365Provider, MockedUserInteraction, @@ -15,6 +16,7 @@ import { CreateApiKeyDriver } from "../../../../src/component/driver/apiKey/crea import { AppStudioClient } from "../../../../src/component/driver/teamsApp/clients/appStudioClient"; import { ApiSecretRegistrationAppType } from "../../../../src/component/driver/teamsApp/interfaces/ApiSecretRegistration"; import { SystemError, err } from "@microsoft/teamsfx-api"; +import { setTools } from "../../../../src/core/globalVars"; chai.use(chaiAsPromised); const expect = chai.expect; @@ -34,6 +36,17 @@ describe("CreateApiKeyDriver", () => { let envRestore: RestoreFn | undefined; + beforeEach(() => { + setTools({ + ui: new MockedUserInteraction(), + logProvider: new MockedLogProvider(), + tokenProvider: { + azureAccountProvider: new MockedAzureAccountProvider(), + m365TokenProvider: new MockedM365Provider(), + }, + }); + }); + afterEach(() => { sinon.restore(); if (envRestore) { @@ -42,7 +55,7 @@ describe("CreateApiKeyDriver", () => { } }); - it("happy path: create registraionid and read domain, clientSecret from env", async () => { + it("happy path: create registraionid and read domain, clientSecret from input", async () => { sinon.stub(AppStudioClient, "createApiKeyRegistration").resolves({ id: "mockedRegistrationId", clientSecrets: [], @@ -64,6 +77,30 @@ describe("CreateApiKeyDriver", () => { } }); + it("happy path: create registraionid and read domain from env and secret from env", async () => { + sinon.stub(AppStudioClient, "createApiKeyRegistration").resolves({ + id: "mockedRegistrationId", + clientSecrets: [], + targetUrlsShouldStartWith: [], + applicableToApps: ApiSecretRegistrationAppType.SpecificApp, + }); + + envRestore = mockedEnv({ + ["api-key"]: "existingvalue", + }); + const args: any = { + name: "test", + domain: "https://test", + appId: "mockedAppId", + }; + const result = await createApiKeyDriver.execute(args, mockedDriverContext, outputEnvVarNames); + expect(result.result.isOk()).to.be.true; + if (result.result.isOk()) { + expect(result.result.value.get(outputKeys.registrationId)).to.equal("mockedRegistrationId"); + expect(result.summaries.length).to.equal(1); + } + }); + it("happy path: registration id exists in env", async () => { sinon.stub(AppStudioClient, "getApiKeyRegistrationById").resolves({ id: "mockedRegistrationId", diff --git a/packages/fx-core/tests/question/question.test.ts b/packages/fx-core/tests/question/question.test.ts index c94ac1698c..8d8401939a 100644 --- a/packages/fx-core/tests/question/question.test.ts +++ b/packages/fx-core/tests/question/question.test.ts @@ -1,9 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. import { + ConditionFunc, + FuncValidation, Inputs, Platform, Question, + TextInputQuestion, UserError, UserInteraction, err, @@ -12,6 +15,7 @@ import { import { assert } from "chai"; import fs from "fs-extra"; import "mocha"; +import mockedEnv, { RestoreFn } from "mocked-env"; import * as path from "path"; import sinon from "sinon"; import { CollaborationConstants, QuestionTreeVisitor, envUtil, traverse } from "../../src"; @@ -20,6 +24,7 @@ import { setTools } from "../../src/core/globalVars"; import { QuestionNames, SPFxImportFolderQuestion, questionNodes } from "../../src/question"; import { TeamsAppValidationOptions, + apiSpecApiKeyQuestion, createNewEnvQuestionNode, envQuestionCondition, isAadMainifestContainsPlaceholder, @@ -37,7 +42,6 @@ import { callFuncs } from "./create.test"; import { MockedAzureTokenProvider } from "../core/other.test"; import { ResourceManagementClient } from "@azure/arm-resources"; import { resourceGroupHelper } from "../../src/component/utils/ResourceGroupHelper"; -import mockedEnv, { RestoreFn } from "mocked-env"; const ui = new MockUserInteraction(); @@ -950,3 +954,67 @@ describe("selectAadManifestQuestion", async () => { assert.equal(question.cliName, "entra-app-manifest-file"); }); }); + +describe("resourceGroupQuestionNode", async () => { + const sandbox = sinon.createSandbox(); + let mockedEnvRestore: RestoreFn = () => {}; + afterEach(() => { + sandbox.restore(); + mockedEnvRestore(); + }); + + it("will pop up question", async () => { + const inputs: Inputs = { + platform: Platform.VSCode, + outputEnvVarNames: new Map(), + }; + const question = apiSpecApiKeyQuestion(); + const condition = question.condition; + const res = await (condition as ConditionFunc)(inputs); + assert.equal(res, true); + }); + + it("will not pop up question due to api key exists", async () => { + const inputs: Inputs = { + platform: Platform.VSCode, + outputEnvVarNames: new Map(), + }; + inputs.outputEnvVarNames.set("registrationId", "registrationId"); + mockedEnvRestore = mockedEnv({ + registrationId: "fake-id", + }); + const question = apiSpecApiKeyQuestion(); + const condition = question.condition; + const res = await (condition as ConditionFunc)(inputs); + assert.equal(res, false); + }); + + it("will not pop up question due to secret exists", async () => { + const inputs: Inputs = { + platform: Platform.VSCode, + outputEnvVarNames: new Map(), + clientSecret: "fakeClientSecret", + }; + const question = apiSpecApiKeyQuestion(); + const condition = question.condition; + const res = await (condition as ConditionFunc)(inputs); + assert.equal(res, false); + }); + + it("validation passed", async () => { + const question = apiSpecApiKeyQuestion(); + const validation = (question.data as TextInputQuestion).validation; + const result = (validation as FuncValidation).validFunc("mockedApiKey"); + assert.equal(result, undefined); + }); + + it("validation failed due to length", async () => { + const question = apiSpecApiKeyQuestion(); + const validation = (question.data as TextInputQuestion).validation; + const result = (validation as FuncValidation).validFunc("abc"); + assert.equal( + result, + "Client secret is invalid. The length of secret should be >= 10 and <= 128" + ); + }); +});