Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/user-guide/asset-registry-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,13 @@ Options:

- `--assetType <assetType>` (required) – The asset type identifier
- `--json` – Write the examples to a JSON file in the working directory

## Troubleshooting

If the asset registry is disabled on your team (feature flag `pacman.asset-registry` is off), commands fail with:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's useful information for the user to know the feature flag string.

Suggested change
If the asset registry is disabled on your team (feature flag `pacman.asset-registry` is off), commands fail with:
If the asset registry is disabled on your team, commands fail with:


```
Asset registry is not enabled for this team. Contact your administrator to enable the feature.
```

This replaces the raw API error and indicates the feature is turned off server-side, not a permissions or connectivity issue on your CLI profile.
26 changes: 7 additions & 19 deletions src/commands/asset-registry/asset-registry-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
AssetRegistryDescriptor,
AssetRegistryMetadata,
} from "./asset-registry.interfaces";
import { FatalError } from "../../core/utils/logger";
import { handleAssetRegistryApiError } from "./asset-registry-error";

export class AssetRegistryApi {
private httpClient: () => HttpClient;
Expand All @@ -17,48 +17,36 @@ export class AssetRegistryApi {
public async listTypes(): Promise<AssetRegistryMetadata> {
return this.httpClient()
.get("/pacman/api/core/asset-registry/types")
.catch((e) => {
throw new FatalError(`Problem listing asset registry types: ${e}`);
});
.catch((e) => handleAssetRegistryApiError("listing asset registry types", e));
}

public async listSkills(): Promise<AgentSkillsResponse> {
return this.httpClient()
.get("/pacman/api/core/asset-registry/skills")
.catch((e) => {
throw new FatalError(`Problem listing asset registry skills: ${e}`);
});
.catch((e) => handleAssetRegistryApiError("listing asset registry skills", e));
}

public async getType(assetType: string): Promise<AssetRegistryDescriptor> {
return this.httpClient()
.get(`/pacman/api/core/asset-registry/types/${encodeURIComponent(assetType)}`)
.catch((e) => {
throw new FatalError(`Problem getting asset type '${assetType}': ${e}`);
});
.catch((e) => handleAssetRegistryApiError(`getting asset type '${assetType}'`, e));
}

public async getSchema(assetType: string): Promise<any> {
return this.httpClient()
.get(`/pacman/api/core/asset-registry/schemas/${encodeURIComponent(assetType)}`)
.catch((e) => {
throw new FatalError(`Problem getting schema for asset type '${assetType}': ${e}`);
});
.catch((e) => handleAssetRegistryApiError(`getting schema for asset type '${assetType}'`, e));
}

public async getExamples(assetType: string): Promise<any> {
return this.httpClient()
.get(`/pacman/api/core/asset-registry/examples/${encodeURIComponent(assetType)}`)
.catch((e) => {
throw new FatalError(`Problem getting examples for asset type '${assetType}': ${e}`);
});
.catch((e) => handleAssetRegistryApiError(`getting examples for asset type '${assetType}'`, e));
}

public async validate(assetType: string, body: any): Promise<any> {
return this.httpClient()
.post(`/pacman/api/core/asset-registry/validate/${encodeURIComponent(assetType)}`, body)
.catch((e) => {
throw new FatalError(`Problem validating asset type '${assetType}': ${e}`);
});
.catch((e) => handleAssetRegistryApiError(`validating asset type '${assetType}'`, e));
}
}
53 changes: 53 additions & 0 deletions src/commands/asset-registry/asset-registry-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { FatalError } from "../../core/utils/logger";

export const ASSET_REGISTRY_DISABLED_ERROR = "Asset registry feature is currently disabled";

export const ASSET_REGISTRY_DISABLED_USER_MESSAGE =
"Asset registry is not enabled for this team. Contact your administrator to enable the feature.";

function extractErrorText(error: unknown): string {
if (typeof error === "string") {
return error;
}
if (error instanceof Error) {
return error.message;
}
return String(error);

Check warning on line 15 in src/commands/asset-registry/asset-registry-error.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'error' will use Object's default stringification format ('[object Object]') when stringified.

See more on https://sonarcloud.io/project/issues?id=celonis_content-cli&issues=AZ5ka7tFkyB13zfFsQW6&open=AZ5ka7tFkyB13zfFsQW6&pullRequest=360
}

function parseErrorField(error: unknown): string | undefined {
const payload = extractJsonPayload(extractErrorText(error));
if (!payload) {
return undefined;
}

try {
const parsed = JSON.parse(payload) as { error?: unknown };
return typeof parsed.error === "string" ? parsed.error : undefined;
} catch {
return undefined;
}
}

function extractJsonPayload(text: string): string | undefined {
let candidate = text.trim();

const fatalErrorPrefix = "FatalError: ";
const fatalErrorIndex = candidate.lastIndexOf(fatalErrorPrefix);
if (fatalErrorIndex >= 0) {
candidate = candidate.slice(fatalErrorIndex + fatalErrorPrefix.length).trim();
}

if (!candidate.startsWith("{")) {
return undefined;
}

return candidate;
}

export function handleAssetRegistryApiError(operation: string, error: unknown): never {
if (parseErrorField(error) === ASSET_REGISTRY_DISABLED_ERROR) {
throw new FatalError(ASSET_REGISTRY_DISABLED_USER_MESSAGE);
}
throw new FatalError(`Problem ${operation}: ${extractErrorText(error)}`);
}
77 changes: 77 additions & 0 deletions tests/commands/asset-registry/asset-registry-error.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { FatalError } from "../../../src/core/utils/logger";
import {
ASSET_REGISTRY_DISABLED_ERROR,
ASSET_REGISTRY_DISABLED_USER_MESSAGE,
handleAssetRegistryApiError,
} from "../../../src/commands/asset-registry/asset-registry-error";
import { mockAxiosGetError } from "../../utls/http-requests-mock";
import { AssetRegistryService } from "../../../src/commands/asset-registry/asset-registry.service";
import { testContext } from "../../utls/test-context";

const TYPES_URL = "https://myTeam.celonis.cloud/pacman/api/core/asset-registry/types";
const SKILLS_URL = "https://myTeam.celonis.cloud/pacman/api/core/asset-registry/skills";

describe("Asset registry error handling", () => {
describe("handleAssetRegistryApiError", () => {
it("Should throw a friendly message when the asset registry feature flag is disabled", () => {
const errorBody = JSON.stringify({ error: ASSET_REGISTRY_DISABLED_ERROR });

expect(() => handleAssetRegistryApiError("listing asset registry types", errorBody))
.toThrow(new FatalError(ASSET_REGISTRY_DISABLED_USER_MESSAGE));
});

it("Should detect the disabled flag when the error is wrapped by HttpClient and AssetRegistryApi", () => {
const wrappedError = new FatalError(
`Problem listing asset registry types: FatalError: ${JSON.stringify({ error: ASSET_REGISTRY_DISABLED_ERROR })}`
);

expect(() => handleAssetRegistryApiError("listing asset registry types", wrappedError))
.toThrow(new FatalError(ASSET_REGISTRY_DISABLED_USER_MESSAGE));
});

it("Should preserve generic errors for other 403 responses", () => {
const errorBody = JSON.stringify({ error: "Access denied" });

expect(() => handleAssetRegistryApiError("listing asset registry types", errorBody))
.toThrow(new FatalError(`Problem listing asset registry types: ${errorBody}`));
});

it("Should preserve generic errors for 404 responses", () => {
const errorBody = JSON.stringify({ error: "Not found" });

expect(() => handleAssetRegistryApiError("getting asset type 'UNKNOWN'", errorBody))
.toThrow(new FatalError(`Problem getting asset type 'UNKNOWN': ${errorBody}`));
});

it("Should preserve generic errors for 500 responses", () => {
const errorBody = "Backend responded with status code 500";

expect(() => handleAssetRegistryApiError("getting schema for asset type 'BOARD_V2'", errorBody))
.toThrow(new FatalError(`Problem getting schema for asset type 'BOARD_V2': ${errorBody}`));
});
});

describe("AssetRegistryService integration", () => {
it("Should surface the friendly message when listing types and the feature flag is disabled", async () => {
mockAxiosGetError(TYPES_URL, 403, { error: ASSET_REGISTRY_DISABLED_ERROR });

await expect(new AssetRegistryService(testContext).listTypes(false))
.rejects.toThrow(new FatalError(ASSET_REGISTRY_DISABLED_USER_MESSAGE));
});

it("Should surface the friendly message when listing skills and the feature flag is disabled", async () => {
mockAxiosGetError(SKILLS_URL, 403, { error: ASSET_REGISTRY_DISABLED_ERROR });

await expect(new AssetRegistryService(testContext).listSkills(false))
.rejects.toThrow(new FatalError(ASSET_REGISTRY_DISABLED_USER_MESSAGE));
});

it("Should surface a generic error for other 403 responses", async () => {
const errorBody = { error: "Access denied" };
mockAxiosGetError(TYPES_URL, 403, errorBody);

await expect(new AssetRegistryService(testContext).listTypes(false))
.rejects.toThrow(/Problem listing asset registry types:/);
});
});
});
51 changes: 38 additions & 13 deletions tests/utls/http-requests-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { AxiosInitializer } from "../../src/core/http/axios-initializer";
const mockedAxiosInstance = {} as AxiosInstance;

const mockedGetResponseByUrl = new Map<string, any>();
const mockedGetErrorByUrl = new Map<string, { status: number; data: any }>();
const mockedPostResponseByUrl = new Map<string, any>();
const mockedPostErrorByUrl = new Map<string, { status: number; data: any }>();
const mockedPostRequestBodyByUrl = new Map<string, any>();
const mockedDeleteResponseByUrl = new Map<string, any>();

Expand All @@ -16,11 +18,12 @@ const mockAxios = () : void => {
mockedAxiosInstance.post = jest.fn();
mockedAxiosInstance.put = jest.fn();
mockedAxiosInstance.delete = jest.fn();
}

const mockAxiosGet = (url: string, responseData: any) => {
mockedGetResponseByUrl.set(url, responseData);
(mockedAxiosInstance.get as jest.Mock).mockImplementation(requestUrl => {
(mockedAxiosInstance.get as jest.Mock).mockImplementation((requestUrl: string) => {
if (mockedGetErrorByUrl.has(requestUrl)) {
const { status, data } = mockedGetErrorByUrl.get(requestUrl)!;
return Promise.reject({ response: { status, data } });
}
if (mockedGetResponseByUrl.has(requestUrl)) {
const response = { data: mockedGetResponseByUrl.get(requestUrl) };

Expand All @@ -35,27 +38,45 @@ const mockAxiosGet = (url: string, responseData: any) => {
} else {
return Promise.resolve(response);
}
} else {
fail("API call not mocked.")
}
fail("API call not mocked.")
});
};

const mockAxiosPost = (url: string, responseData: any) => {
mockedPostResponseByUrl.set(url, responseData);

(mockedAxiosInstance.post as jest.Mock).mockImplementation((requestUrl: string, data: any) => {
if (mockedPostErrorByUrl.has(requestUrl)) {
const { status, data: errorData } = mockedPostErrorByUrl.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);
} else {
fail("API call not mocked.")
}
})
fail("API call not mocked.")
});
}

const mockAxiosGet = (url: string, responseData: any) => {
mockedGetResponseByUrl.set(url, responseData);
mockedGetErrorByUrl.delete(url);
};

const mockAxiosGetError = (url: string, status: number, data: any) => {
mockedGetErrorByUrl.set(url, { status, data });
mockedGetResponseByUrl.delete(url);
};

const mockAxiosPost = (url: string, responseData: any) => {
mockedPostResponseByUrl.set(url, responseData);
mockedPostErrorByUrl.delete(url);
};

const mockAxiosPostError = (url: string, status: number, data: any) => {
mockedPostErrorByUrl.set(url, { status, data });
mockedPostResponseByUrl.delete(url);
};

const mockAxiosPut = (url: string, responseData: any) => {
mockedPostResponseByUrl.set(url, responseData);
(mockedAxiosInstance.put as jest.Mock).mockImplementation((requestUrl: string, data: any) => {
Expand Down Expand Up @@ -83,7 +104,9 @@ const mockAxiosDelete = (url: string) => {

afterEach(() => {
mockedGetResponseByUrl.clear();
mockedGetErrorByUrl.clear();
mockedPostResponseByUrl.clear();
mockedPostErrorByUrl.clear();
mockedPostRequestBodyByUrl.clear();
mockedDeleteResponseByUrl.clear();
})
Expand All @@ -92,7 +115,9 @@ export {
mockedAxiosInstance,
mockAxios,
mockAxiosGet,
mockAxiosGetError,
mockAxiosPost,
mockAxiosPostError,
mockAxiosPut,
mockAxiosDelete,
mockedPostRequestBodyByUrl
Expand Down
Loading