Skip to content
Merged
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
9 changes: 3 additions & 6 deletions client/src/app/pages/advisory-upload/advisory-upload.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from "react";
import { Link } from "react-router-dom";

import type { AxiosError, AxiosResponse } from "axios";
import type { AxiosResponse } from "axios";

import {
Breadcrumb,
Expand All @@ -19,6 +19,7 @@ import { ReadOnlyContext } from "@app/components/ReadOnlyContext";
import { UploadFiles } from "@app/components/UploadFiles";
import { useUploadAdvisory } from "@app/queries/advisories";
import { Paths } from "@app/Routes";
import { getAxiosErrorMessage } from "@app/utils/utils";

export const AdvisoryUpload: React.FC = () => {
const { isReadOnly } = React.useContext(ReadOnlyContext);
Expand Down Expand Up @@ -72,11 +73,7 @@ export const AdvisoryUpload: React.FC = () => {
) => {
return `${response.data.document_id} uploaded`;
}}
extractErrorMessage={(error: AxiosError) =>
error.response?.data
? error.message
: "Error while uploading file"
}
extractErrorMessage={getAxiosErrorMessage}
/>
</PageSection>
</>
Expand Down
9 changes: 3 additions & 6 deletions client/src/app/pages/sbom-upload/sbom-upload.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from "react";
import { Link } from "react-router-dom";

import type { AxiosError, AxiosResponse } from "axios";
import type { AxiosResponse } from "axios";

import {
Breadcrumb,
Expand All @@ -19,6 +19,7 @@ import { ReadOnlyContext } from "@app/components/ReadOnlyContext";
import { UploadFiles } from "@app/components/UploadFiles";
import { useUploadSBOM } from "@app/queries/sboms";
import { Paths } from "@app/Routes";
import { getAxiosErrorMessage } from "@app/utils/utils";

export const SbomUpload: React.FC = () => {
const { isReadOnly } = React.useContext(ReadOnlyContext);
Expand Down Expand Up @@ -74,11 +75,7 @@ export const SbomUpload: React.FC = () => {
) => {
return `${response.data.document_id} uploaded`;
}}
extractErrorMessage={(error: AxiosError) =>
error.response?.data
? error.message
: "Error while uploading file"
}
extractErrorMessage={getAxiosErrorMessage}
/>
</PageSection>
</>
Expand Down
114 changes: 113 additions & 1 deletion client/src/app/utils/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
import { getToolbarChipKey } from "./utils";
import type { AxiosError } from "axios";

import { getAxiosErrorMessage, getToolbarChipKey } from "./utils";

const createAxiosError = (
overrides: Partial<AxiosError> = {},
): AxiosError<unknown> =>
({
message: "Request failed with status code 400",
isAxiosError: true,
name: "AxiosError",
toJSON: () => ({}),
...overrides,
}) as AxiosError<unknown>;

describe("utils", () => {
// getToolbarChipKey
Expand All @@ -12,4 +25,103 @@ describe("utils", () => {
const result = getToolbarChipKey({ key: "myKey", node: "myNode" });
expect(result).toBe("myKey");
});

// getAxiosErrorMessage

it("getAxiosErrorMessage: concatenates error and message (ErrorInformation)", () => {
const error = createAxiosError({
response: {
data: { error: "InvalidFormat", message: "expected CycloneDX or SPDX" },
status: 400,
statusText: "Bad Request",
headers: {},
config: {} as never,
},
});
expect(getAxiosErrorMessage(error)).toBe(
"InvalidFormat: expected CycloneDX or SPDX",
);
});

it("getAxiosErrorMessage: includes details when error and message present", () => {
const error = createAxiosError({
response: {
data: {
error: "InvalidFormat",
message: "could not parse document",
details: "line 42: unexpected token",
},
status: 400,
statusText: "Bad Request",
headers: {},
config: {} as never,
},
});
expect(getAxiosErrorMessage(error)).toBe(
"InvalidFormat: could not parse document\nline 42: unexpected token",
);
});

it("getAxiosErrorMessage: returns message when only message present", () => {
const error = createAxiosError({
response: {
data: { message: "Invalid SBOM format" },
status: 400,
statusText: "Bad Request",
headers: {},
config: {} as never,
},
});
expect(getAxiosErrorMessage(error)).toBe("Invalid SBOM format");
});

it("getAxiosErrorMessage: returns error when only error present", () => {
const error = createAxiosError({
response: {
data: { error: "Something went wrong" },
status: 500,
statusText: "Internal Server Error",
headers: {},
config: {} as never,
},
});
expect(getAxiosErrorMessage(error)).toBe("Something went wrong");
});

it("getAxiosErrorMessage: returns plain string response data", () => {
const error = createAxiosError({
response: {
data: "Plain text error body",
status: 400,
statusText: "Bad Request",
headers: {},
config: {} as never,
},
});
expect(getAxiosErrorMessage(error)).toBe("Plain text error body");
});

it("getAxiosErrorMessage: falls back to axiosError.message", () => {
const error = createAxiosError({
message: "Network Error",
});
expect(getAxiosErrorMessage(error)).toBe("Network Error");
});

it("getAxiosErrorMessage: ignores unknown fields and uses error+message", () => {
const error = createAxiosError({
response: {
data: {
error: "SomeError",
message: "Something happened",
unknownField: "ignored",
},
status: 400,
statusText: "Bad Request",
headers: {},
config: {} as never,
},
});
expect(getAxiosErrorMessage(error)).toBe("SomeError: Something happened");
});
});
25 changes: 18 additions & 7 deletions client/src/app/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,25 @@ import type { ToolbarLabel } from "@patternfly/react-core";

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- allowed
export const getAxiosErrorMessage = (axiosError: AxiosError<any>) => {
if (axiosError.response?.data?.errorMessage) {
return axiosError.response.data.errorMessage;
const data = axiosError.response?.data;

const error = typeof data?.error === "string" ? data.error : undefined;
const message = typeof data?.message === "string" ? data.message : undefined;
const details = typeof data?.details === "string" ? data.details : undefined;

if (error && message) {
const base = `${error}: ${message}`;
return details ? `${base}\n${details}` : base;
}
if (message) {
return message;
}
if (
axiosError.response?.data?.error &&
typeof axiosError?.response?.data?.error === "string"
) {
return axiosError?.response?.data?.error;
if (error) {
return error;
}

if (typeof data === "string") {
return data;
}
return axiosError.message;
};
Expand Down
21 changes: 18 additions & 3 deletions e2e/tests/ui/assertions/FileUploadMatchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface FileUploadMatchers {
toHaveItemUploadStatus(expectedStatus: {
fileName: string;
status: "success" | "danger";
message?: string;
}): Promise<MatcherResult>;
}

Expand Down Expand Up @@ -55,6 +56,7 @@ export const fileUploadAssertions =
expectedStatus: {
fileName: string;
status: "success" | "danger";
message?: string;
},
): Promise<MatcherResult> => {
try {
Expand All @@ -64,9 +66,22 @@ export const fileUploadAssertions =
await baseExpect(
statusItem.locator(`.pf-v6-c-progress.pf-m-${expectedStatus.status}`),
).toBeVisible();
await baseExpect(
statusItem.locator(".pf-v6-c-progress__status", { hasText: "100%" }),
).toBeVisible();

if (expectedStatus.message) {
const helperTextVariant =
expectedStatus.status === "danger" ? "error" : "default";
await baseExpect(
statusItem.locator(
`.pf-v6-c-helper-text__item.pf-m-${helperTextVariant}`,
),
).toContainText(expectedStatus.message);
} else {
await baseExpect(
statusItem.locator(".pf-v6-c-progress__status", {
hasText: "100%",
}),
).toBeVisible();
}

return {
pass: true,
Expand Down
17 changes: 17 additions & 0 deletions e2e/tests/ui/pages/advisory-upload/upload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { login } from "../../helpers/Auth";
import {
testInvalidFileExtensions,
testRemoveFiles,
testUploadApiErrorMessage,
testUploadFilesParallel,
testUploadFilesSequentially,
} from "../common/upload-test-helpers";
Expand Down Expand Up @@ -147,4 +148,20 @@ test.describe("File Upload", { tag: ["@upload"] }, () => {
return { fileUploader };
},
});

testUploadApiErrorMessage("displays API error message for advisory", {
filePath: TEST_FILES.INVALID_JSON,
apiRoutePattern: "**/api/v3/advisory",
errorResponseBody: {
error: "InvalidFormat",
message: "not a valid CSAF, CVE, or OSV document",
},
expectedErrorMessage:
"InvalidFormat: not a valid CSAF, CVE, or OSV document",
getConfig: async ({ page }) => {
const uploadPage = await AdvisoryUploadPage.buildFromBrowserPath(page);
const fileUploader = await uploadPage.getFileUploader();
return { fileUploader };
},
});
});
42 changes: 42 additions & 0 deletions e2e/tests/ui/pages/common/upload-test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,48 @@ export const testRemoveFiles = ({
}
});

export const testUploadApiErrorMessage = (
testName: string,
{
filePath,
apiRoutePattern,
errorResponseBody,
expectedErrorMessage,
getConfig,
}: {
filePath: string;
apiRoutePattern: string;
errorResponseBody: { error: string; message?: string; details?: string };
expectedErrorMessage: string;
getConfig: ({ page }: { page: Page }) => Promise<UploadTestConfig>;
},
) =>
test(`Upload API error message - ${testName}`, async ({ page }) => {
const config = await getConfig({ page });
const fileUploader = config.fileUploader;

await page.route(apiRoutePattern, async (route) => {
await route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify(errorResponseBody),
});
});

try {
await fileUploader.uploadFiles([filePath]);

const fileName = path.basename(filePath);
await expect(fileUploader).toHaveItemUploadStatus({
fileName,
status: "danger",
message: expectedErrorMessage,
});
} finally {
await page.unroute(apiRoutePattern);
}
});

export const testInvalidFileExtensions = ({
filesPaths,
getConfig,
Expand Down
16 changes: 16 additions & 0 deletions e2e/tests/ui/pages/sbom-upload/upload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { login } from "../../helpers/Auth";
import {
testInvalidFileExtensions,
testRemoveFiles,
testUploadApiErrorMessage,
testUploadFilesParallel,
testUploadFilesSequentially,
} from "../common/upload-test-helpers";
Expand Down Expand Up @@ -147,4 +148,19 @@ test.describe("File Upload", { tag: ["@upload"] }, () => {
return { fileUploader };
},
});

testUploadApiErrorMessage("displays API error message for SBOM", {
filePath: TEST_FILES.INVALID_JSON,
apiRoutePattern: "**/api/v3/sbom",
errorResponseBody: {
error: "InvalidFormat",
message: "expected CycloneDX or SPDX document",
},
expectedErrorMessage: "InvalidFormat: expected CycloneDX or SPDX document",
getConfig: async ({ page }) => {
const uploadPage = await SBOMUploadPage.buildFromBrowserPath(page);
const fileUploader = await uploadPage.getFileUploader();
return { fileUploader };
},
});
});
Loading