From be68e00deae2a1a70b7dd2fe063351bd9c12edaf Mon Sep 17 00:00:00 2001 From: denniswo Date: Mon, 10 Aug 2026 15:34:06 +0200 Subject: [PATCH 1/4] SP-1173: mark the single files the pull commands write Route BaseManager.pull through CuiFileService so the five pull commands that emit one .yml or .json file get the same marking as the archive exports, and do the same for the standalone export bookmarks path. Includes-AI-Code: true Co-authored-by: Cursor --- .../bookmarks/bookmarks-command.service.ts | 7 ++- src/core/http/http-shared/base.manager.ts | 45 ++++++------------- 2 files changed, 18 insertions(+), 34 deletions(-) diff --git a/src/commands/bookmarks/bookmarks-command.service.ts b/src/commands/bookmarks/bookmarks-command.service.ts index 3c903f2e..78ac0524 100644 --- a/src/commands/bookmarks/bookmarks-command.service.ts +++ b/src/commands/bookmarks/bookmarks-command.service.ts @@ -1,22 +1,25 @@ import { Context } from "../../core/command/cli-context"; import { BookmarksApi } from "./bookmarks-api"; import { fileService, FileService } from "../../core/utils/file-service"; +import { CuiFileService } from "../../core/utils/cui-file-service"; import { logger } from "../../core/utils/logger"; export class BookmarksCommandService { private readonly bookmarksApi: BookmarksApi; + private readonly cuiFileService: CuiFileService; constructor(context: Context) { this.bookmarksApi = new BookmarksApi(context); + this.cuiFileService = new CuiFileService(context); } public async exportBookmarks(packageKey: string, file?: string): Promise { const exportData = await this.bookmarksApi.exportBookmarks(packageKey); const fileName = file ?? `bookmarks-${packageKey}.json`; - fileService.writeToFileWithGivenName(JSON.stringify(exportData, null, 4), fileName); - logger.info(FileService.fileDownloadedMessage + fileName); + const writtenFilename = await this.cuiFileService.writeToFileWithGivenName(JSON.stringify(exportData, null, 4), fileName); + logger.info(FileService.fileDownloadedMessage + writtenFilename); } public async importBookmarks(packageKey: string, file: string): Promise { diff --git a/src/core/http/http-shared/base.manager.ts b/src/core/http/http-shared/base.manager.ts index b0327b86..1bcdc3f4 100644 --- a/src/core/http/http-shared/base.manager.ts +++ b/src/core/http/http-shared/base.manager.ts @@ -1,10 +1,7 @@ -import * as fs from "fs"; -import * as path from "path"; import { FatalError, logger } from "../../utils/logger"; import { ManagerConfig } from "./manager-config.interface"; import { HttpClient } from "../http-client"; import { Context } from "../../command/cli-context"; -import { FileConstants } from "../../utils/file.constants"; import { CuiFileService } from "../../utils/cui-file-service"; export abstract class BaseManager { @@ -18,24 +15,14 @@ export abstract class BaseManager { } public async pull(): Promise { - return new Promise((resolve, reject) => { - this.httpClient() - .get(this.getConfig().pullUrl) - .then(data => { - try { - const filename = this.writeToFile(data); - logger.info(this.fileDownloadedMessage + filename); - resolve(); - } catch (e) { - logger.error(new FatalError(e)); - reject(); - } - }) - .catch(err => { - logger.error(new FatalError(err)); - reject(); - }); - }); + try { + const data = await this.httpClient().get(this.getConfig().pullUrl); + const filename = await this.writeToFile(data); + logger.info(this.fileDownloadedMessage + filename); + } catch (err) { + logger.error(new FatalError(err)); + throw err; + } } public async pullFile(): Promise { @@ -90,23 +77,17 @@ export abstract class BaseManager { } } - protected writeToFile(data: any): string { - const filename = this.getConfig().exportFileName; - this.writeToFileWithGivenName(data, filename); - return filename; + protected async writeToFile(data: any): Promise { + return this.cuiFileService.writeToFileWithGivenName( + this.getSerializedFileContent(data), + this.getConfig().exportFileName + ); } protected async writeStreamToFile(data: Buffer): Promise { return this.cuiFileService.writeZipToFileWithGivenName(data, this.getConfig().exportFileName); } - protected writeToFileWithGivenName(data: any, filename: string): void { - fs.writeFileSync(path.resolve(process.cwd(), filename), this.getSerializedFileContent(data), { - encoding: "utf-8", - mode: FileConstants.DEFAULT_FILE_PERMISSIONS, - }); - } - protected abstract getConfig(): ManagerConfig; protected abstract getBody(): object; From 99ab7335e50d74741f1e7ea0e9d619079fea6804 Mon Sep 17 00:00:00 2001 From: denniswo Date: Mon, 10 Aug 2026 15:34:06 +0200 Subject: [PATCH 2/4] SP-1173: cover CUI marking of single-file exports One case per call site, asserting the classified name and that the archive carries the cover sheet plus the original payload. The asset case reads its entry back as YAML. Includes-AI-Code: true Co-authored-by: Cursor --- .../cui-marking-single-file-exports.spec.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 tests/commands/cui-marking-single-file-exports.spec.ts diff --git a/tests/commands/cui-marking-single-file-exports.spec.ts b/tests/commands/cui-marking-single-file-exports.spec.ts new file mode 100644 index 00000000..736138cf --- /dev/null +++ b/tests/commands/cui-marking-single-file-exports.spec.ts @@ -0,0 +1,116 @@ +import { resolve } from "node:path"; +import { readFileSync } from "node:fs"; +import AdmZip = require("adm-zip"); +import { mockAxiosGet, mockAxiosGetWithStatus } 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 { parse } from "../../src/core/utils/yaml"; +import { AssetCommandService } from "../../src/commands/studio/command-service/asset-command.service"; +import { SkillCommandService } from "../../src/commands/action-flows/skill/skill-command.service"; +import { DataPoolCommandService } from "../../src/commands/data-pipeline/data-pool/data-pool-command.service"; +import { ViewBookmarksCommandService } from "../../src/commands/view/view-bookmarks-command.service"; +import { AnalysisBookmarksCommandService } from "../../src/commands/analysis/analysis-bookmarks-command.service"; +import { BookmarksCommandService } from "../../src/commands/bookmarks/bookmarks-command.service"; + +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 PROJECT_ID = "project-1"; +const SKILL_ID = "skill-1"; +const POOL_ID = "pool-1"; +const BOARD_ID = "board-1"; +const ANALYSIS_ID = "analysis-1"; +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" }, + }); +} + +function loggedFileName(): string { + const prefix = FileService.fileDownloadedMessage; + const message = loggingTestTransport.logMessages.map(entry => entry.message).find(entry => entry.includes(prefix)); + return message.split(prefix)[1]; +} + +function markedArchive(expectedName: string): AdmZip { + const filename = loggedFileName(); + expect(filename).toEqual(`${CuiFileService.CLASSIFIED_PREFIX}${expectedName}.zip`); + + const archive = new AdmZip(readFileSync(resolve(process.cwd(), filename))); + expect(archive.getEntry(CuiFileService.COVER_SHEET_FILE_NAME).getData().equals(PDF_BYTES)).toBe(true); + return archive; +} + +function markedEntry(expectedName: string, entryName: string): string { + return markedArchive(expectedName).getEntry(entryName).getData().toString(); +} + +function markedJson(expectedName: string, entryName: string): any { + return JSON.parse(markedEntry(expectedName, entryName)); +} + +describe("CUI marking of single-file exports", () => { + + beforeEach(() => { + markAsClassified(); + }); + + it("Should mark the exported asset as YAML", async () => { + 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(markedEntry("asset_asset-1", "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); + + await new SkillCommandService(testContext).pullSkill(null, PROJECT_ID, SKILL_ID); + + expect(markedJson(`skill_${SKILL_ID}`, `skill_${SKILL_ID}.json`)).toEqual(skill); + }); + + it("Should mark the exported data pool", async () => { + const dataPool = { id: POOL_ID, name: "Pool 1" }; + mockAxiosGet(`https://myTeam.celonis.cloud/integration/api/pools/${POOL_ID}/export`, dataPool); + + await new DataPoolCommandService(testContext).pullDataPool(POOL_ID); + + expect(markedJson(`data-pool_${POOL_ID}`, `data-pool_${POOL_ID}.json`)).toEqual(dataPool); + }); + + it("Should mark the exported view bookmarks", async () => { + const bookmarks = [{ bookmark: { name: "My View Bookmark" } }]; + mockAxiosGet(`https://myTeam.celonis.cloud/blueprint/api/bookmarks/export?boardId=${BOARD_ID}&type=USER`, bookmarks); + + await new ViewBookmarksCommandService(testContext).pullViewBookmarks(BOARD_ID, undefined); + + expect(markedJson(`studio_view_bookmarks_${BOARD_ID}`, `studio_view_bookmarks_${BOARD_ID}.json`)).toEqual(bookmarks); + }); + + it("Should mark the exported analysis bookmarks", async () => { + const bookmarks = [{ bookmark: { name: "My Analysis Bookmark" } }]; + mockAxiosGet(`https://myTeam.celonis.cloud/process-analytics/api/bookmarks/export?analysisId=${ANALYSIS_ID}&type=USER`, bookmarks); + + await new AnalysisBookmarksCommandService(testContext).pullAnalysisBookmarks(ANALYSIS_ID, "USER"); + + expect(markedJson(`studio_analysis_bookmarks_${ANALYSIS_ID}`, `studio_analysis_bookmarks_${ANALYSIS_ID}.json`)).toEqual(bookmarks); + }); + + it("Should mark the exported package bookmarks under the user-chosen name", async () => { + const bookmarks = { packageKey: PACKAGE_KEY, entries: [] }; + mockAxiosGet(`https://myTeam.celonis.cloud/package-manager/api/packages/${PACKAGE_KEY}/bookmarks/export`, bookmarks); + + await new BookmarksCommandService(testContext).exportBookmarks(PACKAGE_KEY, "custom.json"); + + expect(markedJson("custom", "custom.json")).toEqual(bookmarks); + }); +}); From d48754106b6f78263f806c68f28e8a1c7349edab Mon Sep 17 00:00:00 2001 From: denniswo Date: Mon, 10 Aug 2026 15:48:38 +0200 Subject: [PATCH 3/4] SP-1173: cover the update path of BaseManager BaseManager.update had no test. Cover both branches through update data-pool, its only caller, which also needed a PUT error helper in the shared axios mock. Includes-AI-Code: true Co-authored-by: Cursor --- .../data-pipeline/data-pool-update.spec.ts | 52 +++++++++++++++++++ tests/utls/http-requests-mock.ts | 33 ++++++++---- 2 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 tests/commands/data-pipeline/data-pool-update.spec.ts diff --git a/tests/commands/data-pipeline/data-pool-update.spec.ts b/tests/commands/data-pipeline/data-pool-update.spec.ts new file mode 100644 index 00000000..4a5ca37b --- /dev/null +++ b/tests/commands/data-pipeline/data-pool-update.spec.ts @@ -0,0 +1,52 @@ +import { mockAxiosPut, mockAxiosPutError, mockedAxiosInstance, mockedPostRequestBodyByUrl } from "../../utls/http-requests-mock"; +import { DataPoolCommandService } from "../../../src/commands/data-pipeline/data-pool/data-pool-command.service"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport } from "../../jest.setup"; +import { writeJsonTempFile } from "../../utls/fs-utils"; + +describe("Update data pool", () => { + + const poolId = "pool-1"; + const updateUrl = `https://myTeam.celonis.cloud/integration/api/pools/${poolId}`; + const file = "data-pool-update.json"; + + const dataPool = { + id: poolId, + name: "Updated Pool", + objects: [], + }; + + beforeEach(() => { + writeJsonTempFile(file, { dataPool }); + }); + + it("Should call the update API and log success", async () => { + mockAxiosPut(updateUrl, dataPool); + + await new DataPoolCommandService(testContext).updateDataPool(poolId, file); + + expect(mockedAxiosInstance.put).toHaveBeenCalledWith(updateUrl, expect.anything(), expect.anything()); + expect(loggingTestTransport.logMessages).toHaveLength(1); + expect(loggingTestTransport.logMessages[0].message).toContain("Data Pool was updated successfully!"); + }); + + it("Should send the data pool from the file as request body", async () => { + mockAxiosPut(updateUrl, dataPool); + + await new DataPoolCommandService(testContext).updateDataPool(poolId, file); + + expect(JSON.parse(mockedPostRequestBodyByUrl.get(updateUrl))).toEqual(dataPool); + }); + + it("Should log a fatal error and reject when the update API fails", async () => { + const exitSpy = jest.spyOn(process, "exit").mockImplementation((() => undefined) as never); + mockAxiosPutError(updateUrl, 500, { message: "Internal Server Error" }); + + await expect(new DataPoolCommandService(testContext).updateDataPool(poolId, file)).rejects.toBeUndefined(); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(loggingTestTransport.logMessages[0].message).toContain("Internal Server Error"); + + exitSpy.mockRestore(); + }); +}); diff --git a/tests/utls/http-requests-mock.ts b/tests/utls/http-requests-mock.ts index 1405cb0d..3878734e 100644 --- a/tests/utls/http-requests-mock.ts +++ b/tests/utls/http-requests-mock.ts @@ -12,6 +12,7 @@ const mockedGetErrorByUrl = new Map(); const mockedPostResponseByUrl = new Map(); const mockedPostErrorByUrl = new Map(); const mockedPostRequestBodyByUrl = new Map(); +const mockedPutErrorByUrl = new Map(); const mockedDeleteResponseByUrl = new Map(); const mockAxios = () : void => { @@ -64,6 +65,20 @@ const mockAxios = () : void => { } fail("API call not mocked.") }); + + (mockedAxiosInstance.put as jest.Mock).mockImplementation((requestUrl: string, data: any) => { + if (mockedPutErrorByUrl.has(requestUrl)) { + const { status, data: errorData } = mockedPutErrorByUrl.get(requestUrl)!; + return Promise.reject({ response: { status, data: errorData } }); + } + if (mockedPostResponseByUrl.has(requestUrl)) { + const response = { data: mockedPostResponseByUrl.get(requestUrl) }; + mockedPostRequestBodyByUrl.set(requestUrl, data); + + return Promise.resolve(response); + } + fail("API call not mocked.") + }); } const mockAxiosGet = (url: string, responseData: any) => { @@ -95,17 +110,13 @@ const mockAxiosPostError = (url: string, status: number, data: any) => { const mockAxiosPut = (url: string, responseData: any) => { mockedPostResponseByUrl.set(url, responseData); - (mockedAxiosInstance.put as jest.Mock).mockImplementation((requestUrl: string, data: any) => { - if (mockedPostResponseByUrl.has(requestUrl)) { - const response = { data: mockedPostResponseByUrl.get(requestUrl) }; - mockedPostRequestBodyByUrl.set(requestUrl, data); + mockedPutErrorByUrl.delete(url); +}; - return Promise.resolve(response); - } else { - fail("API call not mocked.") - } - }) -} +const mockAxiosPutError = (url: string, status: number, data: any) => { + mockedPutErrorByUrl.set(url, { status, data }); + mockedPostResponseByUrl.delete(url); +}; const mockAxiosDelete = (url: string) => { mockedDeleteResponseByUrl.set(url, undefined); @@ -125,6 +136,7 @@ afterEach(() => { mockedPostResponseByUrl.clear(); mockedPostErrorByUrl.clear(); mockedPostRequestBodyByUrl.clear(); + mockedPutErrorByUrl.clear(); mockedDeleteResponseByUrl.clear(); }) @@ -137,6 +149,7 @@ export { mockAxiosPost, mockAxiosPostError, mockAxiosPut, + mockAxiosPutError, mockAxiosDelete, mockedPostRequestBodyByUrl }; From 2298a8dca7cf2682fcfe1059374f98013f17ca90 Mon Sep 17 00:00:00 2001 From: denniswo Date: Tue, 11 Aug 2026 12:08:47 +0200 Subject: [PATCH 4/4] SP-1173: cover the BaseManager error paths pull, pullFile, push and findAll each log a FatalError and reject, but no test exercised those branches. A minimal manager subclass drives every failure through the real HttpClient error handling. Includes-AI-Code: true Co-authored-by: Cursor --- tests/core/http/base.manager.spec.ts | 89 ++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/core/http/base.manager.spec.ts diff --git a/tests/core/http/base.manager.spec.ts b/tests/core/http/base.manager.spec.ts new file mode 100644 index 00000000..056f92eb --- /dev/null +++ b/tests/core/http/base.manager.spec.ts @@ -0,0 +1,89 @@ +import { BaseManager } from "../../../src/core/http/http-shared/base.manager"; +import { ManagerConfig } from "../../../src/core/http/http-shared/manager-config.interface"; +import { FatalError } from "../../../src/core/utils/logger"; +import { loggingTestTransport } from "../../jest.setup"; +import { mockAxiosGetError, mockAxiosPostError } from "../../utls/http-requests-mock"; +import { testContext } from "../../utls/test-context"; + +const TEAM_URL = "https://myTeam.celonis.cloud"; +const PULL_PATH = "/api/test/pull"; +const PUSH_PATH = "/api/test/push"; +const FIND_ALL_PATH = "/api/test/find-all"; +const ERROR_BODY = { message: "Internal Server Error" }; + +class TestManager extends BaseManager { + public constructor() { + super(testContext); + } + + protected getConfig(): ManagerConfig { + return { + pullUrl: PULL_PATH, + pushUrl: PUSH_PATH, + findAllUrl: FIND_ALL_PATH, + exportFileName: "test-export.json", + onPushSuccessMessage: () => "Pushed successfully", + onFindAll: () => undefined, + }; + } + + protected getBody(): object { + return { key: "test" }; + } + + protected getSerializedFileContent(data: any): string { + return JSON.stringify(data); + } +} + +describe("BaseManager error handling", () => { + + let manager: TestManager; + let exitSpy: jest.SpyInstance; + + beforeEach(() => { + manager = new TestManager(); + exitSpy = jest.spyOn(process, "exit").mockImplementation((() => undefined) as never); + }); + + afterEach(() => { + exitSpy.mockRestore(); + }); + + const expectFatalErrorLogged = (): void => { + expect(exitSpy).toHaveBeenCalledWith(1); + expect(loggingTestTransport.logMessages[0].message).toContain("Internal Server Error"); + }; + + it("Should log a fatal error and reject when the pull API fails", async () => { + mockAxiosGetError(TEAM_URL + PULL_PATH, 500, ERROR_BODY); + + await expect(manager.pull()).rejects.toThrow(FatalError); + + expectFatalErrorLogged(); + }); + + it("Should log a fatal error and reject when the file download fails", async () => { + mockAxiosPostError(TEAM_URL + PULL_PATH, 500, ERROR_BODY); + + await expect(manager.pullFile()).rejects.toEqual(JSON.stringify(ERROR_BODY)); + + expectFatalErrorLogged(); + }); + + it("Should log a fatal error and reject when the push API fails", async () => { + mockAxiosPostError(TEAM_URL + PUSH_PATH, 500, ERROR_BODY); + + await expect(manager.push()).rejects.toBeUndefined(); + + expectFatalErrorLogged(); + }); + + it("Should log a fatal error and reject when the find all API fails", async () => { + mockAxiosGetError(TEAM_URL + FIND_ALL_PATH, 500, ERROR_BODY); + + await expect(manager.findAll()).rejects.toThrow(FatalError); + + expectFatalErrorLogged(); + }); +});