fix(ui): show only the chosen registry credential variant (#12966)

This commit is contained in:
Alejandro Bailo
2026-10-07 16:35:20 +02:00
committed by GitHub
parent 9d8060adc5
commit b5a709664d
9 changed files with 479 additions and 28 deletions
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import openaiSchema from "@/lib/provider-credentials/fixtures/openai-credential-schema.json";
import templateSchema from "@/lib/provider-credentials/fixtures/template-credential-schema.json";
import unionSchema from "@/lib/provider-credentials/fixtures/union-credential-schema.json";
const { fetchMock, getProviderSchemas, getAuthHeaders, revalidatePath } =
vi.hoisted(() => ({
fetchMock: vi.fn(),
@@ -136,6 +137,61 @@ describe("dynamic provider credential actions", () => {
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
describe("when the secret type's schema offers several variants", () => {
const basic = {
auth_method: "basic",
host: "api.acme.test",
username: "fixture-user",
password: "fixture-password-not-a-secret",
};
beforeEach(() => {
getProviderSchemas.mockResolvedValue({
status: "success",
providerType: "acme",
secretTypes: { static: unionSchema },
});
});
it("sends a secret that one variant accepts", async () => {
// Given
fetchMock
.mockResolvedValueOnce(response(account()))
.mockResolvedValueOnce(response({ data: { id: "saved" } }, 201));
// When
const result = await saveDynamicProviderCredentials({
...input,
secretType: "static",
secret: basic,
});
// Then
expect(result).toEqual({ status: "saved", secretId: "saved" });
expect(
JSON.parse(fetchMock.mock.calls[1][1].body).data.attributes,
).toEqual({ secret_type: "static", secret: basic });
});
it.each([
["fields of two variants", { ...basic, token: "fixture-token" }],
["another variant's discriminator", { ...basic, auth_method: "token" }],
["a missing required field", { ...basic, password: "" }],
])("does not write a secret with %s", async (_name, secret) => {
// Given
fetchMock.mockResolvedValueOnce(response(account()));
// When
const result = await saveDynamicProviderCredentials({
...input,
secretType: "static",
secret,
});
// Then
expect(result.status).toBe("invalid");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
it.each([
{ ...input, secretType: "invented" },
{ ...input, secret: { token: "" } },
@@ -4,8 +4,8 @@ import { revalidatePath } from "next/cache";
import { z } from "zod";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { parseRegistryCredentialSchema } from "@/lib/provider-credentials/provider-credential-schema";
import { validateCredentialValues } from "@/lib/provider-credentials/provider-credential-values";
import { parseRegistryCredentialVariants } from "@/lib/provider-credentials/provider-credential-schema";
import { validateCredentialVariants } from "@/lib/provider-credentials/provider-credential-values";
import { isKnownProviderType } from "@/types/providers";
import { getProviderSchemas } from "./provider-schemas";
@@ -66,11 +66,11 @@ export async function saveDynamicProviderCredentials(
!Object.hasOwn(schemas.secretTypes, secretType)
)
return { status: "schema_unavailable" };
const schema = parseRegistryCredentialSchema(
const variants = parseRegistryCredentialVariants(
schemas.secretTypes[secretType],
);
if (!schema) return { status: "schema_unavailable" };
const validated = validateCredentialValues(schema, secret);
if (!variants) return { status: "schema_unavailable" };
const validated = validateCredentialVariants(variants, secret);
if (!validated.valid)
return { status: "invalid", errors: validated.errors };
@@ -10,6 +10,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import openaiSchema from "@/lib/provider-credentials/fixtures/openai-credential-schema.json";
import templateSchema from "@/lib/provider-credentials/fixtures/template-credential-schema.json";
import unionSchema from "@/lib/provider-credentials/fixtures/union-credential-schema.json";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import type { ProviderSchemasResult } from "@/types/provider-schema";
@@ -183,6 +184,77 @@ describe("dynamic credentials in the provider wizard", () => {
},
});
});
it("shows only the chosen variant's fields when a schema offers several", async () => {
// Given
const user = userEvent.setup();
getProviderSchemas.mockResolvedValue({
status: "success",
providerType: "acme",
secretTypes: { static: unionSchema },
});
render(<DynamicCredentialsStep {...props} />);
await screen.findByLabelText(/API Host/);
const method = screen.getByRole("combobox", {
name: "Authentication method",
});
// Then
expect(method).toHaveTextContent("API token");
expect(screen.getByLabelText(/^Token/)).toBeVisible();
expect(screen.queryByLabelText(/Username/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Password/)).not.toBeInTheDocument();
expect(
screen.queryByLabelText(/Authentication Method/),
).not.toBeInTheDocument();
// When: a token typed before switching away is gone on return.
await user.type(screen.getByLabelText(/^Token/), "previous-variant-token");
await user.click(method);
await user.click(
screen.getByRole("option", { name: "Username and password" }),
);
await user.click(method);
await user.click(screen.getByRole("option", { name: "API token" }));
// Then
expect(screen.getByLabelText(/^Token/)).toHaveValue("");
// When
await user.click(method);
await user.click(
screen.getByRole("option", { name: "Username and password" }),
);
await user.type(screen.getByLabelText(/API Host/), "api.acme.test");
await user.type(screen.getByLabelText(/Username/), "fixture-user");
await user.type(
screen.getByLabelText(/Password/),
"fixture-password-not-a-secret",
);
// Then
expect(screen.queryByLabelText(/^Token/)).not.toBeInTheDocument();
// When
act(() =>
screen
.getByLabelText(/Username/)
.closest("form")!
.requestSubmit(),
);
// Then: the variant's fixed discriminator travels with its own fields only.
await waitFor(() => expect(props.onNext).toHaveBeenCalledOnce());
expect(saveDynamicProviderCredentials).toHaveBeenCalledWith({
providerId: "account",
secretType: "static",
secret: {
auth_method: "basic",
host: "api.acme.test",
username: "fixture-user",
password: "fixture-password-not-a-secret",
},
});
});
it.each<{ result: ProviderSchemasResult; title: string }>([
{
result: { status: "success", providerType: "acme", secretTypes: {} },
@@ -20,7 +20,7 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { useToast } from "@/components/shadcn/toast";
import { StatusAlert } from "@/components/shared/status-alert";
import {
parseRegistryCredentialSchema,
parseRegistryCredentialVariants,
type RegistryCredentialSchema,
} from "@/lib/provider-credentials/provider-credential-schema";
import {
@@ -28,7 +28,10 @@ import {
validateCredentialValues,
} from "@/lib/provider-credentials/provider-credential-values";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import type { ProviderSchemasResult } from "@/types/provider-schema";
import type {
ProviderSchemasResult,
ProviderSecretTypes,
} from "@/types/provider-schema";
import {
WIZARD_FOOTER_ACTION_TYPE,
@@ -43,6 +46,39 @@ interface DynamicCredentialsStepProps {
onFooterChange: (config: WizardFooterConfig) => void;
}
interface CredentialMethod {
id: string;
secretType: string;
label: string;
schema: RegistryCredentialSchema | null;
}
// Each variant of a secret type's schema is a method of its own.
function listCredentialMethods(
secretTypes: ProviderSecretTypes,
): CredentialMethod[] {
return Object.entries(secretTypes).flatMap(([secretType, value]) => {
const label = secretType.replaceAll("_", " ");
const variants = parseRegistryCredentialVariants(value);
if (!variants || variants.length === 1) {
return [
{
id: secretType,
secretType,
label,
schema: variants?.[0].schema ?? null,
},
];
}
return variants.map((variant, index) => ({
id: `${secretType}/${index}`,
secretType,
label: variant.label ?? `${label} ${index + 1}`,
schema: variant.schema,
}));
});
}
function credentialFormError(status: ProviderSchemasResult["status"]) {
switch (status) {
case "access_denied":
@@ -233,12 +269,11 @@ function DynamicCredentialsContent(props: DynamicCredentialsStepProps) {
}, [providerType, attempt]);
const methods =
schemas?.status === "success" ? Object.keys(schemas.secretTypes) : [];
const secretType = selectedMethod || methods[0];
const schema =
schemas?.status === "success" && secretType
? parseRegistryCredentialSchema(schemas.secretTypes[secretType])
: null;
schemas?.status === "success"
? listCredentialMethods(schemas.secretTypes)
: [];
const method = methods.find(({ id }) => id === selectedMethod) ?? methods[0];
const schema = method?.schema ?? null;
useEffect(() => {
if (!schema)
onFooterChange({
@@ -277,7 +312,7 @@ function DynamicCredentialsContent(props: DynamicCredentialsStepProps) {
Authentication method
</FieldLabel>
<Select
value={secretType}
value={method?.id}
disabled={saving}
onValueChange={setSelectedMethod}
>
@@ -285,20 +320,20 @@ function DynamicCredentialsContent(props: DynamicCredentialsStepProps) {
<SelectValue />
</SelectTrigger>
<SelectContent>
{methods.map((method) => (
<SelectItem key={method} value={method}>
{method.replaceAll("_", " ")}
{methods.map(({ id, label }) => (
<SelectItem key={id} value={id}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
{schema ? (
{method && schema ? (
<DynamicCredentialForm
key={`${secretType}/${attempt}`}
key={`${method.id}/${attempt}`}
{...props}
secretType={secretType}
secretType={method.secretType}
schema={schema}
onLoadingChange={setSaving}
/>
@@ -38,6 +38,7 @@ export function RegistryCredentialFields({
return (
<div className="flex flex-col gap-4">
{schema.fields.map((field, index) => {
if (field.kind === "constant") return null;
const error = errors[field.name];
const fieldId = `registry-credential-${instanceId}-${index}`;
const id = `${fieldId}-control`;
@@ -0,0 +1,71 @@
{
"description": "Acme API credentials.",
"discriminator": {
"mapping": {
"basic": "#/$defs/AcmeBasicCredentials",
"token": "#/$defs/AcmeTokenCredentials"
},
"propertyName": "auth_method"
},
"oneOf": [
{
"additionalProperties": false,
"description": "A long-lived API token issued from the console.",
"properties": {
"auth_method": {
"const": "token",
"default": "token",
"title": "Authentication Method",
"type": "string"
},
"host": {
"description": "Hostname of the API.",
"examples": ["api.acme.test"],
"title": "API Host",
"type": "string"
},
"token": {
"description": "API token.",
"format": "password",
"title": "Token",
"type": "string",
"writeOnly": true
}
},
"required": ["host", "token"],
"title": "API token",
"type": "object"
},
{
"additionalProperties": false,
"description": "A username and password exchanged for a session token.",
"properties": {
"auth_method": {
"const": "basic",
"title": "Authentication Method",
"type": "string"
},
"host": {
"description": "Hostname of the API.",
"examples": ["api.acme.test"],
"title": "API Host",
"type": "string"
},
"username": {
"title": "Username",
"type": "string"
},
"password": {
"format": "password",
"title": "Password",
"type": "string",
"writeOnly": true
}
},
"required": ["auth_method", "host", "username", "password"],
"title": "Username and password",
"type": "object"
}
],
"title": "AcmeStaticCredentials"
}
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
import openaiSchema from "./fixtures/openai-credential-schema.json";
import templateSchema from "./fixtures/template-credential-schema.json";
import unionSchema from "./fixtures/union-credential-schema.json";
import {
parseRegistryCredentialSchema,
parseRegistryCredentialVariants,
REGISTRY_CREDENTIAL_SCHEMA_LIMITS,
} from "./provider-credential-schema";
@@ -157,10 +159,22 @@ describe("parseRegistryCredentialSchema", () => {
["definitions", { definitions: {} }],
["combinators", { anyOf: [] }],
["additional properties", { additionalProperties: true }],
["typed additional properties", { additionalProperties: {} }],
])("rejects risky root keywords: %s", (_name, keyword) => {
expect(parseRegistryCredentialSchema({ ...schema, ...keyword })).toBeNull();
});
it("accepts the closed object pydantic emits for extra='forbid'", () => {
// Given / When
const result = parseRegistryCredentialSchema({
...schema,
additionalProperties: false,
});
// Then
expect(result).toEqual(parseRegistryCredentialSchema(schema));
});
it.each([
["nested objects", { type: "object", properties: {} }],
["arrays", { type: "array" }],
@@ -264,3 +278,98 @@ describe("parseRegistryCredentialSchema", () => {
]);
});
});
describe("parseRegistryCredentialVariants", () => {
it("splits a pydantic union into variants that keep only their own fields", () => {
// Given / When
const result = parseRegistryCredentialVariants(unionSchema);
// Then
expect(
result?.map(({ label, schema }) => [
label,
schema.fields.map(({ name, kind }) => [name, kind]),
]),
).toEqual([
[
"API token",
[
["auth_method", "constant"],
["host", "text"],
["token", "password"],
],
],
[
"Username and password",
[
["auth_method", "constant"],
["host", "text"],
["username", "text"],
["password", "password"],
],
],
]);
expect(result?.[1].schema.fields[0].defaultValue).toBe("basic");
});
it("accepts anyOf, which pydantic emits for a union without discriminator", () => {
// Given
const { oneOf, discriminator: _discriminator, ...root } = unionSchema;
// When
const result = parseRegistryCredentialVariants({ ...root, anyOf: oneOf });
// Then
expect(result?.map(({ label }) => label)).toEqual([
"API token",
"Username and password",
]);
});
it.each([
["an unsupported variant", { oneOf: [templateSchema, { type: "array" }] }],
["no variants", { oneOf: [] }],
["variants that are not a list", { oneOf: templateSchema }],
["both combinators", { oneOf: [templateSchema], anyOf: [templateSchema] }],
["unknown root keywords", { oneOf: [templateSchema], $defs: {} }],
[
"a malformed discriminator",
{ oneOf: [templateSchema], discriminator: 1 },
],
[
"too many variants",
{
oneOf: Array.from(
{ length: REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_VARIANTS + 1 },
() => templateSchema,
),
},
],
])("rejects a union with %s", (_name, union) => {
expect(parseRegistryCredentialVariants(union)).toBeNull();
});
it.each([
{ type: "string", const: "token", default: "basic" },
{ type: "string", const: "" },
{ type: "string", const: "token", enum: ["token"] },
{ type: "integer", const: 1 },
])("rejects a discriminator that is not one fixed string: %j", (property) => {
expect(
parseRegistryCredentialSchema({
type: "object",
properties: { auth_method: property },
}),
).toBeNull();
});
it("returns a flat schema as its only variant", () => {
// Given / When
const result = parseRegistryCredentialVariants(templateSchema);
// Then
expect(result).toEqual([
{ schema: parseRegistryCredentialSchema(templateSchema) },
]);
});
});
@@ -5,6 +5,7 @@ const FIELD_KIND = {
TEXTAREA: "textarea",
CHECKBOX: "checkbox",
INTEGER: "integer",
CONSTANT: "constant",
} as const;
export const REGISTRY_CREDENTIAL_SCHEMA_LIMITS = {
@@ -12,6 +13,7 @@ export const REGISTRY_CREDENTIAL_SCHEMA_LIMITS = {
MAX_NAME_LENGTH: 50,
MAX_TEXT_LENGTH: 200,
MAX_ENUM_OPTIONS: 20,
MAX_VARIANTS: 8,
} as const;
type FieldKind = (typeof FIELD_KIND)[keyof typeof FIELD_KIND];
@@ -34,7 +36,18 @@ export interface RegistryCredentialSchema {
readonly fields: readonly RegistryCredentialField[];
}
const ROOT = new Set("type title description properties required".split(" "));
/** One alternative of a `oneOf`/`anyOf` schema, or the whole flat schema. */
export interface RegistryCredentialVariant {
readonly label?: string;
readonly schema: RegistryCredentialSchema;
}
const ROOT = new Set(
"type title description properties required additionalProperties".split(" "),
);
const UNION_ROOT = new Set(
"title description oneOf anyOf discriminator".split(" "),
);
const FIELD = new Set(
"title description type format writeOnly enum default examples x-prowler-widget".split(
" ",
@@ -44,6 +57,9 @@ const BOOLEAN_FIELD = new Set("title description type default".split(" "));
const INTEGER_FIELD = new Set(
"title description type default minimum maximum".split(" "),
);
const CONSTANT_FIELD = new Set(
"title description type const default".split(" "),
);
const FORBIDDEN_NAMES = new Set(["__proto__", "prototype", "constructor"]);
const FIELD_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
@@ -87,7 +103,14 @@ function hasOnly(
export function parseRegistryCredentialSchema(
value: unknown,
): RegistryCredentialSchema | null {
if (!isRecord(value) || !hasOnly(value, ROOT) || value.type !== "object") {
if (
!isRecord(value) ||
!hasOnly(value, ROOT) ||
value.type !== "object" ||
// Only a closed object: the form never sends undeclared keys anyway.
(value.additionalProperties !== undefined &&
value.additionalProperties !== false)
) {
return null;
}
if (
@@ -189,6 +212,22 @@ export function parseRegistryCredentialSchema(
});
continue;
}
if (property.type === "string" && property.const !== undefined) {
// A union's discriminator: fixed by the variant, never typed by the user.
if (
!hasOnly(property, CONSTANT_FIELD) ||
!isText(property.const) ||
(defaultValue !== undefined && defaultValue !== property.const)
) {
return null;
}
fields.push({
...baseField,
kind: FIELD_KIND.CONSTANT,
defaultValue: property.const,
});
continue;
}
if (property.type !== "string" || !hasOnly(property, FIELD)) return null;
const format = property.format;
@@ -256,3 +295,42 @@ export function parseRegistryCredentialSchema(
}
return { fields };
}
export function parseRegistryCredentialVariants(
value: unknown,
): readonly RegistryCredentialVariant[] | null {
const isUnion =
isRecord(value) &&
(Object.hasOwn(value, "oneOf") || Object.hasOwn(value, "anyOf"));
if (!isUnion) {
const schema = parseRegistryCredentialSchema(value);
return schema ? [{ schema }] : null;
}
if (
!hasOnly(value, UNION_ROOT) ||
(Object.hasOwn(value, "oneOf") && Object.hasOwn(value, "anyOf")) ||
(value.title !== undefined && !isText(value.title)) ||
(value.description !== undefined &&
typeof value.description !== "string") ||
(value.discriminator !== undefined && !isRecord(value.discriminator))
) {
return null;
}
const branches = value.oneOf ?? value.anyOf;
if (
!Array.isArray(branches) ||
branches.length === 0 ||
branches.length > REGISTRY_CREDENTIAL_SCHEMA_LIMITS.MAX_VARIANTS
) {
return null;
}
const variants: RegistryCredentialVariant[] = [];
for (const branch of branches) {
const schema = parseRegistryCredentialSchema(branch);
if (!schema) return null;
const label = isRecord(branch) ? branch.title : undefined;
variants.push({ ...(isText(label) ? { label } : {}), schema });
}
return variants;
}
@@ -1,6 +1,7 @@
import type {
RegistryCredentialSchema,
RegistryCredentialValue,
RegistryCredentialVariant,
} from "./provider-credential-schema";
export function getCredentialDefaults(
@@ -17,16 +18,18 @@ export function getCredentialDefaults(
);
}
export function validateCredentialValues(
schema: RegistryCredentialSchema,
values: unknown,
):
type CredentialValidation =
| {
valid: true;
secret: Record<string, RegistryCredentialValue>;
errors: Record<string, string>;
}
| { valid: false; errors: Record<string, string> } {
| { valid: false; errors: Record<string, string> };
export function validateCredentialValues(
schema: RegistryCredentialSchema,
values: unknown,
): CredentialValidation {
if (!values || typeof values !== "object" || Array.isArray(values))
return {
valid: false,
@@ -47,7 +50,17 @@ export function validateCredentialValues(
const secret: Record<string, RegistryCredentialValue> = {};
for (const field of schema.fields) {
const value = fields.get(field.name);
if (value === undefined || (value === "" && field.kind !== "checkbox")) {
if (field.kind === "constant") {
// Always sent: it tells the API which variant the secret follows.
if (value !== undefined && value !== field.defaultValue) {
errors[field.name] = `Enter a valid ${field.label}`;
} else if (field.defaultValue !== undefined) {
secret[field.name] = field.defaultValue;
}
} else if (
value === undefined ||
(value === "" && field.kind !== "checkbox")
) {
if (field.required) errors[field.name] = `${field.label} is required`;
} else if (field.kind === "checkbox") {
if (typeof value !== "boolean") {
@@ -83,3 +96,19 @@ export function validateCredentialValues(
? { valid: false, errors }
: { valid: true, secret, errors };
}
/** Valid when one variant accepts the values, as JSON Schema `anyOf` does. */
export function validateCredentialVariants(
variants: readonly RegistryCredentialVariant[],
values: unknown,
): CredentialValidation {
const results = variants.map(({ schema }) =>
validateCredentialValues(schema, values),
);
// Otherwise report the variant that owns every submitted field.
return (
results.find(({ valid }) => valid) ??
results.find(({ errors }) => !errors._form) ??
results[0]
);
}