Skip to content

Commit

Permalink
perf(apikey): add question model for api key (#10267)
Browse files Browse the repository at this point in the history
* perf(apikey): add question model for api key

* test: add ut

* docs: update message

* test: add ut
  • Loading branch information
KennethBWSong authored Nov 3, 2023
1 parent 7934530 commit ac51cf0
Show file tree
Hide file tree
Showing 8 changed files with 171 additions and 4 deletions.
2 changes: 2 additions & 0 deletions packages/fx-core/resource/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion packages/fx-core/src/component/driver/apiKey/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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[] {
Expand Down
5 changes: 4 additions & 1 deletion packages/fx-core/src/component/middleware/questionMW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
4 changes: 4 additions & 0 deletions packages/fx-core/src/question/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "./create";
import {
addWebPartQuestionNode,
apiSpecApiKeyQuestion,
copilotPluginAddAPIQuestionNode,
createNewEnvQuestionNode,
deployAadManifestQuestionNode,
Expand Down Expand Up @@ -63,6 +64,9 @@ export class QuestionNodes {
copilotPluginAddAPI(): IQTreeNode {
return copilotPluginAddAPIQuestionNode();
}
apiKey(): IQTreeNode {
return apiSpecApiKeyQuestion();
}
}

export const questionNodes = new QuestionNodes();
41 changes: 41 additions & 0 deletions packages/fx-core/src/question/other.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
},
};
}
1 change: 1 addition & 0 deletions packages/fx-core/src/question/questionNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export enum QuestionNames {
OpenAIPluginManifest = "openai-plugin-manifest",
ApiOperation = "api-operation",
MeArchitectureType = "me-architecture",
ApiSpecApiKey = "api-key",

Features = "features",
Env = "env",
Expand Down
39 changes: 38 additions & 1 deletion packages/fx-core/tests/component/driver/apiKey/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -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: [],
Expand All @@ -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",
Expand Down
70 changes: 69 additions & 1 deletion packages/fx-core/tests/question/question.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import {
ConditionFunc,
FuncValidation,
Inputs,
Platform,
Question,
TextInputQuestion,
UserError,
UserInteraction,
err,
Expand All @@ -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";
Expand All @@ -20,6 +24,7 @@ import { setTools } from "../../src/core/globalVars";
import { QuestionNames, SPFxImportFolderQuestion, questionNodes } from "../../src/question";
import {
TeamsAppValidationOptions,
apiSpecApiKeyQuestion,
createNewEnvQuestionNode,
envQuestionCondition,
isAadMainifestContainsPlaceholder,
Expand All @@ -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();

Expand Down Expand Up @@ -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<string, string>(),
};
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<string, string>(),
};
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<string, string>(),
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<string>).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<string>).validFunc("abc");
assert.equal(
result,
"Client secret is invalid. The length of secret should be >= 10 and <= 128"
);
});
});

0 comments on commit ac51cf0

Please sign in to comment.