diff --git a/docs/cui-marking.md b/docs/cui-marking.md new file mode 100644 index 00000000..5c71903b --- /dev/null +++ b/docs/cui-marking.md @@ -0,0 +1,31 @@ +# CUI marking + +When Content CLI writes a user-facing file or directory to disk, it asks the team’s CUI settings whether marking applies. The answer decides the filename (or directory name) and whether a cover sheet is included. + +Commands that only print to the console, profile and log files, and exports that go straight to a Git branch never mark anything on disk. + +## Classification: cover response → artifact + +Example: `list packages --json` would otherwise write `packages.json`. + +| Cover response | Meaning | Outcome | +|---|---|---| +| **403** | Feature flag disabled | `packages.json` | +| **204** | Team has CUI disabled | `packages.json` | +| **200**, no categories | Marking applies, unclassified | `Unclassified - packages.json` | +| **200**, with categories | Marking applies, classified | `CUI - packages.zip` containing `packages.json` and `CUI_Cover_Sheet.pdf` | +| Unexpected response | Fail closed | Nothing written; the command errors | + +**403** and **204** both leave the artifact unmarked. They are not the same as **200 with no categories**, which still renames it to `Unclassified - …`. + +## Scope: how the write is triggered + +| Trigger | Commands | Example (unclassified) | Example (classified) | +|---|---|---|---| +| `--json` listings and reports | `list spaces`, `list packages`, `list assets` / `assignments` / `data-pools`, `config *`, `t2tc package list` / `diff`, `deployment *`, `asset-registry *` | `list packages --json` → `Unclassified - packages.json` | `list packages --json` → `CUI - packages.zip` containing `packages.json` and `CUI_Cover_Sheet.pdf` | +| `-o, --outputToJsonFile` reports | `analyze` / `import action-flows`, `export data-pool`, `import data-pools`, `t2tc package import` report | `export data-pool -o` → `Unclassified - .json` | `export data-pool -o` → `CUI - .zip` containing the JSON and `CUI_Cover_Sheet.pdf` | +| Artifact is already an archive | `config package export --zip`, `config branch export --zip`, `t2tc package export`, `export action-flows`, `pull package` | `config package export --zip` → `Unclassified - my-package.zip` | `config package export --zip` → `CUI - my-package.zip` with `CUI_Cover_Sheet.pdf` inside the archive | +| Single non-archive export | `pull asset` / `skill` / `data-pool` / `view-bookmarks` / `bookmarks`, `export bookmarks` | `pull asset` → `Unclassified - asset_.yml` | `pull asset` → `CUI - asset_.zip` containing the YAML and `CUI_Cover_Sheet.pdf` | +| Output is a directory | `config package export`, `config branch export`, `t2tc package export --unzip` | `config package export` → `Unclassified - my-package/` | `config package export` → `CUI - my-package/` with `CUI_Cover_Sheet.pdf` inside | +| `--gitBranch` variants | `config package export`, `config branch export`, `t2tc package export` | Out of scope | Out of scope | +| No output flag | Console-only listings, profile / git-profile / log files | Out of scope | Out of scope | 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 768fd43c..b66ef699 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 @@ -162,10 +162,12 @@ export class BranchExportImportCommandService { fs.rmSync(zipPath, { force: true }); } } - const targetDir = resolve(process.cwd(), packageKey); - fs.rmSync(targetDir, { recursive: true, force: true }); - fs.cpSync(sourceDir, targetDir, { recursive: true }); - return `Successful export. Exported directory: ${packageKey}`; + const directoryName = await this.cuiFileService.writeDirectoryWithGivenName(name => { + const targetDir = resolve(process.cwd(), name); + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.cpSync(sourceDir, targetDir, { recursive: true }); + }, packageKey); + return `Successful export. Exported directory: ${directoryName}`; } private prepareLocalWorkingDir(file: string | undefined, directory: string | undefined): string { diff --git a/src/commands/configuration-management/single-package-export.service.ts b/src/commands/configuration-management/single-package-export.service.ts index ad98ff52..b0f0fd2a 100644 --- a/src/commands/configuration-management/single-package-export.service.ts +++ b/src/commands/configuration-management/single-package-export.service.ts @@ -32,8 +32,11 @@ export class SinglePackageExportService { return; } - fileService.extractZipBufferToDirectory(packageData, packageKey); - logger.info(`Successful export. Exported directory: ${packageKey}`); + const directoryName = await this.cuiFileService.writeDirectoryWithGivenName( + targetDir => fileService.extractZipBufferToDirectory(packageData, targetDir), + packageKey + ); + logger.info(`Successful export. Exported directory: ${directoryName}`); } private async exportToGitBranch(packageData: Buffer, gitBranch: string): Promise { diff --git a/src/commands/t2tc/t2tc-package.service.ts b/src/commands/t2tc/t2tc-package.service.ts index 426455b1..35301ede 100644 --- a/src/commands/t2tc/t2tc-package.service.ts +++ b/src/commands/t2tc/t2tc-package.service.ts @@ -245,9 +245,11 @@ export class T2tcPackageService { private async downloadZip(exportedZip: AdmZip, unzip: boolean): Promise { if (unzip) { const fileDownloadedMessage = "Successful download. Downloaded directory: "; - const targetDirectoryName = `export_${uuidv4()}`; - fileService.extractExportedZipWithNestedZipsToDir(exportedZip, targetDirectoryName); - logger.info(fileDownloadedMessage + targetDirectoryName); + const directoryName = await this.cuiFileService.writeDirectoryWithGivenName( + targetDir => fileService.extractExportedZipWithNestedZipsToDir(exportedZip, targetDir), + `export_${uuidv4()}` + ); + logger.info(fileDownloadedMessage + directoryName); } else { const fileDownloadedMessage = "File downloaded successfully. New filename: "; const filename = await this.cuiFileService.writeZipToFileWithGivenName(exportedZip.toBuffer(), `export_${uuidv4()}.zip`); diff --git a/src/core/http/http-shared/base.manager.ts b/src/core/http/http-shared/base.manager.ts index 1bcdc3f4..2f4cb6a3 100644 --- a/src/core/http/http-shared/base.manager.ts +++ b/src/core/http/http-shared/base.manager.ts @@ -14,7 +14,7 @@ export abstract class BaseManager { this.cuiFileService = new CuiFileService(context); } - public async pull(): Promise { + public async pull(): Promise { try { const data = await this.httpClient().get(this.getConfig().pullUrl); const filename = await this.writeToFile(data); @@ -25,7 +25,7 @@ export abstract class BaseManager { } } - public async pullFile(): Promise { + public async pullFile(): Promise { try { const data = await this.httpClient().downloadFile(this.getConfig().pullUrl); const filename = await this.writeStreamToFile(data); diff --git a/src/core/utils/cui-file-service.ts b/src/core/utils/cui-file-service.ts index cfc739c0..7a9b01f1 100644 --- a/src/core/utils/cui-file-service.ts +++ b/src/core/utils/cui-file-service.ts @@ -20,46 +20,61 @@ export class CuiFileService { } public async writeToFileWithGivenName(data: string, filename: string): Promise { - return this.writeWithCoverHandling(data, filename, cover => - this.writeClassifiedArchive(filename, data, cover) + return this.writeWithCoverHandling( + name => fileService.writeToFileWithGivenName(data, name), + 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.writeWithCoverHandling( + name => fileService.writeBufferToFileWithGivenName(zipData, name), + filename, + cover => { + const zip = new AdmZip(zipData); + this.addCoverPage(zip, cover); + + return this.writeArchive(zip, filename); + } + ); + } + + public async writeDirectoryWithGivenName(write: (targetDir: string) => void, directoryName: string): Promise { + return this.writeWithCoverHandling(write, directoryName, cover => { + const coverSheet = this.decodeCoverPage(cover); + const classifiedName = this.prefixFileName(directoryName, CuiFileService.CLASSIFIED_PREFIX); - return this.writeArchive(zip, filename); + write(classifiedName); + fileService.writeBufferToFileWithGivenName( + coverSheet, + path.join(classifiedName, CuiFileService.COVER_SHEET_FILE_NAME) + ); + + return classifiedName; }); } private async writeWithCoverHandling( - payload: string | Buffer, + write: (name: string) => void, filename: string, - onClassified: (cover: CuiPdfCoverResponse) => Promise | string + onClassified: (cover: CuiPdfCoverResponse) => string ): Promise { const cover = await this.cuiApi.getCuiPdfCover(); if (!cover) { - return this.writePayload(payload, filename); + write(filename); + return filename; } if (!this.isClassified(cover)) { - return this.writePayload(payload, this.prefixFileName(filename, CuiFileService.UNCLASSIFIED_PREFIX)); - } + const unclassifiedName = this.prefixFileName(filename, CuiFileService.UNCLASSIFIED_PREFIX); + write(unclassifiedName); - 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 unclassifiedName; } - return filename; + return onClassified(cover); } private writeClassifiedArchive(filename: string, data: string, cover: CuiPdfCoverResponse): string { diff --git a/src/core/utils/file-service.ts b/src/core/utils/file-service.ts index 40619c14..875402b4 100644 --- a/src/core/utils/file-service.ts +++ b/src/core/utils/file-service.ts @@ -57,12 +57,13 @@ export class FileService { } public extractExportedZipWithNestedZipsToDir(zipFile: AdmZip, targetDir: string): string { - this.mkdirRecursive(targetDir); - zipFile.extractAllTo(targetDir, true, true); + const targetPath = path.resolve(process.cwd(), targetDir); + this.mkdirRecursive(targetPath); + zipFile.extractAllTo(targetPath, true, true); - const files = fs.readdirSync(targetDir); + const files = fs.readdirSync(targetPath); for (const file of files) { - const innerZipPath = path.join(targetDir, file); + const innerZipPath = path.join(targetPath, file); if (file.endsWith(".zip")) { const nestedZip = new AdmZip(innerZipPath); const nestedDir = innerZipPath.replace(/\.zip$/, ""); @@ -72,8 +73,8 @@ export class FileService { fs.rmSync(innerZipPath); // Optionally remove the inner zip } } - this.restrictFilePermissions(targetDir); - return targetDir; + this.restrictFilePermissions(targetPath); + return targetPath; } public isDirectory(sourcePath: string): boolean { diff --git a/tests/commands/cui-marking-directory-exports.spec.ts b/tests/commands/cui-marking-directory-exports.spec.ts new file mode 100644 index 00000000..abedef92 --- /dev/null +++ b/tests/commands/cui-marking-directory-exports.spec.ts @@ -0,0 +1,120 @@ +import { resolve } from "node:path"; +import * as fs from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; +import * as os from "node:os"; +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 { 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 EXPORT_MESSAGE = "Successful export. Exported directory: "; +const DOWNLOAD_MESSAGE = "Successful download. Downloaded directory: "; +const BRANCH = "feature-a"; + +function markAsClassified(): void { + mockAxiosGetWithStatus(COVER_URL, 200, { + resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] }, + coverPage: { pdfContent: PDF_BYTES.toString("base64"), encoding: "base64" }, + }); +} + +function loggedDirectoryName(prefix: string): string { + const message = loggingTestTransport.logMessages.map(entry => entry.message).find(entry => entry.includes(prefix)); + return message.split(prefix)[1]; +} + +function markedDirectory(prefix: string, expectedName: string): string { + const directoryName = loggedDirectoryName(prefix); + expect(directoryName).toEqual(`${CuiFileService.CLASSIFIED_PREFIX}${expectedName}`); + + const coverSheet = readFileSync(resolve(process.cwd(), directoryName, CuiFileService.COVER_SHEET_FILE_NAME)); + expect(coverSheet.equals(PDF_BYTES)).toBe(true); + + return directoryName; +} + +function exists(...segments: string[]): boolean { + return existsSync(resolve(process.cwd(), ...segments)); +} + +function buildPackageZip(packageKey: string): Buffer { + const zip = new AdmZip(); + zip.addFile("package.json", Buffer.from(JSON.stringify({ key: packageKey, 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-dir-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 directory exports", () => { + + beforeEach(() => { + markAsClassified(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("Should mark the directory of config package export", async () => { + const packageKey = "pkg-export"; + mockAxiosGet(`https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${packageKey}/export-file`, buildPackageZip(packageKey)); + + await new SinglePackageExportService(testContext).exportPackage(packageKey, false, null); + + const directoryName = markedDirectory(EXPORT_MESSAGE, packageKey); + expect(exists(directoryName, "package.json")).toBe(true); + expect(exists(directoryName, "nodes", "node-1.json")).toBe(true); + expect(exists(packageKey)).toBe(false); + }); + + it("Should mark the directory of config branch export", async () => { + const packageKey = "pkg-branch"; + const branchPackageKey = `${packageKey}@${BRANCH}`; + mockAxiosGet(`https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${branchPackageKey}/export-file`, buildPackageZip(branchPackageKey)); + jest.spyOn(FileService.prototype, "extractZipBufferToTempDirectory").mockReturnValue(seedPackageDir(branchPackageKey)); + + await new BranchExportImportCommandService(testContext).exportBranch(packageKey, BRANCH, {}); + + const directoryName = markedDirectory(EXPORT_MESSAGE, packageKey); + expect(exists(directoryName, "nodes", "root.json")).toBe(true); + + const exported = JSON.parse(readFileSync(resolve(process.cwd(), directoryName, "package.json"), "utf-8")); + expect(exported.key).toEqual(packageKey); + expect(exists(packageKey)).toBe(false); + }); + + it("Should mark the directory of t2tc package export --unzip", 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, true); + + const directoryName = loggedDirectoryName(DOWNLOAD_MESSAGE); + expect(directoryName.startsWith(`${CuiFileService.CLASSIFIED_PREFIX}export_`)).toBe(true); + expect(exists(directoryName, "manifest.json")).toBe(true); + + const coverSheet = readFileSync(resolve(process.cwd(), directoryName, CuiFileService.COVER_SHEET_FILE_NAME)); + expect(coverSheet.equals(PDF_BYTES)).toBe(true); + }); +}); diff --git a/tests/core/utils/cui-file-service.spec.ts b/tests/core/utils/cui-file-service.spec.ts index f47401bb..f05250c4 100644 --- a/tests/core/utils/cui-file-service.spec.ts +++ b/tests/core/utils/cui-file-service.spec.ts @@ -1,4 +1,4 @@ -import { accessSync, readFileSync } from "node:fs"; +import { accessSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import AdmZip = require("adm-zip"); import { CuiFileService } from "../../../src/core/utils/cui-file-service"; @@ -164,4 +164,59 @@ describe("CuiFileService", () => { .rejects.toThrow("CUI marking applies but the response contained no cover page."); }); }); + + describe("when the artifact is a directory", () => { + const writeTree = (targetDir: string): void => { + mkdirSync(resolve(process.cwd(), targetDir, "nodes"), { recursive: true }); + writeFileSync(resolve(process.cwd(), targetDir, "package.json"), PAYLOAD); + writeFileSync(resolve(process.cwd(), targetDir, "nodes", "node-1.json"), PAYLOAD); + }; + + const exists = (...segments: string[]): boolean => existsSync(resolve(process.cwd(), ...segments)); + + it("Should keep the original directory name when no marking applies", async () => { + mockAxiosGetWithStatus(COVER_URL, 204, ""); + + const directoryName = await cuiFileService.writeDirectoryWithGivenName(writeTree, "unmarked-export"); + + expect(directoryName).toEqual("unmarked-export"); + expect(exists(directoryName, "package.json")).toBe(true); + expect(exists(directoryName, CuiFileService.COVER_SHEET_FILE_NAME)).toBe(false); + }); + + it("Should only prefix the directory when the content is unclassified", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, coverResponse([])); + + const directoryName = await cuiFileService.writeDirectoryWithGivenName(writeTree, "plain-export"); + + expect(directoryName).toEqual("Unclassified - plain-export"); + expect(exists(directoryName, "nodes", "node-1.json")).toBe(true); + expect(exists(directoryName, CuiFileService.COVER_SHEET_FILE_NAME)).toBe(false); + expect(exists("plain-export")).toBe(false); + }); + + it("Should prefix the directory and write the cover sheet into it", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, coverResponse([{ code: "PRVCY", name: "Privacy" }])); + + const directoryName = await cuiFileService.writeDirectoryWithGivenName(writeTree, "classified-export"); + + expect(directoryName).toEqual("CUI - classified-export"); + expect(exists(directoryName, "package.json")).toBe(true); + expect(exists(directoryName, "nodes", "node-1.json")).toBe(true); + const coverSheet = readFileSync(resolve(process.cwd(), directoryName, CuiFileService.COVER_SHEET_FILE_NAME)); + expect(coverSheet.equals(PDF_BYTES)).toBe(true); + expect(exists("classified-export")).toBe(false); + }); + + it("Should fail without writing anything when no cover page was returned", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, { + resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] }, + }); + + await expect(cuiFileService.writeDirectoryWithGivenName(writeTree, "broken-export")) + .rejects.toThrow("CUI marking applies but the response contained no cover page."); + expect(exists("broken-export")).toBe(false); + expect(exists("CUI - broken-export")).toBe(false); + }); + }); });