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
2 changes: 1 addition & 1 deletion frontend/src/api/ffc-api-model/index.ts

Large diffs are not rendered by default.

568 changes: 466 additions & 102 deletions frontend/src/api/ffc-api-model/types.gen.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AddWizardFormSchema } from "./CreateEntitlement.Schema";
import { AddWizardFormSchema, toDatasourceIdRegExp } from "./CreateEntitlement.Schema";

describe("AddWizardFormSchema", () => {
const validInput = {
Expand Down Expand Up @@ -52,4 +52,65 @@ describe("AddWizardFormSchema", () => {
});
expect(result.success).toBe(false);
});

describe("dataSource.id format from the affiliate cloud provider", () => {
const withFormat = (datasource_id_format: string, id: string) => ({
...validInput,
affiliate: { ...validInput.affiliate, cloud_provider: { datasource_id_format } },
dataSource: { ...validInput.dataSource, id },
});

it("accepts an id matching the cloud provider format", () => {
const result = AddWizardFormSchema.safeParse(withFormat("[0-9]{12}", "123456789012"));
expect(result.success).toBe(true);
});

it("rejects an id not matching the format and reports it on dataSource.id", () => {
const result = AddWizardFormSchema.safeParse(withFormat("[0-9]{12}", "not-an-aws-id"));
expect(result.success).toBe(false);
expect(result.error?.issues).toEqual([
expect.objectContaining({ code: "custom", path: ["dataSource", "id"] }),
]);
});

it("requires the whole id to match, not just a substring", () => {
const result = AddWizardFormSchema.safeParse(withFormat("[0-9]{12}", "x123456789012x"));
expect(result.success).toBe(false);
});

it("skips the format check when the affiliate has no cloud provider", () => {
const result = AddWizardFormSchema.safeParse({
...validInput,
affiliate: { ...validInput.affiliate, cloud_provider: null },
});
expect(result.success).toBe(true);
});

it("skips the format check when the pattern is malformed", () => {
const result = AddWizardFormSchema.safeParse(withFormat("([unclosed", "anything"));
expect(result.success).toBe(true);
});

it("keeps cloud_provider in the parsed output", () => {
const result = AddWizardFormSchema.parse(withFormat(".+", "ds-1"));
expect(result.affiliate.cloud_provider).toEqual({ datasource_id_format: ".+" });
});
});

describe("toDatasourceIdRegExp", () => {
it("returns null for an empty pattern", () => {
expect(toDatasourceIdRegExp(undefined)).toBeNull();
expect(toDatasourceIdRegExp("")).toBeNull();
});

it("anchors patterns that are already anchored without breaking them", () => {
expect(toDatasourceIdRegExp("^[a-z]+$")?.test("abc")).toBe(true);
});

it("full-matches alternations", () => {
const regex = toDatasourceIdRegExp("abc|def");
expect(regex?.test("abc")).toBe(true);
expect(regex?.test("abcdef")).toBe(false);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,53 @@ import { i18n } from "~i18n/translations";

const tValidation = i18n.getFixedT(null, null, "entitlements:addWizard:validations");

export const AddWizardFormSchema = z.object({
id: z.string().nullish(),
name: z.string({ error: () => tValidation("name:required") }),
affiliate: z.object(
{
id: z.string({ error: () => tValidation("affiliate:required") }),
name: z.string(),
integration: z.string().nullish(),
},
{ error: () => tValidation("affiliate:required") },
),
dataSource: z.object({
id: z.string({ error: () => tValidation("dataSource:id:required") }),
affiliate_external_id: z.string({
error: () => tValidation("dataSource:affiliate_external_id:required"),
/**
* Builds a full-match RegExp from an API-provided pattern.
* Returns `null` for a missing or malformed pattern so that validation is skipped
* instead of crashing the form; the backend remains the source of truth.
*/
export const toDatasourceIdRegExp = (pattern: string | null | undefined): RegExp | null => {
if (!pattern) return null;
try {
return new RegExp(`^(?:${pattern})$`);
} catch {
return null;
}
};

export const AddWizardFormSchema = z
.object({
id: z.string().nullish(),
name: z.string({ error: () => tValidation("name:required") }),
affiliate: z.object(
{
id: z.string({ error: () => tValidation("affiliate:required") }),
name: z.string(),
integration: z.string().nullish(),
// Declared so it survives parsing and is available to the refinement below.
cloud_provider: z.object({ datasource_id_format: z.string() }).nullish(),
},
{ error: () => tValidation("affiliate:required") },
),
dataSource: z.object({
id: z.string({ error: () => tValidation("dataSource:id:required") }),
affiliate_external_id: z.string({
error: () => tValidation("dataSource:affiliate_external_id:required"),
}),
}),
}),
});
})
.superRefine(({ affiliate, dataSource }, ctx) => {
const regex = toDatasourceIdRegExp(affiliate.cloud_provider?.datasource_id_format);

if (regex && !regex.test(dataSource.id)) {
ctx.addIssue({
code: "custom",
path: ["dataSource", "id"],
message: tValidation("dataSource:id:invalid", {
format: affiliate.cloud_provider?.datasource_id_format,
}),
});
}
});

export type AddWizardForm = z.output<typeof AddWizardFormSchema>;
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { ReviewStep } from "./steps/ReviewStep";
import type { SummaryStep } from "./steps/SummaryStep";
import type { useSteps } from "./useSteps";

export { mockGetErrorMessage } from "~test-utils/mocks/errorDetails";
export { mockUseUserRole } from "~test-utils/mocks/userRole";

type EntitlementsApi = ReturnType<typeof useEntitlementsApi>;
Expand All @@ -30,6 +31,14 @@ export const mockUseSteps = jest.fn();
// per render. Tests mutate `.activeStepIndex` before rendering.
export const mockStepContext = { activeStepIndex: 0 };

// Schema-valid values written into the real form by the "fill form" button,
// so `handleSubmit` passes zod validation and reaches the mutation.
export const mockValidForm: AddWizardForm = {
name: "My entitlement",
affiliate: { id: "acc-1", name: "Affiliate 1", integration: "aws", cloud_provider: null },
dataSource: { id: "ds-1", affiliate_external_id: "ext-1" },
};

jest.mock("~entitlements/api/useEntitlementsApi", () => ({
useEntitlementsApi: () => ({ save: mockSave }),
}));
Expand All @@ -38,22 +47,58 @@ jest.mock("~shared/hooks/useErrorDetails", () => mockErrorDetailsModule);
jest.mock("~shared/hooks/useUserRole", () => mockUserRoleModule);

jest.mock("@swo/design-system/modal", () => ({
Modal: ({ children }: ModalProps) => <div data-testid="modal">{children}</div>,
Modal: ({ children, onClose }: ModalProps) => (
<div data-testid="modal">
<button onClick={() => onClose?.()} type="button">
dismiss modal
</button>
{children}
</div>
),
}));

jest.mock("@swo/design-system/wizard", () => {
const { useFormContext } =
jest.requireActual<typeof import("react-hook-form")>("react-hook-form");

// Stands in for the (mocked) steps editing the real form owned by the wizard.
const FormControls = () => {
const { setValue } = useFormContext<AddWizardForm>();
const fillForm = () => {
setValue("name", mockValidForm.name);
setValue("affiliate", mockValidForm.affiliate);
setValue("dataSource", mockValidForm.dataSource);
};

return (
<>
<button onClick={fillForm} type="button">
fill form
</button>
<button onClick={() => setValue("name", "Edited", { shouldDirty: true })} type="button">
change name
</button>
<button onClick={() => setValue("name", "Edited")} type="button">
change name without dirtying
</button>
</>
);
};

const Wizard = (props: MockWizardProps) => {
mockWizard(props);
return (
<div data-testid="wizard">
<form data-testid="wizard" onSubmit={props.onSubmit}>
<button onClick={() => props.onClose?.()} type="button">
close wizard
</button>
<button onClick={() => props.onSave?.()} type="button">
save wizard
</button>
<button type="submit">submit wizard</button>
<FormControls />
{props.children}
</div>
</form>
);
};
Wizard.Header = ({ children }: WizardHeaderProps) => (
Expand Down
Loading
Loading