diff --git a/src/commands/studio/manager/space.manager.ts b/src/commands/studio/manager/space.manager.ts index 3bdeaca9..67dd5518 100644 --- a/src/commands/studio/manager/space.manager.ts +++ b/src/commands/studio/manager/space.manager.ts @@ -4,15 +4,18 @@ import { BaseManager } from "../../../core/http/http-shared/base.manager"; import { ManagerConfig } from "../../../core/http/http-shared/manager-config.interface"; import { SpaceTransport } from "../interfaces/space.interface"; import { logger } from "../../../core/utils/logger"; +import { CuiFileService } from "../../../core/utils/cui-file-service"; export class SpaceManager extends BaseManager { private static BASE_URL = "/package-manager/api/spaces"; private _jsonResponse: boolean; + private readonly cuiFileService: CuiFileService; constructor(context: Context) { super(context); + this.cuiFileService = new CuiFileService(context); } public get jsonResponse(): boolean { @@ -30,11 +33,11 @@ export class SpaceManager extends BaseManager { }; } - private listSpaces(nodes: SpaceTransport[]): void { + private async listSpaces(nodes: SpaceTransport[]): Promise { if (this.jsonResponse) { const filename = uuidv4() + ".json"; - this.writeToFileWithGivenName(JSON.stringify(nodes, ["id", "name"]), filename); - logger.info(this.fileDownloadedMessage + filename); + const writtenFilename = await this.cuiFileService.writeToFileWithGivenName(JSON.stringify(nodes, ["id", "name"]), filename); + logger.info(this.fileDownloadedMessage + writtenFilename); } else { nodes.forEach(node => { logger.info(`${node.id} - Name: "${node.name}"`); diff --git a/src/commands/studio/service/package.service.ts b/src/commands/studio/service/package.service.ts index 729fc170..353749e3 100644 --- a/src/commands/studio/service/package.service.ts +++ b/src/commands/studio/service/package.service.ts @@ -6,7 +6,8 @@ import { PackageDependencyTransport, PackageManagerVariableType, } from "../interfaces/package-manager.interfaces"; -import { FileService, fileService } from "../../../core/utils/file-service"; +import { FileService } from "../../../core/utils/file-service"; +import { CuiFileService } from "../../../core/utils/cui-file-service"; import { BatchExportNodeTransport } from "../interfaces/batch-export-node.interfaces"; import { PackageDependenciesApi } from "../api/package-dependencies-api"; import { DataModelService } from "./data-model.service"; @@ -20,12 +21,14 @@ export class PackageService { private dataModelService: DataModelService; private variableService: StudioVariableService; + private readonly cuiFileService: CuiFileService; constructor(context: Context) { this.packageApi = new PackageApi(context); this.packageDependenciesApi = new PackageDependenciesApi(context); this.dataModelService = new DataModelService(context); this.variableService = new StudioVariableService(context); + this.cuiFileService = new CuiFileService(context); } public async listPackages(): Promise { @@ -66,7 +69,7 @@ export class PackageService { return nodeToExport; }) } - this.exportListOfPackages(nodesListToExport, fieldsToInclude); + await this.exportListOfPackages(nodesListToExport, fieldsToInclude); } public async getNodesWithActiveVersion(nodes: BatchExportNodeTransport[]): Promise { @@ -83,9 +86,9 @@ export class PackageService { return await this.packageDependenciesApi.findPackageDependenciesByIds(draftIdByNodeId); } - private exportListOfPackages(nodes: BatchExportNodeTransport[], fieldsToInclude: string[]): void { + private async exportListOfPackages(nodes: BatchExportNodeTransport[], fieldsToInclude: string[]): Promise { const filename = uuidv4() + ".json"; - fileService.writeToFileWithGivenName(JSON.stringify(nodes, fieldsToInclude), filename); - logger.info(FileService.fileDownloadedMessage + filename); + const writtenFilename = await this.cuiFileService.writeToFileWithGivenName(JSON.stringify(nodes, fieldsToInclude), filename); + logger.info(FileService.fileDownloadedMessage + writtenFilename); } } diff --git a/src/core/http/http-client.ts b/src/core/http/http-client.ts index 0be3ba19..a66b62a2 100644 --- a/src/core/http/http-client.ts +++ b/src/core/http/http-client.ts @@ -35,6 +35,23 @@ export class HttpClient { }) } + public async getStatusAndData(url: string): Promise<{ status: number; data: any }> { + const fullUrl = this.resolveUrl(url); + logger.debug(`HttpClient - GET ${fullUrl}`); + return this.axios.get(fullUrl, { + headers: this.buildHeaders(), + validateStatus: () => true, + }).then(response => { + logger.debug(`Response ${response.status}`); + return { status: response.status, data: response.data }; + }).catch(err => { + if (err.response) { + return { status: err.response.status, data: err.response.data }; + } + throw new FatalError(err); + }); + } + public async getFile(url: string): Promise { return new Promise((resolve, reject) => { this.axios.get(this.resolveUrl(url), { diff --git a/src/core/http/http-shared/base.manager.ts b/src/core/http/http-shared/base.manager.ts index 912436a6..1e9295c3 100644 --- a/src/core/http/http-shared/base.manager.ts +++ b/src/core/http/http-shared/base.manager.ts @@ -82,18 +82,14 @@ export abstract class BaseManager { } public async findAll(): Promise { - return new Promise((resolve, reject) => { - this.httpClient() - .get(this.getConfig().findAllUrl) - .then(data => { - this.getConfig().onFindAll(data); - resolve(data); - }) - .catch(err => { - logger.error(new FatalError(err)); - reject(); - }); - }); + try { + const data = await this.httpClient().get(this.getConfig().findAllUrl); + await this.getConfig().onFindAll(data); + return data; + } catch (err) { + logger.error(new FatalError(err)); + throw err; + } } protected writeToFile(data: any): string { diff --git a/src/core/http/http-shared/manager-config.interface.ts b/src/core/http/http-shared/manager-config.interface.ts index abead4c4..e7f365e9 100644 --- a/src/core/http/http-shared/manager-config.interface.ts +++ b/src/core/http/http-shared/manager-config.interface.ts @@ -6,6 +6,6 @@ export interface ManagerConfig { exportFileName?: string; onPushSuccessMessage?: (data: any) => string; onUpdateSuccessMessage?: () => string; - onFindAll?: (data: any) => void; + onFindAll?: (data: any) => void | Promise; onFindAllAndExport?: (data: any) => void; } diff --git a/src/core/utils/cui-api.ts b/src/core/utils/cui-api.ts new file mode 100644 index 00000000..e17254e1 --- /dev/null +++ b/src/core/utils/cui-api.ts @@ -0,0 +1,42 @@ +import { HttpClient } from "../http/http-client"; +import { FatalError, logger } from "./logger"; +import { Context } from "../command/cli-context"; + +export interface CuiPdfCoverResponse { + resolvedCuiMarking?: { categories?: unknown[] }; + coverPage?: { pdfContent: string; encoding: string }; +} + +export class CuiApi { + private static readonly CUI_PDF_COVER_SHEET_URL = "/api/team/cui-settings/cui-pdf-cover"; + + private static readonly STATUS_OK = 200; + private static readonly STATUS_NO_CONTENT = 204; + private static readonly STATUS_FORBIDDEN = 403; + + private readonly httpClient: () => HttpClient; + + constructor(context: Context) { + this.httpClient = () => context.httpClient; + } + + public async getCuiPdfCover(): Promise { + const { status, data } = await this.httpClient().getStatusAndData(CuiApi.CUI_PDF_COVER_SHEET_URL); + + if (status === CuiApi.STATUS_FORBIDDEN) { + logger.debug("CUI marking does not apply, the feature flag is disabled"); + return null; + } + + if (status === CuiApi.STATUS_NO_CONTENT) { + logger.debug("CUI marking does not apply, the team has CUI disabled"); + return null; + } + + if (status === CuiApi.STATUS_OK && data) { + return data as CuiPdfCoverResponse; + } + + throw new FatalError("Problem fetching cui pdf cover"); + } +} diff --git a/src/core/utils/cui-file-service.ts b/src/core/utils/cui-file-service.ts new file mode 100644 index 00000000..9d5c58b2 --- /dev/null +++ b/src/core/utils/cui-file-service.ts @@ -0,0 +1,81 @@ +import * as path from "node:path"; +import AdmZip = require("adm-zip"); +import { Context } from "../command/cli-context"; +import { CuiApi, CuiPdfCoverResponse } from "./cui-api"; +import { fileService } from "./file-service"; +import { FileConstants } from "./file.constants"; +import { FatalError } from "./logger"; + +export class CuiFileService { + public static readonly COVER_SHEET_FILE_NAME = "CUI_Cover_Sheet.pdf"; + public static readonly CLASSIFIED_PREFIX = "CUI - "; + public static readonly UNCLASSIFIED_PREFIX = "Unclassified - "; + + private static readonly BASE64_ENCODING = "base64"; + + private readonly cuiApi: CuiApi; + + constructor(context: Context) { + this.cuiApi = new CuiApi(context); + } + + public async writeToFileWithGivenName(data: string, filename: string): Promise { + const cover = await this.cuiApi.getCuiPdfCover(); + + if (cover === null) { + fileService.writeToFileWithGivenName(data, filename); + return filename; + } + + if (!this.isClassified(cover)) { + const unclassifiedName = this.prefixFileName(filename, CuiFileService.UNCLASSIFIED_PREFIX); + fileService.writeToFileWithGivenName(data, unclassifiedName); + return unclassifiedName; + } + + return this.writeClassifiedArchive(filename, data, cover); + } + + private writeClassifiedArchive(filename: string, data: string, cover: CuiPdfCoverResponse): string { + const zip = new AdmZip(); + zip.addFile(path.basename(filename), Buffer.from(data, "utf-8"), "", FileConstants.DEFAULT_FILE_PERMISSIONS); + zip.addFile( + CuiFileService.COVER_SHEET_FILE_NAME, + this.decodeCoverPage(cover), + "", + FileConstants.DEFAULT_FILE_PERMISSIONS + ); + + const archiveName = this.buildClassifiedArchiveName(filename); + fileService.writeBufferToFileWithGivenName(zip.toBuffer(), archiveName); + + return archiveName; + } + + private decodeCoverPage(cover: CuiPdfCoverResponse): Buffer { + const coverPage = cover.coverPage; + if (!coverPage?.pdfContent) { + throw new FatalError("CUI marking applies but the response contained no cover page."); + } + if (coverPage.encoding !== CuiFileService.BASE64_ENCODING) { + throw new FatalError(`Unsupported CUI cover page encoding: ${coverPage.encoding}`); + } + + return Buffer.from(coverPage.pdfContent, CuiFileService.BASE64_ENCODING); + } + + private isClassified(cover: CuiPdfCoverResponse): boolean { + return (cover.resolvedCuiMarking?.categories?.length ?? 0) > 0; + } + + private buildClassifiedArchiveName(filename: string): string { + const baseName = path.basename(filename); + const nameWithoutExtension = baseName.slice(0, baseName.length - path.extname(baseName).length); + + return path.join(path.dirname(filename), `${CuiFileService.CLASSIFIED_PREFIX}${nameWithoutExtension}.zip`); + } + + private prefixFileName(filename: string, prefix: string): string { + return path.join(path.dirname(filename), `${prefix}${path.basename(filename)}`); + } +} diff --git a/tests/commands/studio/list-cui-marking.spec.ts b/tests/commands/studio/list-cui-marking.spec.ts new file mode 100644 index 00000000..f07dc53c --- /dev/null +++ b/tests/commands/studio/list-cui-marking.spec.ts @@ -0,0 +1,126 @@ +import { resolve } from "node:path"; +import { readFileSync } from "node:fs"; +import AdmZip = require("adm-zip"); +import { mockAxiosGet, mockAxiosGetWithStatus, mockedAxiosInstance } from "../../utls/http-requests-mock"; +import { SpaceCommandService } from "../../../src/commands/studio/command-service/space-command.service"; +import { PackageCommandService } from "../../../src/commands/studio/command-service/package-command.service"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport } from "../../jest.setup"; +import { FileService } from "../../../src/core/utils/file-service"; +import { CuiFileService } from "../../../src/core/utils/cui-file-service"; + +const SPACES_URL = "https://myTeam.celonis.cloud/package-manager/api/spaces"; +const PACKAGES_URL = "https://myTeam.celonis.cloud/package-manager/api/packages"; +const COVER_URL = "https://myTeam.celonis.cloud/api/team/cui-settings/cui-pdf-cover"; + +const SPACES = [{ id: "space-1", name: "My Space" }]; +const PACKAGES = [{ key: "pkg-1", name: "My Package", rootNodeKey: "pkg-1" }]; +const LISTED_PACKAGES = [{ key: "pkg-1", name: "My Package" }]; +const PDF_BYTES = Buffer.from("%PDF-1.4 cover sheet"); + +function classifiedCover(): object { + return { + resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] }, + coverPage: { pdfContent: PDF_BYTES.toString("base64"), encoding: "base64" }, + }; +} + +function loggedFileName(): string { + return loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; +} + +function readWrittenJson(filename: string): any { + return JSON.parse(readFileSync(resolve(process.cwd(), filename), "utf-8")); +} + +function payloadFromArchive(filename: string): any { + const archive = new AdmZip(readFileSync(resolve(process.cwd(), filename))); + const entries = archive.getEntries().map(entry => entry.entryName); + + expect(entries).toContain(CuiFileService.COVER_SHEET_FILE_NAME); + expect(archive.getEntry(CuiFileService.COVER_SHEET_FILE_NAME).getData().equals(PDF_BYTES)).toBe(true); + + const payloadEntry = entries.find(entry => entry.endsWith(".json")); + return JSON.parse(archive.getEntry(payloadEntry).getData().toString()); +} + +function coverWasRequested(): boolean { + return (mockedAxiosInstance.get as jest.Mock).mock.calls.some(call => call[0] === COVER_URL); +} + +describe("CUI marking of Studio listings", () => { + + describe("list spaces --json", () => { + const listSpaces = () => new SpaceCommandService(testContext).listSpaces(true); + + beforeEach(() => { + mockAxiosGet(SPACES_URL, SPACES); + }); + + it("Should wrap the listing and the cover sheet into a CUI archive when classified", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, classifiedCover()); + + await listSpaces(); + + const filename = loggedFileName(); + expect(filename.startsWith(CuiFileService.CLASSIFIED_PREFIX)).toBe(true); + expect(filename.endsWith(".zip")).toBe(true); + expect(payloadFromArchive(filename)).toEqual(SPACES); + }); + + it("Should keep the original filename when no marking applies", async () => { + mockAxiosGetWithStatus(COVER_URL, 204, ""); + + await listSpaces(); + + const filename = loggedFileName(); + expect(filename.startsWith(CuiFileService.UNCLASSIFIED_PREFIX)).toBe(false); + expect(filename.startsWith(CuiFileService.CLASSIFIED_PREFIX)).toBe(false); + expect(readWrittenJson(filename)).toEqual(SPACES); + }); + + it("Should not probe CUI when the listing only goes to the console", async () => { + await new SpaceCommandService(testContext).listSpaces(false); + + expect(loggingTestTransport.logMessages[0].message).toContain(`space-1 - Name: "My Space"`); + expect(coverWasRequested()).toBe(false); + }); + }); + + describe("list packages --json", () => { + const listPackages = () => new PackageCommandService(testContext).listPackages(true, false, []); + + beforeEach(() => { + mockAxiosGet(PACKAGES_URL, PACKAGES); + }); + + it("Should wrap the listing and the cover sheet into a CUI archive when classified", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, classifiedCover()); + + await listPackages(); + + const filename = loggedFileName(); + expect(filename.startsWith(CuiFileService.CLASSIFIED_PREFIX)).toBe(true); + expect(filename.endsWith(".zip")).toBe(true); + expect(payloadFromArchive(filename)).toEqual(LISTED_PACKAGES); + }); + + it("Should keep the original filename when no marking applies", async () => { + mockAxiosGetWithStatus(COVER_URL, 204, ""); + + await listPackages(); + + const filename = loggedFileName(); + expect(filename.startsWith(CuiFileService.UNCLASSIFIED_PREFIX)).toBe(false); + expect(filename.startsWith(CuiFileService.CLASSIFIED_PREFIX)).toBe(false); + expect(readWrittenJson(filename)).toEqual(LISTED_PACKAGES); + }); + + it("Should not probe CUI when the listing only goes to the console", async () => { + await new PackageCommandService(testContext).listPackages(false, false, []); + + expect(loggingTestTransport.logMessages[0].message).toContain(`My Package - Key: "pkg-1"`); + expect(coverWasRequested()).toBe(false); + }); + }); +}); diff --git a/tests/core/utils/cui-file-service.spec.ts b/tests/core/utils/cui-file-service.spec.ts new file mode 100644 index 00000000..0b91b57d --- /dev/null +++ b/tests/core/utils/cui-file-service.spec.ts @@ -0,0 +1,109 @@ +import { accessSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import AdmZip = require("adm-zip"); +import { CuiFileService } from "../../../src/core/utils/cui-file-service"; +import { FatalError } from "../../../src/core/utils/logger"; +import { testContext } from "../../utls/test-context"; +import { mockAxiosGetError, mockAxiosGetWithStatus } from "../../utls/http-requests-mock"; + +describe("CuiFileService", () => { + const COVER_URL = "https://myTeam.celonis.cloud/api/team/cui-settings/cui-pdf-cover"; + const PDF_BYTES = Buffer.from("%PDF-1.4 cover sheet"); + const PAYLOAD = JSON.stringify({ key: "pkg-1" }); + + let cuiFileService: CuiFileService; + + const coverResponse = (categories: Array<{ code: string; name: string }>) => ({ + resolvedCuiMarking: { categories }, + coverPage: { + pdfContent: PDF_BYTES.toString("base64"), + encoding: "base64", + }, + }); + + const readFile = (filename: string): Buffer => readFileSync(resolve(process.cwd(), filename)); + + beforeEach(() => { + cuiFileService = new CuiFileService(testContext); + }); + + describe("when CUI marking does not apply", () => { + it("Should keep the original filename when the feature flag is disabled", async () => { + mockAxiosGetError(COVER_URL, 403, { errorCode: "feature-disabled" }); + + const filename = await cuiFileService.writeToFileWithGivenName(PAYLOAD, "report.json"); + + expect(filename).toEqual("report.json"); + expect(readFile("report.json").toString()).toEqual(PAYLOAD); + }); + + it("Should keep the original filename when the team has CUI disabled", async () => { + mockAxiosGetWithStatus(COVER_URL, 204, ""); + + const filename = await cuiFileService.writeToFileWithGivenName(PAYLOAD, "no-marking.json"); + + expect(filename).toEqual("no-marking.json"); + expect(readFile("no-marking.json").toString()).toEqual(PAYLOAD); + }); + + it("Should fail on an unexpected backend error", async () => { + mockAxiosGetError(COVER_URL, 500, { message: "boom" }); + + await expect(cuiFileService.writeToFileWithGivenName(PAYLOAD, "broken.json")).rejects.toThrow(FatalError); + expect(() => accessSync(resolve(process.cwd(), "broken.json"))).toThrow(); + }); + + it("Should fail when the marking applies but the response body is empty", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, ""); + + await expect(cuiFileService.writeToFileWithGivenName(PAYLOAD, "empty-cover.json")).rejects.toThrow(FatalError); + expect(() => accessSync(resolve(process.cwd(), "empty-cover.json"))).toThrow(); + }); + }); + + describe("when the content is unclassified", () => { + it("Should only prefix the filename and write no cover sheet", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, coverResponse([])); + + const filename = await cuiFileService.writeToFileWithGivenName(PAYLOAD, "packages.json"); + + expect(filename).toEqual("Unclassified - packages.json"); + expect(readFile(filename).toString()).toEqual(PAYLOAD); + expect(() => accessSync(resolve(process.cwd(), CuiFileService.COVER_SHEET_FILE_NAME))).toThrow(); + }); + }); + + describe("when the content is classified", () => { + it("Should wrap the payload and the decoded cover sheet into a CUI archive", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, coverResponse([{ code: "PRVCY", name: "Privacy" }])); + + const filename = await cuiFileService.writeToFileWithGivenName(PAYLOAD, "packages.json"); + + expect(filename).toEqual("CUI - packages.zip"); + + const zip = new AdmZip(readFile(filename)); + expect(zip.getEntries().map(entry => entry.entryName).sort()) + .toEqual([CuiFileService.COVER_SHEET_FILE_NAME, "packages.json"]); + expect(zip.getEntry("packages.json").getData().toString()).toEqual(PAYLOAD); + expect(zip.getEntry(CuiFileService.COVER_SHEET_FILE_NAME).getData().equals(PDF_BYTES)).toBe(true); + }); + + it("Should fail when the cover page uses an unsupported encoding", async () => { + const response = coverResponse([{ code: "PRVCY", name: "Privacy" }]); + response.coverPage.encoding = "hex"; + mockAxiosGetWithStatus(COVER_URL, 200, response); + + await expect(cuiFileService.writeToFileWithGivenName(PAYLOAD, "packages.json")) + .rejects.toThrow("Unsupported CUI cover page encoding: hex"); + }); + + it("Should fail when the marking applies but no cover page was returned", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, { + resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] }, + }); + + await expect(cuiFileService.writeToFileWithGivenName(PAYLOAD, "packages.json")) + .rejects.toThrow("CUI marking applies but the response contained no cover page."); + }); + }); +}); diff --git a/tests/utls/http-requests-mock.ts b/tests/utls/http-requests-mock.ts index 6e4d325e..1405cb0d 100644 --- a/tests/utls/http-requests-mock.ts +++ b/tests/utls/http-requests-mock.ts @@ -4,7 +4,10 @@ import { AxiosInitializer } from "../../src/core/http/axios-initializer"; const mockedAxiosInstance = {} as AxiosInstance; +const CUI_PDF_COVER_PATH = "/api/team/cui-settings/cui-pdf-cover"; + const mockedGetResponseByUrl = new Map(); +const mockedGetStatusByUrl = new Map(); const mockedGetErrorByUrl = new Map(); const mockedPostResponseByUrl = new Map(); const mockedPostErrorByUrl = new Map(); @@ -25,20 +28,26 @@ const mockAxios = () : void => { return Promise.reject({ response: { status, data } }); } if (mockedGetResponseByUrl.has(requestUrl)) { - const response = { data: mockedGetResponseByUrl.get(requestUrl) }; + const data = mockedGetResponseByUrl.get(requestUrl); + const status = mockedGetStatusByUrl.get(requestUrl) ?? 200; - if (response.data instanceof Buffer) { + if (data instanceof Buffer) { const readableStream = new Readable(); - readableStream.push(response.data) + readableStream.push(data) readableStream.push(null); return Promise.resolve({ status: 200, data: readableStream, }); } else { - return Promise.resolve(response); + return Promise.resolve({ status, data }); } } + // CUI marking is probed on every user-facing write. Unless a test opts in, + // answer 204 so the CLI keeps the original filename. + if (requestUrl.endsWith(CUI_PDF_COVER_PATH)) { + return Promise.resolve({ status: 204, data: "" }); + } fail("API call not mocked.") }); @@ -59,6 +68,13 @@ const mockAxios = () : void => { const mockAxiosGet = (url: string, responseData: any) => { mockedGetResponseByUrl.set(url, responseData); + mockedGetStatusByUrl.delete(url); + mockedGetErrorByUrl.delete(url); +}; + +const mockAxiosGetWithStatus = (url: string, status: number, responseData: any) => { + mockedGetResponseByUrl.set(url, responseData); + mockedGetStatusByUrl.set(url, status); mockedGetErrorByUrl.delete(url); }; @@ -104,6 +120,7 @@ const mockAxiosDelete = (url: string) => { afterEach(() => { mockedGetResponseByUrl.clear(); + mockedGetStatusByUrl.clear(); mockedGetErrorByUrl.clear(); mockedPostResponseByUrl.clear(); mockedPostErrorByUrl.clear(); @@ -115,6 +132,7 @@ export { mockedAxiosInstance, mockAxios, mockAxiosGet, + mockAxiosGetWithStatus, mockAxiosGetError, mockAxiosPost, mockAxiosPostError,