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: 6 additions & 3 deletions src/commands/studio/manager/space.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -30,11 +33,11 @@ export class SpaceManager extends BaseManager {
};
}

private listSpaces(nodes: SpaceTransport[]): void {
private async listSpaces(nodes: SpaceTransport[]): Promise<void> {
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}"`);
Expand Down
13 changes: 8 additions & 5 deletions src/commands/studio/service/package.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<void> {
Expand Down Expand Up @@ -66,7 +69,7 @@ export class PackageService {
return nodeToExport;
})
}
this.exportListOfPackages(nodesListToExport, fieldsToInclude);
await this.exportListOfPackages(nodesListToExport, fieldsToInclude);
}

public async getNodesWithActiveVersion(nodes: BatchExportNodeTransport[]): Promise<BatchExportNodeTransport[]> {
Expand All @@ -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<void> {
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);
}
}
17 changes: 17 additions & 0 deletions src/core/http/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> {
return new Promise<any>((resolve, reject) => {
this.axios.get(this.resolveUrl(url), {
Expand Down
20 changes: 8 additions & 12 deletions src/core/http/http-shared/base.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,14 @@ export abstract class BaseManager {
}

public async findAll(): Promise<any> {
return new Promise<any>((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 {
Expand Down
2 changes: 1 addition & 1 deletion src/core/http/http-shared/manager-config.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ export interface ManagerConfig {
exportFileName?: string;
onPushSuccessMessage?: (data: any) => string;
onUpdateSuccessMessage?: () => string;
onFindAll?: (data: any) => void;
onFindAll?: (data: any) => void | Promise<void>;
onFindAllAndExport?: (data: any) => void;
}
42 changes: 42 additions & 0 deletions src/core/utils/cui-api.ts
Original file line number Diff line number Diff line change
@@ -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<CuiPdfCoverResponse | null> {
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");
}
}
81 changes: 81 additions & 0 deletions src/core/utils/cui-file-service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
dwoditsch marked this conversation as resolved.
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<string> {
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)}`);
}
}
126 changes: 126 additions & 0 deletions tests/commands/studio/list-cui-marking.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading
Loading