Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/cui-marking.md
Original file line number Diff line number Diff line change
@@ -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 - <report>.json` | `export data-pool -o` → `CUI - <report>.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_<key>.yml` | `pull asset` → `CUI - asset_<key>.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 |
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand Down
8 changes: 5 additions & 3 deletions src/commands/t2tc/t2tc-package.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,11 @@ export class T2tcPackageService {
private async downloadZip(exportedZip: AdmZip, unzip: boolean): Promise<void> {
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`);
Expand Down
4 changes: 2 additions & 2 deletions src/core/http/http-shared/base.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export abstract class BaseManager {
this.cuiFileService = new CuiFileService(context);
}

public async pull(): Promise<any> {
public async pull(): Promise<void> {
try {
const data = await this.httpClient().get(this.getConfig().pullUrl);
const filename = await this.writeToFile(data);
Expand All @@ -25,7 +25,7 @@ export abstract class BaseManager {
}
}

public async pullFile(): Promise<any> {
public async pullFile(): Promise<void> {
try {
const data = await this.httpClient().downloadFile(this.getConfig().pullUrl);
const filename = await this.writeStreamToFile(data);
Expand Down
55 changes: 35 additions & 20 deletions src/core/utils/cui-file-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,46 +20,61 @@ export class CuiFileService {
}

public async writeToFileWithGivenName(data: string, filename: string): Promise<string> {
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<string> {
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<string> {
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> | string
onClassified: (cover: CuiPdfCoverResponse) => string
): Promise<string> {
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 {
Expand Down
13 changes: 7 additions & 6 deletions src/core/utils/file-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/, "");
Expand All @@ -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 {
Expand Down
120 changes: 120 additions & 0 deletions tests/commands/cui-marking-directory-exports.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
57 changes: 56 additions & 1 deletion tests/core/utils/cui-file-service.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
});
});
});
Loading