lib/apiScenario/newmanReportValidator.ts (387 lines of code) (raw):

import * as path from "path"; import { findReadMe } from "@azure/openapi-markdown"; import { inject, injectable } from "inversify"; import { TYPES } from "../inversifyUtils"; import { LiveValidationResult, LiveValidator, RequestResponseLiveValidationResult, RequestResponsePair, } from "../liveValidation/liveValidator"; import { LiveRequest, LiveResponse } from "../liveValidation/operationValidator"; import { FileLoader } from "../swagger/fileLoader"; import { setDefaultOpts } from "../swagger/loader"; import { SwaggerExample } from "../swagger/swaggerTypes"; import { SeverityString } from "../util/severity"; import { getApiVersionFromFilePath, getProviderFromFilePath } from "../util/utils"; import { logger } from "./logger"; import { ApiScenarioLoaderOption } from "./apiScenarioLoader"; import { NewmanExecution, NewmanReport, Scenario, Step } from "./apiScenarioTypes"; import { DataMasker } from "./dataMasker"; import { JUnitReporter } from "./junitReport"; import { generateMarkdownReport } from "./markdownReport"; import { SwaggerAnalyzer, SwaggerAnalyzerOption } from "./swaggerAnalyzer"; export interface ApiScenarioTestResult { apiScenarioFilePath: string; readmeFilePath?: string; swaggerFilePaths: string[]; operationIds?: { [specPath: string]: string[]; }; tag?: string; // New added fields environment?: string; armEnv?: string; rootPath?: string; providerNamespace?: string; apiVersion?: string; startTime?: string; endTime?: string; runId?: string; repository?: string; branch?: string; commitHash?: string; armEndpoint?: string; apiScenarioName?: string; stepResult: StepResult[]; } export interface StepResult { statusCode: number; specFilePath?: string; exampleFilePath?: string; example?: SwaggerExample; payloadPath?: string; operationId: string; responseTime?: number; stepName: string; runtimeError?: RuntimeError[]; liveValidationResult?: RequestResponseLiveValidationResult; roundtripValidationResult?: LiveValidationResult; liveValidationForLroFinalGetResult?: RequestResponseLiveValidationResult; } export interface RuntimeError { code: string; message: string; detail: string; severity: SeverityString; } export interface NewmanReportValidatorOption extends ApiScenarioLoaderOption, SwaggerAnalyzerOption { apiScenarioFilePath: string; reportOutputFilePath: string; markdown?: boolean; junit?: boolean; html?: boolean; armEndpoint?: string; runId?: string; skipValidation?: boolean; savePayload?: boolean; generateExample?: boolean; } @injectable() export class NewmanReportValidator { private scenario: Scenario; private testResult: ApiScenarioTestResult; private fileRoot: string; private liveValidator: LiveValidator; // eslint-disable-next-line @typescript-eslint/explicit-member-accessibility constructor( @inject(TYPES.opts) private opts: NewmanReportValidatorOption, private fileLoader: FileLoader, private dataMasker: DataMasker, private swaggerAnalyzer: SwaggerAnalyzer, private junitReporter: JUnitReporter ) { setDefaultOpts(this.opts, { skipValidation: false, savePayload: false, generateExample: false, } as NewmanReportValidatorOption); } public async initialize(scenario: Scenario) { this.scenario = scenario; this.fileRoot = (await findReadMe(this.opts.apiScenarioFilePath)) || path.dirname(this.opts.apiScenarioFilePath); this.testResult = { apiScenarioFilePath: path.relative(this.fileRoot, this.opts.apiScenarioFilePath), swaggerFilePaths: scenario._scenarioDef._swaggerFilePaths.map((specPath) => { if (process.env.REPORT_SPEC_PATH_PREFIX) { specPath = path.join( process.env.REPORT_SPEC_PATH_PREFIX, specPath.substring(specPath.indexOf("specification")) ); } return specPath; }), providerNamespace: getProviderFromFilePath(this.opts.apiScenarioFilePath), apiVersion: getApiVersionFromFilePath(this.opts.apiScenarioFilePath), runId: this.opts.runId, rootPath: this.fileRoot, repository: process.env.SPEC_REPOSITORY, branch: process.env.SPEC_BRANCH, commitHash: process.env.COMMIT_HASH, environment: process.env.ENVIRONMENT || "test", apiScenarioName: this.scenario.scenario, armEndpoint: this.opts.armEndpoint, stepResult: [], }; await this.swaggerAnalyzer.initialize(); this.liveValidator = new LiveValidator({ fileRoot: "/", swaggerPaths: [...scenario._scenarioDef._swaggerFilePaths], enableRoundTripValidator: !this.opts.skipValidation, }); if (!this.opts.skipValidation) { await this.liveValidator.initialize(); } } public async generateReport(rawReport: NewmanReport) { await this.generateApiScenarioTestResult(rawReport); await this.outputReport(); } private async generateApiScenarioTestResult(newmanReport: NewmanReport) { this.testResult.operationIds = this.swaggerAnalyzer.getOperations(); this.testResult.startTime = new Date(newmanReport.timings.started).toISOString(); this.testResult.endTime = new Date(newmanReport.timings.completed).toISOString(); const visitedIds = new Set<string>(); for (const it of newmanReport.executions) { if (!it.annotation) { continue; } if (visitedIds.has(it.id)) { continue; } visitedIds.add(it.id); if (it.annotation.type !== "simple" && it.annotation.type !== "LRO") { continue; } const payload = this.convertToLiveValidationPayload(it); let lroFinalPayLoad; // associate final get response to the if (it.annotation.type === "LRO" && [200, 201, 202].includes(it.response.statusCode)) { const lroFinalGetExecution = this.getLROFinalResponse( newmanReport.executions, it.annotation.step ); if (lroFinalGetExecution?.response.statusCode === 200) { lroFinalPayLoad = this.convertToLROLiveValidationPayload(it, lroFinalGetExecution); } } let payloadFilePath; let lroPayloadFilePath; if (this.opts.savePayload) { payloadFilePath = `./payloads/${it.annotation.itemName}.json`; await this.fileLoader.writeFile( path.resolve(path.dirname(this.opts.reportOutputFilePath), payloadFilePath), JSON.stringify(payload, null, 2) ); if (lroFinalPayLoad) { lroPayloadFilePath = `./payloads/${it.annotation.itemName}-finalResult.json`; await this.fileLoader.writeFile( path.resolve(path.dirname(this.opts.reportOutputFilePath), lroPayloadFilePath), JSON.stringify(lroFinalPayLoad, null, 2) ); } } const matchedStep = this.getMatchedStep(it.annotation.step); if (matchedStep === undefined) { continue; } const runtimeError: RuntimeError[] = []; it.assertions.forEach((assertion) => { if (assertion.message.includes("expected response code to be 2XX")) { const error = this.getRuntimeError(it); runtimeError.push(error); return; } runtimeError.push({ code: "ASSERTION_ERROR", message: `${assertion.message}`, severity: "Error", detail: this.dataMasker.jsonStringify(assertion.stack), }); }); let liveValidationResult = undefined; let roundtripValidationResult = undefined; let liveValidationForLroFinalGetResult = undefined; let specFilePath = undefined; if (matchedStep.type === "restCall" && !matchedStep.externalReference) { if (this.opts.generateExample) { const statusCode = `${it.response.statusCode}`; let statusCodes = { [statusCode]: { headers: payload.liveResponse.headers, body: payload.liveResponse.body, }, }; if (lroFinalPayLoad) { const statusCode = lroFinalPayLoad.liveResponse.statusCode; statusCodes[statusCode] = { headers: lroFinalPayLoad.liveResponse.headers, body: lroFinalPayLoad.liveResponse.body, }; } const generatedExample: SwaggerExample = { operationId: matchedStep.operationId, title: matchedStep.step, description: matchedStep.description, parameters: matchedStep._resolvedParameters!, responses: { ...statusCodes, }, }; const exampleFilePath = `./examples/${matchedStep.operationId}_${Object.keys( statusCodes ).join("_")}.json`; await this.fileLoader.writeFile( path.resolve(path.dirname(this.opts.reportOutputFilePath), exampleFilePath), JSON.stringify(generatedExample, null, 2) ); } // Schema validation liveValidationResult = !this.opts.skipValidation ? await this.liveValidator.validateLiveRequestResponse(payload) : undefined; liveValidationForLroFinalGetResult = !this.opts.skipValidation && lroFinalPayLoad && matchedStep.isManagementPlane ? await this.liveValidator.validateLiveRequestResponse(lroFinalPayLoad) : undefined; // Roundtrip validation if ( !this.opts.skipValidation && matchedStep.isManagementPlane && matchedStep.operation?._method === "put" && it.response.statusCode >= 200 && it.response.statusCode <= 202 ) { if (it.annotation.type === "LRO") { // For LRO, get the final response to compose payload const lroFinal = this.getLROFinalResponse(newmanReport.executions, it.annotation.step); if (lroFinal !== undefined && lroFinal.response.statusCode === 200) { const lroPayload = this.convertToLROLiveValidationPayload(it, lroFinal); roundtripValidationResult = await this.liveValidator.validateRoundTrip(lroPayload); } } else if (it.annotation.type === "simple") { roundtripValidationResult = await this.liveValidator.validateRoundTrip(payload); } } specFilePath = matchedStep.operation?._path._spec._filePath; } this.testResult.stepResult.push({ specFilePath, operationId: it.annotation.operationId, payloadPath: payloadFilePath ? path.join(path.basename(path.dirname(this.opts.reportOutputFilePath)), payloadFilePath) : undefined, runtimeError, responseTime: it.response.responseTime, statusCode: it.response.statusCode, stepName: it.annotation.step, liveValidationResult, roundtripValidationResult, liveValidationForLroFinalGetResult, }); } } private convertToLiveValidationPayload(execution: NewmanExecution): RequestResponsePair { const request = execution.request; const response = execution.response; const liveRequest: LiveRequest = { url: request.url, method: request.method.toLowerCase(), headers: request.headers, body: this.parseBody(request.body), }; const liveResponse: LiveResponse = { statusCode: `${response.statusCode}`, headers: response.headers, body: this.parseBody(response.body), }; return { liveRequest, liveResponse, }; } private convertToLROLiveValidationPayload( putReq: NewmanExecution, getReq: NewmanExecution ): RequestResponsePair { const request = putReq.request; const response = getReq.response; const liveRequest: LiveRequest = { url: request.url, method: request.method.toLowerCase(), headers: request.headers, body: this.parseBody(request.body), }; const liveResponse: LiveResponse = { statusCode: `${response.statusCode}`, headers: response.headers, body: this.parseBody(response.body), }; return { liveRequest, liveResponse, }; } // body may not be json string private parseBody(body: string): any { try { return JSON.parse(body); } catch (e) { return body ? body : undefined; } } private convertPostmanFormat<T>(obj: T, convertString: (s: string) => string): T { if (typeof obj === "string") { return convertString(obj) as unknown as T; } if (typeof obj !== "object") { return obj; } if (obj === null || obj === undefined) { return obj; } if (Array.isArray(obj)) { return (obj as any[]).map((v) => this.convertPostmanFormat(v, convertString)) as unknown as T; } const result: any = {}; for (const key of Object.keys(obj)) { result[key] = this.convertPostmanFormat((obj as any)[key], convertString); } return result; } private getLROFinalResponse(executions: NewmanExecution[], initialStep: string) { return executions.find( (it) => it.annotation?.type === "finalGet" && it.annotation.step === initialStep ); } private getMatchedStep(stepName: string): Step | undefined { return this.scenario.steps?.find((s) => s.step === stepName); } private getRuntimeError(it: NewmanExecution): RuntimeError { const responseObj = this.dataMasker.jsonParse(it.response.body); return { code: `${it.response.statusCode >= 500 ? "SERVER_ERROR" : "CLIENT_ERROR"}`, message: `statusCode: ${it.response.statusCode},\nerrorCode: ${responseObj?.error?.code},\nerrorMessage: ${responseObj?.error?.message}`, severity: "Error", detail: this.dataMasker.jsonStringify(it.response.body), }; } private async outputReport(): Promise<void> { if (this.opts.reportOutputFilePath !== undefined) { logger.info(`Write generated report file: ${this.opts.reportOutputFilePath}`); await this.fileLoader.writeFile( this.opts.reportOutputFilePath, JSON.stringify(this.testResult, null, 2) ); } if (this.opts.markdown) { await this.fileLoader.appendFile( path.join(path.dirname(path.dirname(this.opts.reportOutputFilePath)), "report.md"), generateMarkdownReport(this.testResult) ); } if (this.opts.junit) { await this.junitReporter.addSuiteToBuild( this.testResult, path.join(path.dirname(path.dirname(this.opts.reportOutputFilePath)), "junit.xml") ); } } }