diff --git a/src/commands/action-flows/action-flow/action-flow.service.ts b/src/commands/action-flows/action-flow/action-flow.service.ts index 3da6dba8..32976c46 100644 --- a/src/commands/action-flows/action-flow/action-flow.service.ts +++ b/src/commands/action-flows/action-flow/action-flow.service.ts @@ -8,7 +8,6 @@ import { fileService, FileService } from "../../../core/utils/file-service"; import { CuiFileService } from "../../../core/utils/cui-file-service"; import { logger } from "../../../core/utils/logger"; import { FileConstants } from "../../../core/utils/file.constants"; -import { resolve } from "node:path"; export class ActionFlowService { public static readonly METADATA_FILE_NAME = "metadata.json"; @@ -35,9 +34,8 @@ export class ActionFlowService { } const fileName = "action-flows_export_" + uuidv4() + ".zip"; - const fullFilePath = resolve(process.cwd(), fileName); - zip.writeZip(fullFilePath, () => fs.chmodSync(fullFilePath, FileConstants.DEFAULT_FILE_PERMISSIONS)); - logger.info(FileService.fileDownloadedMessage + fileName); + const writtenFilename = await this.cuiFileService.writeZipToFileWithGivenName(zip.toBuffer(), fileName); + logger.info(FileService.fileDownloadedMessage + writtenFilename); } public async analyzeActionFlows(packageId: string, outputToJsonFile: boolean): Promise { diff --git a/src/commands/configuration-management/branch/branch-export-import.command.service.ts b/src/commands/configuration-management/branch/branch-export-import.command.service.ts index 1f3c765d..768fd43c 100644 --- a/src/commands/configuration-management/branch/branch-export-import.command.service.ts +++ b/src/commands/configuration-management/branch/branch-export-import.command.service.ts @@ -64,7 +64,7 @@ export class BranchExportImportCommandService { const branchPackageKey = BranchUtils.constructBranchKey(packageKey, branchKey); const sourceDir = await this.exportRewrittenPackageDir(branchPackageKey); try { - const message = this.writeLocalArtifact(sourceDir, packageKey, !!options.zip); + const message = await this.writeLocalArtifact(sourceDir, packageKey, !!options.zip); if (jsonResponse) { await this.writeJson({ packageKey: branchPackageKey, branchName: branchKey }); } else { @@ -152,12 +152,11 @@ export class BranchExportImportCommandService { return extractedDir; } - private writeLocalArtifact(sourceDir: string, packageKey: string, zip: boolean): string { + private async writeLocalArtifact(sourceDir: string, packageKey: string, zip: boolean): Promise { if (zip) { const zipPath = fileService.zipDirectoryAsSinglePackage(sourceDir); try { - const fileName = `${packageKey}.zip`; - fileService.writeBufferToFileWithGivenName(fs.readFileSync(zipPath), resolve(process.cwd(), fileName)); + const fileName = await this.cuiFileService.writeZipToFileWithGivenName(fs.readFileSync(zipPath), `${packageKey}.zip`); return FileService.fileDownloadedMessage + fileName; } finally { fs.rmSync(zipPath, { force: true }); diff --git a/src/commands/configuration-management/single-package-export.service.ts b/src/commands/configuration-management/single-package-export.service.ts index 7eb9001e..ad98ff52 100644 --- a/src/commands/configuration-management/single-package-export.service.ts +++ b/src/commands/configuration-management/single-package-export.service.ts @@ -4,16 +4,18 @@ import { fileService, FileService } from "../../core/utils/file-service"; import { logger } from "../../core/utils/logger"; import { GitService } from "../../core/git-profile/git/git.service"; import { SinglePackageExportApi } from "./api/single-package-export-api"; -import { resolve } from "node:path"; +import { CuiFileService } from "../../core/utils/cui-file-service"; export class SinglePackageExportService { private readonly singlePackageExportApi: SinglePackageExportApi; private readonly gitService: GitService; + private readonly cuiFileService: CuiFileService; constructor(context: Context) { this.singlePackageExportApi = new SinglePackageExportApi(context); this.gitService = new GitService(context); + this.cuiFileService = new CuiFileService(context); } public async exportPackage(packageKey: string, zip: boolean, gitBranch: string): Promise { @@ -25,8 +27,7 @@ export class SinglePackageExportService { } if (zip) { - const fileName = `${packageKey}.zip`; - fileService.writeBufferToFileWithGivenName(packageData, resolve(process.cwd(), fileName)); + const fileName = await this.cuiFileService.writeZipToFileWithGivenName(packageData, `${packageKey}.zip`); logger.info(FileService.fileDownloadedMessage + fileName); return; } diff --git a/src/commands/studio/manager/space.manager.ts b/src/commands/studio/manager/space.manager.ts index 67dd5518..2509b044 100644 --- a/src/commands/studio/manager/space.manager.ts +++ b/src/commands/studio/manager/space.manager.ts @@ -4,18 +4,15 @@ 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 { diff --git a/src/commands/t2tc/t2tc-package.service.ts b/src/commands/t2tc/t2tc-package.service.ts index 3cb96fb1..426455b1 100644 --- a/src/commands/t2tc/t2tc-package.service.ts +++ b/src/commands/t2tc/t2tc-package.service.ts @@ -21,7 +21,6 @@ import { StudioService } from "./studio.service"; import { GitService } from "../../core/git-profile/git/git.service"; import * as fs from "fs"; import { FileConstants } from "../../core/utils/file.constants"; -import { resolve } from "node:path"; export class T2tcPackageService { @@ -117,7 +116,7 @@ export class T2tcPackageService { logger.info("Successfully exported packages to branch: " + gitBranch); fs.rmSync(extractedDirectory, { recursive: true }); } else { - this.downloadZip(exportedPackagesZip, unzip); + await this.downloadZip(exportedPackagesZip, unzip); } } @@ -243,7 +242,7 @@ export class T2tcPackageService { return null; } - private downloadZip(exportedZip: AdmZip, unzip: boolean): void { + private async downloadZip(exportedZip: AdmZip, unzip: boolean): Promise { if (unzip) { const fileDownloadedMessage = "Successful download. Downloaded directory: "; const targetDirectoryName = `export_${uuidv4()}`; @@ -251,9 +250,7 @@ export class T2tcPackageService { logger.info(fileDownloadedMessage + targetDirectoryName); } else { const fileDownloadedMessage = "File downloaded successfully. New filename: "; - const filename = `export_${uuidv4()}.zip`; - const fullFilePath = resolve(process.cwd(), filename); - exportedZip.writeZip(fullFilePath, () => fs.chmodSync(fullFilePath, FileConstants.DEFAULT_FILE_PERMISSIONS)); + const filename = await this.cuiFileService.writeZipToFileWithGivenName(exportedZip.toBuffer(), `export_${uuidv4()}.zip`); logger.info(fileDownloadedMessage + filename); } } diff --git a/src/core/http/http-shared/base.manager.ts b/src/core/http/http-shared/base.manager.ts index 1e9295c3..b0327b86 100644 --- a/src/core/http/http-shared/base.manager.ts +++ b/src/core/http/http-shared/base.manager.ts @@ -5,13 +5,16 @@ import { ManagerConfig } from "./manager-config.interface"; import { HttpClient } from "../http-client"; import { Context } from "../../command/cli-context"; import { FileConstants } from "../../utils/file.constants"; +import { CuiFileService } from "../../utils/cui-file-service"; export abstract class BaseManager { private httpClient: () => HttpClient; + protected readonly cuiFileService: CuiFileService; protected readonly fileDownloadedMessage = "File downloaded successfully. New filename: "; protected constructor(context: Context) { this.httpClient = () => context.httpClient; + this.cuiFileService = new CuiFileService(context); } public async pull(): Promise { @@ -36,19 +39,14 @@ export abstract class BaseManager { } public async pullFile(): Promise { - return new Promise((resolve, reject) => { - this.httpClient() - .downloadFile(this.getConfig().pullUrl) - .then(data => { - const filename = this.writeStreamToFile(data); - logger.info(this.fileDownloadedMessage + filename); - resolve(); - }) - .catch(err => { - logger.error(new FatalError(err)); - reject(); - }); - }); + try { + const data = await this.httpClient().downloadFile(this.getConfig().pullUrl); + const filename = await this.writeStreamToFile(data); + logger.info(this.fileDownloadedMessage + filename); + } catch (err) { + logger.error(new FatalError(err)); + throw err; + } } public async push(): Promise { @@ -98,10 +96,8 @@ export abstract class BaseManager { return filename; } - protected writeStreamToFile(data: any): string { - const filename = this.getConfig().exportFileName; - fs.writeFileSync(filename, data, { mode: FileConstants.DEFAULT_FILE_PERMISSIONS }); - return filename; + protected async writeStreamToFile(data: Buffer): Promise { + return this.cuiFileService.writeZipToFileWithGivenName(data, this.getConfig().exportFileName); } protected writeToFileWithGivenName(data: any, filename: string): void { diff --git a/src/core/utils/cui-file-service.ts b/src/core/utils/cui-file-service.ts index 9d5c58b2..cfc739c0 100644 --- a/src/core/utils/cui-file-service.ts +++ b/src/core/utils/cui-file-service.ts @@ -20,32 +20,66 @@ export class CuiFileService { } public async writeToFileWithGivenName(data: string, filename: string): Promise { + return this.writeWithCoverHandling(data, filename, cover => + this.writeClassifiedArchive(filename, data, cover) + ); + } + + public async writeZipToFileWithGivenName(zipData: Buffer, filename: string): Promise { + return this.writeWithCoverHandling(zipData, filename, cover => { + const zip = new AdmZip(zipData); + this.addCoverPage(zip, cover); + + return this.writeArchive(zip, filename); + }); + } + + private async writeWithCoverHandling( + payload: string | Buffer, + filename: string, + onClassified: (cover: CuiPdfCoverResponse) => Promise | string + ): Promise { const cover = await this.cuiApi.getCuiPdfCover(); - if (cover === null) { - fileService.writeToFileWithGivenName(data, filename); - return filename; + if (!cover) { + return this.writePayload(payload, filename); } if (!this.isClassified(cover)) { - const unclassifiedName = this.prefixFileName(filename, CuiFileService.UNCLASSIFIED_PREFIX); - fileService.writeToFileWithGivenName(data, unclassifiedName); - return unclassifiedName; + return this.writePayload(payload, this.prefixFileName(filename, CuiFileService.UNCLASSIFIED_PREFIX)); + } + + return onClassified(cover); + } + + private writePayload(payload: string | Buffer, filename: string): string { + if (Buffer.isBuffer(payload)) { + fileService.writeBufferToFileWithGivenName(payload, filename); + } else { + fileService.writeToFileWithGivenName(payload, filename); } - return this.writeClassifiedArchive(filename, data, cover); + return filename; } 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); + this.addCoverPage(zip, cover); + + return this.writeArchive(zip, filename); + } + + private addCoverPage(zip: AdmZip, cover: CuiPdfCoverResponse): void { zip.addFile( CuiFileService.COVER_SHEET_FILE_NAME, this.decodeCoverPage(cover), "", FileConstants.DEFAULT_FILE_PERMISSIONS ); + } + private writeArchive(zip: AdmZip, filename: string): string { const archiveName = this.buildClassifiedArchiveName(filename); fileService.writeBufferToFileWithGivenName(zip.toBuffer(), archiveName); diff --git a/tests/commands/cui-marking-zip-commands.spec.ts b/tests/commands/cui-marking-zip-commands.spec.ts new file mode 100644 index 00000000..bf66ad3f --- /dev/null +++ b/tests/commands/cui-marking-zip-commands.spec.ts @@ -0,0 +1,146 @@ +import { resolve } from "node:path"; +import * as fs from "node:fs"; +import { readFileSync } from "node:fs"; +import * as os from "node:os"; +import { Readable } from "stream"; +import AdmZip = require("adm-zip"); +import { mockAxiosGet, mockAxiosGetWithStatus, mockAxiosPost } from "../utls/http-requests-mock"; +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"; +import { ConfigUtils } from "../utls/config-utils"; +import { SinglePackageExportService } from "../../src/commands/configuration-management/single-package-export.service"; +import { BranchExportImportCommandService } from "../../src/commands/configuration-management/branch/branch-export-import.command.service"; +import { T2tcCommandService } from "../../src/commands/t2tc/t2tc-command.service"; +import { ActionFlowCommandService } from "../../src/commands/action-flows/action-flow/action-flow-command.service"; +import { PackageCommandService } from "../../src/commands/studio/command-service/package-command.service"; +import { PackageManifestTransport } from "../../src/commands/configuration-management/interfaces/package-export.interfaces"; + +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 PACKAGE_KEY = "pkg-1"; +const BRANCH = "feature-a"; +const PACKAGE_ID = "123-456-789"; +const T2TC_DOWNLOAD_MESSAGE = "File downloaded successfully. New filename: "; + +function markAsClassified(): void { + mockAxiosGetWithStatus(COVER_URL, 200, { + resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] }, + coverPage: { pdfContent: PDF_BYTES.toString("base64"), encoding: "base64" }, + }); +} + +function loggedFileName(prefix: string = FileService.fileDownloadedMessage): string { + const message = loggingTestTransport.logMessages.map(entry => entry.message).find(entry => entry.includes(prefix)); + return message.split(prefix)[1]; +} + +function markedArchive(prefix?: string): AdmZip { + const filename = loggedFileName(prefix); + expect(filename.startsWith(CuiFileService.CLASSIFIED_PREFIX)).toBe(true); + expect(filename.endsWith(".zip")).toBe(true); + + const archive = new AdmZip(readFileSync(resolve(process.cwd(), filename))); + expect(archive.getEntry(CuiFileService.COVER_SHEET_FILE_NAME).getData().equals(PDF_BYTES)).toBe(true); + + return archive; +} + +function entryNames(archive: AdmZip): string[] { + return archive.getEntries().map(entry => entry.entryName).sort(); +} + +function buildPackageZip(): Buffer { + const zip = new AdmZip(); + zip.addFile("package.json", Buffer.from(JSON.stringify({ key: PACKAGE_KEY, name: "My Package" }))); + zip.addFile("nodes/node-1.json", Buffer.from(JSON.stringify({ key: "node-1", type: "VIEW" }))); + return zip.toBuffer(); +} + +function seedPackageDir(packageKey: string): string { + const dir = fs.mkdtempSync(resolve(os.tmpdir(), "cui-zip-test-")); + fs.mkdirSync(resolve(dir, "nodes")); + fs.writeFileSync(resolve(dir, "package.json"), JSON.stringify({ key: packageKey, name: "My Package" })); + fs.writeFileSync(resolve(dir, "nodes", "root.json"), JSON.stringify({ key: "root", type: "FOLDER" })); + return dir; +} + +describe("CUI marking of archive exports", () => { + + beforeEach(() => { + markAsClassified(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("Should mark the archive of config package export --zip", async () => { + mockAxiosGet(`https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/export-file`, buildPackageZip()); + + await new SinglePackageExportService(testContext).exportPackage(PACKAGE_KEY, true, null); + + expect(loggedFileName()).toEqual(`${CuiFileService.CLASSIFIED_PREFIX}${PACKAGE_KEY}.zip`); + expect(entryNames(markedArchive())).toEqual([ + CuiFileService.COVER_SHEET_FILE_NAME, + "nodes/node-1.json", + "package.json", + ]); + }); + + it("Should mark the archive of config branch export --zip", async () => { + const branchPackageKey = `${PACKAGE_KEY}@${BRANCH}`; + mockAxiosGet(`https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${branchPackageKey}/export-file`, buildPackageZip()); + jest.spyOn(FileService.prototype, "extractZipBufferToTempDirectory").mockReturnValue(seedPackageDir(branchPackageKey)); + + await new BranchExportImportCommandService(testContext).exportBranch(PACKAGE_KEY, BRANCH, { zip: true }); + + expect(loggedFileName()).toEqual(`${CuiFileService.CLASSIFIED_PREFIX}${PACKAGE_KEY}.zip`); + + const archive = markedArchive(); + expect(entryNames(archive)).toContain("package.json"); + expect(JSON.parse(archive.getEntry("package.json").getData().toString()).key).toEqual(PACKAGE_KEY); + }); + + it("Should mark the archive of t2tc package export", async () => { + const manifest: PackageManifestTransport[] = [ConfigUtils.buildManifestForKeyAndFlavor("key-1", "TEST")]; + mockAxiosGet( + "https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch?packageKeys=key-1&withDependencies=false", + ConfigUtils.buildBatchExportZip(manifest, []).toBuffer() + ); + mockAxiosPost("https://myTeam.celonis.cloud/package-manager/api/core/packages/export/batch/variables-with-assignments", []); + + await new T2tcCommandService(testContext).batchExportPackages(["key-1"], undefined, false, null, false); + + expect(entryNames(markedArchive(T2TC_DOWNLOAD_MESSAGE))).toContain("manifest.json"); + }); + + it("Should mark the archive of export action-flows", async () => { + const actionFlowFileName = "20240711-scenario-1234.json"; + const actionFlows = new AdmZip(); + actionFlows.addFile(actionFlowFileName, Buffer.from(JSON.stringify({ name: "Automation" }))); + mockAxiosGet(`https://myTeam.celonis.cloud/ems-automation/api/root/${PACKAGE_ID}/export/assets`, actionFlows.toBuffer()); + + await new ActionFlowCommandService(testContext).exportActionFlows(PACKAGE_ID, null); + + expect(entryNames(markedArchive())).toEqual([actionFlowFileName, CuiFileService.COVER_SHEET_FILE_NAME].sort()); + }); + + it("Should mark the archive of the deprecated pull package", async () => { + mockAxiosPost( + `https://myTeam.celonis.cloud/package-manager/api/packages/${PACKAGE_KEY}/export?store=false&draft=false`, + Readable.from(buildPackageZip()) + ); + + await new PackageCommandService(testContext).pullPackage(PACKAGE_KEY, false, null, false); + + expect(loggedFileName()).toEqual(`${CuiFileService.CLASSIFIED_PREFIX}package_${PACKAGE_KEY}.zip`); + expect(entryNames(markedArchive())).toEqual([ + CuiFileService.COVER_SHEET_FILE_NAME, + "nodes/node-1.json", + "package.json", + ]); + }); +}); diff --git a/tests/core/utils/cui-file-service.spec.ts b/tests/core/utils/cui-file-service.spec.ts index 0b91b57d..f47401bb 100644 --- a/tests/core/utils/cui-file-service.spec.ts +++ b/tests/core/utils/cui-file-service.spec.ts @@ -106,4 +106,62 @@ describe("CuiFileService", () => { .rejects.toThrow("CUI marking applies but the response contained no cover page."); }); }); + + describe("when the artifact is already an archive", () => { + const buildExportZip = (): Buffer => { + const zip = new AdmZip(); + zip.addFile("manifest.json", Buffer.from(JSON.stringify({ packageKey: "pkg-1" }))); + zip.addFile("nodes/node-1.json", Buffer.from(JSON.stringify({ key: "node-1" }))); + return zip.toBuffer(); + }; + + const entryNames = (filename: string): string[] => + new AdmZip(readFile(filename)).getEntries().map(entry => entry.entryName).sort(); + + it("Should keep the archive untouched when no marking applies", async () => { + mockAxiosGetWithStatus(COVER_URL, 204, ""); + const exportZip = buildExportZip(); + + const filename = await cuiFileService.writeZipToFileWithGivenName(exportZip, "export.zip"); + + expect(filename).toEqual("export.zip"); + expect(readFile(filename).equals(exportZip)).toBe(true); + }); + + it("Should only prefix the archive when the content is unclassified", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, coverResponse([])); + const exportZip = buildExportZip(); + + const filename = await cuiFileService.writeZipToFileWithGivenName(exportZip, "export.zip"); + + expect(filename).toEqual("Unclassified - export.zip"); + expect(readFile(filename).equals(exportZip)).toBe(true); + }); + + it("Should add the cover sheet into the given archive instead of nesting it", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, coverResponse([{ code: "PRVCY", name: "Privacy" }])); + + const filename = await cuiFileService.writeZipToFileWithGivenName(buildExportZip(), "export.zip"); + + expect(filename).toEqual("CUI - export.zip"); + expect(entryNames(filename)).toEqual([ + CuiFileService.COVER_SHEET_FILE_NAME, + "manifest.json", + "nodes/node-1.json", + ]); + + const archive = new AdmZip(readFile(filename)); + expect(archive.getEntry(CuiFileService.COVER_SHEET_FILE_NAME).getData().equals(PDF_BYTES)).toBe(true); + expect(archive.getEntries().some(entry => entry.entryName.endsWith(".zip"))).toBe(false); + }); + + 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.writeZipToFileWithGivenName(buildExportZip(), "export.zip")) + .rejects.toThrow("CUI marking applies but the response contained no cover page."); + }); + }); });