@startsimpli/integrations 0.1.0 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -0
- package/package.json +18 -9
- package/src/__tests__/connections.test.tsx +453 -0
- package/src/components/CredentialSchemaForm.tsx +196 -0
- package/src/components/index.ts +20 -0
- package/src/components/pages/ConnectionsPage.tsx +610 -0
- package/src/components/pages/types.ts +197 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The connections layer: a generic renderer and a page composer
|
|
3
|
+
* (bd startsim-768w.22.3.2 RED, startsim-768w.22.3.4 GREEN).
|
|
4
|
+
*
|
|
5
|
+
* THE ONE RULE THIS SUITE EXISTS TO ENFORCE: no per-vendor components, ever.
|
|
6
|
+
* The backend already refuses to hardcode a vendor — the catalog is
|
|
7
|
+
* `DestinationProvider` rows and the field schemas come off the connector class
|
|
8
|
+
* — and that is worth nothing if the browser answers with `if (key === 'n8n')`.
|
|
9
|
+
* So every assertion below drives TWO different schemas through ONE component
|
|
10
|
+
* and checks the output differs only in the way the DATA differs.
|
|
11
|
+
*
|
|
12
|
+
* The composer is injected, not contextual: `<ConnectionsPage api={...}
|
|
13
|
+
* foundrySlug="..." />`, the same shape as `TeamSettingsPage`. No React context,
|
|
14
|
+
* no app-local provider — an app mounts it in ten lines and owns nothing but
|
|
15
|
+
* theme classNames.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
18
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
19
|
+
import userEvent from '@testing-library/user-event';
|
|
20
|
+
|
|
21
|
+
import { CredentialSchemaForm, schemaFields } from '../components/CredentialSchemaForm';
|
|
22
|
+
import { ConnectionsPage } from '../components/pages/ConnectionsPage';
|
|
23
|
+
import type { ConnectionsApi } from '../components/pages/types';
|
|
24
|
+
|
|
25
|
+
// --------------------------------------------------------------------------
|
|
26
|
+
// two schemas, one component
|
|
27
|
+
// --------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** Vendor A: a single secret. The Granola shape. */
|
|
30
|
+
const ONE_SECRET = [
|
|
31
|
+
{ key: 'api_key', label: 'API key', type: 'secret', write_only: true, required: true,
|
|
32
|
+
help: 'Starts with grn_' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** Vendor B: url + secret + select. The self-hosted n8n shape. */
|
|
36
|
+
const URL_SECRET_SELECT = [
|
|
37
|
+
{ key: 'base_url', label: 'Instance URL', type: 'url', required: true,
|
|
38
|
+
placeholder: 'https://n8n.example.com' },
|
|
39
|
+
{ key: 'api_key', label: 'Public API key', type: 'secret', write_only: true, required: false },
|
|
40
|
+
{
|
|
41
|
+
key: 'environment',
|
|
42
|
+
label: 'Environment',
|
|
43
|
+
type: 'select',
|
|
44
|
+
required: false,
|
|
45
|
+
default: 'production',
|
|
46
|
+
options: [
|
|
47
|
+
{ value: 'production', label: 'Production' },
|
|
48
|
+
{ value: 'staging', label: 'Staging' },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
function renderForm(fields: unknown[], props: Record<string, unknown> = {}) {
|
|
54
|
+
const onChange = vi.fn();
|
|
55
|
+
const utils = render(
|
|
56
|
+
<CredentialSchemaForm
|
|
57
|
+
fields={fields as never}
|
|
58
|
+
values={{}}
|
|
59
|
+
onChange={onChange}
|
|
60
|
+
{...props}
|
|
61
|
+
/>,
|
|
62
|
+
);
|
|
63
|
+
return { ...utils, onChange };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('CredentialSchemaForm — one renderer, every vendor', () => {
|
|
67
|
+
it('renders a one-secret schema from data alone', () => {
|
|
68
|
+
renderForm(ONE_SECRET);
|
|
69
|
+
expect(screen.getByLabelText(/API key/i)).toBeInTheDocument();
|
|
70
|
+
expect(screen.getByText(/Starts with grn_/i)).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('renders a url+secret+select schema with the SAME component', () => {
|
|
74
|
+
renderForm(URL_SECRET_SELECT);
|
|
75
|
+
expect(screen.getByLabelText(/Instance URL/i)).toBeInTheDocument();
|
|
76
|
+
expect(screen.getByLabelText(/Public API key/i)).toBeInTheDocument();
|
|
77
|
+
expect(screen.getByLabelText(/Environment/i)).toBeInTheDocument();
|
|
78
|
+
expect(screen.getByRole('option', { name: 'Staging' })).toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('keeps the declared array order', () => {
|
|
82
|
+
const { container } = renderForm(URL_SECRET_SELECT);
|
|
83
|
+
const names = Array.from(container.querySelectorAll('[data-field]')).map(
|
|
84
|
+
(el) => el.getAttribute('data-field'),
|
|
85
|
+
);
|
|
86
|
+
expect(names).toEqual(['base_url', 'api_key', 'environment']);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('honours placeholder and default', () => {
|
|
90
|
+
renderForm(URL_SECRET_SELECT);
|
|
91
|
+
expect(screen.getByPlaceholderText('https://n8n.example.com')).toBeInTheDocument();
|
|
92
|
+
expect((screen.getByLabelText(/Environment/i) as HTMLSelectElement).value)
|
|
93
|
+
.toBe('production');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('masks a secret field', () => {
|
|
97
|
+
renderForm(ONE_SECRET);
|
|
98
|
+
expect(screen.getByLabelText(/API key/i)).toHaveAttribute('type', 'password');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('NEVER populates a secret from a read response', () => {
|
|
102
|
+
// The read surface returns config, never credential values. If a `values`
|
|
103
|
+
// map ever carries one, the form still must not put it in the DOM — a
|
|
104
|
+
// masked input whose value is the real key is a copy-paste away from a leak.
|
|
105
|
+
renderForm(ONE_SECRET, { values: { api_key: 'grn_live_SHOULD_NOT_RENDER' } });
|
|
106
|
+
expect((screen.getByLabelText(/API key/i) as HTMLInputElement).value).toBe('');
|
|
107
|
+
expect(document.body.innerHTML).not.toContain('grn_live_SHOULD_NOT_RENDER');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('degrades an unknown type to text and still renders its neighbours', () => {
|
|
111
|
+
renderForm([
|
|
112
|
+
{ key: 'first', label: 'First' },
|
|
113
|
+
{ key: 'exotic', label: 'Exotic', type: 'colour-wheel-from-the-future' },
|
|
114
|
+
{ key: 'last', label: 'Last' },
|
|
115
|
+
]);
|
|
116
|
+
expect(screen.getByLabelText(/Exotic/i)).toHaveAttribute('type', 'text');
|
|
117
|
+
expect(screen.getByLabelText(/First/i)).toBeInTheDocument();
|
|
118
|
+
expect(screen.getByLabelText(/Last/i)).toBeInTheDocument();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('reports every edit through onChange', async () => {
|
|
122
|
+
const { onChange } = renderForm(ONE_SECRET);
|
|
123
|
+
await userEvent.type(screen.getByLabelText(/API key/i), 'grn_x');
|
|
124
|
+
expect(onChange).toHaveBeenCalled();
|
|
125
|
+
expect(onChange.mock.calls[0][0]).toBe('api_key');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// --------------------------------------------------------------------------
|
|
130
|
+
// the composer
|
|
131
|
+
// --------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
const CATALOG = {
|
|
134
|
+
results: [
|
|
135
|
+
{
|
|
136
|
+
key: 'n8n', name: 'n8n', description: 'Call a workflow', logo_url: '', docs_url: '',
|
|
137
|
+
capabilities: ['action', 'poll'], auth_mode: 'api_key',
|
|
138
|
+
credential_schema: { version: 1, fields: URL_SECRET_SELECT },
|
|
139
|
+
config_schema: { version: 1, fields: [] },
|
|
140
|
+
record_types: [], supports_webhooks: true,
|
|
141
|
+
availability: { available: true, reason: '' },
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
key: 'granola', name: 'Granola', description: 'Meeting notes', logo_url: '', docs_url: '',
|
|
145
|
+
capabilities: ['poll'], auth_mode: 'api_key',
|
|
146
|
+
credential_schema: { version: 1, fields: ONE_SECRET },
|
|
147
|
+
config_schema: { version: 1, fields: [] },
|
|
148
|
+
record_types: [], supports_webhooks: false,
|
|
149
|
+
availability: { available: false, reason: 'ImportError: no granola sdk' },
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const CONNECTED = {
|
|
155
|
+
results: [
|
|
156
|
+
{
|
|
157
|
+
id: 'c1', public_id: 'pub1', name: 'Ops n8n', connector_key: 'n8n', connector_name: 'n8n',
|
|
158
|
+
external_account_id: '', account_label: 'https://n8n.example.com',
|
|
159
|
+
capabilities_enabled: ['action'], config: {}, status: 'connected',
|
|
160
|
+
health: 'ok', auth_mode: 'api_key',
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
permissions: { view: true, manage: true, issue_tokens: true },
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
function stubApi(overrides: Partial<ConnectionsApi> = {}): ConnectionsApi {
|
|
167
|
+
return {
|
|
168
|
+
connectorCatalog: vi.fn().mockResolvedValue(CATALOG),
|
|
169
|
+
connections: vi.fn().mockResolvedValue(CONNECTED),
|
|
170
|
+
connect: vi.fn().mockResolvedValue(CONNECTED.results[0]),
|
|
171
|
+
checkConnection: vi.fn().mockResolvedValue({ ok: true, account_label: '', detail: 'ok' }),
|
|
172
|
+
disconnect: vi.fn().mockResolvedValue(undefined),
|
|
173
|
+
connectionChoices: vi.fn().mockResolvedValue({
|
|
174
|
+
results: [
|
|
175
|
+
{
|
|
176
|
+
id: 'wf-1:node-a', label: 'Nightly digest — Webhook', kind: 'workflow',
|
|
177
|
+
selectable: true, reason: 'wirable', remediation: '', hidden_by_default: false,
|
|
178
|
+
detail: {},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: 'wf-2', label: 'Schedule only', kind: 'workflow', selectable: false,
|
|
182
|
+
reason: 'not_wirable:schedule_only',
|
|
183
|
+
remediation: 'Add a Webhook trigger node.', hidden_by_default: false, detail: {},
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
kind: 'workflow', cursor: null, has_more: false,
|
|
187
|
+
}),
|
|
188
|
+
wireTarget: vi.fn().mockResolvedValue({
|
|
189
|
+
id: 'd1', name: 'Nightly digest', provider_key: 'n8n', entity_type: 'contact',
|
|
190
|
+
is_active: true, verified: false,
|
|
191
|
+
destination_config: { webhook_url: 'https://n8n.example.com/webhook/nightly' },
|
|
192
|
+
}),
|
|
193
|
+
confirmTarget: vi.fn().mockResolvedValue({
|
|
194
|
+
id: 'd1', name: 'Nightly digest', provider_key: 'n8n', entity_type: 'contact',
|
|
195
|
+
is_active: true, verified: true,
|
|
196
|
+
destination_config: { webhook_url: 'https://n8n.example.com/webhook/nightly' },
|
|
197
|
+
}),
|
|
198
|
+
connectionActions: vi.fn().mockResolvedValue({
|
|
199
|
+
results: [{ name: 'trigger_workflow', label: 'Trigger a workflow', input_schema: {} }],
|
|
200
|
+
}),
|
|
201
|
+
invokeConnectionAction: vi.fn().mockResolvedValue({
|
|
202
|
+
ok: true, action: 'trigger_workflow', destination_id: 'd1', run_id: 'r1',
|
|
203
|
+
run_status: 'running', external_ref: null, output: {}, error: '',
|
|
204
|
+
}),
|
|
205
|
+
connectionRuns: vi.fn().mockResolvedValue({ results: [] }),
|
|
206
|
+
updateConnection: vi.fn().mockResolvedValue(CONNECTED.results[0]),
|
|
207
|
+
...overrides,
|
|
208
|
+
} as ConnectionsApi;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
describe('ConnectionsPage — injected, not contextual', () => {
|
|
212
|
+
it('renders the catalog and the connected list from the injected api', async () => {
|
|
213
|
+
const api = stubApi();
|
|
214
|
+
render(<ConnectionsPage api={api} foundrySlug="acme" />);
|
|
215
|
+
expect(await screen.findByText('Ops n8n')).toBeInTheDocument();
|
|
216
|
+
expect(await screen.findByText('Granola')).toBeInTheDocument();
|
|
217
|
+
expect(api.connectorCatalog).toHaveBeenCalledWith('acme');
|
|
218
|
+
expect(api.connections).toHaveBeenCalledWith('acme');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('shows an unavailable catalog row WITH its reason instead of hiding it', async () => {
|
|
222
|
+
render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
|
|
223
|
+
expect(await screen.findByText(/no granola sdk/i)).toBeInTheDocument();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('renders read-only when the permissions envelope says manage is false', async () => {
|
|
227
|
+
const api = stubApi({
|
|
228
|
+
connections: vi.fn().mockResolvedValue({
|
|
229
|
+
...CONNECTED,
|
|
230
|
+
permissions: { view: true, manage: false, issue_tokens: false },
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
233
|
+
render(<ConnectionsPage api={api} foundrySlug="acme" />);
|
|
234
|
+
await screen.findByText('Ops n8n');
|
|
235
|
+
expect(screen.queryByRole('button', { name: /^connect$/i })).not.toBeInTheDocument();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('surfaces a DENIED read as a reason, not an empty page', async () => {
|
|
239
|
+
const denied = Object.assign(new Error('forbidden'), { status: 403 });
|
|
240
|
+
const api = stubApi({ connections: vi.fn().mockRejectedValue(denied) });
|
|
241
|
+
render(<ConnectionsPage api={api} foundrySlug="acme" />);
|
|
242
|
+
expect(await screen.findByText(/do not have access|not have permission/i)).toBeInTheDocument();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('drives discovery -> wire -> confirm without anything hand-typed', async () => {
|
|
246
|
+
const api = stubApi();
|
|
247
|
+
render(<ConnectionsPage api={api} foundrySlug="acme" />);
|
|
248
|
+
await userEvent.click(await screen.findByRole('button', { name: /workflows|discover/i }));
|
|
249
|
+
|
|
250
|
+
// Nothing is dropped: the unpickable row is present and says why.
|
|
251
|
+
expect(await screen.findByText(/Nightly digest/)).toBeInTheDocument();
|
|
252
|
+
expect(screen.getByText(/Add a Webhook trigger node\./)).toBeInTheDocument();
|
|
253
|
+
|
|
254
|
+
await userEvent.click(screen.getByRole('button', { name: /^wire$/i }));
|
|
255
|
+
await waitFor(() => expect(api.wireTarget).toHaveBeenCalledWith('acme', 'c1', {
|
|
256
|
+
choice_id: 'wf-1:node-a',
|
|
257
|
+
}));
|
|
258
|
+
|
|
259
|
+
await userEvent.click(await screen.findByRole('button', { name: /confirm/i }));
|
|
260
|
+
await waitFor(() => expect(api.confirmTarget).toHaveBeenCalledWith('acme', 'c1', 'd1'));
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('will not offer to wire an unpickable choice', async () => {
|
|
264
|
+
render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
|
|
265
|
+
await userEvent.click(await screen.findByRole('button', { name: /workflows|discover/i }));
|
|
266
|
+
await screen.findByText(/Schedule only/);
|
|
267
|
+
// One selectable row -> exactly one wire button.
|
|
268
|
+
expect(screen.getAllByRole('button', { name: /^wire$/i })).toHaveLength(1);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// --------------------------------------------------------------------------
|
|
273
|
+
// the capability choice (bd startsim-768w.22.22)
|
|
274
|
+
// --------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* WHAT WENT WRONG ON PRODUCTION. An admin connected Granola on the `tandem`
|
|
278
|
+
* foundry through this very page. The card went green — `check()` really
|
|
279
|
+
* reached the vendor — and `capabilities_enabled` was stored as `[]`. With an
|
|
280
|
+
* empty list NO capability is enabled: not `poll`, not `action`, not
|
|
281
|
+
* `webhook_in`. So `poll_is_enabled()` answered false, no scheduled ingest leg
|
|
282
|
+
* was ever created, and the card sat there connected and completely inert.
|
|
283
|
+
*
|
|
284
|
+
* The server was not the liar. `POST /connections/` has accepted
|
|
285
|
+
* `capabilities_enabled[]` since the endpoint was designed, and
|
|
286
|
+
* `PATCH /connections/<id>/` has accepted edits to it and created the poll leg
|
|
287
|
+
* on the spot. THIS PAGE never sent either one — there was no control for it
|
|
288
|
+
* anywhere, at connect time or after — so the server received nothing, wrote
|
|
289
|
+
* nothing, and the admin had no way to say what they wanted.
|
|
290
|
+
*
|
|
291
|
+
* Both halves are asserted here: the choice is offered when connecting, and it
|
|
292
|
+
* stays changeable afterwards, because the connections that already exist
|
|
293
|
+
* cannot be fixed by anything the connect form does.
|
|
294
|
+
*/
|
|
295
|
+
describe('capabilities are the admin’s choice, and the page has to ask', () => {
|
|
296
|
+
/** The capability picker inside ONE card. Both surfaces render one. */
|
|
297
|
+
function picker(scope: HTMLElement, which: 'catalog' | 'connection') {
|
|
298
|
+
const group = scope.querySelector<HTMLElement>(`[data-capabilities="${which}"]`);
|
|
299
|
+
if (!group) throw new Error(`no ${which} capability picker rendered`);
|
|
300
|
+
return within(group);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function openConnectForm() {
|
|
304
|
+
const api = stubApi();
|
|
305
|
+
const { container } = render(<ConnectionsPage api={api} foundrySlug="acme" />);
|
|
306
|
+
// The catalog's available row. The unavailable one offers no button at all.
|
|
307
|
+
await userEvent.click(await screen.findByRole('button', { name: /^connect$/i }));
|
|
308
|
+
return { api, container };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
it('offers every capability the connector declares, from the catalog row', async () => {
|
|
312
|
+
const { container } = await openConnectForm();
|
|
313
|
+
// The n8n fixture declares action + poll. Both are offered; nothing else is.
|
|
314
|
+
const boxes = picker(container, 'catalog').getAllByRole('checkbox');
|
|
315
|
+
expect(boxes.map((b) => b.getAttribute('value')).sort()).toEqual(['action', 'poll']);
|
|
316
|
+
for (const box of boxes) expect(box).toBeChecked();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('SENDS the capability list on connect instead of leaving the card inert', async () => {
|
|
320
|
+
const { api } = await openConnectForm();
|
|
321
|
+
await userEvent.type(screen.getByLabelText(/Instance URL/i), 'https://n8n.example.com');
|
|
322
|
+
await userEvent.click(screen.getByRole('button', { name: /test and save/i }));
|
|
323
|
+
|
|
324
|
+
await waitFor(() => expect(api.connect).toHaveBeenCalled());
|
|
325
|
+
const sent = (api.connect as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
|
326
|
+
expect(sent.capabilities_enabled).toEqual(['action', 'poll']);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('honours an admin who unchecks one before saving', async () => {
|
|
330
|
+
const { api, container } = await openConnectForm();
|
|
331
|
+
await userEvent.type(screen.getByLabelText(/Instance URL/i), 'https://n8n.example.com');
|
|
332
|
+
await userEvent.click(picker(container, 'catalog').getByRole('checkbox', { name: /action/i }));
|
|
333
|
+
await userEvent.click(screen.getByRole('button', { name: /test and save/i }));
|
|
334
|
+
|
|
335
|
+
await waitFor(() => expect(api.connect).toHaveBeenCalled());
|
|
336
|
+
const sent = (api.connect as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
|
337
|
+
expect(sent.capabilities_enabled).toEqual(['poll']);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('will not let an admin save a connection that can do nothing', async () => {
|
|
341
|
+
const { container } = await openConnectForm();
|
|
342
|
+
await userEvent.type(screen.getByLabelText(/Instance URL/i), 'https://n8n.example.com');
|
|
343
|
+
for (const box of picker(container, 'catalog').getAllByRole('checkbox')) {
|
|
344
|
+
await userEvent.click(box);
|
|
345
|
+
}
|
|
346
|
+
// The server refuses an empty list with a 400 (bd startsim-768w.22.22);
|
|
347
|
+
// refusing it here means the admin finds out before a round trip.
|
|
348
|
+
expect(screen.getByRole('button', { name: /test and save/i })).toBeDisabled();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('lets an admin turn a capability ON for a connection that already exists', async () => {
|
|
352
|
+
// THE UNBLOCK. The `tandem` Granola card is already there with an empty
|
|
353
|
+
// list; nothing the connect form does can reach it. `Ops n8n` here has only
|
|
354
|
+
// `action` enabled, and the connector declares `poll` too.
|
|
355
|
+
const api = stubApi();
|
|
356
|
+
const { container } = render(<ConnectionsPage api={api} foundrySlug="acme" />);
|
|
357
|
+
await screen.findByText('Ops n8n');
|
|
358
|
+
|
|
359
|
+
const on = picker(container, 'connection');
|
|
360
|
+
const poll = on.getByRole('checkbox', { name: /poll/i });
|
|
361
|
+
expect(poll).not.toBeChecked();
|
|
362
|
+
await userEvent.click(poll);
|
|
363
|
+
await userEvent.click(on.getByRole('button', { name: /save/i }));
|
|
364
|
+
|
|
365
|
+
await waitFor(() => expect(api.updateConnection).toHaveBeenCalledWith('acme', 'c1', {
|
|
366
|
+
capabilities_enabled: ['action', 'poll'],
|
|
367
|
+
}));
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('shows the capability state read-only when manage is false', async () => {
|
|
371
|
+
const api = stubApi({
|
|
372
|
+
connections: vi.fn().mockResolvedValue({
|
|
373
|
+
...CONNECTED,
|
|
374
|
+
permissions: { view: true, manage: false, issue_tokens: false },
|
|
375
|
+
}),
|
|
376
|
+
});
|
|
377
|
+
const { container } = render(<ConnectionsPage api={api} foundrySlug="acme" />);
|
|
378
|
+
await screen.findByText('Ops n8n');
|
|
379
|
+
|
|
380
|
+
const on = picker(container, 'connection');
|
|
381
|
+
expect(on.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
|
|
382
|
+
for (const box of on.getAllByRole('checkbox')) expect(box).toBeDisabled();
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe('the catalog envelope, as the server actually ships it', () => {
|
|
387
|
+
// REGRESSION. The first browser drive of this page died on
|
|
388
|
+
// `(entry.credential_schema ?? []) is not iterable`, because these fixtures
|
|
389
|
+
// were invented as bare arrays while `schema_to_wire` ships
|
|
390
|
+
// `{version, fields[]}`. Unit tests cannot catch a fixture that lies about
|
|
391
|
+
// the wire — only driving the real API can — so the shape is pinned here now.
|
|
392
|
+
it('reads fields out of the {version, fields} envelope', () => {
|
|
393
|
+
renderForm(ONE_SECRET);
|
|
394
|
+
expect(screen.getByLabelText(/API key/i)).toBeInTheDocument();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('renders a catalog row whose schema arrives in the envelope', async () => {
|
|
398
|
+
render(<ConnectionsPage api={stubApi()} foundrySlug="acme" />);
|
|
399
|
+
expect(await screen.findByText('n8n')).toBeInTheDocument();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('does NOT blank the form on a schema version this build has never seen', () => {
|
|
403
|
+
const { container } = render(
|
|
404
|
+
<CredentialSchemaForm
|
|
405
|
+
fields={schemaFields({ version: 7, fields: ONE_SECRET } as never)}
|
|
406
|
+
values={{}}
|
|
407
|
+
onChange={() => {}}
|
|
408
|
+
/>,
|
|
409
|
+
);
|
|
410
|
+
expect(container.querySelectorAll('[data-field]')).toHaveLength(1);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('treats a missing or malformed schema as no fields, not a crash', () => {
|
|
414
|
+
expect(schemaFields(null)).toEqual([]);
|
|
415
|
+
expect(schemaFields(undefined)).toEqual([]);
|
|
416
|
+
expect(schemaFields({} as never)).toEqual([]);
|
|
417
|
+
expect(schemaFields({ version: 1 } as never)).toEqual([]);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('masks on type:"secret" — the server spelling, not a boolean', () => {
|
|
421
|
+
renderForm([{ key: 'k', label: 'Key', type: 'secret' }]);
|
|
422
|
+
expect(screen.getByLabelText(/Key/i)).toHaveAttribute('type', 'password');
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe('no per-vendor components', () => {
|
|
427
|
+
it('the connections layer names no vendor', async () => {
|
|
428
|
+
// The guard, as an assertion rather than a code-review habit. A component
|
|
429
|
+
// file whose text contains a vendor key is the thing the DB-driven catalog
|
|
430
|
+
// exists to prevent; the fixtures above prove two vendors already render
|
|
431
|
+
// through one path, and this stops the third from arriving as a branch.
|
|
432
|
+
const fs = await import('node:fs/promises');
|
|
433
|
+
const path = await import('node:path');
|
|
434
|
+
const dir = path.resolve(__dirname, '../components');
|
|
435
|
+
const offenders: string[] = [];
|
|
436
|
+
const walk = async (d: string): Promise<void> => {
|
|
437
|
+
for (const entry of await fs.readdir(d, { withFileTypes: true })) {
|
|
438
|
+
const full = path.join(d, entry.name);
|
|
439
|
+
if (entry.isDirectory()) { await walk(full); continue; }
|
|
440
|
+
if (!/\.tsx?$/.test(entry.name)) continue;
|
|
441
|
+
const text = await fs.readFile(full, 'utf8');
|
|
442
|
+
for (const vendor of ['n8n', 'granola', 'stripe', 'soundcharts', 'companies_house']) {
|
|
443
|
+
// A vendor named in a COMMENT is fine — it is an example. A vendor in
|
|
444
|
+
// code is a branch.
|
|
445
|
+
const code = text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*/g, '');
|
|
446
|
+
if (code.toLowerCase().includes(vendor)) offenders.push(`${entry.name}: ${vendor}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
await walk(dir);
|
|
451
|
+
expect(offenders).toEqual([]);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE form for every vendor, rendered from the catalog's own field schema
|
|
3
|
+
* (bd startsim-768w.22.3.4).
|
|
4
|
+
*
|
|
5
|
+
* The backend already refuses to hardcode a vendor: the catalog is
|
|
6
|
+
* `DestinationProvider` rows and the field specs come off the connector class,
|
|
7
|
+
* so adding a vendor costs a row and a class. That is worth nothing if the
|
|
8
|
+
* browser answers with `if (connector === 'x')` — the duplication would just
|
|
9
|
+
* have moved to where nobody is looking. So this component knows about FIELDS
|
|
10
|
+
* and knows nothing about vendors, and the suite next door drives two different
|
|
11
|
+
* schemas through it to prove it.
|
|
12
|
+
*
|
|
13
|
+
* FORWARD COMPATIBILITY IS THE POINT. The catalog is data and will outrun the
|
|
14
|
+
* deployed bundle — a connector shipped today can declare a field type this
|
|
15
|
+
* build has never heard of. An unknown type degrades to a text input and every
|
|
16
|
+
* other field still renders, because a blank form is a support ticket and a
|
|
17
|
+
* text input is a usable answer.
|
|
18
|
+
*/
|
|
19
|
+
import * as React from 'react';
|
|
20
|
+
|
|
21
|
+
import type { ConnectorFieldSpec, ConnectorSchemaLike } from './pages/types';
|
|
22
|
+
|
|
23
|
+
export interface CredentialSchemaFormProps {
|
|
24
|
+
/** The catalog's own schema, rendered in DECLARED ORDER. */
|
|
25
|
+
fields: ConnectorFieldSpec[];
|
|
26
|
+
/** Current values, keyed by field key. Secrets are never read back from here. */
|
|
27
|
+
values: Record<string, string>;
|
|
28
|
+
onChange: (key: string, value: string) => void;
|
|
29
|
+
/** Per-field messages. A `pattern` miss WARNS; only `required` blocks submit. */
|
|
30
|
+
errors?: Record<string, string>;
|
|
31
|
+
warnings?: Record<string, string>;
|
|
32
|
+
idPrefix?: string;
|
|
33
|
+
classNames?: {
|
|
34
|
+
field?: string;
|
|
35
|
+
label?: string;
|
|
36
|
+
input?: string;
|
|
37
|
+
help?: string;
|
|
38
|
+
error?: string;
|
|
39
|
+
warning?: string;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Field types this build renders natively. Anything else becomes text. */
|
|
44
|
+
const NATIVE_TYPES = new Set(['text', 'url', 'email', 'number', 'select', 'textarea']);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Is this field a secret? `type: "secret"` is the server's spelling and
|
|
48
|
+
* `write_only` is the flag it enforces alongside it; `secret: true` is accepted
|
|
49
|
+
* for hand-built schemas. Any one of the three masks the field — asking the
|
|
50
|
+
* question in one place is what stops the three from disagreeing.
|
|
51
|
+
*/
|
|
52
|
+
export function isSecret(spec: ConnectorFieldSpec): boolean {
|
|
53
|
+
return spec.type === 'secret' || Boolean(spec.write_only) || Boolean(spec.secret);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The fields of a schema, whatever envelope it arrived in.
|
|
58
|
+
*
|
|
59
|
+
* The catalog ships `{version, fields[]}`; a caller composing a schema by hand
|
|
60
|
+
* passes a bare array. An UNRECOGNISED VERSION still renders its fields rather
|
|
61
|
+
* than blanking the form — the catalog is data and will outrun this bundle, and
|
|
62
|
+
* a blank connect form is a dead end with nothing to act on.
|
|
63
|
+
*/
|
|
64
|
+
export function schemaFields(schema: ConnectorSchemaLike): ConnectorFieldSpec[] {
|
|
65
|
+
if (!schema) return [];
|
|
66
|
+
if (Array.isArray(schema)) return schema;
|
|
67
|
+
return Array.isArray(schema.fields) ? schema.fields : [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function inputType(spec: ConnectorFieldSpec): string {
|
|
71
|
+
if (isSecret(spec)) return 'password';
|
|
72
|
+
const declared = String(spec.type || 'text');
|
|
73
|
+
return NATIVE_TYPES.has(declared) ? declared : 'text';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function CredentialSchemaForm({
|
|
77
|
+
fields,
|
|
78
|
+
values,
|
|
79
|
+
onChange,
|
|
80
|
+
errors = {},
|
|
81
|
+
warnings = {},
|
|
82
|
+
idPrefix = 'csf',
|
|
83
|
+
classNames = {},
|
|
84
|
+
}: CredentialSchemaFormProps) {
|
|
85
|
+
return (
|
|
86
|
+
<div className="space-y-4">
|
|
87
|
+
{fields.map((spec) => {
|
|
88
|
+
const id = `${idPrefix}-${spec.key}`;
|
|
89
|
+
const type = inputType(spec);
|
|
90
|
+
// A SECRET IS NEVER RENDERED. Not "not fetched" — not rendered, even if
|
|
91
|
+
// a value for it somehow reached this component. A masked input holding
|
|
92
|
+
// the real key is one devtools panel away from being the leak the whole
|
|
93
|
+
// credential surface is built to prevent.
|
|
94
|
+
const value = isSecret(spec)
|
|
95
|
+
? (values[`${spec.key}__draft`] ?? '')
|
|
96
|
+
: (values[spec.key] ?? spec.default ?? '');
|
|
97
|
+
const error = errors[spec.key];
|
|
98
|
+
const warning = warnings[spec.key];
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div key={spec.key} data-field={spec.key} className={classNames.field}>
|
|
102
|
+
<label htmlFor={id} className={classNames.label ?? 'block text-sm font-medium text-gray-900'}>
|
|
103
|
+
{spec.label}
|
|
104
|
+
{spec.required ? <span aria-hidden="true"> *</span> : null}
|
|
105
|
+
</label>
|
|
106
|
+
|
|
107
|
+
{type === 'select' ? (
|
|
108
|
+
<select
|
|
109
|
+
id={id}
|
|
110
|
+
value={value}
|
|
111
|
+
required={Boolean(spec.required)}
|
|
112
|
+
onChange={(e) => onChange(spec.key, e.target.value)}
|
|
113
|
+
className={classNames.input ?? 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm'}
|
|
114
|
+
>
|
|
115
|
+
{(spec.options ?? []).map((option) => (
|
|
116
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
117
|
+
))}
|
|
118
|
+
</select>
|
|
119
|
+
) : type === 'textarea' ? (
|
|
120
|
+
<textarea
|
|
121
|
+
id={id}
|
|
122
|
+
value={value}
|
|
123
|
+
required={Boolean(spec.required)}
|
|
124
|
+
placeholder={spec.placeholder || undefined}
|
|
125
|
+
onChange={(e) => onChange(spec.key, e.target.value)}
|
|
126
|
+
className={classNames.input ?? 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm'}
|
|
127
|
+
/>
|
|
128
|
+
) : (
|
|
129
|
+
<input
|
|
130
|
+
id={id}
|
|
131
|
+
type={type}
|
|
132
|
+
value={value}
|
|
133
|
+
required={Boolean(spec.required)}
|
|
134
|
+
placeholder={spec.placeholder || undefined}
|
|
135
|
+
autoComplete={isSecret(spec) ? 'new-password' : 'off'}
|
|
136
|
+
onChange={(e) => onChange(spec.key, e.target.value)}
|
|
137
|
+
className={classNames.input ?? 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm'}
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{spec.help ? (
|
|
142
|
+
<p className={classNames.help ?? 'mt-1 text-xs text-gray-500'}>{spec.help}</p>
|
|
143
|
+
) : null}
|
|
144
|
+
{warning ? (
|
|
145
|
+
<p className={classNames.warning ?? 'mt-1 text-xs text-amber-600'}>{warning}</p>
|
|
146
|
+
) : null}
|
|
147
|
+
{error ? (
|
|
148
|
+
<p className={classNames.error ?? 'mt-1 text-xs text-red-600'} role="alert">{error}</p>
|
|
149
|
+
) : null}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
})}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Which required fields are still empty. The ONLY client-side gate.
|
|
159
|
+
*
|
|
160
|
+
* A `pattern` miss deliberately does not block: the client never decides a
|
|
161
|
+
* credential is valid — `check()` does, against the vendor. A regex that
|
|
162
|
+
* refuses a legitimate key the vendor would have accepted is a support ticket
|
|
163
|
+
* nobody can work around.
|
|
164
|
+
*/
|
|
165
|
+
export function missingRequired(
|
|
166
|
+
fields: ConnectorFieldSpec[],
|
|
167
|
+
values: Record<string, string>,
|
|
168
|
+
): string[] {
|
|
169
|
+
return fields
|
|
170
|
+
.filter((spec) => spec.required)
|
|
171
|
+
.filter((spec) => !String(values[isSecret(spec) ? `${spec.key}__draft` : spec.key] ?? '').trim())
|
|
172
|
+
.map((spec) => spec.key);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Fields whose value does not match a declared `pattern`. WARNS, never blocks. */
|
|
176
|
+
export function patternWarnings(
|
|
177
|
+
fields: ConnectorFieldSpec[],
|
|
178
|
+
values: Record<string, string>,
|
|
179
|
+
): Record<string, string> {
|
|
180
|
+
const out: Record<string, string> = {};
|
|
181
|
+
for (const spec of fields) {
|
|
182
|
+
if (!spec.pattern) continue;
|
|
183
|
+
const value = String(values[isSecret(spec) ? `${spec.key}__draft` : spec.key] ?? '');
|
|
184
|
+
if (!value) continue;
|
|
185
|
+
let ok = true;
|
|
186
|
+
try {
|
|
187
|
+
ok = new RegExp(spec.pattern).test(value);
|
|
188
|
+
} catch {
|
|
189
|
+
// A pattern this build cannot compile is the catalog outrunning the
|
|
190
|
+
// bundle again. Silence beats a false warning.
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!ok) out[spec.key] = `This does not look like the expected format for ${spec.label}.`;
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -4,3 +4,23 @@ export * from './EmailList';
|
|
|
4
4
|
export * from './EventList';
|
|
5
5
|
export * from './EventListItem';
|
|
6
6
|
export * from './ReconsentPromptCard';
|
|
7
|
+
|
|
8
|
+
// The connections layer (bd startsim-768w.22.3.4): ONE schema-driven form and
|
|
9
|
+
// ONE page composer for every connector in the DB-driven catalog. Ship the
|
|
10
|
+
// composer, not only the primitives — otherwise the duplication just moves up
|
|
11
|
+
// one level into each app.
|
|
12
|
+
export * from './CredentialSchemaForm';
|
|
13
|
+
export * from './pages/ConnectionsPage';
|
|
14
|
+
export type {
|
|
15
|
+
ConnectionsApi,
|
|
16
|
+
ConnectorCatalogEntry,
|
|
17
|
+
ConnectorFieldSpec,
|
|
18
|
+
Connection as FoundryConnection,
|
|
19
|
+
ConnectionPermissions,
|
|
20
|
+
ConnectionChoice,
|
|
21
|
+
ConnectionChoicePage,
|
|
22
|
+
ConnectionTarget,
|
|
23
|
+
ConnectorAction,
|
|
24
|
+
ConnectionInvokeResult,
|
|
25
|
+
ConnectionRun,
|
|
26
|
+
} from './pages/types';
|