From b804c13ed10eed55d53e39f53f9ee6f043088a7c Mon Sep 17 00:00:00 2001 From: zgjimhaziri Date: Thu, 4 Jun 2026 19:11:44 +0200 Subject: [PATCH 1/2] SP-874: add 'config package import' command for single-package import Adds the CLI counterpart to the new pacman single-package import API. The command lives under a new 'config package' subgroup, separate from the existing batch 'config import', because the two use different, non-interchangeable archive formats. Supports importing from a package zip (--file) or a directory (--directory, flat-zipped by the CLI), with --overwrite and optional --json output. Config docs and CLI help updated to frame the batch commands as their own batch-specific set. Includes-AI-Code: true Co-authored-by: Cursor --- docs/user-guide/config-commands.md | 117 ++++++++++- .../api/single-package-import-api.ts | 21 ++ .../single-package-import.interfaces.ts | 21 ++ .../configuration-management/module.ts | 29 ++- .../single-package-import.service.ts | 103 ++++++++++ src/core/utils/file-service.ts | 16 ++ .../configuration-management/module.spec.ts | 59 +++++- .../single-package-import.spec.ts | 189 ++++++++++++++++++ tests/core/utils/file.service.spec.ts | 27 +++ 9 files changed, 573 insertions(+), 9 deletions(-) create mode 100644 src/commands/configuration-management/api/single-package-import-api.ts create mode 100644 src/commands/configuration-management/interfaces/single-package-import.interfaces.ts create mode 100644 src/commands/configuration-management/single-package-import.service.ts create mode 100644 tests/commands/configuration-management/single-package-import.spec.ts diff --git a/docs/user-guide/config-commands.md b/docs/user-guide/config-commands.md index 697c72a..d823e27 100644 --- a/docs/user-guide/config-commands.md +++ b/docs/user-guide/config-commands.md @@ -1,6 +1,36 @@ # Configuration Management Commands -The `config` command group allows you to list, batch export, import packages of different flavors such as Studio and OCDM packages. +The `config` command group manages package configurations. Most of its commands work with a package and its resources — importing a package, working with nodes, versions, or variables, and validating configurations. Separately, it includes a set of **batch commands** built for specific bulk use-cases, such as moving many packages at once between teams and realms. + +The batch commands are **batch-specific**: they use their own archive format that only the batch commands understand, so their artifacts are **not interchangeable** with the package commands (see [Two command families](#two-command-families)). + +- **Package commands** — work with a package and its resources: + - [`config package import`](#package-commands-config-package) — import a package + - [`config nodes …`](#finding-nodes) — work with individual nodes + - [`config versions …`](#package-version) — read and create package versions + - [`config variables list`](#listing--mapping-variables) — read package variables + - [`config validate`](#validate-package-configurations) — validate a package's staging version +- **Batch commands** — bulk multi-package transport for specific use-cases: + - [`config list`](#list-packages), [`config export`](#batch-export-packages), [`config import`](#batch-import-packages), [`config diff`](#diff-local-zip-with-deployed-versionspecific-versionstaging), [`config metadata export`](#batch-export-packages) + +## Two command families + +The batch commands are a **self-contained, batch-specific set**. `config export` produces a multi-package **batch archive** — a top-level `manifest.json`, `variables.json`, `studio.json`, and one nested `_.zip` per package — that is produced and consumed **only** by other batch commands. `config package import`, by contrast, works with a plain **package zip** (a `package.json`, an optional `variables.json`, and a `nodes/` folder). The two formats are **not interchangeable**: + +- An archive from `config export` can be imported with `config import` or inspected with `config diff` — but **not** with `config package import`. +- A package zip used by `config package import` **cannot** be imported with `config import` or diffed with `config diff`. + +| Command | Group | Artifact it reads / writes | +|---|---|---| +| `config package import` | Package commands | Package zip/dir (`package.json`, optional `variables.json`, `nodes/`) | +| `config nodes …` | Package commands | Node JSON payload | +| `config validate`, `config versions …`, `config variables list` | Package commands | — (operate directly on the platform) | +| `config export` | Batch commands | Batch archive (multi-package) | +| `config import` | Batch commands | Batch archive (multi-package) | +| `config diff` | Batch commands | Batch archive (multi-package) | +| `config list`, `config metadata export` | Batch commands | — (JSON list output) | + +**Which should I use?** Reach for the batch commands only for their specific bulk use-cases — moving a set of packages together (for example, a migration between teams). For everything else, use the package commands. ## Permissions @@ -18,7 +48,7 @@ This applies to every command that reads or modifies a single package or its nod - `config variables list` - `config versions get`, `config versions create` - `config validate`, `config diff` -- `config export`, `config import`, `config metadata export` +- `config export`, `config import`, `config package import`, `config metadata export` If the authenticated profile does not have the required permission, the command fails with `Access is Denied`. @@ -26,6 +56,8 @@ If the authenticated profile does not have the required permission, the command ## List Packages +> **Batch command.** Part of the [batch family](#two-command-families) — a bulk listing utility typically used to discover packages before a `config export`. + Packages can be listed using the following command: ```bash @@ -73,6 +105,8 @@ content-cli config list -p --packageKeys key1 ... keyN ## Batch Export Packages +> **Batch command.** Part of the [batch family](#two-command-families). It produces a multi-package **batch archive** that can only be re-imported with [`config import`](#batch-import-packages) or inspected with [`config diff`](#diff-local-zip-with-deployed-versionspecific-versionstaging) — **not** with `config package import`. To work with one package, use [`config package import`](#package-commands-config-package). + Packages can be exported using the following command: ```bash @@ -127,6 +161,8 @@ Inside the nodes directory, a file for each node will be present: ## Batch Import Packages +> **Batch command.** Part of the [batch family](#two-command-families). It expects a multi-package **batch archive** produced by [`config export`](#batch-export-packages). To import one package from a package zip, use [`config package import`](#package-commands-config-package) instead. + Packages can be imported using the following commands, if importing from a zip file: ```bash @@ -177,6 +213,81 @@ content-cli config import -p -d --validate --overwr `config import --validate` runs the **SCHEMA** layer only. It does **not** run BUSINESS-layer checks (PQL parsing, data-model availability, KPI uniqueness, etc.) or PACKAGE_SETTINGS checks (package dependencies, variables, and flavor-specific package settings). To run those validations, use [`config validate`](#validate-package-configurations) after the import. +## Package Commands (`config package`) + +The `config package` command group works with a package and its contents. It is **not** part of the batch-specific set — it uses the plain package format described below, which is not interchangeable with the batch archive (see [Two command families](#two-command-families)). + +### Import a Package + +`config package import` imports a package from a package zip (or directory). Unlike [`config import`](#batch-import-packages) — which performs a **batch** import and expects the multi-package batch archive (`manifest.json`, a top-level `variables.json`, `studio.json`, and a nested `_.zip` per package) — `config package import` takes a plain, flat package layout and imports it on its own. + +> A zip produced by `config export` is a **batch archive** and cannot be imported with `config package import`. Likewise, a package zip cannot be imported with `config import`. Use the command that matches how the artifact was produced. + +```bash +content-cli config package import -p -f +``` + +Where `-f` is the shorthand for `--file`. You can also point at an unzipped directory with `-d` / `--directory`, in which case the CLI zips it for you before uploading: + +```bash +content-cli config package import -p -d +``` + +`--file` and `--directory` are mutually exclusive — provide exactly one. + +This command requires **edit permission** on the target package, or **create** permission when the package does not yet exist (see [Permissions](#permissions)). + +#### Package Zip Format + +The zip (or directory) must contain a package in the following flat layout: + +```bash +package/ +├─ package.json # package metadata and configuration (key, name, type, flavor, configuration) +├─ variables.json # optional — variable assignments for the package +├─ nodes/ +│ ├─ .json # one file per node +│ ├─ ... +``` + +- `package.json` is the **source of truth for variable declarations**. Every assignment in `variables.json` must reference a variable declared in `package.json`, otherwise the import is rejected. +- `variables.json` is optional. If you do not want to import variable assignments, simply omit the file. + +This is intentionally different from the batch archive: there is no `manifest.json`, no `studio.json`, and no nested per-package zips — just the one package's files at the top level. + +#### Overwriting an Existing Package + +By default the import fails if a package with the same key already exists. Use `--overwrite` to replace it: + +```bash +content-cli config package import -p -f --overwrite +``` + +When overwriting, variable assignments whose key is no longer declared in the imported `package.json` are pruned, keeping declarations and assignments consistent. + +#### Output + +On success, the command prints a summary of the imported package and its nodes to the console: + +```bash +info: Successfully imported package: my-package +info: Name: My Package +info: Flavor: STUDIO +info: Imported 2 node(s). +info: - my-view (VIEW) +info: - my-knowledge-model (KNOWLEDGE_MODEL) +``` + +Use `--json` to write the full import result (imported package and nodes) to a JSON file in the current working directory instead: + +```bash +content-cli config package import -p -f --json +``` + +```bash +info: File downloaded successfully. New filename: 9560f81f-f746-4117-83ee-dd1f614ad624.json +``` + ## Validate Package Configurations The `config validate` command validates the **staging (draft) version** of a package by sending its nodes through one or more validation layers. The command runs against the Pacman validate API and returns a structured report of errors, warnings, and info findings. @@ -832,6 +943,8 @@ content-cli config nodes dependencies list --packageKey --nodeKey < ## Diff local zip with deployed version/specific version/staging +> **Batch command.** Part of the [batch family](#two-command-families). It expects a multi-package **batch archive** produced by [`config export`](#batch-export-packages); a package zip is not supported here. + To compare local zipped packages with online packages use: ```bash content-cli config diff --file diff --git a/src/commands/configuration-management/api/single-package-import-api.ts b/src/commands/configuration-management/api/single-package-import-api.ts new file mode 100644 index 0000000..2c786ba --- /dev/null +++ b/src/commands/configuration-management/api/single-package-import-api.ts @@ -0,0 +1,21 @@ +import * as FormData from "form-data"; +import { HttpClient } from "../../../core/http/http-client"; +import { Context } from "../../../core/command/cli-context"; +import { SinglePackageImportResult } from "../interfaces/single-package-import.interfaces"; + +export class SinglePackageImportApi { + + private httpClient: () => HttpClient; + + constructor(context: Context) { + this.httpClient = () => context.httpClient; + } + + public async importPackage(data: FormData, overwrite: boolean): Promise { + return this.httpClient().postFile( + "/pacman/api/core/staging/packages/import-file", + data, + { overwrite } + ); + } +} diff --git a/src/commands/configuration-management/interfaces/single-package-import.interfaces.ts b/src/commands/configuration-management/interfaces/single-package-import.interfaces.ts new file mode 100644 index 0000000..0c34d52 --- /dev/null +++ b/src/commands/configuration-management/interfaces/single-package-import.interfaces.ts @@ -0,0 +1,21 @@ +import { NodeTransport } from "./node.interfaces"; + +export interface PackageTransport { + id: string; + key: string; + name: string; + type?: string; + version?: string; + flavor?: string; + schemaVersion?: number; + createdBy?: string; + updatedBy?: string; + creationDate?: string; + changeDate?: string; + [key: string]: any; +} + +export interface SinglePackageImportResult { + importedPackage: PackageTransport; + importedNodes: NodeTransport[]; +} diff --git a/src/commands/configuration-management/module.ts b/src/commands/configuration-management/module.ts index bd643f0..ebbecb9 100644 --- a/src/commands/configuration-management/module.ts +++ b/src/commands/configuration-management/module.ts @@ -12,13 +12,15 @@ import { NodeDiffService } from "./node-diff.service"; import { NodeDependencyService } from "./node-dependency.service"; import { PackageVersionCommandService } from "./package-version-command.service"; import { PackageValidationService } from "./package-validation.service"; +import { SinglePackageImportService } from "./single-package-import.service"; class Module extends IModule { public register(context: Context, configurator: Configurator): void { - const configCommand = configurator.command("config"); + const configCommand = configurator.command("config") + .description("Manage package configurations. Most commands work with a package and its resources. The batch commands (list, export, import, diff, metadata) are a separate, batch-specific set for bulk multi-package transport: their archive format is only understood by other batch commands and is not interchangeable with the package commands."); configCommand.command("list") - .description("Command to list packages") + .description("[Batch] List packages in the target team. Part of the batch export/import workflow.") .option("--json", "Return response as json type", "") .option("--flavors ", "Lists only packages of the given flavors") .option("--withDependencies", "Include dependencies", "") @@ -31,7 +33,7 @@ class Module extends IModule { .action(this.listPackages); configCommand.command("export") - .description("Command to export package configs") + .description("[Batch] Export one or more packages into a batch archive. The archive only works with the batch commands ('config import' / 'config diff') and cannot be imported with 'config package import'.") .option("--packageKeys ", "Keys of packages to export. Exports the latest deployed version only") .option("--keysByVersion ", "Keys of packages to export by version") .option("--withDependencies", "Include variables and dependencies", "") @@ -45,13 +47,13 @@ class Module extends IModule { metadataCommand .command("export") - .description("Command to show whether packages have unpublished changes") + .description("[Batch] Show whether multiple packages have unpublished changes (bulk metadata export).") .requiredOption("--packageKeys ", "Keys of packages to find the metadata of") .option("--json", "Return response as json type", "") .action(this.batchExportPackagesMetadata); configCommand.command("import") - .description("Command to import package configs") + .description("[Batch] Import packages from a batch archive produced by 'config export'. To import one package, use 'config package import' instead.") .option("--overwrite", "Flag to allow overwriting of packages") .option("--validate", "Validate node configurations before import", false) .option("--gitProfile ", "Git profile which you want to use for the Git operations") @@ -60,6 +62,17 @@ class Module extends IModule { .option("-d, --directory ", "Exported packages directory (relative path)") .action(this.batchImportPackages); + const packageCommand = configCommand.command("package") + .description("Commands for working with a package."); + + packageCommand.command("import") + .description("Import a package from a zip file or directory. Uses the package format, which is not interchangeable with the batch 'config export' / 'config import' archive.") + .option("-f, --file ", "Package zip file (relative path)") + .option("-d, --directory ", "Package directory (relative path)") + .option("--overwrite", "Flag to allow overwriting an existing package with the same key") + .option("--json", "Return the response as a JSON file") + .action(this.importSinglePackage); + configCommand.command("validate") .description("Validate package node configurations") .requiredOption("--packageKey ", "Key of the package to validate") @@ -73,7 +86,7 @@ class Module extends IModule { .action(this.validatePackage); configCommand.command("diff") - .description("Command to diff configs of packages") + .description("[Batch] Diff a local batch archive (from 'config export') against deployed or staging packages.") .option("--hasChanges", "Flag to return only the information if the package has changes without the actual changes") .option("--baseVersion ", "Compare against a given version or STAGING") .option("--json", "Return the response as a JSON file") @@ -256,6 +269,10 @@ class Module extends IModule { await new ConfigCommandService(context).batchImportPackages(options.file, options.directory, options.overwrite, options.gitBranch, options.validate); } + private async importSinglePackage(context: Context, command: Command, options: OptionValues): Promise { + await new SinglePackageImportService(context).importPackage(options.file, options.directory, options.overwrite, options.json); + } + private async diffPackages(context: Context, command: Command, options: OptionValues): Promise { await new ConfigCommandService(context).diffPackages(options.file, options.hasChanges, options.baseVersion, options.json); } diff --git a/src/commands/configuration-management/single-package-import.service.ts b/src/commands/configuration-management/single-package-import.service.ts new file mode 100644 index 0000000..72f7820 --- /dev/null +++ b/src/commands/configuration-management/single-package-import.service.ts @@ -0,0 +1,103 @@ +import { v4 as uuidv4 } from "uuid"; +import * as FormData from "form-data"; +import { Readable } from "stream"; +import * as AdmZip from "adm-zip"; +import * as fs from "fs"; +import { Context } from "../../core/command/cli-context"; +import { fileService, FileService } from "../../core/utils/file-service"; +import { logger } from "../../core/utils/logger"; +import { SinglePackageImportApi } from "./api/single-package-import-api"; +import { SinglePackageImportResult } from "./interfaces/single-package-import.interfaces"; + +export class SinglePackageImportService { + + private static readonly MAX_UNCOMPRESSED_ZIP_SIZE = 4 * 1024 * 1024 * 1024; + + private singlePackageImportApi: SinglePackageImportApi; + + constructor(context: Context) { + this.singlePackageImportApi = new SinglePackageImportApi(context); + } + + public async importPackage(file: string, directory: string, overwrite: boolean, jsonResponse: boolean): Promise { + const resolvedSource = this.resolveSource(file, directory); + + try { + const packageZip = new AdmZip(resolvedSource.zipPath); + const formData = this.buildBodyForImport(packageZip, resolvedSource.zipPath); + const result = await this.singlePackageImportApi.importPackage(formData, overwrite); + this.outputResult(result, jsonResponse); + } finally { + if (resolvedSource.isTemporary) { + fs.rmSync(resolvedSource.zipPath); + } + } + } + + private resolveSource(file: string, directory: string): { zipPath: string; isTemporary: boolean } { + if (file && directory) { + throw new Error("You cannot use both --file and --directory options at the same time. Only one import source can be defined."); + } + if (!file && !directory) { + throw new Error("You must provide either a --file or a --directory option to import a package."); + } + if (file) { + if (fileService.isDirectory(file)) { + throw new Error("The --file option accepts only zip files."); + } + return { zipPath: file, isTemporary: false }; + } + if (!fileService.isDirectory(directory)) { + throw new Error("The --directory option accepts only directories."); + } + return { zipPath: fileService.zipDirectoryAsSinglePackage(directory), isTemporary: true }; + } + + private buildBodyForImport(packageZip: AdmZip, sourcePath: string): FormData { + this.assertUncompressedSizeWithinLimit(packageZip, sourcePath); + + const formData = new FormData(); + formData.append("packageFile", this.getReadableStream(packageZip), { filename: "package.zip" }); + return formData; + } + + private assertUncompressedSizeWithinLimit(packageZip: AdmZip, sourcePath: string): void { + const totalUncompressedBytes = packageZip.getEntries().reduce((sum, entry) => sum + entry.header.size, 0); + if (totalUncompressedBytes > SinglePackageImportService.MAX_UNCOMPRESSED_ZIP_SIZE) { + throw new Error( + `Failed to handle zip file "${sourcePath}": uncompressed size ${(totalUncompressedBytes / (1024 ** 3)).toFixed(2)} GB exceeds the 4 GB limit.` + ); + } + } + + private getReadableStream(packageZip: AdmZip): Readable { + return new Readable({ + read(): void { + this.push(packageZip.toBuffer()); + this.push(null); + }, + }); + } + + private outputResult(result: SinglePackageImportResult, jsonResponse: boolean): void { + if (jsonResponse) { + const filename = uuidv4() + ".json"; + fileService.writeToFileWithGivenName(JSON.stringify(result, null, 2), filename); + logger.info(FileService.fileDownloadedMessage + filename); + return; + } + + const importedPackage = result.importedPackage; + const importedNodes = result.importedNodes ?? []; + + logger.info(`Successfully imported package: ${importedPackage.key}`); + logger.info(`Name: ${importedPackage.name}`); + if (importedPackage.flavor) { + logger.info(`Flavor: ${importedPackage.flavor}`); + } + logger.info(`Imported ${importedNodes.length} node(s).`); + importedNodes.forEach(node => { + logger.info(` - ${node.key} (${node.type})`); + }); + } +} diff --git a/src/core/utils/file-service.ts b/src/core/utils/file-service.ts index 2f6ce9e..cbf232b 100644 --- a/src/core/utils/file-service.ts +++ b/src/core/utils/file-service.ts @@ -90,6 +90,22 @@ export class FileService { return zipFilePath; } + public zipDirectoryAsSinglePackage(sourceDir: string): string { + if (fs.lstatSync(sourceDir).isSymbolicLink()) { + throw new FatalError("Source directory cannot be a symbolic link."); + } + + const zip = new AdmZip(); + zip.addLocalFolder(sourceDir); + + const tempDir = path.join(os.tmpdir(), "content-cli-imports"); + fs.mkdirSync(tempDir, { recursive: true, mode: FileConstants.DEFAULT_FOLDER_PERMISSIONS }); + const zipFilePath = path.join(tempDir, `single_package_${uuidv4()}.zip`); + zip.writeZip(zipFilePath, () => fs.chmodSync(zipFilePath, FileConstants.DEFAULT_FILE_PERMISSIONS)); + + return zipFilePath; + } + private getSerializedFileContent(data: any): string { return data; diff --git a/tests/commands/configuration-management/module.spec.ts b/tests/commands/configuration-management/module.spec.ts index fc7f77d..80ef7ea 100644 --- a/tests/commands/configuration-management/module.spec.ts +++ b/tests/commands/configuration-management/module.spec.ts @@ -4,6 +4,7 @@ import { ConfigCommandService } from "../../../src/commands/configuration-manage import { NodeDependencyService } from "../../../src/commands/configuration-management/node-dependency.service"; import { PackageVersionCommandService } from "../../../src/commands/configuration-management/package-version-command.service"; import { NodeDiffService } from "../../../src/commands/configuration-management/node-diff.service"; +import { SinglePackageImportService } from "../../../src/commands/configuration-management/single-package-import.service"; import { testContext } from "../../utls/test-context"; import { createMockConfigurator } from "../../utls/configurator-mock"; @@ -11,6 +12,7 @@ jest.mock("../../../src/commands/configuration-management/config-command.service jest.mock("../../../src/commands/configuration-management/node-dependency.service"); jest.mock("../../../src/commands/configuration-management/node-diff.service"); jest.mock("../../../src/commands/configuration-management/package-version-command.service"); +jest.mock("../../../src/commands/configuration-management/single-package-import.service"); /** Mirrors default values on `config variables list` Commander options (keep in sync with module.ts). */ const variablesListOptionDefaults: OptionValues = { @@ -25,6 +27,7 @@ describe("Configuration Management Module - Action Validations", () => { let mockConfigCommandService: jest.Mocked; let mockNodeDependencyService: jest.Mocked; let mockNodeDiffService: jest.Mocked; + let mockSinglePackageImportService: jest.Mocked; beforeEach(() => { jest.clearAllMocks(); @@ -48,9 +51,14 @@ describe("Configuration Management Module - Action Validations", () => { diffWithFile: jest.fn().mockResolvedValue(undefined), } as any; + mockSinglePackageImportService = { + importPackage: jest.fn().mockResolvedValue(undefined), + } as any; + (ConfigCommandService as jest.MockedClass).mockImplementation(() => mockConfigCommandService); (NodeDependencyService as jest.MockedClass).mockImplementation(() => mockNodeDependencyService); (NodeDiffService as jest.MockedClass).mockImplementation(() => mockNodeDiffService); + (SinglePackageImportService as jest.MockedClass).mockImplementation(() => mockSinglePackageImportService); }); describe("listActivePackages validation", () => { @@ -537,6 +545,55 @@ describe("Configuration Management Module - Action Validations", () => { }); }); + describe("importSinglePackage", () => { + it("should pass file option correctly", async () => { + const options: OptionValues = { + file: "single-package.zip", + }; + + await (module as any).importSinglePackage(testContext, mockCommand, options); + + expect(mockSinglePackageImportService.importPackage).toHaveBeenCalledWith( + "single-package.zip", + undefined, + undefined, + undefined + ); + }); + + it("should pass directory option correctly", async () => { + const options: OptionValues = { + directory: "./single-package-dir", + }; + + await (module as any).importSinglePackage(testContext, mockCommand, options); + + expect(mockSinglePackageImportService.importPackage).toHaveBeenCalledWith( + undefined, + "./single-package-dir", + undefined, + undefined + ); + }); + + it("should pass overwrite and json options correctly", async () => { + const options: OptionValues = { + file: "single-package.zip", + overwrite: true, + json: true, + }; + + await (module as any).importSinglePackage(testContext, mockCommand, options); + + expect(mockSinglePackageImportService.importPackage).toHaveBeenCalledWith( + "single-package.zip", + undefined, + true, + true + ); + }); + }); + describe("listVariables validation", () => { it("should throw when --packageKeys and --keysByVersion are both provided", async () => { const options: OptionValues = { @@ -725,7 +782,7 @@ describe("Configuration Management Module - Action Validations", () => { // Each leaf command terminates the fluent chain with .action(handler). // Keep this count in sync when adding or removing commands in module.ts. - const expectedLeafCommands = 17; + const expectedLeafCommands = 18; expect(mockConfigurator.action).toHaveBeenCalledTimes(expectedLeafCommands); for (const call of mockConfigurator.action.mock.calls) { expect(typeof call[0]).toBe("function"); diff --git a/tests/commands/configuration-management/single-package-import.spec.ts b/tests/commands/configuration-management/single-package-import.spec.ts new file mode 100644 index 0000000..b001534 --- /dev/null +++ b/tests/commands/configuration-management/single-package-import.spec.ts @@ -0,0 +1,189 @@ +import * as path from "path"; +import * as fs from "fs"; +import AdmZip = require("adm-zip"); +import { mockCreateReadStream, mockExistsSync, mockReadFileSync } from "../../utls/fs-mock-utils"; + +jest.mock("adm-zip", () => { + const realAdmZip = jest.requireActual("adm-zip"); + return jest.fn((...args: any[]) => realAdmZip(...args)); +}); + +import { mockAxiosPost, mockedAxiosInstance, mockedPostRequestBodyByUrl } from "../../utls/http-requests-mock"; +import { SinglePackageImportService } from "../../../src/commands/configuration-management/single-package-import.service"; +import { SinglePackageImportResult } from "../../../src/commands/configuration-management/interfaces/single-package-import.interfaces"; +import { testContext } from "../../utls/test-context"; +import { loggingTestTransport, mockWriteFileSync } from "../../jest.setup"; +import { FileService } from "../../../src/core/utils/file-service"; + +const IMPORT_URL = "https://myTeam.celonis.cloud/pacman/api/core/staging/packages/import-file"; + +function buildSinglePackageZip(): AdmZip { + const zip = new AdmZip(); + zip.addFile("package.json", Buffer.from(JSON.stringify({ + key: "pkg-1", + name: "My Package", + type: "PACKAGE", + flavor: "STUDIO", + schemaVersion: 1, + configuration: { variables: [] }, + }))); + zip.addFile("nodes/", Buffer.alloc(0)); + zip.addFile("nodes/node-1.json", Buffer.from(JSON.stringify({ key: "node-1", type: "VIEW" }))); + return zip; +} + +function buildImportResponse(): SinglePackageImportResult { + return { + importedPackage: { + id: "package-id-1", + key: "pkg-1", + name: "My Package", + flavor: "STUDIO", + }, + importedNodes: [ + { + id: "node-id-1", + key: "node-1", + name: "My View", + packageNodeKey: "pkg-1", + packageNodeId: "package-node-id", + type: "VIEW", + invalidContent: false, + creationDate: "2025-01-01T00:00:00.000Z", + changeDate: "2025-01-01T00:00:00.000Z", + createdBy: "user@celonis.com", + updatedBy: "user@celonis.com", + schemaVersion: 1, + }, + ], + }; +} + +describe("Single package import", () => { + + beforeEach(() => { + mockExistsSync(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it.each([true, false])("Should import a single package from a zip file with overwrite %p", async (overwrite: boolean) => { + const packageZip = buildSinglePackageZip(); + mockReadFileSync(packageZip.toBuffer()); + mockCreateReadStream(packageZip.toBuffer()); + + const importResponse = buildImportResponse(); + mockAxiosPost(IMPORT_URL, importResponse); + + await new SinglePackageImportService(testContext).importPackage("./package.zip", null, overwrite, false); + + expect(mockedAxiosInstance.post).toHaveBeenCalledWith( + IMPORT_URL, + expect.anything(), + expect.objectContaining({ params: { overwrite } }) + ); + expect(loggingTestTransport.logMessages[0].message).toContain("Successfully imported package: pkg-1"); + expect(loggingTestTransport.logMessages[3].message).toContain("Imported 1 node(s)."); + }); + + it("Should import a single package from a directory by zipping it first", async () => { + const packageZip = buildSinglePackageZip(); + const temporaryZipPath = "/tmp/content-cli-imports/single_package_test.zip"; + + jest.spyOn(FileService.prototype, "isDirectory").mockReturnValue(true); + const zipDirectorySpy = jest.spyOn(FileService.prototype, "zipDirectoryAsSinglePackage").mockReturnValue(temporaryZipPath); + mockReadFileSync(packageZip.toBuffer()); + mockCreateReadStream(packageZip.toBuffer()); + + const importResponse = buildImportResponse(); + mockAxiosPost(IMPORT_URL, importResponse); + + await new SinglePackageImportService(testContext).importPackage(null, "./package-dir", true, false); + + expect(zipDirectorySpy).toHaveBeenCalledWith("./package-dir"); + expect(mockedAxiosInstance.post).toHaveBeenCalledWith(IMPORT_URL, expect.anything(), expect.anything()); + expect(fs.rmSync).toHaveBeenCalledWith(temporaryZipPath); + }); + + it("Should write the import result to a json file when jsonResponse is true", async () => { + const packageZip = buildSinglePackageZip(); + mockReadFileSync(packageZip.toBuffer()); + mockCreateReadStream(packageZip.toBuffer()); + + const importResponse = buildImportResponse(); + mockAxiosPost(IMPORT_URL, importResponse); + + await new SinglePackageImportService(testContext).importPackage("./package.zip", null, false, true); + + const expectedFileName = loggingTestTransport.logMessages[0].message.split(FileService.fileDownloadedMessage)[1]; + expect(mockWriteFileSync).toHaveBeenCalledWith( + path.resolve(process.cwd(), expectedFileName), + JSON.stringify(importResponse, null, 2), + { encoding: "utf-8", mode: 0o600 } + ); + }); + + it("Should pass the overwrite flag to the API", async () => { + const packageZip = buildSinglePackageZip(); + mockReadFileSync(packageZip.toBuffer()); + mockCreateReadStream(packageZip.toBuffer()); + + mockAxiosPost(IMPORT_URL, buildImportResponse()); + + await new SinglePackageImportService(testContext).importPackage("./package.zip", null, true, false); + + expect(mockedPostRequestBodyByUrl.has(IMPORT_URL)).toBe(true); + expect(mockedAxiosInstance.post).toHaveBeenCalledWith( + IMPORT_URL, + expect.anything(), + expect.objectContaining({ params: { overwrite: true } }) + ); + }); + + it("Should throw when both --file and --directory are provided", async () => { + await expect( + new SinglePackageImportService(testContext).importPackage("./package.zip", "./package-dir", false, false) + ).rejects.toThrow("You cannot use both --file and --directory options at the same time. Only one import source can be defined."); + }); + + it("Should throw when neither --file nor --directory is provided", async () => { + await expect( + new SinglePackageImportService(testContext).importPackage(null, null, false, false) + ).rejects.toThrow("You must provide either a --file or a --directory option to import a package."); + }); + + it("Should throw when the --file option points to a directory", async () => { + jest.spyOn(FileService.prototype, "isDirectory").mockReturnValue(true); + + await expect( + new SinglePackageImportService(testContext).importPackage("./package-dir", null, false, false) + ).rejects.toThrow("The --file option accepts only zip files."); + }); + + it("Should throw when the --directory option points to a file", async () => { + jest.spyOn(FileService.prototype, "isDirectory").mockReturnValue(false); + + await expect( + new SinglePackageImportService(testContext).importPackage(null, "./package.zip", false, false) + ).rejects.toThrow("The --directory option accepts only directories."); + }); + + it("Should throw when the uncompressed zip size exceeds the 4 GB limit", async () => { + const packageZip = buildSinglePackageZip(); + mockReadFileSync(packageZip.toBuffer()); + mockCreateReadStream(packageZip.toBuffer()); + + const FIVE_GB = 5 * 1024 * 1024 * 1024; + (AdmZip as unknown as jest.Mock).mockImplementationOnce((...args: any[]) => { + const instance = jest.requireActual("adm-zip")(...args); + instance.getEntries = () => [{ header: { size: FIVE_GB } }]; + return instance; + }); + + await expect( + new SinglePackageImportService(testContext).importPackage("./package.zip", null, false, false) + ).rejects.toThrow('Failed to handle zip file "./package.zip": uncompressed size 5.00 GB exceeds the 4 GB limit.'); + }); +}); diff --git a/tests/core/utils/file.service.spec.ts b/tests/core/utils/file.service.spec.ts index 46e5dc0..ca11f68 100644 --- a/tests/core/utils/file.service.spec.ts +++ b/tests/core/utils/file.service.spec.ts @@ -82,4 +82,31 @@ describe("FileService", () => { expect(entries).not.toContain("fileSymlink.txt"); }); }); + + describe("zipDirectoryAsSinglePackage", () => { + test("Should throw error if sourceDir is a symlink", () => { + const targetDir = path.join(tempDir, "realPackageDir"); + fs.mkdirSync(targetDir, 0o700); + + const symlinkDir = path.join(tempDir, "symlinkPackageDir"); + fs.symlinkSync(targetDir, symlinkDir, "dir"); + + expect(() => fileService.zipDirectoryAsSinglePackage(symlinkDir)).toThrow(FatalError); + }); + + test("Should create a flat zip preserving the single package structure", () => { + fs.writeFileSync(path.join(tempDir, "package.json"), JSON.stringify({ key: "pkg-1" })); + const nodesDir = path.join(tempDir, "nodes"); + fs.mkdirSync(nodesDir, 0o700); + fs.writeFileSync(path.join(nodesDir, "node-1.json"), JSON.stringify({ key: "node-1" })); + + const zipPath = fileService.zipDirectoryAsSinglePackage(tempDir); + const zip = new AdmZip(zipPath); + const entries = zip.getEntries().map(e => e.entryName); + + expect(entries).toContain("package.json"); + expect(entries).toContain("nodes/node-1.json"); + expect(entries.some(name => name.endsWith(".zip"))).toBe(false); + }); + }); }); From c01de65f7176c6d9406b7fbb0e7b72ee9ccee099 Mon Sep 17 00:00:00 2001 From: zgjimhaziri Date: Fri, 5 Jun 2026 14:22:51 +0200 Subject: [PATCH 2/2] SP-874: address Sonar code smells (readonly members, node: import prefixes) Mark injected members as readonly and use node: import prefixes for built-in modules. The single-package-import test mocks node:fs explicitly so the temp file cleanup is intercepted (the global fs mock only covers the bare specifier). Includes-AI-Code: true Co-authored-by: Cursor --- .../api/single-package-import-api.ts | 2 +- .../single-package-import.service.ts | 6 +++--- .../configuration-management/single-package-import.spec.ts | 7 ++++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/commands/configuration-management/api/single-package-import-api.ts b/src/commands/configuration-management/api/single-package-import-api.ts index 2c786ba..8afc237 100644 --- a/src/commands/configuration-management/api/single-package-import-api.ts +++ b/src/commands/configuration-management/api/single-package-import-api.ts @@ -5,7 +5,7 @@ import { SinglePackageImportResult } from "../interfaces/single-package-import.i export class SinglePackageImportApi { - private httpClient: () => HttpClient; + private readonly httpClient: () => HttpClient; constructor(context: Context) { this.httpClient = () => context.httpClient; diff --git a/src/commands/configuration-management/single-package-import.service.ts b/src/commands/configuration-management/single-package-import.service.ts index 72f7820..fb6f803 100644 --- a/src/commands/configuration-management/single-package-import.service.ts +++ b/src/commands/configuration-management/single-package-import.service.ts @@ -1,8 +1,8 @@ import { v4 as uuidv4 } from "uuid"; import * as FormData from "form-data"; -import { Readable } from "stream"; +import { Readable } from "node:stream"; import * as AdmZip from "adm-zip"; -import * as fs from "fs"; +import * as fs from "node:fs"; import { Context } from "../../core/command/cli-context"; import { fileService, FileService } from "../../core/utils/file-service"; import { logger } from "../../core/utils/logger"; @@ -13,7 +13,7 @@ export class SinglePackageImportService { private static readonly MAX_UNCOMPRESSED_ZIP_SIZE = 4 * 1024 * 1024 * 1024; - private singlePackageImportApi: SinglePackageImportApi; + private readonly singlePackageImportApi: SinglePackageImportApi; constructor(context: Context) { this.singlePackageImportApi = new SinglePackageImportApi(context); diff --git a/tests/commands/configuration-management/single-package-import.spec.ts b/tests/commands/configuration-management/single-package-import.spec.ts index b001534..5d74973 100644 --- a/tests/commands/configuration-management/single-package-import.spec.ts +++ b/tests/commands/configuration-management/single-package-import.spec.ts @@ -1,8 +1,13 @@ import * as path from "path"; -import * as fs from "fs"; +import * as fs from "node:fs"; import AdmZip = require("adm-zip"); import { mockCreateReadStream, mockExistsSync, mockReadFileSync } from "../../utls/fs-mock-utils"; +// The service imports `node:fs`; the global jest.mock("fs") in jest.setup only +// covers the bare "fs" specifier, so mock the prefixed module explicitly to +// intercept the temp-file cleanup (fs.rmSync). +jest.mock("node:fs"); + jest.mock("adm-zip", () => { const realAdmZip = jest.requireActual("adm-zip"); return jest.fn((...args: any[]) => realAdmZip(...args));