Skip to content

Commit 421092e

Browse files
jcgerkibanamachine
andauthored
[Response Ops] Fix single file connector auth type on edit (#244635)
## Summary Closes #244390 ## QA 0. Create a single file connector with multiple auth types. <details> <summary>Webhook.patch</summary> <pre> diff --git a/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts b/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts index fefd2a6..534be6c8c3f5 100644 --- a/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts +++ b/src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts @@ -13,3 +13,4 @@ export * from './specs/greynoise'; export * from './specs/shodan'; export * from './specs/urlvoid'; export * from './specs/virustotal'; +export * from './specs/webhook'; diff --git a/src/platform/packages/shared/kbn-connector-specs/src/specs/webhook.ts b/src/platform/packages/shared/kbn-connector-specs/src/specs/webhook.ts new file mode 100644 index 000000000000..b5f1e6375ae2 --- /dev/null +++ b/src/platform/packages/shared/kbn-connector-specs/src/specs/webhook.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod/v4'; +import type { ConnectorSpec } from '../connector_spec'; + +export const SingleFileWebhookConnector: ConnectorSpec = { + metadata: { + id: '.sf-webhook', + displayName: 'Single File Webhook', + description: 'demo', + minimumLicense: 'gold', + supportedFeatureIds: ['workflows'], + }, + + schema: z.object({ + method: z + .enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) + .meta({ + label: 'Method', + }) + .default('POST'), + url: z.url().meta({ label: 'URL', placeholder: 'https://...' }), + }), + + authTypes: ['none', 'basic', 'bearer'], + + actions: { + submit: { + isTool: true, + input: z.object({ + body: z.string(), + }), + handler: async (ctx, input) => { + try { + ctx.client.request({ + method, + url, + data: input.body, + }); + + return { + ok: true, + message: 'Successfully connected to Single File Webhook', + }; + } catch (error) { + return { + ok: false, + message: `Failed to connect: ${error}`, + }; + } + }, + }, + }, + + test: { + handler: async (ctx) => { + try { + await ctx.client.get(''); + return { + ok: true, + message: 'Successfully connected to Single File Webhook', + }; + } catch (error) { + return { + ok: false, + message: `Failed to connect: ${error}`, + }; + } + }, + description: 'Verifies webhook connection alive', + }, +}; </pre> </details> 1. Create a connector with an auth type that isn't the default one 2. Open the connector to edit 3. Check that it's selecting the the auth type the connector was created with --------- Co-authored-by: kibanamachine <[email protected]>
1 parent c61193a commit 421092e

File tree

9 files changed

+413
-12
lines changed

9 files changed

+413
-12
lines changed

src/platform/packages/shared/kbn-connector-specs/src/lib/__snapshots__/generate_secrets_schema_from_spec.test.ts.snap

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/platform/packages/shared/kbn-connector-specs/src/lib/generate_secrets_schema_from_spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,5 @@ export const generateSecretsSchemaFromSpec = (authTypes: ConnectorSpec['authType
2121
z
2222
.discriminatedUnion('authType', [secretSchemas[0], ...secretSchemas.slice(1)])
2323
.meta({ label: 'Authentication' })
24-
: z.object({}).default({});
24+
: z.object({});
2525
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import React from 'react';
11+
import { render, screen, waitFor } from '@testing-library/react';
12+
import userEvent from '@testing-library/user-event';
13+
import { z } from '@kbn/zod/v4';
14+
import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
15+
import { addMeta } from '../../../schema_connector_metadata';
16+
import { MultiOptionUnionWidget } from './multi_option_union_widget';
17+
18+
describe('MultiOptionUnionWidget - Serializer/Deserializer Integration', () => {
19+
// Simulates a connector schema with secrets discriminated union
20+
const createSchema = () => {
21+
const noneOption = z.object({ authType: z.literal('none') });
22+
addMeta(noneOption, { label: 'None' });
23+
24+
const basicOption = z.object({
25+
authType: z.literal('basic'),
26+
username: z.string(),
27+
password: z.string(),
28+
});
29+
addMeta(basicOption, { label: 'Basic Auth' });
30+
31+
const bearerOption = z.object({
32+
authType: z.literal('bearer'),
33+
token: z.string(),
34+
});
35+
addMeta(bearerOption, { label: 'Bearer Token' });
36+
37+
return z.object({
38+
config: z.object({
39+
url: z.string(),
40+
authType: z.string().optional(),
41+
}),
42+
secrets: z.discriminatedUnion('authType', [noneOption, basicOption, bearerOption]),
43+
});
44+
};
45+
46+
// Simulates the deserializer that reconstructs secrets from config
47+
const deserializer = (apiData: any) => {
48+
if (!apiData?.config?.authType || apiData.secrets?.authType) {
49+
return apiData;
50+
}
51+
52+
// Reconstruct secrets from config.authType
53+
return {
54+
...apiData,
55+
secrets: { authType: apiData.config.authType },
56+
};
57+
};
58+
59+
// Simulates the serializer that copies authType to config
60+
const serializer = (formData: any) => {
61+
if (!formData?.secrets?.authType) {
62+
return formData;
63+
}
64+
65+
return {
66+
...formData,
67+
config: {
68+
...formData.config,
69+
authType: formData.secrets.authType,
70+
},
71+
};
72+
};
73+
74+
const TestComponent = ({ initialData }: { initialData: any }) => {
75+
const schema = createSchema();
76+
const { form } = useForm({
77+
defaultValue: initialData,
78+
serializer,
79+
deserializer,
80+
});
81+
82+
return (
83+
<Form form={form}>
84+
<MultiOptionUnionWidget
85+
path="secrets"
86+
options={schema.shape.secrets.options}
87+
discriminatorKey="authType"
88+
schema={schema.shape.secrets}
89+
fieldConfig={{
90+
defaultValue: undefined,
91+
validations: [{ validator: () => undefined }],
92+
}}
93+
fieldProps={{ label: 'Authentication', euiFieldProps: {} }}
94+
formConfig={{}}
95+
/>
96+
<button
97+
type="button"
98+
onClick={async () => {
99+
const { data } = await form.submit();
100+
// Expose serialized data for testing
101+
(window as any).serializedData = data;
102+
}}
103+
>
104+
Submit
105+
</button>
106+
</Form>
107+
);
108+
};
109+
110+
beforeEach(() => {
111+
(window as any).serializedData = undefined;
112+
});
113+
114+
it('should initialize from async form data (deserializer)', async () => {
115+
const apiData = {
116+
config: { url: 'https://example.com', authType: 'basic' },
117+
secrets: {}, // Secrets are stripped by API
118+
};
119+
120+
render(<TestComponent initialData={apiData} />);
121+
122+
await waitFor(() => {
123+
const basicCard = screen.getByLabelText('Basic Auth');
124+
expect(basicCard).toBeChecked();
125+
});
126+
});
127+
128+
it('should not overwrite user selection when form re-renders (ref flag pattern)', async () => {
129+
const apiData = {
130+
config: { url: 'https://example.com', authType: 'basic' },
131+
secrets: {},
132+
};
133+
134+
const { rerender } = render(<TestComponent initialData={apiData} />);
135+
136+
await waitFor(() => {
137+
expect(screen.getByLabelText('Basic Auth')).toBeChecked();
138+
});
139+
140+
await userEvent.click(screen.getByLabelText('Bearer Token'));
141+
142+
await waitFor(() => {
143+
expect(screen.getByLabelText('Bearer Token')).toBeChecked();
144+
});
145+
146+
// simulating parent re-render
147+
rerender(<TestComponent initialData={apiData} />);
148+
149+
await waitFor(() => {
150+
expect(screen.getByLabelText('Bearer Token')).toBeChecked();
151+
});
152+
});
153+
});

src/platform/packages/shared/response-ops/form-generator/src/widgets/components/discriminated_union_widget/multi_option_union_widget.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
* License v3.0 only", or the "Server Side Public License, v 1".
88
*/
99

10-
import React, { useState } from 'react';
10+
import React, { useState, useEffect, useRef } from 'react';
1111
import { EuiCheckableCard, EuiFormFieldset, EuiSpacer, EuiTitle } from '@elastic/eui';
12+
import { useFormData, useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
1213
import { addMeta, getMeta } from '../../../schema_connector_metadata';
1314
import {
1415
getDiscriminatorFieldValue,
@@ -81,10 +82,31 @@ export const MultiOptionUnionWidget: React.FC<DiscriminatedUnionWidgetProps> = (
8182
fieldProps,
8283
formConfig,
8384
}) => {
84-
const defaultOption = getDefaultOption(options, discriminatorKey, fieldConfig);
85-
const [selectedOption, setSelectedOption] = useState(() =>
86-
getDiscriminatorFieldValue(defaultOption, discriminatorKey)
87-
);
85+
const [selectedOption, setSelectedOption] = useState(() => {
86+
const defaultOption = getDefaultOption(options, discriminatorKey, fieldConfig);
87+
return getDiscriminatorFieldValue(defaultOption, discriminatorKey);
88+
});
89+
90+
const [formData] = useFormData();
91+
const { setFieldValue } = useFormContext();
92+
93+
const hasInitializedFromFormData = useRef(false);
94+
const discriminatorValueFromForm = formData[rootPath]?.[discriminatorKey] as string | undefined;
95+
const discriminatorFieldPath = `${rootPath}.${discriminatorKey}`;
96+
97+
useEffect(() => {
98+
if (discriminatorValueFromForm && !hasInitializedFromFormData.current) {
99+
setSelectedOption(discriminatorValueFromForm);
100+
hasInitializedFromFormData.current = true;
101+
return;
102+
}
103+
104+
// After initialization: Sync selectedOption changes back to form data
105+
// This happens when user clicks a different option
106+
if (hasInitializedFromFormData.current && discriminatorValueFromForm !== selectedOption) {
107+
setFieldValue(discriminatorFieldPath, selectedOption);
108+
}
109+
}, [discriminatorFieldPath, discriminatorValueFromForm, selectedOption, setFieldValue]);
88110

89111
const isFieldsetDisabled = formConfig.disabled || getMeta(schema).disabled;
90112

x-pack/platform/plugins/shared/actions/server/lib/single_file_connectors/generate_config_schema.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@
66
*/
77

88
import type { ConnectorSpec } from '@kbn/connector-specs';
9-
import { z as z4 } from '@kbn/zod/v4';
9+
import { z } from '@kbn/zod/v4';
1010

1111
import type { ActionTypeConfig, ValidatorType } from '../../types';
1212

1313
export const generateConfigSchema = (
1414
schema: ConnectorSpec['schema']
15-
): ValidatorType<ActionTypeConfig> => ({ schema: schema ?? z4.object({}) });
15+
): ValidatorType<ActionTypeConfig> => {
16+
const authType = z.string().optional();
17+
const configSchema = schema ? schema.extend({ authType }) : z.object({ authType });
18+
return { schema: configSchema };
19+
};
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { z } from '@kbn/zod/v4';
9+
import {
10+
createConnectorFormSerializer,
11+
createConnectorFormDeserializer,
12+
} from './connector_form_serializers';
13+
14+
describe('createConnectorFormSerializer', () => {
15+
it('should copy authType from secrets to config', () => {
16+
const serializer = createConnectorFormSerializer();
17+
const formData = {
18+
config: { url: 'https://example.com' },
19+
secrets: { authType: 'basic', username: 'user', password: 'pass' },
20+
};
21+
22+
const result = serializer(formData);
23+
24+
expect(result.config.authType).toBe('basic');
25+
expect(result.secrets.authType).toBe('basic');
26+
});
27+
28+
it('should return data unchanged when secrets.authType is missing', () => {
29+
const serializer = createConnectorFormSerializer();
30+
const formData = {
31+
config: { url: 'https://example.com' },
32+
secrets: {},
33+
};
34+
35+
const result = serializer(formData);
36+
37+
expect(result).toEqual(formData);
38+
expect(result.config.authType).toBeUndefined();
39+
});
40+
41+
it('should overwrite existing config.authType if secrets.authType exists', () => {
42+
const serializer = createConnectorFormSerializer();
43+
const formData = {
44+
config: { url: 'https://example.com', authType: 'old' },
45+
secrets: { authType: 'bearer', token: 'xyz' },
46+
};
47+
48+
const result = serializer(formData);
49+
50+
expect(result.config.authType).toBe('bearer');
51+
});
52+
53+
it('should handle undefined formData', () => {
54+
const serializer = createConnectorFormSerializer();
55+
56+
const result = serializer(undefined);
57+
58+
expect(result).toBeUndefined();
59+
});
60+
});
61+
62+
describe('createConnectorFormDeserializer', () => {
63+
const commonApiData = {
64+
actionTypeId: 'test-connector',
65+
isDeprecated: false,
66+
};
67+
68+
const createTestSchema = () => {
69+
return z.object({
70+
config: z.object({
71+
url: z.string(),
72+
authType: z.string().optional(),
73+
}),
74+
secrets: z.discriminatedUnion('authType', [
75+
z.object({ authType: z.literal('none') }),
76+
z.object({ authType: z.literal('basic'), username: z.string(), password: z.string() }),
77+
z.object({ authType: z.literal('bearer'), token: z.string() }),
78+
]),
79+
});
80+
};
81+
82+
it('should copy authType from config to secrets when editing', () => {
83+
const schema = createTestSchema();
84+
const deserializer = createConnectorFormDeserializer(schema);
85+
const apiData = {
86+
...commonApiData,
87+
config: { url: 'https://example.com', authType: 'basic' },
88+
secrets: {},
89+
};
90+
91+
const result = deserializer(apiData);
92+
93+
expect(result.secrets.authType).toBe('basic');
94+
expect(result.config.authType).toBe('basic');
95+
});
96+
97+
it('should return data unchanged when config.authType is missing', () => {
98+
const schema = createTestSchema();
99+
const deserializer = createConnectorFormDeserializer(schema);
100+
const apiData = {
101+
...commonApiData,
102+
config: { url: 'https://example.com' },
103+
secrets: {},
104+
};
105+
106+
const result = deserializer(apiData);
107+
108+
expect(result).toEqual(apiData);
109+
expect(result.secrets.authType).toBeUndefined();
110+
});
111+
112+
it('should return data unchanged when secrets.authType already exists', () => {
113+
const schema = createTestSchema();
114+
const deserializer = createConnectorFormDeserializer(schema);
115+
const apiData = {
116+
...commonApiData,
117+
config: { url: 'https://example.com', authType: 'basic' },
118+
secrets: { authType: 'bearer', token: 'xyz' },
119+
};
120+
121+
const result = deserializer(apiData);
122+
123+
expect(result).toEqual(apiData);
124+
expect(result.secrets.authType).toBe('bearer');
125+
});
126+
127+
it('should handle schema without discriminated union gracefully', () => {
128+
const schema = z.object({
129+
config: z.object({ url: z.string() }),
130+
secrets: z.object({ token: z.string() }),
131+
});
132+
const deserializer = createConnectorFormDeserializer(schema);
133+
const apiData = {
134+
...commonApiData,
135+
config: { url: 'https://example.com', authType: 'basic' },
136+
secrets: {},
137+
};
138+
139+
const result = deserializer(apiData);
140+
141+
expect(result).toEqual(apiData);
142+
});
143+
144+
it('should handle errors', () => {
145+
const invalidSchema = {} as z.ZodObject<z.ZodRawShape>;
146+
const deserializer = createConnectorFormDeserializer(invalidSchema);
147+
const apiData = {
148+
...commonApiData,
149+
config: { url: 'https://example.com', authType: 'basic' },
150+
secrets: {},
151+
};
152+
153+
const result = deserializer(apiData);
154+
155+
expect(result).toEqual(apiData);
156+
});
157+
});

0 commit comments

Comments
 (0)