From 404b82ae348287e94aaa4cf7fac414458a640800 Mon Sep 17 00:00:00 2001 From: denniswo Date: Fri, 7 Aug 2026 15:29:24 +0200 Subject: [PATCH 1/7] SP-1173: mark CUI content when writing Studio listings to disk Route the JSON output of `list spaces` and `list packages` through a new CuiFileService, which asks the team's CUI settings how the content is classified and names the output accordingly: a `CUI - .zip` holding the listing plus the decoded cover sheet when categories apply, an `Unclassified - ` rename when none do, and the original filename when no marking applies at all. CuiService owns the API contract and derives enablement from the response status, so the CLI translates the backend's answer rather than deciding entitlement itself. HttpClient.getStatusAndData exposes the status code because get() throws on 4xx and would otherwise hide the 204/403 signal. BaseManager.findAll now awaits onFindAll so an async listing callback completes before the command resolves. Includes-AI-Code: true Co-authored-by: Cursor --- src/commands/studio/manager/space.manager.ts | 9 +- .../studio/service/package.service.ts | 13 +- src/core/http/http-client.ts | 17 +++ src/core/http/http-shared/base.manager.ts | 20 ++- .../http-shared/manager-config.interface.ts | 2 +- src/core/utils/cui-file-service.ts | 81 +++++++++++ src/core/utils/cui-service.ts | 61 +++++++++ .../commands/studio/list-cui-marking.spec.ts | 126 ++++++++++++++++++ tests/core/utils/cui-file-service.spec.ts | 102 ++++++++++++++ tests/utls/http-requests-mock.ts | 26 +++- 10 files changed, 432 insertions(+), 25 deletions(-) create mode 100644 src/core/utils/cui-file-service.ts create mode 100644 src/core/utils/cui-service.ts create mode 100644 tests/commands/studio/list-cui-marking.spec.ts create mode 100644 tests/core/utils/cui-file-service.spec.ts diff --git a/src/commands/studio/manager/space.manager.ts b/src/commands/studio/manager/space.manager.ts index 3bdeaca9..67dd5518 100644 --- a/src/commands/studio/manager/space.manager.ts +++ b/src/commands/studio/manager/space.manager.ts @@ -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 { @@ -30,11 +33,11 @@ export class SpaceManager extends BaseManager { }; } - private listSpaces(nodes: SpaceTransport[]): void { + private async listSpaces(nodes: SpaceTransport[]): Promise { 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}"`); diff --git a/src/commands/studio/service/package.service.ts b/src/commands/studio/service/package.service.ts index 729fc170..353749e3 100644 --- a/src/commands/studio/service/package.service.ts +++ b/src/commands/studio/service/package.service.ts @@ -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"; @@ -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 { @@ -66,7 +69,7 @@ export class PackageService { return nodeToExport; }) } - this.exportListOfPackages(nodesListToExport, fieldsToInclude); + await this.exportListOfPackages(nodesListToExport, fieldsToInclude); } public async getNodesWithActiveVersion(nodes: BatchExportNodeTransport[]): Promise { @@ -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 { 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); } } diff --git a/src/core/http/http-client.ts b/src/core/http/http-client.ts index 0be3ba19..a66b62a2 100644 --- a/src/core/http/http-client.ts +++ b/src/core/http/http-client.ts @@ -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 { return new Promise((resolve, reject) => { this.axios.get(this.resolveUrl(url), { diff --git a/src/core/http/http-shared/base.manager.ts b/src/core/http/http-shared/base.manager.ts index 912436a6..800d8fe1 100644 --- a/src/core/http/http-shared/base.manager.ts +++ b/src/core/http/http-shared/base.manager.ts @@ -82,18 +82,14 @@ export abstract class BaseManager { } public async findAll(): Promise { - return new Promise((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)); + return Promise.reject(); + } } protected writeToFile(data: any): string { diff --git a/src/core/http/http-shared/manager-config.interface.ts b/src/core/http/http-shared/manager-config.interface.ts index abead4c4..e7f365e9 100644 --- a/src/core/http/http-shared/manager-config.interface.ts +++ b/src/core/http/http-shared/manager-config.interface.ts @@ -6,6 +6,6 @@ export interface ManagerConfig { exportFileName?: string; onPushSuccessMessage?: (data: any) => string; onUpdateSuccessMessage?: () => string; - onFindAll?: (data: any) => void; + onFindAll?: (data: any) => void | Promise; onFindAllAndExport?: (data: any) => void; } diff --git a/src/core/utils/cui-file-service.ts b/src/core/utils/cui-file-service.ts new file mode 100644 index 00000000..e9f1f677 --- /dev/null +++ b/src/core/utils/cui-file-service.ts @@ -0,0 +1,81 @@ +import * as path from "path"; +import AdmZip = require("adm-zip"); +import { Context } from "../command/cli-context"; +import { CuiPdfCoverResponse, CuiService } from "./cui-service"; +import { fileService } from "./file-service"; +import { FileConstants } from "./file.constants"; +import { FatalError } from "./logger"; + +export class CuiFileService { + 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 cuiService: CuiService; + + constructor(context: Context) { + this.cuiService = new CuiService(context); + } + + public async writeToFileWithGivenName(data: string, filename: string): Promise { + const cover = await this.cuiService.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)}`); + } +} diff --git a/src/core/utils/cui-service.ts b/src/core/utils/cui-service.ts new file mode 100644 index 00000000..39f5f4b5 --- /dev/null +++ b/src/core/utils/cui-service.ts @@ -0,0 +1,61 @@ +import { HttpClient } from "../http/http-client"; +import { FatalError, logger } from "./logger"; +import { Context } from "../command/cli-context"; + +export interface CuiCategory { + code: string; + name: string; +} + +export interface ResolvedCuiMarking { + categories?: CuiCategory[]; +} + +export interface CuiCoverPage { + pdfContent: string; + encoding: string; +} + +export interface CuiPdfCoverResponse { + resolvedCuiMarking?: ResolvedCuiMarking; + coverPage?: CuiCoverPage; +} + +export class CuiService { + private static readonly 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; + } + + /** + * Returns null when no marking applies: 403 (team.cui-settings not enabled) or 204 (CUI disabled). + */ + public async getCuiPdfCover(): Promise { + const { status, data } = await this.httpClient().getStatusAndData(CuiService.COVER_SHEET_URL); + + if (status === CuiService.STATUS_FORBIDDEN || status === CuiService.STATUS_NO_CONTENT) { + logger.debug(`CUI marking does not apply, cover sheet endpoint responded with ${status}${this.describeBody(data)}`); + return null; + } + + if (status !== CuiService.STATUS_OK) { + throw new FatalError(`Problem fetching cui: ${this.describeBody(data) || `Backend responded with status code ${status}`}`); + } + + return data ? (data as CuiPdfCoverResponse) : null; + } + + private describeBody(data: any): string { + if (!data) { + return ""; + } + return `: ${typeof data === "string" ? data : JSON.stringify(data)}`; + } +} diff --git a/tests/commands/studio/list-cui-marking.spec.ts b/tests/commands/studio/list-cui-marking.spec.ts new file mode 100644 index 00000000..f07dc53c --- /dev/null +++ b/tests/commands/studio/list-cui-marking.spec.ts @@ -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); + }); + }); +}); diff --git a/tests/core/utils/cui-file-service.spec.ts b/tests/core/utils/cui-file-service.spec.ts new file mode 100644 index 00000000..714ac479 --- /dev/null +++ b/tests/core/utils/cui-file-service.spec.ts @@ -0,0 +1,102 @@ +import { accessSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import AdmZip = require("adm-zip"); +import { CuiFileService } from "../../../src/core/utils/cui-file-service"; +import { FatalError } from "../../../src/core/utils/logger"; +import { testContext } from "../../utls/test-context"; +import { mockAxiosGetError, mockAxiosGetWithStatus } from "../../utls/http-requests-mock"; + +describe("CuiFileService", () => { + 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 PAYLOAD = JSON.stringify({ key: "pkg-1" }); + + let cuiFileService: CuiFileService; + + const coverResponse = (categories: Array<{ code: string; name: string }>) => ({ + resolvedCuiMarking: { categories }, + coverPage: { + pdfContent: PDF_BYTES.toString("base64"), + encoding: "base64", + }, + }); + + const readFile = (filename: string): Buffer => readFileSync(resolve(process.cwd(), filename)); + + beforeEach(() => { + cuiFileService = new CuiFileService(testContext); + }); + + describe("when CUI marking does not apply", () => { + it("Should keep the original filename when the feature is not enabled for the team", async () => { + mockAxiosGetError(COVER_URL, 403, { errorCode: "feature-disabled" }); + + const filename = await cuiFileService.writeToFileWithGivenName(PAYLOAD, "report.json"); + + expect(filename).toEqual("report.json"); + expect(readFile("report.json").toString()).toEqual(PAYLOAD); + }); + + it("Should keep the original filename when the team has CUI disabled", async () => { + mockAxiosGetWithStatus(COVER_URL, 204, ""); + + const filename = await cuiFileService.writeToFileWithGivenName(PAYLOAD, "no-marking.json"); + + expect(filename).toEqual("no-marking.json"); + expect(readFile("no-marking.json").toString()).toEqual(PAYLOAD); + }); + + it("Should fail on an unexpected backend error", async () => { + mockAxiosGetError(COVER_URL, 500, { message: "boom" }); + + await expect(cuiFileService.writeToFileWithGivenName(PAYLOAD, "broken.json")).rejects.toThrow(FatalError); + expect(() => accessSync(resolve(process.cwd(), "broken.json"))).toThrow(); + }); + }); + + describe("when the content is unclassified", () => { + it("Should only prefix the filename and write no cover sheet", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, coverResponse([])); + + const filename = await cuiFileService.writeToFileWithGivenName(PAYLOAD, "packages.json"); + + expect(filename).toEqual("Unclassified - packages.json"); + expect(readFile(filename).toString()).toEqual(PAYLOAD); + expect(() => accessSync(resolve(process.cwd(), CuiFileService.COVER_SHEET_FILE_NAME))).toThrow(); + }); + }); + + describe("when the content is classified", () => { + it("Should wrap the payload and the decoded cover sheet into a CUI archive", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, coverResponse([{ code: "PRVCY", name: "Privacy" }])); + + const filename = await cuiFileService.writeToFileWithGivenName(PAYLOAD, "packages.json"); + + expect(filename).toEqual("CUI - packages.zip"); + + const zip = new AdmZip(readFile(filename)); + expect(zip.getEntries().map(entry => entry.entryName).sort()) + .toEqual([CuiFileService.COVER_SHEET_FILE_NAME, "packages.json"]); + expect(zip.getEntry("packages.json").getData().toString()).toEqual(PAYLOAD); + expect(zip.getEntry(CuiFileService.COVER_SHEET_FILE_NAME).getData().equals(PDF_BYTES)).toBe(true); + }); + + it("Should fail when the cover page uses an unsupported encoding", async () => { + const response = coverResponse([{ code: "PRVCY", name: "Privacy" }]); + response.coverPage.encoding = "hex"; + mockAxiosGetWithStatus(COVER_URL, 200, response); + + await expect(cuiFileService.writeToFileWithGivenName(PAYLOAD, "packages.json")) + .rejects.toThrow("Unsupported CUI cover page encoding: hex"); + }); + + it("Should fail when the marking applies but no cover page was returned", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, { + resolvedCuiMarking: { categories: [{ code: "PRVCY", name: "Privacy" }] }, + }); + + await expect(cuiFileService.writeToFileWithGivenName(PAYLOAD, "packages.json")) + .rejects.toThrow("CUI marking applies but the response contained no cover page."); + }); + }); +}); diff --git a/tests/utls/http-requests-mock.ts b/tests/utls/http-requests-mock.ts index 6e4d325e..1405cb0d 100644 --- a/tests/utls/http-requests-mock.ts +++ b/tests/utls/http-requests-mock.ts @@ -4,7 +4,10 @@ import { AxiosInitializer } from "../../src/core/http/axios-initializer"; const mockedAxiosInstance = {} as AxiosInstance; +const CUI_PDF_COVER_PATH = "/api/team/cui-settings/cui-pdf-cover"; + const mockedGetResponseByUrl = new Map(); +const mockedGetStatusByUrl = new Map(); const mockedGetErrorByUrl = new Map(); const mockedPostResponseByUrl = new Map(); const mockedPostErrorByUrl = new Map(); @@ -25,20 +28,26 @@ const mockAxios = () : void => { return Promise.reject({ response: { status, data } }); } if (mockedGetResponseByUrl.has(requestUrl)) { - const response = { data: mockedGetResponseByUrl.get(requestUrl) }; + const data = mockedGetResponseByUrl.get(requestUrl); + const status = mockedGetStatusByUrl.get(requestUrl) ?? 200; - if (response.data instanceof Buffer) { + if (data instanceof Buffer) { const readableStream = new Readable(); - readableStream.push(response.data) + readableStream.push(data) readableStream.push(null); return Promise.resolve({ status: 200, data: readableStream, }); } else { - return Promise.resolve(response); + return Promise.resolve({ status, data }); } } + // CUI marking is probed on every user-facing write. Unless a test opts in, + // answer 204 so the CLI keeps the original filename. + if (requestUrl.endsWith(CUI_PDF_COVER_PATH)) { + return Promise.resolve({ status: 204, data: "" }); + } fail("API call not mocked.") }); @@ -59,6 +68,13 @@ const mockAxios = () : void => { const mockAxiosGet = (url: string, responseData: any) => { mockedGetResponseByUrl.set(url, responseData); + mockedGetStatusByUrl.delete(url); + mockedGetErrorByUrl.delete(url); +}; + +const mockAxiosGetWithStatus = (url: string, status: number, responseData: any) => { + mockedGetResponseByUrl.set(url, responseData); + mockedGetStatusByUrl.set(url, status); mockedGetErrorByUrl.delete(url); }; @@ -104,6 +120,7 @@ const mockAxiosDelete = (url: string) => { afterEach(() => { mockedGetResponseByUrl.clear(); + mockedGetStatusByUrl.clear(); mockedGetErrorByUrl.clear(); mockedPostResponseByUrl.clear(); mockedPostErrorByUrl.clear(); @@ -115,6 +132,7 @@ export { mockedAxiosInstance, mockAxios, mockAxiosGet, + mockAxiosGetWithStatus, mockAxiosGetError, mockAxiosPost, mockAxiosPostError, From f6cc020e1ce9658349271bb01833558c0320a6d5 Mon Sep 17 00:00:00 2001 From: denniswo Date: Fri, 7 Aug 2026 15:49:12 +0200 Subject: [PATCH 2/7] SP-1173: throw instead of Promise.reject in BaseManager.findAll Once findAll moved to async/await, return Promise.reject() was equivalent to throwing undefined: Sonar flags it, and the command handler logged "undefined" instead of the real cause. Rethrow the original error. Includes-AI-Code: true Co-authored-by: Cursor --- src/core/http/http-shared/base.manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/http/http-shared/base.manager.ts b/src/core/http/http-shared/base.manager.ts index 800d8fe1..1e9295c3 100644 --- a/src/core/http/http-shared/base.manager.ts +++ b/src/core/http/http-shared/base.manager.ts @@ -88,7 +88,7 @@ export abstract class BaseManager { return data; } catch (err) { logger.error(new FatalError(err)); - return Promise.reject(); + throw err; } } From f08c6aee530ea1f08f54d1acf6149c8e962646af Mon Sep 17 00:00:00 2001 From: denniswo Date: Fri, 7 Aug 2026 16:09:03 +0200 Subject: [PATCH 3/7] SP-1173: treat only 204 as "no CUI marking" Drop the 403 branch from CuiService. Any status other than 200 or 204 now raises a FatalError, so no file is written when the marking cannot be resolved. Also flatten the nested template literal in the error path, which Sonar flags. Includes-AI-Code: true Co-authored-by: Cursor --- src/core/utils/cui-service.ts | 10 +++++----- tests/core/utils/cui-file-service.spec.ts | 16 +++++++--------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/core/utils/cui-service.ts b/src/core/utils/cui-service.ts index 39f5f4b5..22f4a6a6 100644 --- a/src/core/utils/cui-service.ts +++ b/src/core/utils/cui-service.ts @@ -26,7 +26,6 @@ export class CuiService { private static readonly STATUS_OK = 200; private static readonly STATUS_NO_CONTENT = 204; - private static readonly STATUS_FORBIDDEN = 403; private readonly httpClient: () => HttpClient; @@ -35,18 +34,19 @@ export class CuiService { } /** - * Returns null when no marking applies: 403 (team.cui-settings not enabled) or 204 (CUI disabled). + * Returns null when the team has CUI disabled. */ public async getCuiPdfCover(): Promise { const { status, data } = await this.httpClient().getStatusAndData(CuiService.COVER_SHEET_URL); - if (status === CuiService.STATUS_FORBIDDEN || status === CuiService.STATUS_NO_CONTENT) { - logger.debug(`CUI marking does not apply, cover sheet endpoint responded with ${status}${this.describeBody(data)}`); + if (status === CuiService.STATUS_NO_CONTENT) { + logger.debug("CUI marking does not apply, the team has CUI disabled"); return null; } if (status !== CuiService.STATUS_OK) { - throw new FatalError(`Problem fetching cui: ${this.describeBody(data) || `Backend responded with status code ${status}`}`); + const detail = this.describeBody(data) || `Backend responded with status code ${status}`; + throw new FatalError(`Problem fetching cui: ${detail}`); } return data ? (data as CuiPdfCoverResponse) : null; diff --git a/tests/core/utils/cui-file-service.spec.ts b/tests/core/utils/cui-file-service.spec.ts index 714ac479..92defc69 100644 --- a/tests/core/utils/cui-file-service.spec.ts +++ b/tests/core/utils/cui-file-service.spec.ts @@ -28,15 +28,6 @@ describe("CuiFileService", () => { }); describe("when CUI marking does not apply", () => { - it("Should keep the original filename when the feature is not enabled for the team", async () => { - mockAxiosGetError(COVER_URL, 403, { errorCode: "feature-disabled" }); - - const filename = await cuiFileService.writeToFileWithGivenName(PAYLOAD, "report.json"); - - expect(filename).toEqual("report.json"); - expect(readFile("report.json").toString()).toEqual(PAYLOAD); - }); - it("Should keep the original filename when the team has CUI disabled", async () => { mockAxiosGetWithStatus(COVER_URL, 204, ""); @@ -52,6 +43,13 @@ describe("CuiFileService", () => { await expect(cuiFileService.writeToFileWithGivenName(PAYLOAD, "broken.json")).rejects.toThrow(FatalError); expect(() => accessSync(resolve(process.cwd(), "broken.json"))).toThrow(); }); + + it("Should fail when the cover endpoint is not reachable", async () => { + mockAxiosGetError(COVER_URL, 403, { errorCode: "feature-disabled" }); + + await expect(cuiFileService.writeToFileWithGivenName(PAYLOAD, "forbidden.json")).rejects.toThrow(FatalError); + expect(() => accessSync(resolve(process.cwd(), "forbidden.json"))).toThrow(); + }); }); describe("when the content is unclassified", () => { From 843a0943f7bf7b1ccd3a1d57b3e3b5db79c5ae0b Mon Sep 17 00:00:00 2001 From: denniswo Date: Fri, 7 Aug 2026 16:54:42 +0200 Subject: [PATCH 4/7] SP-1173: treat 403 and 204 as no CUI marking 403 means the feature flag is disabled, 204 means the team has CUI disabled. Both keep the original filename, as separate branches so the debug log says which one applied. Any other non-200 status still fails closed. Includes-AI-Code: true Co-authored-by: Cursor --- src/core/utils/cui-service.ts | 9 ++++++--- tests/core/utils/cui-file-service.spec.ts | 16 +++++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/core/utils/cui-service.ts b/src/core/utils/cui-service.ts index 22f4a6a6..f07be62a 100644 --- a/src/core/utils/cui-service.ts +++ b/src/core/utils/cui-service.ts @@ -26,6 +26,7 @@ export class CuiService { private static readonly STATUS_OK = 200; private static readonly STATUS_NO_CONTENT = 204; + private static readonly STATUS_FORBIDDEN = 403; private readonly httpClient: () => HttpClient; @@ -33,12 +34,14 @@ export class CuiService { this.httpClient = () => context.httpClient; } - /** - * Returns null when the team has CUI disabled. - */ public async getCuiPdfCover(): Promise { const { status, data } = await this.httpClient().getStatusAndData(CuiService.COVER_SHEET_URL); + if (status === CuiService.STATUS_FORBIDDEN) { + logger.debug("CUI marking does not apply, the feature flag is disabled"); + return null; + } + if (status === CuiService.STATUS_NO_CONTENT) { logger.debug("CUI marking does not apply, the team has CUI disabled"); return null; diff --git a/tests/core/utils/cui-file-service.spec.ts b/tests/core/utils/cui-file-service.spec.ts index 92defc69..5afd40cf 100644 --- a/tests/core/utils/cui-file-service.spec.ts +++ b/tests/core/utils/cui-file-service.spec.ts @@ -28,6 +28,15 @@ describe("CuiFileService", () => { }); describe("when CUI marking does not apply", () => { + it("Should keep the original filename when the feature flag is disabled", async () => { + mockAxiosGetError(COVER_URL, 403, { errorCode: "feature-disabled" }); + + const filename = await cuiFileService.writeToFileWithGivenName(PAYLOAD, "report.json"); + + expect(filename).toEqual("report.json"); + expect(readFile("report.json").toString()).toEqual(PAYLOAD); + }); + it("Should keep the original filename when the team has CUI disabled", async () => { mockAxiosGetWithStatus(COVER_URL, 204, ""); @@ -43,13 +52,6 @@ describe("CuiFileService", () => { await expect(cuiFileService.writeToFileWithGivenName(PAYLOAD, "broken.json")).rejects.toThrow(FatalError); expect(() => accessSync(resolve(process.cwd(), "broken.json"))).toThrow(); }); - - it("Should fail when the cover endpoint is not reachable", async () => { - mockAxiosGetError(COVER_URL, 403, { errorCode: "feature-disabled" }); - - await expect(cuiFileService.writeToFileWithGivenName(PAYLOAD, "forbidden.json")).rejects.toThrow(FatalError); - expect(() => accessSync(resolve(process.cwd(), "forbidden.json"))).toThrow(); - }); }); describe("when the content is unclassified", () => { From a317f63e01453f273c8ea5d27e2143350f80986b Mon Sep 17 00:00:00 2001 From: denniswo Date: Fri, 7 Aug 2026 17:00:25 +0200 Subject: [PATCH 5/7] SP-1173: trim the CuiService response types CuiPdfCoverResponse is the only type consumed outside the module, so the three helper interfaces are inlined into it. Categories are counted, never read, so their element type carries no weight. Includes-AI-Code: true Co-authored-by: Cursor --- src/core/utils/cui-service.ts | 32 +++++--------------------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/src/core/utils/cui-service.ts b/src/core/utils/cui-service.ts index f07be62a..b4f3f795 100644 --- a/src/core/utils/cui-service.ts +++ b/src/core/utils/cui-service.ts @@ -2,27 +2,13 @@ import { HttpClient } from "../http/http-client"; import { FatalError, logger } from "./logger"; import { Context } from "../command/cli-context"; -export interface CuiCategory { - code: string; - name: string; -} - -export interface ResolvedCuiMarking { - categories?: CuiCategory[]; -} - -export interface CuiCoverPage { - pdfContent: string; - encoding: string; -} - export interface CuiPdfCoverResponse { - resolvedCuiMarking?: ResolvedCuiMarking; - coverPage?: CuiCoverPage; + resolvedCuiMarking?: { categories?: unknown[] }; + coverPage?: { pdfContent: string; encoding: string }; } export class CuiService { - private static readonly COVER_SHEET_URL = "/api/team/cui-settings/cui-pdf-cover"; + 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; @@ -35,7 +21,7 @@ export class CuiService { } public async getCuiPdfCover(): Promise { - const { status, data } = await this.httpClient().getStatusAndData(CuiService.COVER_SHEET_URL); + const { status, data } = await this.httpClient().getStatusAndData(CuiService.CUI_PDF_COVER_SHEET_URL); if (status === CuiService.STATUS_FORBIDDEN) { logger.debug("CUI marking does not apply, the feature flag is disabled"); @@ -48,17 +34,9 @@ export class CuiService { } if (status !== CuiService.STATUS_OK) { - const detail = this.describeBody(data) || `Backend responded with status code ${status}`; - throw new FatalError(`Problem fetching cui: ${detail}`); + throw new FatalError("Problem fetching cui pdf cover"); } return data ? (data as CuiPdfCoverResponse) : null; } - - private describeBody(data: any): string { - if (!data) { - return ""; - } - return `: ${typeof data === "string" ? data : JSON.stringify(data)}`; - } } From 57457839354ae0940cce8519c1f3455db9d8e390 Mon Sep 17 00:00:00 2001 From: denniswo Date: Mon, 10 Aug 2026 09:47:48 +0200 Subject: [PATCH 6/7] SP-1173: sonar --- src/core/utils/cui-file-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/utils/cui-file-service.ts b/src/core/utils/cui-file-service.ts index e9f1f677..72dbda66 100644 --- a/src/core/utils/cui-file-service.ts +++ b/src/core/utils/cui-file-service.ts @@ -1,4 +1,4 @@ -import * as path from "path"; +import * as path from "node:path"; import AdmZip = require("adm-zip"); import { Context } from "../command/cli-context"; import { CuiPdfCoverResponse, CuiService } from "./cui-service"; From 66a75f86dd111357233a2e2057e5f5990500e4c9 Mon Sep 17 00:00:00 2001 From: denniswo Date: Mon, 10 Aug 2026 11:24:56 +0200 Subject: [PATCH 7/7] SP-1173: comment and bugbot --- src/core/utils/{cui-service.ts => cui-api.ts} | 14 +++++++------- src/core/utils/cui-file-service.ts | 8 ++++---- tests/core/utils/cui-file-service.spec.ts | 7 +++++++ 3 files changed, 18 insertions(+), 11 deletions(-) rename src/core/utils/{cui-service.ts => cui-api.ts} (75%) diff --git a/src/core/utils/cui-service.ts b/src/core/utils/cui-api.ts similarity index 75% rename from src/core/utils/cui-service.ts rename to src/core/utils/cui-api.ts index b4f3f795..e17254e1 100644 --- a/src/core/utils/cui-service.ts +++ b/src/core/utils/cui-api.ts @@ -7,7 +7,7 @@ export interface CuiPdfCoverResponse { coverPage?: { pdfContent: string; encoding: string }; } -export class CuiService { +export class CuiApi { private static readonly CUI_PDF_COVER_SHEET_URL = "/api/team/cui-settings/cui-pdf-cover"; private static readonly STATUS_OK = 200; @@ -21,22 +21,22 @@ export class CuiService { } public async getCuiPdfCover(): Promise { - const { status, data } = await this.httpClient().getStatusAndData(CuiService.CUI_PDF_COVER_SHEET_URL); + const { status, data } = await this.httpClient().getStatusAndData(CuiApi.CUI_PDF_COVER_SHEET_URL); - if (status === CuiService.STATUS_FORBIDDEN) { + if (status === CuiApi.STATUS_FORBIDDEN) { logger.debug("CUI marking does not apply, the feature flag is disabled"); return null; } - if (status === CuiService.STATUS_NO_CONTENT) { + if (status === CuiApi.STATUS_NO_CONTENT) { logger.debug("CUI marking does not apply, the team has CUI disabled"); return null; } - if (status !== CuiService.STATUS_OK) { - throw new FatalError("Problem fetching cui pdf cover"); + if (status === CuiApi.STATUS_OK && data) { + return data as CuiPdfCoverResponse; } - return data ? (data as CuiPdfCoverResponse) : null; + throw new FatalError("Problem fetching cui pdf cover"); } } diff --git a/src/core/utils/cui-file-service.ts b/src/core/utils/cui-file-service.ts index 72dbda66..9d5c58b2 100644 --- a/src/core/utils/cui-file-service.ts +++ b/src/core/utils/cui-file-service.ts @@ -1,7 +1,7 @@ import * as path from "node:path"; import AdmZip = require("adm-zip"); import { Context } from "../command/cli-context"; -import { CuiPdfCoverResponse, CuiService } from "./cui-service"; +import { CuiApi, CuiPdfCoverResponse } from "./cui-api"; import { fileService } from "./file-service"; import { FileConstants } from "./file.constants"; import { FatalError } from "./logger"; @@ -13,14 +13,14 @@ export class CuiFileService { private static readonly BASE64_ENCODING = "base64"; - private readonly cuiService: CuiService; + private readonly cuiApi: CuiApi; constructor(context: Context) { - this.cuiService = new CuiService(context); + this.cuiApi = new CuiApi(context); } public async writeToFileWithGivenName(data: string, filename: string): Promise { - const cover = await this.cuiService.getCuiPdfCover(); + const cover = await this.cuiApi.getCuiPdfCover(); if (cover === null) { fileService.writeToFileWithGivenName(data, filename); diff --git a/tests/core/utils/cui-file-service.spec.ts b/tests/core/utils/cui-file-service.spec.ts index 5afd40cf..0b91b57d 100644 --- a/tests/core/utils/cui-file-service.spec.ts +++ b/tests/core/utils/cui-file-service.spec.ts @@ -52,6 +52,13 @@ describe("CuiFileService", () => { await expect(cuiFileService.writeToFileWithGivenName(PAYLOAD, "broken.json")).rejects.toThrow(FatalError); expect(() => accessSync(resolve(process.cwd(), "broken.json"))).toThrow(); }); + + it("Should fail when the marking applies but the response body is empty", async () => { + mockAxiosGetWithStatus(COVER_URL, 200, ""); + + await expect(cuiFileService.writeToFileWithGivenName(PAYLOAD, "empty-cover.json")).rejects.toThrow(FatalError); + expect(() => accessSync(resolve(process.cwd(), "empty-cover.json"))).toThrow(); + }); }); describe("when the content is unclassified", () => {