diff --git a/src/commands/setup.md b/src/commands/setup.md index a2175988a..ddcf77ea5 100644 --- a/src/commands/setup.md +++ b/src/commands/setup.md @@ -13,6 +13,11 @@ for a few questions 3. Azure DevOps Personal Access Token. The token needs to have these permissions 1. Read and write projects. 2. Read and write codes. +4. To create a sample application Repo + 1. If Yes, a Azure Service Principal is needed. You have 2 options + 1. have the command line tool to create it. Azure command line tool shall + be used + 2. provide the Service Principal Id, Password and Tenant Id. It can also run in a non interactive mode by providing a file that contains answers to the above questions. @@ -27,6 +32,11 @@ Content of this file is as follow azdo_org_name= azdo_project_name= azdo_pat= +az_create_app= +az_create_sp= +az_sp_id= +az_sp_password= +az_sp_tenant= ``` `azdo_project_name` is optional and default value is `BedrockRocks`. @@ -41,9 +51,19 @@ The followings shall be created 4. A Git Repo, `quick-start-manifest`, it shall be deleted and recreated if it already exists. 1. And initial commit shall be made to this repo +5. A High Level Definition (HLD) to Manifest pipeline. +6. A Service Principal (if requested) ## Setup log A `setup.log` file is created after running this command. This file contains information about what are created and the execution status (completed or incomplete). This file will not be created if input validation failed. + +## Note + +To remove the service principal that it is created by the tool, you can do the +followings: + +1. Get the identifier from `setup.log` (look for `az_sp_id`) +2. run on terminal `az ad sp delete --id ` diff --git a/src/commands/setup.test.ts b/src/commands/setup.test.ts index 4ed13c3d1..d417afd4b 100644 --- a/src/commands/setup.test.ts +++ b/src/commands/setup.test.ts @@ -3,7 +3,7 @@ import { readYaml } from "../config"; import * as config from "../config"; import * as azdoClient from "../lib/azdoClient"; import { createTempDir } from "../lib/ioUtil"; -import { WORKSPACE } from "../lib/setup/constants"; +import { IRequestContext, WORKSPACE } from "../lib/setup/constants"; import * as fsUtil from "../lib/setup/fsUtil"; import * as gitService from "../lib/setup/gitService"; import * as pipelineService from "../lib/setup/pipelineService"; @@ -11,6 +11,7 @@ import * as projectService from "../lib/setup/projectService"; import * as promptInstance from "../lib/setup/prompt"; import * as scaffold from "../lib/setup/scaffold"; import * as setupLog from "../lib/setup/setupLog"; +import { deepClone } from "../lib/util"; import { IConfigYaml } from "../types"; import { createSPKConfig, execute, getErrorMessage } from "./setup"; import * as setup from "./setup"; @@ -34,6 +35,31 @@ describe("test createSPKConfig function", () => { project: "project" }); }); + it("positive test: with service principal", () => { + const tmpFile = path.join(createTempDir(), "config.yaml"); + jest.spyOn(config, "defaultConfigFile").mockReturnValueOnce(tmpFile); + const rc: IRequestContext = deepClone(mockRequestContext); + rc.toCreateAppRepo = true; + rc.toCreateSP = true; + rc.servicePrincipalId = "1eba2d04-1506-4278-8f8c-b1eb2fc462a8"; + rc.servicePrincipalPassword = "e4c19d72-96d6-4172-b195-66b3b1c36db1"; + rc.servicePrincipalTenantId = "72f988bf-86f1-41af-91ab-2d7cd011db47"; + createSPKConfig(rc); + + const data = readYaml(tmpFile); + expect(data.azure_devops).toStrictEqual({ + access_token: "pat", + org: "orgname", + project: "project" + }); + expect(data.introspection).toStrictEqual({ + azure: { + service_principal_id: rc.servicePrincipalId, + service_principal_secret: rc.servicePrincipalPassword, + tenant_id: rc.servicePrincipalTenantId + } + }); + }); }); const testExecuteFunc = async (usePrompt = true, hasProject = true) => { diff --git a/src/commands/setup.ts b/src/commands/setup.ts index b7871904d..9c4923573 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -30,14 +30,30 @@ interface IAPIError { * @param answers Answers provided to the commander */ export const createSPKConfig = (rc: IRequestContext) => { - const data = yaml.safeDump({ - azure_devops: { - access_token: rc.accessToken, - org: rc.orgName, - project: rc.projectName - } - }); - fs.writeFileSync(defaultConfigFile(), data); + const data = rc.toCreateAppRepo + ? { + azure_devops: { + access_token: rc.accessToken, + org: rc.orgName, + project: rc.projectName + }, + introspection: { + azure: { + service_principal_id: rc.servicePrincipalId, + service_principal_secret: rc.servicePrincipalPassword, + tenant_id: rc.servicePrincipalTenantId + } + } + } + : { + azure_devops: { + access_token: rc.accessToken, + org: rc.orgName, + project: rc.projectName + }, + introspection: {} + }; + fs.writeFileSync(defaultConfigFile(), yaml.safeDump(data)); }; export const getErrorMessage = ( @@ -74,8 +90,8 @@ export const execute = async ( try { requestContext = opts.file ? getAnswerFromFile(opts.file) : await prompt(); createDirectory(WORKSPACE, true); + createSPKConfig(requestContext); - createSPKConfig(requestContext!); const webAPI = await getWebApi(); const coreAPI = await webAPI.getCoreApi(); const gitAPI = await getGitApi(webAPI); diff --git a/src/lib/setup/constants.ts b/src/lib/setup/constants.ts index 342764de8..b6e8d9e07 100644 --- a/src/lib/setup/constants.ts +++ b/src/lib/setup/constants.ts @@ -3,10 +3,16 @@ export interface IRequestContext { projectName: string; accessToken: string; workspace: string; + toCreateAppRepo?: boolean; + toCreateSP?: boolean; createdProject?: boolean; scaffoldHLD?: boolean; scaffoldManifest?: boolean; createdHLDtoManifestPipeline?: boolean; + createServicePrincipal?: boolean; + servicePrincipalId?: string; + servicePrincipalPassword?: string; + servicePrincipalTenantId?: string; error?: string; } diff --git a/src/lib/setup/projectService.test.ts b/src/lib/setup/projectService.test.ts index 3d5e9aa20..c5a883c5e 100644 --- a/src/lib/setup/projectService.test.ts +++ b/src/lib/setup/projectService.test.ts @@ -59,7 +59,9 @@ describe("test createProject function", () => { await createProject( { getProject: () => { - return {}; + return { + state: "wellFormed" + }; }, queueCreateProject: async () => { return; diff --git a/src/lib/setup/projectService.ts b/src/lib/setup/projectService.ts index be67fdd32..1a8def0ee 100644 --- a/src/lib/setup/projectService.ts +++ b/src/lib/setup/projectService.ts @@ -1,5 +1,8 @@ import { ICoreApi } from "azure-devops-node-api/CoreApi"; -import { ProjectVisibility } from "azure-devops-node-api/interfaces/CoreInterfaces"; +import { + ProjectVisibility, + TeamProject +} from "azure-devops-node-api/interfaces/CoreInterfaces"; import { sleep } from "../../lib/util"; import { logger } from "../../logger"; import { IRequestContext } from "./constants"; @@ -10,7 +13,10 @@ import { IRequestContext } from "./constants"; * @param coreAPI Core API service * @param name Name of Project */ -export const getProject = async (coreAPI: ICoreApi, name: string) => { +export const getProject = async ( + coreAPI: ICoreApi, + name: string +): Promise => { try { return await coreAPI.getProject(name); } catch (err) { @@ -55,7 +61,8 @@ export const createProject = async ( // poll to check if project is checked. let created = false; while (tries > 0 && !created) { - created = !!(await getProject(coreAPI, name)); + const p = await getProject(coreAPI, name); + created = p && p.state === "wellFormed"; if (!created) { await sleep(sleepDuration); tries--; diff --git a/src/lib/setup/prompt.test.ts b/src/lib/setup/prompt.test.ts index b836296d2..3f7326a43 100644 --- a/src/lib/setup/prompt.test.ts +++ b/src/lib/setup/prompt.test.ts @@ -6,13 +6,15 @@ import uuid from "uuid/v4"; import { createTempDir } from "../../lib/ioUtil"; import { DEFAULT_PROJECT_NAME, WORKSPACE } from "./constants"; import { getAnswerFromFile, prompt } from "./prompt"; +import * as servicePrincipalService from "./servicePrincipalService"; describe("test prompt function", () => { - it("positive test", async () => { + it("positive test: No App Creation", async () => { const answers = { azdo_org_name: "org", azdo_pat: "pat", - azdo_project_name: "project" + azdo_project_name: "project", + create_app_repo: false }; jest.spyOn(inquirer, "prompt").mockResolvedValueOnce(answers); const ans = await prompt(); @@ -20,6 +22,60 @@ describe("test prompt function", () => { accessToken: "pat", orgName: "org", projectName: "project", + toCreateAppRepo: false, + workspace: WORKSPACE + }); + }); + it("positive test: create SP", async () => { + const answers = { + azdo_org_name: "org", + azdo_pat: "pat", + azdo_project_name: "project", + create_app_repo: true + }; + jest.spyOn(inquirer, "prompt").mockResolvedValueOnce(answers); + jest.spyOn(inquirer, "prompt").mockResolvedValueOnce({ + create_service_principal: true + }); + jest + .spyOn(servicePrincipalService, "createWithAzCLI") + .mockReturnValueOnce(Promise.resolve()); + const ans = await prompt(); + expect(ans).toStrictEqual({ + accessToken: "pat", + orgName: "org", + projectName: "project", + toCreateAppRepo: true, + toCreateSP: true, + workspace: WORKSPACE + }); + }); + it("positive test: no create SP", async () => { + const answers = { + azdo_org_name: "org", + azdo_pat: "pat", + azdo_project_name: "project", + create_app_repo: true + }; + jest.spyOn(inquirer, "prompt").mockResolvedValueOnce(answers); + jest.spyOn(inquirer, "prompt").mockResolvedValueOnce({ + create_service_principal: false + }); + jest.spyOn(inquirer, "prompt").mockResolvedValueOnce({ + az_sp_id: "b510c1ff-358c-4ed4-96c8-eb23f42bb65b", + az_sp_password: "a510c1ff-358c-4ed4-96c8-eb23f42bbc5b", + az_sp_tenant: "72f988bf-86f1-41af-91ab-2d7cd011db47" + }); + const ans = await prompt(); + expect(ans).toStrictEqual({ + accessToken: "pat", + orgName: "org", + projectName: "project", + servicePrincipalId: "b510c1ff-358c-4ed4-96c8-eb23f42bb65b", + servicePrincipalPassword: "a510c1ff-358c-4ed4-96c8-eb23f42bbc5b", + servicePrincipalTenantId: "72f988bf-86f1-41af-91ab-2d7cd011db47", + toCreateAppRepo: true, + toCreateSP: false, workspace: WORKSPACE }); }); @@ -87,4 +143,60 @@ describe("test getAnswerFromFile function", () => { getAnswerFromFile(file); }).toThrow(); }); + it("positive test: with app creation, without SP creation", () => { + const dir = createTempDir(); + const file = path.join(dir, "testfile"); + const data = [ + "azdo_org_name=orgname", + "azdo_pat=pat", + "azdo_project_name=project", + "az_create_app=true", + "az_sp_id=b510c1ff-358c-4ed4-96c8-eb23f42bb65b", + "az_sp_password=a510c1ff-358c-4ed4-96c8-eb23f42bbc5b", + "az_sp_tenant=72f988bf-86f1-41af-91ab-2d7cd011db47" + ]; + fs.writeFileSync(file, data.join("\n")); + const requestContext = getAnswerFromFile(file); + expect(requestContext.orgName).toBe("orgname"); + expect(requestContext.accessToken).toBe("pat"); + expect(requestContext.projectName).toBe("project"); + expect(requestContext.toCreateAppRepo).toBeTruthy(); + expect(requestContext.toCreateSP).toBeFalsy(); + expect(requestContext.servicePrincipalId).toBe( + "b510c1ff-358c-4ed4-96c8-eb23f42bb65b" + ); + expect(requestContext.servicePrincipalPassword).toBe( + "a510c1ff-358c-4ed4-96c8-eb23f42bbc5b" + ); + expect(requestContext.servicePrincipalTenantId).toBe( + "72f988bf-86f1-41af-91ab-2d7cd011db47" + ); + }); + it("negative test: with app creation, incorrect SP values", () => { + const dir = createTempDir(); + const file = path.join(dir, "testfile"); + const data = [ + "azdo_org_name=orgname", + "azdo_pat=pat", + "azdo_project_name=project", + "az_create_app=true" + ]; + [".", ".##", ".abc"].forEach((v, i) => { + if (i === 0) { + data.push(`az_sp_id=${v}`); + } else if (i === 1) { + data.pop(); + data.push("az_sp_id=b510c1ff-358c-4ed4-96c8-eb23f42bb65b"); + data.push(`az_sp_password=${v}`); + } else { + data.pop(); + data.push("az_sp_password=a510c1ff-358c-4ed4-96c8-eb23f42bbc5b"); + data.push(`az_sp_tenant=${v}`); + } + fs.writeFileSync(file, data.join("\n")); + expect(() => { + getAnswerFromFile(file); + }).toThrow(); + }); + }); }); diff --git a/src/lib/setup/prompt.ts b/src/lib/setup/prompt.ts index c05a566ec..b3efff998 100644 --- a/src/lib/setup/prompt.ts +++ b/src/lib/setup/prompt.ts @@ -3,9 +3,75 @@ import inquirer from "inquirer"; import { validateAccessToken, validateOrgName, - validateProjectName + validateProjectName, + validateServicePrincipalId, + validateServicePrincipalPassword, + validateServicePrincipalTenantId } from "../validator"; import { DEFAULT_PROJECT_NAME, IRequestContext, WORKSPACE } from "./constants"; +import { createWithAzCLI } from "./servicePrincipalService"; + +/** + * Prompts for service principal identifer, password and tenant identifer. + * Request context will have the service principal information + * when this function is completed successfully. + * + * @param rc Request Context + */ +export const promptForServicePrincipal = async (rc: IRequestContext) => { + const questions = [ + { + message: "Enter Service Principal Id\n", + name: "az_sp_id", + type: "input", + validate: validateServicePrincipalId + }, + { + mask: "*", + message: "Enter Service Principal Password\n", + name: "az_sp_password", + type: "password", + validate: validateServicePrincipalPassword + }, + { + message: "Enter Service Principal Tenant Id\n", + name: "az_sp_tenant", + type: "input", + validate: validateServicePrincipalTenantId + } + ]; + const answers = await inquirer.prompt(questions); + rc.servicePrincipalId = answers.az_sp_id as string; + rc.servicePrincipalPassword = answers.az_sp_password as string; + rc.servicePrincipalTenantId = answers.az_sp_tenant as string; +}; + +/** + * Prompts for creating service principal. User can choose + * Yes or No. + * + * @param rc Request Context + */ +export const promptForServicePrincipalCreation = async ( + rc: IRequestContext +) => { + const questions = [ + { + default: true, + message: `Do you want to create a service principal?`, + name: "create_service_principal", + type: "confirm" + } + ]; + const answers = await inquirer.prompt(questions); + if (answers.create_service_principal) { + rc.toCreateSP = true; + await createWithAzCLI(rc); + } else { + rc.toCreateSP = false; + await promptForServicePrincipal(rc); + } +}; /** * Prompts for questions @@ -33,23 +99,56 @@ export const prompt = async (): Promise => { name: "azdo_pat", type: "password", validate: validateAccessToken + }, + { + default: true, + message: `Do you like create a sample application repository?`, + name: "create_app_repo", + type: "confirm" } ]; const answers = await inquirer.prompt(questions); - return { + const rc: IRequestContext = { accessToken: answers.azdo_pat as string, orgName: answers.azdo_org_name as string, projectName: answers.azdo_project_name as string, + toCreateAppRepo: answers.create_app_repo as boolean, workspace: WORKSPACE }; + + if (rc.toCreateAppRepo) { + await promptForServicePrincipalCreation(rc); + } + return rc; }; -/** - * Returns answers that are provided in a file. - * - * @param file file name - */ -export const getAnswerFromFile = (file: string): IRequestContext => { +const validationServicePrincipalInfoFromFile = ( + rc: IRequestContext, + map: { [key: string]: string } +) => { + if (rc.toCreateAppRepo) { + rc.toCreateSP = map.az_create_sp === "true"; + + // file needs to contain sp information if user + // choose not to create SP + if (!rc.toCreateSP) { + const vSPId = validateServicePrincipalId(map.az_sp_id); + if (typeof vSPId === "string") { + throw new Error(vSPId); + } + const vSPPassword = validateServicePrincipalPassword(map.az_sp_password); + if (typeof vSPPassword === "string") { + throw new Error(vSPPassword); + } + const vSPTenantId = validateServicePrincipalTenantId(map.az_sp_tenant); + if (typeof vSPTenantId === "string") { + throw new Error(vSPTenantId); + } + } + } +}; + +const parseInformationFromFile = (file: string): { [key: string]: string } => { let content = ""; try { content = fs.readFileSync(file, "utf-8"); @@ -67,6 +166,16 @@ export const getAnswerFromFile = (file: string): IRequestContext => { map[s.substring(0, idx).trim()] = s.substring(idx + 1).trim(); } }); + return map; +}; + +/** + * Returns answers that are provided in a file. + * + * @param file file name + */ +export const getAnswerFromFile = (file: string): IRequestContext => { + const map = parseInformationFromFile(file); map.azdo_project_name = map.azdo_project_name || DEFAULT_PROJECT_NAME; const vOrgName = validateOrgName(map.azdo_org_name); @@ -84,10 +193,17 @@ export const getAnswerFromFile = (file: string): IRequestContext => { throw new Error(vToken); } - return { + const rc: IRequestContext = { accessToken: map.azdo_pat, orgName: map.azdo_org_name, projectName: map.azdo_project_name, + servicePrincipalId: map.az_sp_id, + servicePrincipalPassword: map.az_sp_password, + servicePrincipalTenantId: map.az_sp_tenant, workspace: WORKSPACE }; + + rc.toCreateAppRepo = map.az_create_app === "true"; + validationServicePrincipalInfoFromFile(rc, map); + return rc; }; diff --git a/src/lib/setup/servicePrincipalService.test.ts b/src/lib/setup/servicePrincipalService.test.ts new file mode 100644 index 000000000..d54085c06 --- /dev/null +++ b/src/lib/setup/servicePrincipalService.test.ts @@ -0,0 +1,58 @@ +import * as shell from "../shell"; +import { IRequestContext, WORKSPACE } from "./constants"; +import { azCLILogin, createWithAzCLI } from "./servicePrincipalService"; +import * as servicePrincipalService from "./servicePrincipalService"; + +describe("test azCLILogin function", () => { + it("positive test", async () => { + jest.spyOn(shell, "exec").mockReturnValueOnce(Promise.resolve("")); + await azCLILogin(); + }); + it("negative test", async () => { + jest + .spyOn(shell, "exec") + .mockReturnValueOnce(Promise.reject(new Error("fake"))); + await expect(azCLILogin()).rejects.toThrow(); + }); +}); + +describe("test createWithAzCLI function", () => { + it("positive test", async () => { + const result = { + appId: "b510c1ff-358c-4ed4-96c8-eb23f42bb65b", + password: "a510c1ff-358c-4ed4-96c8-eb23f42bbc5b", + tenant: "72f988bf-86f1-41af-91ab-2d7cd011db47" + }; + jest + .spyOn(servicePrincipalService, "azCLILogin") + .mockReturnValueOnce(Promise.resolve()); + jest + .spyOn(shell, "exec") + .mockReturnValueOnce(Promise.resolve(JSON.stringify(result))); + const rc: IRequestContext = { + accessToken: "pat", + orgName: "orgName", + projectName: "project", + workspace: WORKSPACE + }; + await createWithAzCLI(rc); + expect(rc.createServicePrincipal).toBeTruthy(); + expect(rc.servicePrincipalPassword).toBe(result.password); + expect(rc.servicePrincipalTenantId).toBe(result.tenant); + }); + it("negative test", async () => { + jest + .spyOn(servicePrincipalService, "azCLILogin") + .mockReturnValueOnce(Promise.resolve()); + jest + .spyOn(shell, "exec") + .mockReturnValueOnce(Promise.reject(Error("fake"))); + const rc: IRequestContext = { + accessToken: "pat", + orgName: "orgName", + projectName: "project", + workspace: WORKSPACE + }; + await expect(createWithAzCLI(rc)).rejects.toThrow(); + }); +}); diff --git a/src/lib/setup/servicePrincipalService.ts b/src/lib/setup/servicePrincipalService.ts new file mode 100644 index 000000000..e3fdc880c --- /dev/null +++ b/src/lib/setup/servicePrincipalService.ts @@ -0,0 +1,46 @@ +import { logger } from "../../logger"; +import { exec } from "../shell"; +import { IRequestContext } from "./constants"; + +/** + * Login to az command line tool. This is done by + * doing a shell exec with `az login`; then browser opens + * prompting user to select the identity. + */ +export const azCLILogin = async () => { + try { + logger.info("attempting to login to az command line"); + await exec("az", ["login"]); + logger.info("Successfully login to az command line"); + } catch (err) { + logger.error("Unable to execute az login"); + logger.error(err); + throw err; + } +}; + +/** + * Create a service principal with az command line tool. + * this service principal should have contributor privileges. + * Request context will have the service principal information + * when service principal is successfully created. + * + * @param rc Request Context + */ +export const createWithAzCLI = async (rc: IRequestContext) => { + await azCLILogin(); + try { + logger.info("attempting to create service principal with az command line"); + const result = await exec("az", ["ad", "sp", "create-for-rbac"]); + const oResult = JSON.parse(result); + rc.createServicePrincipal = true; + rc.servicePrincipalId = oResult.appId; + rc.servicePrincipalPassword = oResult.password; + rc.servicePrincipalTenantId = oResult.tenant; + logger.info("Successfully created service principal with az command line"); + } catch (err) { + logger.error("Unable to create service principal with az command line"); + logger.error(err); + throw err; + } +}; diff --git a/src/lib/setup/setupLog.test.ts b/src/lib/setup/setupLog.test.ts index aa074c79d..6cbe04fed 100644 --- a/src/lib/setup/setupLog.test.ts +++ b/src/lib/setup/setupLog.test.ts @@ -2,9 +2,10 @@ import fs from "fs"; import path from "path"; import uuid from "uuid/v4"; import { createTempDir } from "../ioUtil"; +import { IRequestContext } from "./constants"; import { create } from "./setupLog"; -const positiveTest = (logExist?: boolean) => { +const positiveTest = (logExist?: boolean, withAppCreation = false) => { const dir = createTempDir(); const file = path.join(dir, uuid()); @@ -12,32 +13,65 @@ const positiveTest = (logExist?: boolean) => { fs.writeFileSync(file, "dummy"); } - create( - { - accessToken: "accessToken", - createdHLDtoManifestPipeline: true, - createdProject: true, - orgName: "orgName", - projectName: "projectName", - scaffoldHLD: true, - scaffoldManifest: true, - workspace: "workspace" - }, - file - ); + const rc: IRequestContext = { + accessToken: "accessToken", + createdHLDtoManifestPipeline: true, + createdProject: true, + orgName: "orgName", + projectName: "projectName", + scaffoldHLD: true, + scaffoldManifest: true, + workspace: "workspace" + }; + + if (withAppCreation) { + (rc.toCreateAppRepo = true), + (rc.toCreateSP = true), + (rc.servicePrincipalId = "b510c1ff-358c-4ed4-96c8-eb23f42bb65b"); + rc.servicePrincipalPassword = "a510c1ff-358c-4ed4-96c8-eb23f42bbc5b"; + rc.servicePrincipalTenantId = "72f988bf-86f1-41af-91ab-2d7cd011db47"; + } + create(rc, file); expect(fs.existsSync(file)).toBeTruthy(); - expect(fs.readFileSync(file, "UTF-8").split("\n")).toStrictEqual([ - "azdo_org_name=orgName", - "azdo_project_name=projectName", - "azdo_pat=*********", - "workspace: workspace", - "Project Created: yes", - "High Level Definition Repo Scaffolded: yes", - "Manifest Repo Scaffolded: yes", - "HLD to Manifest Pipeline Created: yes", - "Status: Completed" - ]); + + if (withAppCreation) { + expect(fs.readFileSync(file, "UTF-8").split("\n")).toStrictEqual([ + "azdo_org_name=orgName", + "azdo_project_name=projectName", + "azdo_pat=*********", + "az_create_app=true", + "az_create_sp=true", + "az_sp_id=b510c1ff-358c-4ed4-96c8-eb23f42bb65b", + "az_sp_password=********", + "az_sp_tenant=72f988bf-86f1-41af-91ab-2d7cd011db47", + "workspace: workspace", + "Project Created: yes", + "High Level Definition Repo Scaffolded: yes", + "Manifest Repo Scaffolded: yes", + "HLD to Manifest Pipeline Created: yes", + "Service Principal Created: no", + "Status: Completed" + ]); + } else { + expect(fs.readFileSync(file, "UTF-8").split("\n")).toStrictEqual([ + "azdo_org_name=orgName", + "azdo_project_name=projectName", + "azdo_pat=*********", + "az_create_app=false", + "az_create_sp=false", + "az_sp_id=", + "az_sp_password=", + "az_sp_tenant=", + "workspace: workspace", + "Project Created: yes", + "High Level Definition Repo Scaffolded: yes", + "Manifest Repo Scaffolded: yes", + "HLD to Manifest Pipeline Created: yes", + "Service Principal Created: no", + "Status: Completed" + ]); + } }; describe("test create function", () => { @@ -53,6 +87,9 @@ describe("test create function", () => { it("positive test: no errors and log already exists", () => { positiveTest(true); }); + it("positive test: no errors and app and sp creation", () => { + positiveTest(false, true); + }); it("positive test: with errors", () => { const dir = createTempDir(); const file = path.join(dir, uuid()); @@ -77,11 +114,17 @@ describe("test create function", () => { "azdo_org_name=orgName", "azdo_project_name=projectName", "azdo_pat=*********", + "az_create_app=false", + "az_create_sp=false", + "az_sp_id=", + "az_sp_password=", + "az_sp_tenant=", "workspace: workspace", "Project Created: yes", "High Level Definition Repo Scaffolded: yes", "Manifest Repo Scaffolded: yes", "HLD to Manifest Pipeline Created: yes", + "Service Principal Created: no", "Error: things broke", "Status: Incomplete" ]); diff --git a/src/lib/setup/setupLog.ts b/src/lib/setup/setupLog.ts index 34a1491b9..33d431a00 100644 --- a/src/lib/setup/setupLog.ts +++ b/src/lib/setup/setupLog.ts @@ -14,14 +14,20 @@ export const create = (rc: IRequestContext | undefined, file?: string) => { const buff = [ `azdo_org_name=${rc.orgName}`, `azdo_project_name=${rc.projectName}`, - `azdo_pat=*********`, + "azdo_pat=*********", + `az_create_app=${!!rc.toCreateAppRepo}`, + `az_create_sp=${!!rc.toCreateSP}`, + `az_sp_id=${rc.servicePrincipalId || ""}`, + `az_sp_password=${rc.servicePrincipalPassword ? "********" : ""}`, + `az_sp_tenant=${rc.servicePrincipalTenantId || ""}`, `workspace: ${rc.workspace}`, `Project Created: ${getBooleanVal(rc.createdProject)}`, `High Level Definition Repo Scaffolded: ${getBooleanVal(rc.scaffoldHLD)}`, `Manifest Repo Scaffolded: ${getBooleanVal(rc.scaffoldManifest)}`, `HLD to Manifest Pipeline Created: ${getBooleanVal( rc.createdHLDtoManifestPipeline - )}` + )}`, + `Service Principal Created: ${getBooleanVal(rc.createServicePrincipal)}` ]; if (rc.error) { buff.push(`Error: ${rc.error}`); diff --git a/src/lib/validator.test.ts b/src/lib/validator.test.ts index 0462e31c7..55eab927f 100644 --- a/src/lib/validator.test.ts +++ b/src/lib/validator.test.ts @@ -2,6 +2,7 @@ import path from "path"; import { Config, loadConfiguration } from "../config"; import { hasValue, + isDashHex, isIntegerString, isPortNumberString, ORG_NAME_VIOLATION, @@ -9,7 +10,10 @@ import { validateForNonEmptyValue, validateOrgName, validatePrereqs, - validateProjectName + validateProjectName, + validateServicePrincipalId, + validateServicePrincipalPassword, + validateServicePrincipalTenantId } from "./validator"; describe("Tests on validator helper functions", () => { @@ -185,3 +189,36 @@ describe("test validateAccessToken function", () => { expect(validateAccessToken("mysecretshhhh")).toBe(true); }); }); + +describe("test isDashHex function", () => { + it("sanity test", () => { + expect(isDashHex("")).toBe(false); + expect(isDashHex("b510c1ff-358c-4ed4-96c8-eb23f42bb65b")).toBe(true); + expect(isDashHex(".eb23f42bb65b")).toBe(false); + }); +}); + +describe("test validateServicePrincipal functions", () => { + it("sanity test", () => { + [ + { + fn: validateServicePrincipalId, + prop: "Service Principal Id" + }, + { + fn: validateServicePrincipalPassword, + prop: "Service Principal Password" + }, + { + fn: validateServicePrincipalTenantId, + prop: "Service Principal Tenant Id" + } + ].forEach(item => { + expect(item.fn("")).toBe(`Must enter a ${item.prop}.`); + expect(item.fn("b510c1ff-358c-4ed4-96c8-eb23f42bb65b")).toBe(true); + expect(item.fn(".eb23f42bb65b")).toBe( + `The value for ${item.prop} is invalid.` + ); + }); + }); +}); diff --git a/src/lib/validator.ts b/src/lib/validator.ts index 728f13a60..081f1147c 100644 --- a/src/lib/validator.ts +++ b/src/lib/validator.ts @@ -112,6 +112,10 @@ export const validateOrgName = (value: string): string | boolean => { return ORG_NAME_VIOLATION; }; +export const isDashHex = (value: string): boolean => { + return !!value.match(/^[a-f0-9\-]+$/); +}; + /** * Returns true if project name is proper. * @@ -178,3 +182,47 @@ export const validateAccessToken = (value: string): string | boolean => { } return true; }; + +export const validateServicePrincipal = ( + value: string, + property: string +): string | boolean => { + if (!hasValue(value)) { + return `Must enter a ${property}.`; + } + if (!isDashHex(value)) { + return `The value for ${property} is invalid.`; + } + return true; +}; + +/** + * Returns true if service principal id is valid + * + * @param value service principal id + */ +export const validateServicePrincipalId = (value: string): string | boolean => { + return validateServicePrincipal(value, "Service Principal Id"); +}; + +/** + * Returns true if service principal password is valid + * + * @param value service principal password + */ +export const validateServicePrincipalPassword = ( + value: string +): string | boolean => { + return validateServicePrincipal(value, "Service Principal Password"); +}; + +/** + * Returns true if service principal tenant identifier is valid + * + * @param value service principal tenant identifier. + */ +export const validateServicePrincipalTenantId = ( + value: string +): string | boolean => { + return validateServicePrincipal(value, "Service Principal Tenant Id"); +};