diff --git a/docs/user-guide/asset-registry-commands.md b/docs/user-guide/asset-registry-commands.md index e094d35..c333a55 100644 --- a/docs/user-guide/asset-registry-commands.md +++ b/docs/user-guide/asset-registry-commands.md @@ -148,3 +148,13 @@ Options: - `--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, 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. diff --git a/src/commands/asset-registry/asset-registry-api.ts b/src/commands/asset-registry/asset-registry-api.ts index f0b2c02..abb23aa 100644 --- a/src/commands/asset-registry/asset-registry-api.ts +++ b/src/commands/asset-registry/asset-registry-api.ts @@ -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; @@ -17,48 +17,36 @@ export class AssetRegistryApi { public async listTypes(): Promise { 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 { 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 { 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 { 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 { 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 { 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)); } } diff --git a/src/commands/asset-registry/asset-registry-error.ts b/src/commands/asset-registry/asset-registry-error.ts new file mode 100644 index 0000000..8842690 --- /dev/null +++ b/src/commands/asset-registry/asset-registry-error.ts @@ -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); +} + +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)}`); +} diff --git a/tests/commands/asset-registry/asset-registry-error.spec.ts b/tests/commands/asset-registry/asset-registry-error.spec.ts new file mode 100644 index 0000000..50decc2 --- /dev/null +++ b/tests/commands/asset-registry/asset-registry-error.spec.ts @@ -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:/); + }); + }); +}); diff --git a/tests/utls/http-requests-mock.ts b/tests/utls/http-requests-mock.ts index 8dea73f..6e4d325 100644 --- a/tests/utls/http-requests-mock.ts +++ b/tests/utls/http-requests-mock.ts @@ -5,7 +5,9 @@ import { AxiosInitializer } from "../../src/core/http/axios-initializer"; const mockedAxiosInstance = {} as AxiosInstance; const mockedGetResponseByUrl = new Map(); +const mockedGetErrorByUrl = new Map(); const mockedPostResponseByUrl = new Map(); +const mockedPostErrorByUrl = new Map(); const mockedPostRequestBodyByUrl = new Map(); const mockedDeleteResponseByUrl = new Map(); @@ -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) }; @@ -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) => { @@ -83,7 +104,9 @@ const mockAxiosDelete = (url: string) => { afterEach(() => { mockedGetResponseByUrl.clear(); + mockedGetErrorByUrl.clear(); mockedPostResponseByUrl.clear(); + mockedPostErrorByUrl.clear(); mockedPostRequestBodyByUrl.clear(); mockedDeleteResponseByUrl.clear(); }) @@ -92,7 +115,9 @@ export { mockedAxiosInstance, mockAxios, mockAxiosGet, + mockAxiosGetError, mockAxiosPost, + mockAxiosPostError, mockAxiosPut, mockAxiosDelete, mockedPostRequestBodyByUrl