Skip to content

Commit c37a122

Browse files
fix(jira): collect Atlassian subdomain and handle ConnectedAccount_MissingRequiredFields (tinyhumansai#1726)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
1 parent eecd11c commit c37a122

2 files changed

Lines changed: 528 additions & 124 deletions

File tree

app/src/components/composio/ComposioConnectModal.test.tsx

Lines changed: 259 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
33

44
import { authorize } from '../../lib/composio/composioApi';
55
import { type ComposioConnection } from '../../lib/composio/types';
6-
import { openUrl } from '../../utils/openUrl';
76
import ComposioConnectModal, {
8-
isMissingAtlassianSubdomainError,
9-
normalizeAtlassianSubdomain,
7+
isMissingRequiredFieldsError,
8+
isValidAtlassianSubdomain,
9+
sanitizeAuthError,
1010
} from './ComposioConnectModal';
1111
import { composioToolkitMeta } from './toolkitMeta';
1212

@@ -26,16 +26,124 @@ vi.mock('./TriggerToggles', () => ({ default: () => <div data-testid="trigger-to
2626
const mockToolkit = composioToolkitMeta('gmail');
2727
const jiraToolkit = composioToolkitMeta('jira');
2828

29-
describe('<ComposioConnectModal>', () => {
30-
beforeEach(() => {
31-
vi.clearAllMocks();
32-
vi.mocked(authorize).mockResolvedValue({
33-
connectUrl: 'https://composio.example/jira/consent',
34-
connectionId: 'conn-123',
35-
});
36-
vi.mocked(openUrl).mockResolvedValue(undefined);
29+
// ── Pure helper unit tests ────────────────────────────────────────────
30+
31+
describe('isValidAtlassianSubdomain', () => {
32+
it('accepts typical lowercase subdomain', () => {
33+
expect(isValidAtlassianSubdomain('acme')).toBe(true);
34+
expect(isValidAtlassianSubdomain('my-company')).toBe(true);
35+
expect(isValidAtlassianSubdomain('org123')).toBe(true);
36+
});
37+
38+
it('accepts mixed-case subdomain (case-insensitive check)', () => {
39+
expect(isValidAtlassianSubdomain('MyCompany')).toBe(true);
40+
});
41+
42+
it('accepts single-character subdomain', () => {
43+
expect(isValidAtlassianSubdomain('a')).toBe(true);
44+
expect(isValidAtlassianSubdomain('z')).toBe(true);
45+
expect(isValidAtlassianSubdomain('5')).toBe(true);
46+
});
47+
48+
it('rejects full URLs', () => {
49+
expect(isValidAtlassianSubdomain('https://acme.atlassian.net')).toBe(false);
50+
expect(isValidAtlassianSubdomain('acme.atlassian.net')).toBe(false);
51+
});
52+
53+
it('rejects leading/trailing hyphens', () => {
54+
expect(isValidAtlassianSubdomain('-acme')).toBe(false);
55+
expect(isValidAtlassianSubdomain('acme-')).toBe(false);
56+
});
57+
58+
it('rejects empty string', () => {
59+
expect(isValidAtlassianSubdomain('')).toBe(false);
60+
expect(isValidAtlassianSubdomain(' ')).toBe(false);
3761
});
3862

63+
it('rejects strings with spaces', () => {
64+
expect(isValidAtlassianSubdomain('my company')).toBe(false);
65+
});
66+
67+
it('trims whitespace before validation', () => {
68+
expect(isValidAtlassianSubdomain(' acme ')).toBe(true);
69+
});
70+
});
71+
72+
describe('isMissingRequiredFieldsError', () => {
73+
it('matches the Composio error slug', () => {
74+
const err = new Error(
75+
'Authorization failed: [composio] authorize failed: Backend returned 400 Bad Request: Composio authorization failed: 400 {"error":{"message":"Missing required fields","code":612,"slug":"ConnectedAccount_MissingRequiredFields"}}'
76+
);
77+
expect(isMissingRequiredFieldsError(err)).toBe(true);
78+
});
79+
80+
it('does NOT match on the numeric code alone — avoids false positives from port/resource numbers', () => {
81+
// The slug-only check prevents unrelated "612" occurrences (e.g. port numbers, IDs)
82+
// from being misidentified as the Composio missing-fields error.
83+
const err = new Error('error code 612 from server');
84+
expect(isMissingRequiredFieldsError(err)).toBe(false);
85+
});
86+
87+
it('returns false for unrelated errors', () => {
88+
expect(isMissingRequiredFieldsError(new Error('Network timeout'))).toBe(false);
89+
expect(isMissingRequiredFieldsError(new Error('401 Unauthorized'))).toBe(false);
90+
});
91+
92+
it('returns false for null / undefined', () => {
93+
expect(isMissingRequiredFieldsError(null)).toBe(false);
94+
expect(isMissingRequiredFieldsError(undefined)).toBe(false);
95+
});
96+
97+
it('accepts non-Error objects with the slug in stringified form', () => {
98+
expect(isMissingRequiredFieldsError('ConnectedAccount_MissingRequiredFields')).toBe(true);
99+
});
100+
});
101+
102+
describe('sanitizeAuthError', () => {
103+
it('returns a generic message for missing-required-fields errors', () => {
104+
const err = new Error(
105+
'Authorization failed: [composio] authorize failed: Backend returned 400 Bad Request for POST https://api.tinyhumans.ai/agent-integrations/composio/authorize: Composio authorization failed: 400 {"error":{"slug":"ConnectedAccount_MissingRequiredFields","code":612}}'
106+
);
107+
const result = sanitizeAuthError(err);
108+
expect(result).not.toContain('ConnectedAccount_MissingRequiredFields');
109+
expect(result).not.toContain('api.tinyhumans.ai');
110+
expect(result).not.toContain('612');
111+
expect(result).toContain('required field');
112+
});
113+
114+
it('strips backend URLs from plain authorization errors', () => {
115+
const err = new Error(
116+
'Authorization failed: Backend returned 500 Internal Server Error for POST https://api.tinyhumans.ai/agent-integrations/composio/authorize: internal error'
117+
);
118+
const result = sanitizeAuthError(err);
119+
expect(result).not.toContain('api.tinyhumans.ai');
120+
expect(result).not.toContain('https://');
121+
});
122+
123+
it('strips raw JSON payloads', () => {
124+
const err = new Error(
125+
'Authorization failed: something happened: {"error":{"code":500,"message":"internal"}}'
126+
);
127+
const result = sanitizeAuthError(err);
128+
expect(result).not.toContain('"code"');
129+
expect(result).not.toContain('"message"');
130+
});
131+
132+
it('returns a safe fallback for null/undefined', () => {
133+
expect(sanitizeAuthError(null)).toBe('Something went wrong.');
134+
expect(sanitizeAuthError(undefined)).toBe('Something went wrong.');
135+
});
136+
137+
it('handles non-Error thrown values', () => {
138+
const result = sanitizeAuthError('plain string error');
139+
expect(typeof result).toBe('string');
140+
expect(result.length).toBeGreaterThan(0);
141+
});
142+
});
143+
144+
// ── Component render tests ────────────────────────────────────────────
145+
146+
describe('<ComposioConnectModal>', () => {
39147
it('hides raw connection ID and "id:" label in connected phase', () => {
40148
const connection: ComposioConnection = { id: 'ca_xyz', toolkit: 'gmail', status: 'ACTIVE' };
41149

@@ -112,79 +220,177 @@ describe('<ComposioConnectModal>', () => {
112220
expect(screen.queryByText('(Acme)')).not.toBeInTheDocument();
113221
expect(screen.queryByText('(oxox)')).not.toBeInTheDocument();
114222
});
223+
});
115224

116-
it('keeps default toolkit authorization free of empty extra params', async () => {
117-
render(
118-
<ComposioConnectModal toolkit={mockToolkit} connection={undefined} onClose={() => {}} />
119-
);
225+
// ── Jira-specific flow tests ──────────────────────────────────────────
226+
227+
describe('<ComposioConnectModal> — Jira subdomain collection', () => {
228+
beforeEach(() => {
229+
vi.clearAllMocks();
230+
});
231+
232+
it('shows the Atlassian subdomain input in the idle phase for Jira', () => {
233+
render(<ComposioConnectModal toolkit={jiraToolkit} onClose={() => {}} />);
234+
235+
expect(screen.getByLabelText(/Atlassian subdomain/i)).toBeInTheDocument();
236+
expect(screen.getByPlaceholderText('your-subdomain')).toBeInTheDocument();
237+
});
120238

121-
fireEvent.click(screen.getByRole('button', { name: 'Connect Gmail' }));
239+
it('does NOT show the Atlassian subdomain input for non-Jira toolkits', () => {
240+
render(<ComposioConnectModal toolkit={mockToolkit} onClose={() => {}} />);
241+
242+
expect(screen.queryByLabelText(/Atlassian subdomain/i)).not.toBeInTheDocument();
243+
expect(screen.queryByPlaceholderText('your-subdomain')).not.toBeInTheDocument();
244+
});
245+
246+
it('shows a validation error when connect is clicked with an empty subdomain', async () => {
247+
render(<ComposioConnectModal toolkit={jiraToolkit} onClose={() => {}} />);
248+
249+
const connectButton = screen.getByRole('button', { name: /Connect Jira/i });
250+
fireEvent.click(connectButton);
251+
252+
await waitFor(() => {
253+
expect(screen.getByText(/Please enter your Atlassian subdomain/i)).toBeInTheDocument();
254+
});
255+
});
256+
257+
it('shows a validation error when the subdomain looks like a full URL', async () => {
258+
render(<ComposioConnectModal toolkit={jiraToolkit} onClose={() => {}} />);
259+
260+
const input = screen.getByPlaceholderText('your-subdomain');
261+
fireEvent.change(input, { target: { value: 'https://acme.atlassian.net' } });
262+
263+
const connectButton = screen.getByRole('button', { name: /Connect Jira/i });
264+
fireEvent.click(connectButton);
265+
266+
await waitFor(() => {
267+
expect(screen.getByText(/short subdomain only/i)).toBeInTheDocument();
268+
});
269+
});
270+
271+
it('clears subdomain validation error when the user types', async () => {
272+
render(<ComposioConnectModal toolkit={jiraToolkit} onClose={() => {}} />);
273+
274+
// Trigger validation error
275+
const connectButton = screen.getByRole('button', { name: /Connect Jira/i });
276+
fireEvent.click(connectButton);
277+
278+
await waitFor(() => {
279+
expect(screen.getByText(/Please enter your Atlassian subdomain/i)).toBeInTheDocument();
280+
});
281+
282+
// Type to clear the error
283+
const input = screen.getByPlaceholderText('your-subdomain');
284+
fireEvent.change(input, { target: { value: 'a' } });
122285

123286
await waitFor(() => {
124-
expect(authorize).toHaveBeenCalledWith('gmail', undefined);
287+
expect(screen.queryByText(/Please enter your Atlassian subdomain/i)).not.toBeInTheDocument();
125288
});
126289
});
290+
});
291+
292+
// ── needs-subdomain phase tests ───────────────────────────────────────
127293

128-
it('normalizes pasted Atlassian URLs to the Jira subdomain', () => {
129-
expect(normalizeAtlassianSubdomain('https://Acme.atlassian.net/jira/software')).toBe('acme');
130-
expect(normalizeAtlassianSubdomain('acme.atlassian.net')).toBe('acme');
294+
describe('<ComposioConnectModal> — needs-subdomain recovery phase', () => {
295+
beforeEach(() => {
296+
vi.clearAllMocks();
131297
});
132298

133-
it('detects Composio missing-subdomain errors without exposing raw payloads', () => {
134-
expect(
135-
isMissingAtlassianSubdomainError(
136-
'Composio authorization failed: {"error":{"slug":"ConnectedAccount_MissingRequiredFields","message":"Missing required fields: Your Subdomain"}}'
299+
it('transitions to needs-subdomain phase for Jira when Composio returns the missing-required-fields error', async () => {
300+
// needs-subdomain phase is only shown for Atlassian toolkits (jira).
301+
vi.mocked(authorize).mockRejectedValueOnce(
302+
new Error(
303+
'Authorization failed: Backend returned 400: {"error":{"slug":"ConnectedAccount_MissingRequiredFields","code":612}}'
137304
)
138-
).toBe(true);
305+
);
306+
307+
render(<ComposioConnectModal toolkit={jiraToolkit} onClose={() => {}} />);
308+
309+
const input = screen.getByPlaceholderText('your-subdomain');
310+
fireEvent.change(input, { target: { value: 'acme' } });
311+
fireEvent.click(screen.getByRole('button', { name: /Connect Jira/i }));
312+
313+
await waitFor(() => {
314+
expect(screen.getByRole('button', { name: /Retry connection/i })).toBeInTheDocument();
315+
expect(screen.getByText(/To connect Jira/i)).toBeInTheDocument();
316+
});
139317
});
140318

141-
it('requires an Atlassian subdomain before Jira authorization', async () => {
142-
render(
143-
<ComposioConnectModal toolkit={jiraToolkit} connection={undefined} onClose={() => {}} />
319+
it('routes non-Jira missing-required-fields errors to the error phase (not needs-subdomain)', async () => {
320+
// Gmail does not have an Atlassian subdomain — showing the Atlassian subdomain
321+
// form for it would be misleading and the retry would loop forever.
322+
vi.mocked(authorize).mockRejectedValueOnce(
323+
new Error(
324+
'Authorization failed: Backend returned 400: {"error":{"slug":"ConnectedAccount_MissingRequiredFields","code":612}}'
325+
)
144326
);
145327

146-
fireEvent.click(screen.getByRole('button', { name: 'Connect Jira' }));
328+
render(<ComposioConnectModal toolkit={mockToolkit} onClose={() => {}} />);
329+
fireEvent.click(screen.getByRole('button', { name: /Connect Gmail/i }));
147330

148-
expect(await screen.findByText(/Enter your Atlassian subdomain/i)).toBeInTheDocument();
149-
expect(authorize).not.toHaveBeenCalled();
150-
expect(openUrl).not.toHaveBeenCalled();
331+
await waitFor(() => {
332+
expect(screen.getByRole('button', { name: /Dismiss/i })).toBeInTheDocument();
333+
expect(screen.queryByRole('button', { name: /Retry connection/i })).not.toBeInTheDocument();
334+
});
151335
});
152336

153-
it('sends the normalized Jira subdomain as an authorize extra param', async () => {
154-
render(
155-
<ComposioConnectModal toolkit={jiraToolkit} connection={undefined} onClose={() => {}} />
337+
it('does NOT show raw backend payload in the needs-subdomain phase', async () => {
338+
vi.mocked(authorize).mockRejectedValueOnce(
339+
new Error(
340+
'Authorization failed: Backend returned 400: {"error":{"slug":"ConnectedAccount_MissingRequiredFields","code":612,"message":"very sensitive backend payload"}}'
341+
)
156342
);
157343

158-
fireEvent.change(screen.getByLabelText(/Atlassian subdomain/i), {
159-
target: { value: 'https://Acme.atlassian.net/jira/software' },
344+
render(<ComposioConnectModal toolkit={jiraToolkit} onClose={() => {}} />);
345+
346+
const input = screen.getByPlaceholderText('your-subdomain');
347+
fireEvent.change(input, { target: { value: 'acme' } });
348+
fireEvent.click(screen.getByRole('button', { name: /Connect Jira/i }));
349+
350+
await waitFor(() => {
351+
expect(screen.queryByText(/very sensitive backend payload/i)).not.toBeInTheDocument();
352+
expect(screen.queryByText(/ConnectedAccount_MissingRequiredFields/i)).not.toBeInTheDocument();
353+
});
354+
});
355+
356+
it('clicking Cancel in needs-subdomain goes back to idle', async () => {
357+
vi.mocked(authorize).mockRejectedValueOnce(new Error('ConnectedAccount_MissingRequiredFields'));
358+
359+
render(<ComposioConnectModal toolkit={jiraToolkit} onClose={() => {}} />);
360+
361+
const input = screen.getByPlaceholderText('your-subdomain');
362+
fireEvent.change(input, { target: { value: 'acme' } });
363+
fireEvent.click(screen.getByRole('button', { name: /Connect Jira/i }));
364+
365+
await waitFor(() => {
366+
expect(screen.getByRole('button', { name: /Retry connection/i })).toBeInTheDocument();
160367
});
161-
fireEvent.click(screen.getByRole('button', { name: 'Connect Jira' }));
368+
369+
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }));
162370

163371
await waitFor(() => {
164-
expect(authorize).toHaveBeenCalledWith('jira', { subdomain: 'acme' });
372+
expect(screen.getByRole('button', { name: /Connect Jira/i })).toBeInTheDocument();
165373
});
166-
expect(openUrl).toHaveBeenCalledWith('https://composio.example/jira/consent');
167374
});
168375

169-
it('maps Jira missing-field backend errors back to the inline subdomain form', async () => {
376+
it('surfaces a sanitized (non-raw) error for unrelated authorization failures', async () => {
170377
vi.mocked(authorize).mockRejectedValueOnce(
171378
new Error(
172-
'Composio authorization failed: 400 {"error":{"slug":"ConnectedAccount_MissingRequiredFields","message":"Missing required fields: Your Subdomain"}}'
379+
'Authorization failed: Backend returned 500 Internal Server Error for POST https://api.tinyhumans.ai/agent-integrations/composio/authorize: {"error":{"message":"internal server error payload","code":500}}'
173380
)
174381
);
175382

176-
render(
177-
<ComposioConnectModal toolkit={jiraToolkit} connection={undefined} onClose={() => {}} />
178-
);
383+
render(<ComposioConnectModal toolkit={mockToolkit} onClose={() => {}} />);
179384

180-
fireEvent.change(screen.getByLabelText(/Atlassian subdomain/i), { target: { value: 'acme' } });
181-
fireEvent.click(screen.getByRole('button', { name: 'Connect Jira' }));
385+
fireEvent.click(screen.getByRole('button', { name: /Connect Gmail/i }));
182386

183-
expect(
184-
await screen.findByText(/Jira needs your Atlassian subdomain before authorization/i)
185-
).toBeInTheDocument();
186-
expect(screen.getByLabelText(/Atlassian subdomain/i)).toBeInTheDocument();
187-
expect(screen.queryByText(/ConnectedAccount_MissingRequiredFields/i)).not.toBeInTheDocument();
188-
expect(openUrl).not.toHaveBeenCalled();
387+
await waitFor(() => {
388+
// Should be in error phase, not needs-subdomain
389+
expect(screen.getByRole('button', { name: /Dismiss/i })).toBeInTheDocument();
390+
// Raw URL should not be shown
391+
expect(screen.queryByText(/api.tinyhumans.ai/i)).not.toBeInTheDocument();
392+
// Raw JSON payload should not be shown
393+
expect(screen.queryByText(/internal server error payload/i)).not.toBeInTheDocument();
394+
});
189395
});
190396
});

0 commit comments

Comments
 (0)