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
9 changes: 4 additions & 5 deletions docs/cui-marking.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,11 @@ 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 |
| **204** | Unclassified | `Unclassified - packages.json` |
| **200** | Classified | `CUI - packages.zip` containing `packages.json` and `CUI_Cover_Sheet.pdf` |
| Any other 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 - …`.
The status code alone decides the outcome. Any failure of the cover call, including an unexpected status or a **200** without a usable cover page, aborts the command and leaves no output behind.

## Scope: how the write is triggered

Expand Down
22 changes: 16 additions & 6 deletions src/core/utils/cui-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,20 @@ import { FatalError, logger } from "./logger";
import { Context } from "../command/cli-context";

export interface CuiPdfCoverResponse {
resolvedCuiMarking?: { categories?: unknown[] };
coverPage?: { pdfContent: string; encoding: string };
}

export enum CuiMarking {
DISABLED = "DISABLED",
UNCLASSIFIED = "UNCLASSIFIED",
CLASSIFIED = "CLASSIFIED",
}

export type CuiMarkingDecision =
| { marking: CuiMarking.DISABLED }
| { marking: CuiMarking.UNCLASSIFIED }
| { marking: CuiMarking.CLASSIFIED; cover: CuiPdfCoverResponse };

export class CuiApi {
private static readonly CUI_PDF_COVER_SHEET_URL = "/api/team/cui-settings/cui-pdf-cover";

Expand All @@ -20,21 +30,21 @@ export class CuiApi {
this.httpClient = () => context.httpClient;
}

public async getCuiPdfCover(): Promise<CuiPdfCoverResponse | null> {
public async getCuiMarking(): Promise<CuiMarkingDecision> {
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;
return { marking: CuiMarking.DISABLED };
}

if (status === CuiApi.STATUS_NO_CONTENT) {
logger.debug("CUI marking does not apply, the team has CUI disabled");
return null;
logger.debug("CUI marking applies, the content is unclassified");
return { marking: CuiMarking.UNCLASSIFIED };
}

if (status === CuiApi.STATUS_OK && data) {
return data as CuiPdfCoverResponse;
return { marking: CuiMarking.CLASSIFIED, cover: data as CuiPdfCoverResponse };
}

throw new FatalError("Problem fetching cui pdf cover");
Expand Down
14 changes: 5 additions & 9 deletions src/core/utils/cui-file-service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as path from "node:path";
import AdmZip = require("adm-zip");
import { Context } from "../command/cli-context";
import { CuiApi, CuiPdfCoverResponse } from "./cui-api";
import { CuiApi, CuiMarking, CuiPdfCoverResponse } from "./cui-api";
import { fileService } from "./file-service";
import { FileConstants } from "./file.constants";
import { FatalError } from "./logger";
Expand Down Expand Up @@ -60,21 +60,21 @@ export class CuiFileService {
filename: string,
onClassified: (cover: CuiPdfCoverResponse) => string
): Promise<string> {
const cover = await this.cuiApi.getCuiPdfCover();
const decision = await this.cuiApi.getCuiMarking();

if (!cover) {
if (decision.marking === CuiMarking.DISABLED) {
write(filename);
return filename;
}

if (!this.isClassified(cover)) {
if (decision.marking === CuiMarking.UNCLASSIFIED) {
const unclassifiedName = this.prefixFileName(filename, CuiFileService.UNCLASSIFIED_PREFIX);
write(unclassifiedName);

return unclassifiedName;
}

return onClassified(cover);
return onClassified(decision.cover);
}

private writeClassifiedArchive(filename: string, data: string, cover: CuiPdfCoverResponse): string {
Expand Down Expand Up @@ -113,10 +113,6 @@ export class CuiFileService {
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);
Expand Down
25 changes: 24 additions & 1 deletion tests/commands/cui-marking-directory-exports.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ 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" },
});
}
Expand All @@ -47,6 +46,17 @@ function exists(...segments: string[]): boolean {
return existsSync(resolve(process.cwd(), ...segments));
}

function markAsUnclassified(): void {
mockAxiosGetWithStatus(COVER_URL, 204, "");
}

function unclassifiedDirectory(prefix: string, expectedName: string): string {
const directoryName = loggedDirectoryName(prefix);
expect(directoryName).toEqual(`${CuiFileService.UNCLASSIFIED_PREFIX}${expectedName}`);

return directoryName;
}

function buildPackageZip(packageKey: string): Buffer {
const zip = new AdmZip();
zip.addFile("package.json", Buffer.from(JSON.stringify({ key: packageKey, name: "My Package" })));
Expand Down Expand Up @@ -84,6 +94,19 @@ describe("CUI marking of directory exports", () => {
expect(exists(packageKey)).toBe(false);
});

it("Should only prefix the directory when the content is unclassified", async () => {
markAsUnclassified();
const packageKey = "pkg-unclassified";
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 = unclassifiedDirectory(EXPORT_MESSAGE, packageKey);
expect(exists(directoryName, "nodes", "node-1.json")).toBe(true);
expect(exists(directoryName, CuiFileService.COVER_SHEET_FILE_NAME)).toBe(false);
expect(exists(packageKey)).toBe(false);
});

it("Should mark the directory of config branch export", async () => {
const packageKey = "pkg-branch";
const branchPackageKey = `${packageKey}@${BRANCH}`;
Expand Down
34 changes: 32 additions & 2 deletions tests/commands/cui-marking-json-commands.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { resolve } from "node:path";
import { readFileSync } from "node:fs";
import AdmZip = require("adm-zip");
import { mockAxiosGet, mockAxiosGetWithStatus, mockAxiosPost } from "../utls/http-requests-mock";
import { mockAxiosGet, mockAxiosGetError, 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 { FatalError } from "../../src/core/utils/logger";
import { ConfigUtils } from "../utls/config-utils";
import { zipToTempFolder } from "../utls/fs-utils";
import { DeploymentService } from "../../src/commands/deployment/deployment.service";
Expand All @@ -24,7 +25,6 @@ const PDF_BYTES = Buffer.from("%PDF-1.4 cover sheet");

function markAsClassified(): void {
mockAxiosGetWithStatus(COVER_URL, 200, {
resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] },
coverPage: { pdfContent: PDF_BYTES.toString("base64"), encoding: "base64" },
});
}
Expand All @@ -49,6 +49,18 @@ function markedPayload(prefix?: string): any {
return payloadFromArchive(loggedFileName(prefix));
}

function markAsUnclassified(): void {
mockAxiosGetWithStatus(COVER_URL, 204, "");
}

function unclassifiedPayload(prefix?: string): any {
const filename = loggedFileName(prefix);
expect(filename.startsWith(CuiFileService.UNCLASSIFIED_PREFIX)).toBe(true);
expect(filename.endsWith(".json")).toBe(true);

return JSON.parse(readFileSync(resolve(process.cwd(), filename), "utf-8"));
}

describe("CUI marking of --json commands", () => {

beforeEach(() => {
Expand All @@ -64,6 +76,24 @@ describe("CUI marking of --json commands", () => {
expect(markedPayload()).toEqual(targets);
});

it("Should only prefix the listing when the content is unclassified", async () => {
markAsUnclassified();
const targets = [{ id: "target-1", name: "First target" }];
mockAxiosGet("https://myTeam.celonis.cloud/pacman/api/deployments/targets?deployableType=app-package&packageKey=package-key", targets);

await new DeploymentService(testContext).getTargets(true, "app-package", "package-key");

expect(unclassifiedPayload()).toEqual(targets);
});

it("Should fail the command without writing anything when the cover call fails", async () => {
mockAxiosGetError(COVER_URL, 500, { message: "boom" });
mockAxiosGet("https://myTeam.celonis.cloud/pacman/api/deployments/targets?deployableType=app-package&packageKey=package-key", []);

await expect(new DeploymentService(testContext).getTargets(true, "app-package", "package-key")).rejects.toThrow(FatalError);
expect(loggingTestTransport.logMessages.some(entry => entry.message.includes(FileService.fileDownloadedMessage))).toBe(false);
});

it("Should mark configuration node listings", async () => {
const nodes = [{ id: "node-id-1", key: "node-key-1", name: "Node 1" }];
mockAxiosGet("https://myTeam.celonis.cloud/pacman/api/core/packages/package-key/nodes?version=1.0.0&withConfiguration=false&limit=10", nodes);
Expand Down
23 changes: 22 additions & 1 deletion tests/commands/cui-marking-output-to-json-file.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ const POOL_ID = "pool-1";

function markAsClassified(): void {
mockAxiosGetWithStatus(COVER_URL, 200, {
resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] },
coverPage: { pdfContent: PDF_BYTES.toString("base64"), encoding: "base64" },
});
}
Expand All @@ -46,6 +45,18 @@ function markedPayload(prefix?: string): any {
return payloadFromArchive(loggedFileName(prefix));
}

function markAsUnclassified(): void {
mockAxiosGetWithStatus(COVER_URL, 204, "");
}

function unclassifiedPayload(prefix?: string): any {
const filename = loggedFileName(prefix);
expect(filename.startsWith(CuiFileService.UNCLASSIFIED_PREFIX)).toBe(true);
expect(filename.endsWith(".json")).toBe(true);

return JSON.parse(readFileSync(resolve(process.cwd(), filename), "utf-8"));
}

describe("CUI marking of --outputToJsonFile commands", () => {

beforeEach(() => {
Expand Down Expand Up @@ -79,6 +90,16 @@ describe("CUI marking of --outputToJsonFile commands", () => {
expect(markedPayload()).toEqual(dataPool);
});

it("Should only prefix the report when the content is unclassified", async () => {
markAsUnclassified();
const dataPool = { id: POOL_ID, name: "Pool 1", objects: [] };
mockAxiosGet(`https://myTeam.celonis.cloud/integration/api/pools/${POOL_ID}/v2/export`, dataPool);

await new DataPoolCommandService(testContext).exportDataPool(POOL_ID, true);

expect(unclassifiedPayload()).toEqual(dataPool);
});

it("Should mark the data pool batch import report", async () => {
const report = { installedVersions: [{ poolId: POOL_ID, version: "1.0.0" }] };
mockAxiosPost("https://myTeam.celonis.cloud/integration/api/pool/batch-import", report);
Expand Down
22 changes: 21 additions & 1 deletion tests/commands/cui-marking-single-file-exports.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ const PACKAGE_KEY = "my-package";

function markAsClassified(): void {
mockAxiosGetWithStatus(COVER_URL, 200, {
resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] },
coverPage: { pdfContent: PDF_BYTES.toString("base64"), encoding: "base64" },
});
}
Expand Down Expand Up @@ -54,6 +53,17 @@ function markedJson(expectedName: string, entryName: string): any {
return JSON.parse(markedEntry(expectedName, entryName));
}

function markAsUnclassified(): void {
mockAxiosGetWithStatus(COVER_URL, 204, "");
}

function unclassifiedFile(expectedName: string): string {
const filename = loggedFileName();
expect(filename).toEqual(`${CuiFileService.UNCLASSIFIED_PREFIX}${expectedName}`);

return readFileSync(resolve(process.cwd(), filename), "utf-8");
}

describe("CUI marking of single-file exports", () => {

beforeEach(() => {
Expand All @@ -69,6 +79,16 @@ describe("CUI marking of single-file exports", () => {
expect(parse(markedEntry("asset_asset-1", "asset_asset-1.yml"))).toEqual(asset);
});

it("Should only prefix the export when the content is unclassified", async () => {
markAsUnclassified();
const asset = { key: "asset-1", name: "My Asset", rootNodeKey: PACKAGE_KEY };
mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/nodes/asset/export/${PACKAGE_KEY}.asset-1`, asset);

await new AssetCommandService(testContext).pullAsset(`${PACKAGE_KEY}.asset-1`);

expect(parse(unclassifiedFile("asset_asset-1.yml"))).toEqual(asset);
});

it("Should mark the exported skill", async () => {
const skill = { id: SKILL_ID, name: "My Skill" };
mockAxiosGet(`https://myTeam.celonis.cloud/action-engine/api/projects/${PROJECT_ID}/skills/${SKILL_ID}/export`, skill);
Expand Down
21 changes: 20 additions & 1 deletion tests/commands/cui-marking-zip-commands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ 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" },
});
}
Expand All @@ -52,6 +51,17 @@ function entryNames(archive: AdmZip): string[] {
return archive.getEntries().map(entry => entry.entryName).sort();
}

function markAsUnclassified(): void {
mockAxiosGetWithStatus(COVER_URL, 204, "");
}

function unclassifiedArchive(expectedName: string): AdmZip {
const filename = loggedFileName();
expect(filename).toEqual(`${CuiFileService.UNCLASSIFIED_PREFIX}${expectedName}`);

return new AdmZip(readFileSync(resolve(process.cwd(), filename)));
}

function buildPackageZip(): Buffer {
const zip = new AdmZip();
zip.addFile("package.json", Buffer.from(JSON.stringify({ key: PACKAGE_KEY, name: "My Package" })));
Expand Down Expand Up @@ -90,6 +100,15 @@ describe("CUI marking of archive exports", () => {
]);
});

it("Should only prefix the archive when the content is unclassified", async () => {
markAsUnclassified();
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(entryNames(unclassifiedArchive(`${PACKAGE_KEY}.zip`))).toEqual(["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());
Expand Down
Loading
Loading