Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { IntegrationProviderResponse } from '@trycompai/integration-platform';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { EmptyStateOnboarding } from './EmptyStateOnboarding';

Expand Down Expand Up @@ -45,8 +46,7 @@ vi.mock('@trycompai/integration-platform', () => ({
getAwsCloudShellUrl: () => 'https://console.aws.amazon.com/cloudshell',
getAwsCloudShellScript: () => '',
getAwsRemediationScript: () => '',
normalizeAwsEnvironment: (value: unknown) =>
value === 'aws-us-gov' ? 'aws-us-gov' : 'aws',
normalizeAwsEnvironment: (value: unknown) => (value === 'aws-us-gov' ? 'aws-us-gov' : 'aws'),
}));

vi.mock('sonner', () => ({
Expand All @@ -67,18 +67,20 @@ describe('EmptyStateOnboarding', () => {

render(
<EmptyStateOnboarding
provider={{
id: 'dynamic-security',
slug: 'dynamic-security',
name: 'Dynamic Security',
description: 'Dynamic integration',
category: 'Security',
logoUrl: '',
authType: 'custom',
capabilities: ['checks'],
isActive: true,
docsUrl: 'https://example.com/docs',
} as any}
provider={
{
id: 'dynamic-security',
slug: 'dynamic-security',
name: 'Dynamic Security',
description: 'Dynamic integration',
category: 'Security',
logoUrl: '',
authType: 'custom',
capabilities: ['checks'],
isActive: true,
docsUrl: 'https://example.com/docs',
} as any
}
orgId="org_1"
onConnected={onConnected}
/>,
Expand All @@ -98,17 +100,19 @@ describe('EmptyStateOnboarding', () => {

render(
<EmptyStateOnboarding
provider={{
id: 'dynamic-api',
slug: 'dynamic-api',
name: 'Dynamic API',
description: 'Dynamic API integration',
category: 'Security',
logoUrl: '',
authType: 'api_key',
capabilities: ['checks'],
isActive: true,
} as any}
provider={
{
id: 'dynamic-api',
slug: 'dynamic-api',
name: 'Dynamic API',
description: 'Dynamic API integration',
category: 'Security',
logoUrl: '',
authType: 'api_key',
capabilities: ['checks'],
isActive: true,
} as any
}
orgId="org_1"
onConnected={vi.fn()}
/>,
Expand All @@ -125,5 +129,79 @@ describe('EmptyStateOnboarding', () => {
expect(mockCreateConnection).toHaveBeenCalledWith('dynamic-api', { api_key: 'secret' });
});
});
});

const conditionalProvider = {
id: 'cybedefend',
slug: 'cybedefend',
name: 'CybeDefend',
description: 'Security scanning',
category: 'Security',
logoUrl: '',
authType: 'custom',
capabilities: ['checks'],
isActive: true,
credentialFields: [
{
id: 'region',
label: 'Region',
type: 'select',
required: true,
options: [
{ value: 'eu', label: 'Europe' },
{ value: 'dedicated', label: 'Dedicated tenant' },
],
},
{
id: 'tenant',
label: 'Tenant name',
type: 'text',
required: true,
showIf: { field: 'region', equals: 'dedicated' },
},
],
} satisfies IntegrationProviderResponse;

it('does not submit a hidden field whose value was typed then hidden again', async () => {
// The value survives in component state, so without filtering it would be
// encrypted and stored even though the operator took it back off screen.
render(
<EmptyStateOnboarding provider={conditionalProvider} orgId="org_1" onConnected={vi.fn()} />,
);

fireEvent.change(screen.getByLabelText('Region'), { target: { value: 'dedicated' } });
fireEvent.change(screen.getByLabelText('Tenant name'), { target: { value: 'acme' } });
fireEvent.change(screen.getByLabelText('Region'), { target: { value: 'eu' } });

fireEvent.click(screen.getByRole('button', { name: /connect account/i }));

await waitFor(() => {
expect(mockCreateConnection).toHaveBeenCalledWith('cybedefend', { region: 'eu' });
});
});

it('hides a conditional field until its controlling value is chosen', () => {
render(
<EmptyStateOnboarding provider={conditionalProvider} orgId="org_1" onConnected={vi.fn()} />,
);

expect(screen.queryByLabelText('Tenant name')).not.toBeInTheDocument();
});

it('does not block submission on a hidden required field', async () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// Tenant name is required but hidden on a public region, so the form must
// submit without it rather than gate on an error pointing at nothing.
mockCreateConnection.mockResolvedValue({ success: true });

render(
<EmptyStateOnboarding provider={conditionalProvider} orgId="org_1" onConnected={vi.fn()} />,
);

fireEvent.change(screen.getByLabelText('Region'), { target: { value: 'eu' } });
fireEvent.click(screen.getByRole('button', { name: /connect account/i }));

await waitFor(() => {
expect(mockCreateConnection).toHaveBeenCalledWith('cybedefend', { region: 'eu' });
});
expect(screen.queryByText('Tenant name is required')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { CloudShellSetup } from '@/components/integrations/CloudShellSetup';
import { CredentialInput } from '@/components/integrations/CredentialInput';
import { visibleCredentialFields } from '@/components/integrations/credential-field-visibility';
import type { IntegrationProvider } from '@/hooks/use-integration-platform';
import { useIntegrationMutations } from '@/hooks/use-integration-platform';
import { Button, Label } from '@trycompai/design-system';
Expand Down Expand Up @@ -363,7 +364,13 @@ function CredentialSetup({

return configuredFields;
}, [provider.authType, provider.credentialFields]);
const hasConfigurableFields = fields.length > 0;

const visibleFields = useMemo(
() => visibleCredentialFields(fields, credentials),

@cubic-dev-ai cubic-dev-ai Bot Sep 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Cloud providers still bypass showIf: CloudSetup renders conditional fields, validates hidden required fields, and submits their stale values. Apply the shared visibility predicate to the cloud path as well.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/src/app/(app)/[orgId]/integrations/[slug]/components/EmptyStateOnboarding.tsx, line 369:

<comment>Cloud providers still bypass `showIf`: `CloudSetup` renders conditional fields, validates hidden required fields, and submits their stale values. Apply the shared visibility predicate to the cloud path as well.</comment>

<file context>
@@ -363,7 +364,13 @@ function CredentialSetup({
-  const hasConfigurableFields = fields.length > 0;
+
+  const visibleFields = useMemo(
+    () => visibleCredentialFields(fields, credentials),
+    [fields, credentials],
+  );
</file context>
Fix with cubic

[fields, credentials],
);

const hasConfigurableFields = visibleFields.length > 0;

const updateCredential = (fieldId: string, value: string | string[]) => {
setCredentials((prev) => ({ ...prev, [fieldId]: value }));
Expand All @@ -378,7 +385,7 @@ function CredentialSetup({

const handleConnect = useCallback(async () => {
const newErrors: Record<string, string> = {};
for (const field of fields) {
for (const field of visibleFields) {
const value = credentials[field.id];
const isMissing =
field.type === 'multi-select'
Expand All @@ -393,9 +400,14 @@ function CredentialSetup({
return;
}

// Only what the operator could actually see.
const visibleCredentials = Object.fromEntries(
Object.entries(credentials).filter(([id]) => visibleFields.some((field) => field.id === id)),
);

setConnecting(true);
try {
const result = await createConnection(provider.id, credentials);
const result = await createConnection(provider.id, visibleCredentials);
if (!result.success) {
toast.error(result.error || 'Failed to connect');
return;
Expand All @@ -407,7 +419,7 @@ function CredentialSetup({
} finally {
setConnecting(false);
}
}, [fields, credentials, createConnection, provider, onConnected]);
}, [visibleFields, credentials, createConnection, provider, onConnected]);

return (
<div className="py-6 space-y-6">
Expand All @@ -423,7 +435,7 @@ function CredentialSetup({
<div className="rounded-xl border bg-background shadow-sm">
<div className="p-6 space-y-4">
{hasConfigurableFields ? (
fields.map((field) => (
visibleFields.map((field) => (
<FieldRow
key={field.id}
field={field}
Expand Down Expand Up @@ -505,9 +517,7 @@ function CloudSetup({
// AWS only — which scan engine the customer is choosing for this
// connection. Sent in createConnection's credentials payload as the
// `awsScanMode` variable, then read on every scan in cloud-security.service.
const [awsScanMode, setAwsScanMode] = useState<AwsScanModeChoice>(
DEFAULT_AWS_SCAN_MODE_CHOICE,
);
const [awsScanMode, setAwsScanMode] = useState<AwsScanModeChoice>(DEFAULT_AWS_SCAN_MODE_CHOICE);

const allFields = provider.credentialFields ?? [];
const visibleFields = allFields.filter(
Expand Down Expand Up @@ -597,9 +607,7 @@ function CloudSetup({
)
: regionOptions;
const setupScript =
provider.id === 'aws'
? getAwsCloudShellScript(awsEnvironment)
: (provider.setupScript ?? '');
provider.id === 'aws' ? getAwsCloudShellScript(awsEnvironment) : (provider.setupScript ?? '');
const remediationScript = getAwsRemediationScript(awsEnvironment);
const cloudShellUrl = getAwsCloudShellUrl(awsEnvironment);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ vi.mock('@trycompai/design-system', () => ({
<label htmlFor={htmlFor}>{children}</label>
),
Spinner: () => <span data-testid="spinner" />,
Select: ({ children }: { children: React.ReactNode }) => (
<div data-testid="ds-select">{children}</div>
Select: ({ children, value }: { children: React.ReactNode; value?: string }) => (
<div data-testid="ds-select" data-value={value}>
{children}
</div>
),
SelectTrigger: ({ children, id }: { children: React.ReactNode; id?: string }) => (
<div data-trigger-id={id}>{children}</div>
Expand Down Expand Up @@ -77,6 +79,36 @@ function renderFields(
);
}

describe('ConnectionVariablesFields default preselection', () => {
const thresholdVariable = {
id: 'severity_threshold',
label: 'Fail at or above severity',
type: 'select',
required: false,
default: 'high',
options: [
{ value: 'critical', label: 'Critical only' },
{ value: 'high', label: 'High and above (default)' },
{ value: 'low', label: 'Low and above' },
],
} satisfies ConnectionVariable;

it('preselects a select variable default when no value is stored yet', () => {
// The boolean branch already falls back to `variable.default`; the select
// branch did not, so every defaulted dropdown rendered empty and the
// operator could not tell which value would be applied.
renderFields([thresholdVariable]);

expect(screen.getByTestId('ds-select')).toHaveAttribute('data-value', 'high');
});

it('leaves a select with no default empty', () => {
renderFields([{ ...thresholdVariable, default: undefined }]);

expect(screen.getByTestId('ds-select')).toHaveAttribute('data-value', '');
});
});

describe('ConnectionVariablesFields dropdown clickability inside a modal', () => {
const modalSelectContentOptions = {
portal: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export function ConnectionVariablesFields({
/>
) : variable.type === 'select' ? (
<Select
value={String(variableValues[variable.id] ?? '')}
value={String(variableValues[variable.id] ?? variable.default ?? '')}

@cubic-dev-ai cubic-dev-ai Bot Sep 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The fallback only changes what the dropdown renders; it does not persist the default into variableValues. On save, ManageIntegrationDialog sends variableValues verbatim, and that state is seeded only for variables with an existing currentValue. For a new connection the dropdown shows the declared default while the key stays absent from the payload, so the claimed empty-value fix is display-only unless the operator re-selects the value. Seed each select's default into variableValues (or commit it on change/save) so the rendered value matches what is actually stored.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/src/components/integrations/ConnectionVariablesForm.tsx, line 166:

<comment>The fallback only changes what the dropdown renders; it does not persist the default into `variableValues`. On save, `ManageIntegrationDialog` sends `variableValues` verbatim, and that state is seeded only for variables with an existing `currentValue`. For a new connection the dropdown shows the declared default while the key stays absent from the payload, so the claimed empty-value fix is display-only unless the operator re-selects the value. Seed each select's default into `variableValues` (or commit it on change/save) so the rendered value matches what is actually stored.</comment>

<file context>
@@ -163,7 +163,7 @@ export function ConnectionVariablesFields({
             ) : variable.type === 'select' ? (
               <Select
-                value={String(variableValues[variable.id] ?? '')}
+                value={String(variableValues[variable.id] ?? variable.default ?? '')}
                 onValueChange={(value) => {
                   if (value === null) return;
</file context>
Fix with cubic

onValueChange={(value) => {
if (value === null) return;
setVariableValues((prev) => ({ ...prev, [variable.id]: value }));
Expand Down
21 changes: 11 additions & 10 deletions apps/app/src/components/integrations/ManageIntegrationDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import Image from 'next/image';
import { useParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { visibleCredentialFields } from './credential-field-visibility';

interface VariableWithValue extends ConnectionVariable {
currentValue?: string | number | boolean | string[];
Expand Down Expand Up @@ -249,18 +250,13 @@ export function ManageIntegrationDialog({
const handleSaveCredentials = async () => {
if (!connectionId || !orgId) return;

// Check if any credentials were actually entered
const hasValues = Object.values(credentialValues).some((value) =>
Array.isArray(value) ? value.length > 0 : value.trim() !== '',
// Only non-empty values, and only from fields the operator could see.
const visibleIds = new Set(
visibleCredentialFields(credentialFields, credentialValues).map((field) => field.id),
);
if (!hasValues) {
toast.error('Please enter at least one credential value to update');
return;
}

// Only send non-empty values
const credentialsToSave: Record<string, string | string[]> = {};
for (const [key, value] of Object.entries(credentialValues)) {
if (!visibleIds.has(key)) continue;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (Array.isArray(value)) {
if (value.length > 0) {
credentialsToSave[key] = value;
Expand All @@ -270,6 +266,11 @@ export function ManageIntegrationDialog({
}
}

if (Object.keys(credentialsToSave).length === 0) {
toast.error('Please enter at least one credential value to update');
return;
}

setSavingCredentials(true);
try {
const result = await updateConnectionCredentials(connectionId, credentialsToSave);
Expand Down Expand Up @@ -505,7 +506,7 @@ function ConfigurationContent({
<span>Your credentials are encrypted at rest using AES-256-GCM encryption.</span>
</p>
</div>
{credentialFields.map((field) => (
{visibleCredentialFields(credentialFields, credentialValues).map((field) => (
<div key={field.id} className="space-y-2">
<Label htmlFor={`cred-${field.id}`}>
{field.label}
Expand Down
Loading