From 54cd601a5b8fce8df43b3c983f1b4f4ddd9f9847 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 3c903f2..78ac052 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 b0327b8..1bcdc3f 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 16e62185318f766b18c457b7d5f8d1c148044fa9 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 0000000..736138c --- /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 6a102993b458023ce5a4488fae424108057bb729 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 0000000..4a5ca37 --- /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 1405cb0..3878734 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 64b65efe603ce4d022fcf959b80e944fce5a9f59 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 0000000..056f92e --- /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(); + }); +});