-
Notifications
You must be signed in to change notification settings - Fork 4
SP-1173: add CUI marking for files the CLI writes to disk #405
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
404b82a
f6cc020
f08c6ae
843a094
a317f63
5745783
66a75f8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } | ||
|
Comment on lines
+36
to
+38
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you check the response shape here instead of just truthiness? Stubbing this endpoint with Requiring |
||
|
|
||
| throw new FatalError("Problem fetching cui pdf cover"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could this message tell the user what to do? Someone running |
||
| } | ||
| } | ||
| 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 { | ||
|
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(); | ||
|
Comment on lines
+18
to
+23
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could the cover response be memoised per |
||
|
|
||
| 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)}`); | ||
| } | ||
| } | ||
| 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); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is difference between the two?