diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index e75db8a95c1..cb2ea9264cd 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -299,6 +299,7 @@ jobs: path: | ${{ runner.temp }}/e2e/**/*.json ${{ runner.temp }}/e2e/browser-extension/**/*.png + ${{ runner.temp }}/e2e/*-ui-report.json.artifacts/*.png if-no-files-found: ignore retention-days: 7 diff --git a/apps/docs/content/docs/search/connect-your-account.mdx b/apps/docs/content/docs/search/connect-your-account.mdx index 714e1800e44..36c106d58d2 100644 --- a/apps/docs/content/docs/search/connect-your-account.mdx +++ b/apps/docs/content/docs/search/connect-your-account.mdx @@ -22,7 +22,7 @@ After your admin adds a source in **Settings → Sources**, connect your own acc variant="overview" /> -If your organization makes another connection available, choose **Add account** in the header. When several accounts are connected, select an account to see its permissions and connection actions. +Your organization currently supports one connected account per provider. Open the app to see that account's permissions and connection actions. The connection is usable when authorization completes; no initial indexing run is required. If a provider is missing, ask an admin to add it in **Sources**. `Not configured` means its app or deployment setup needs an admin's attention. @@ -46,7 +46,7 @@ Creating Jira sprints requires the Jira sprint permission. If your account was c ## Tool permissions -Open the app in **Integrations** and select your account. Tool permissions appear in its details when the app has supported Assistant tools available to you. Set a preference for each operation or use the **Read-only tools** and **Write tools** group controls: +Open the app in **Integrations** and select your account. Tool permissions appear in its details when the app has supported Assistant tools available to you. Hover over or focus the info button beside a tool to read its description. Set a preference for each operation or use the **Read-only tools** and **Write tools** group controls: | Preference | Behavior | | --- | --- | @@ -54,7 +54,7 @@ Open the app in **Integrations** and select your account. Tool permissions appea | **Ask** | Waits for you to review the exact proposal. | | **Block** | Cannot run through Assistant. | -Read-only operations default to Allow; write operations default to Ask. An organization can require review even when you select Allow. A group-level Allow covers the currently listed operation versions; newly added or changed operations still ask. **Reset** restores the group's current default for that operation. +Read-only operations default to Allow; write operations default to Ask. An organization can require review even when you select Allow. A group-level Allow covers the currently listed operation versions; newly added or changed operations still ask. These preferences belong to your account. They cannot grant access to another person's credentials or bypass an administrator's source restrictions. If a related app uses a restricted Search source, direct operations remain unavailable; Assistant can search and read within the configured scope. @@ -70,7 +70,7 @@ Search sources and apps used for actions share the same Integrations list. An ad ## Reconnect or disconnect -Open the app and select the account, then choose **Reconnect** in the header when a grant expires, is revoked, or needs more scopes. A temporary provider outage does not necessarily require reconnecting. +Open the app and select the account, then choose **Reconnect** in the header when a grant expires, is revoked, or needs more scopes. Reconnect with the same provider account; switching identities on an existing connection is not supported, including after disconnecting. A temporary provider outage does not necessarily require reconnecting. Choose **Disconnect** in the same header to stop future use of the grant. This can affect workflows sharing the account. Review the confirmation before removing it. Disconnecting does not remove your Sim membership. diff --git a/apps/docs/public/static/search/assistant-action-review.png b/apps/docs/public/static/search/assistant-action-review.png index 167434244f9..1a72ff41735 100644 Binary files a/apps/docs/public/static/search/assistant-action-review.png and b/apps/docs/public/static/search/assistant-action-review.png differ diff --git a/apps/docs/public/static/search/assistant-tool-permissions.png b/apps/docs/public/static/search/assistant-tool-permissions.png index 90dd2f891ad..988170815c5 100644 Binary files a/apps/docs/public/static/search/assistant-tool-permissions.png and b/apps/docs/public/static/search/assistant-tool-permissions.png differ diff --git a/apps/docs/public/static/search/organization-search.png b/apps/docs/public/static/search/organization-search.png index 753b749d533..618b7ee9463 100644 Binary files a/apps/docs/public/static/search/organization-search.png and b/apps/docs/public/static/search/organization-search.png differ diff --git a/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx b/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx index 1f356a06d82..2cd906c2f0e 100644 --- a/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/account-connection-actions.tsx @@ -1,7 +1,8 @@ 'use client' import { useState } from 'react' -import { Chip, ChipConfirmModal, ChipModalError } from '@sim/emcn' +import { ChipConfirmModal, ChipModalError } from '@sim/emcn' +import { type SettingsAction, SettingsActionChips } from '@/components/settings/settings-header' import type { ViewerSearchSourceAccount } from '@/lib/api/contracts/knowledge/connectors' import { useDisconnectPersonalOrganizationAccount } from '@/hooks/queries/organization-accounts' @@ -32,48 +33,57 @@ export function AccountConnectionActions({ const disconnect = useDisconnectPersonalOrganizationAccount(organizationId) const [confirming, setConfirming] = useState(false) const pending = connectionPending || disconnect.isPending + const actions: SettingsAction[] = [] + if (account) { + actions.push({ + id: 'reconnect', + text: 'Reconnect', + disabled: pending || !canReconnect, + onSelect: onReconnect, + }) + } + if (onConnect) { + actions.push({ + id: 'connect', + text: connectLabel, + disabled: pending || !canConnect, + onSelect: onConnect, + }) + } + if (account) { + actions.push({ + id: 'delete', + text: 'Disconnect', + textTone: 'error', + disabled: pending, + onSelect: () => { + disconnect.reset() + setConfirming(true) + }, + }) + } return ( <> + {account && ( - - Reconnect - - )} - {onConnect && ( - - {connectLabel} - - )} - {account && ( - <> - { - disconnect.reset() - setConfirming(true) - }} - > - Disconnect - - { - if (!disconnect.isPending) setConfirming(open) - }} - title={`Disconnect ${integrationName}`} - text={`Disconnect ${account.displayName} from all ${integrationName} connections in this organization. Workflows using this account will also lose access. You can reconnect later.`} - confirm={{ - label: 'Disconnect', - pendingLabel: 'Disconnecting', - pending: disconnect.isPending, - disabled: disconnect.isPending, - onClick: () => - disconnect.mutate(account.credentialId, { onSuccess: () => setConfirming(false) }), - }} - > - {disconnect.error?.message} - - + { + if (!disconnect.isPending) setConfirming(open) + }} + title={`Disconnect ${integrationName}`} + text={`Disconnect ${account.displayName} from all ${integrationName} connections in this organization. Workflows using this account will also lose access. You can reconnect later.`} + confirm={{ + label: 'Disconnect', + pendingLabel: 'Disconnecting', + pending: disconnect.isPending, + disabled: disconnect.isPending, + onClick: () => + disconnect.mutate(account.credentialId, { onSuccess: () => setConfirming(false) }), + }} + > + {disconnect.error?.message} + )} ) diff --git a/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx b/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx index e8d86c823a0..1dd4337492e 100644 --- a/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx @@ -1,6 +1,6 @@ 'use client' -import { Chip, ChipInput, ChipLink, ChipSelect, ChipSwitch, Tooltip, toast } from '@sim/emcn' +import { ChipInput, ChipLink, ChipSelect, ChipSwitch, Info, toast } from '@sim/emcn' import { ArrowLeft, Search } from '@sim/emcn/icons' import { useQueryStates } from 'nuqs' import type { @@ -182,8 +182,6 @@ function ToolPermissionGroup({ `${tool.name} ${tool.description}`.toLowerCase().includes(query) ) if (tools.length === 0) return null - const defaultPolicy = effect === 'read' ? data.readPolicy : data.writePolicy - const defaultLabel = POLICY_OPTIONS.find((option) => option.value === defaultPolicy)?.label const groupValue = tools.every((tool) => tool.policy === tools[0].policy) ? tools[0].policy : undefined @@ -208,41 +206,29 @@ function ToolPermissionGroup({ /> } > -
- {visible.map((tool) => ( -
-
- - - {tool.name} - - {tool.description} - -
-
- {!tool.inherited && ( - onChange({ kind: 'tool', toolId: tool.toolId, policy: null })} - > - Reset to {defaultLabel} - - )} - onChange({ kind: 'tool', toolId: tool.toolId, policy })} - /> -
+ {visible.map((tool) => ( +
+
+ {tool.name} + {tool.description && ( + + {tool.description} + + )}
- ))} -
+ onChange({ kind: 'tool', toolId: tool.toolId, policy })} + /> +
+ ))} {!visible.length && ( No matching tools )} diff --git a/apps/sim/app/o/[organizationId]/search/search.tsx b/apps/sim/app/o/[organizationId]/search/search.tsx index bd08de7084b..6e92e7e5f29 100644 --- a/apps/sim/app/o/[organizationId]/search/search.tsx +++ b/apps/sim/app/o/[organizationId]/search/search.tsx @@ -248,7 +248,7 @@ function OrganizationSearchContent({ userId, chatId }: OrganizationSearchProps) {(hasConversation || (activeMode === 'search' && q.trim())) && ( + account?: string full?: boolean } /** Readable content first; supplied secondary parameters remain inspectable. */ -export function ActionPreview({ action, full = false }: ActionPreviewProps) { +export function ActionPreview({ action, account, full = false }: ActionPreviewProps) { const [expanded, setExpanded] = useState(false) const detailsId = useId() const { targets, primary, details } = assistantActionPresentation(action) return (
- {targets.length > 0 && ( -
+ {(account || targets.length > 0) && ( +
+ {account && ( +
+
Account
+
{account}
+
+ )} {targets.map((field) => ( -
-
{field.label}
-
- {field.value} -
-
+ ))} -
+
+ )} + {primary.length > 0 && ( +
+ {primary.map((field, index) => ( + + ))} +
)} - {primary.map((field, index) => ( - - ))} {details.length > 0 && ( <> {!full && ( @@ -62,7 +71,7 @@ export function ActionPreview({ action, full = false }: ActionPreviewProps) { {(expanded || full) && (
{details.map((field) => ( - + ))}
)} @@ -74,30 +83,36 @@ export function ActionPreview({ action, full = false }: ActionPreviewProps) { interface PreviewFieldProps { field: ReviewField - primary?: boolean + compact?: boolean prominent?: boolean } -function PreviewField({ field, primary = false, prominent = false }: PreviewFieldProps) { +function PreviewField({ field, compact = false, prominent = false }: PreviewFieldProps) { const scroll = useRef(null) const edges = useScrollEdges(scroll) - let value = field.value + const display = assistantActionFieldPresentation(field) + let value = display.value if (field.format === 'json') { try { value = JSON.stringify(JSON.parse(value), null, 2) } catch {} } return ( -
- {(!primary || field.format === 'json') && ( -
{field.label}
+
+ > +
{display.label}
+
setExpanded(!expanded)} @@ -77,15 +77,17 @@ export function AssistantActionReceipt({ receipt }: AssistantActionReceiptProps) )} {expanded && ( <> -

- {receipt.account?.displayName ?? 'Account unavailable'} -

{receipt.detailsAvailable ? ( - + ) : ( -

- Details are no longer available for this action. -

+
+

{receipt.account?.displayName ?? 'Account unavailable'}

+

Details are no longer available for this action.

+
)} )} diff --git a/apps/sim/components/assistant-actions/presentation.ts b/apps/sim/components/assistant-actions/presentation.ts index c023e4e2968..f2688be565d 100644 --- a/apps/sim/components/assistant-actions/presentation.ts +++ b/apps/sim/components/assistant-actions/presentation.ts @@ -1,10 +1,31 @@ import type { AssistantActionsResponse } from '@/lib/api/contracts/assistant-actions' +import { humanizeDisplayIdentifier } from '@/lib/mothership/tools/display' type PresentedAction = Pick< AssistantActionsResponse['actions'][number], 'toolId' | 'name' | 'fields' > +const CONTENT_TYPE_LABELS = new Map([ + ['text', 'Plain text'], + ['html', 'HTML'], +]) + +/** Readable labels and enum text leave the reviewed invocation unchanged. */ +export function assistantActionFieldPresentation(field: PresentedAction['fields'][number]) { + const identifier = field.name + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2') + return { + label: + field.label === field.name ? humanizeDisplayIdentifier(identifier, 'sentence') : field.label, + value: + field.name === 'contentType' + ? (CONTENT_TYPE_LABELS.get(field.value) ?? field.value) + : field.value, + } +} + /** Display labels and preview fields only; model-facing tool definitions remain unchanged. */ const PRESENTATIONS: Record = { gmail_draft_v2: { title: 'Draft email', primary: ['subject', 'body'] }, diff --git a/apps/sim/components/assistant-actions/review.tsx b/apps/sim/components/assistant-actions/review.tsx index f6f5c1df4c7..0e89c725760 100644 --- a/apps/sim/components/assistant-actions/review.tsx +++ b/apps/sim/components/assistant-actions/review.tsx @@ -15,7 +15,10 @@ import { getErrorMessage } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import { ActionPreview } from '@/components/assistant-actions/action-preview' import { AssistantActionReceipt } from '@/components/assistant-actions/action-receipt' -import { assistantActionPresentation } from '@/components/assistant-actions/presentation' +import { + assistantActionFieldPresentation, + assistantActionPresentation, +} from '@/components/assistant-actions/presentation' import { ActivityStatus } from '@/components/ui/activity-status' import type { AssistantActionReceiptsResponse, @@ -652,11 +655,12 @@ function ActionReview({ action, review }: ActionReviewProps) { } >
-

- {action.account?.displayName ?? 'Account unavailable'} -

{editing ? (
+

+ Account + {action.account?.displayName ?? 'Account unavailable'} +

{action.fields .filter((field) => field.value || field.editable) .map((field) => ( @@ -672,6 +676,7 @@ function ActionReview({ action, review }: ActionReviewProps) {
) : ( ({ @@ -694,7 +699,7 @@ function ActionReview({ action, review }: ActionReviewProps) { {error.message}

)} -
+
{field.format === 'text' ? ( ) : (
-
{field.label}
+
{display.label}
- {value} + {display.value}
) diff --git a/apps/sim/lib/assistant-actions/catalog.ts b/apps/sim/lib/assistant-actions/catalog.ts index 03b4930a0f3..8c23bcd2939 100644 --- a/apps/sim/lib/assistant-actions/catalog.ts +++ b/apps/sim/lib/assistant-actions/catalog.ts @@ -17,7 +17,7 @@ export async function loadAssistantIntegrationTools( executor?: DbOrTx ) { const [visibility, config] = await Promise.all([ - getBlockVisibility({ userId: scope.userId, orgId: scope.organizationId }), + getBlockVisibility({ userId: scope.userId, orgId: scope.organizationId }, executor), getUserPermissionConfigForOrganization(scope.organizationId, executor), ]) const tools = projectIntegrationToolsForViewer(visibility, config) diff --git a/apps/sim/lib/assistant-actions/tool-permissions.integration.ts b/apps/sim/lib/assistant-actions/tool-permissions.integration.ts index 988be8bdb1c..d76acee998f 100644 --- a/apps/sim/lib/assistant-actions/tool-permissions.integration.ts +++ b/apps/sim/lib/assistant-actions/tool-permissions.integration.ts @@ -2,6 +2,10 @@ * Real authorization and persistence boundary: account/tenant isolation, session-only grants, * lost updates, absent defaults, revocation, and unsupported operation escalation. */ +import { + AppConfigDataClient, + StartConfigurationSessionCommand, +} from '@aws-sdk/client-appconfigdata' import type { OrganizationDelegatedPrincipal, SessionPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { @@ -19,13 +23,17 @@ import { resourcePolicy, user, } from '@sim/db/schema' +import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure' +import { sha256Hex } from '@sim/security/hash' import { generateId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' import { eq, inArray } from 'drizzle-orm' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { claimOrganizationAssistantAction, prepareOrganizationAssistantAction, } from '@/lib/assistant-actions/application/admission' +import { listOrganizationAssistantActionReceipts } from '@/lib/assistant-actions/application/receipts' import { decideOrganizationAssistantAction, listOrganizationAssistantActions, @@ -34,6 +42,7 @@ import { readOrganizationToolPermissions, updateOrganizationToolPermissions, } from '@/lib/assistant-actions/application/tool-permissions' +import { loadAssistantIntegrationTools } from '@/lib/assistant-actions/catalog' import { requireAssistantActionExecution, withAssistantActionExecution, @@ -42,7 +51,17 @@ import { loadAssistantToolPermissions, resolveAssistantToolPolicy, } from '@/lib/assistant-actions/tool-permissions' +import { env } from '@/lib/core/config/env' +import * as envFlags from '@/lib/core/config/env-flags' +import { closeRedisConnection } from '@/lib/core/config/redis' import * as providerTransport from '@/lib/core/security/input-validation.server' +import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments' +import { + completeCredentialGroupOAuth, + startCredentialGroupOAuth, +} from '@/lib/credential-groups/oauth' +import type { CredentialGroupConnectionIntent } from '@/lib/credential-groups/oauth-intent' +import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' import { credentialGroupScopePolicyVersion } from '@/lib/credential-groups/provider-adapter' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' import { @@ -50,6 +69,7 @@ import { encryptManagedOAuthTokenSet, rejectManagedOAuthToken, } from '@/lib/credentials/managed-oauth' +import { loadCopilotSearchIntegrations } from '@/lib/mothership/application/load-search-integrations' import { claimAssistantToolExecution, claimToolExecution, @@ -429,6 +449,129 @@ describe('Assistant discovery for legacy account setup', () => { }) }) +describe('Assistant account and tool discovery', () => { + it('evaluates admin-gated tools using the current transaction', async () => { + const originalConfig = { + APPCONFIG_APPLICATION: env.APPCONFIG_APPLICATION, + APPCONFIG_ENVIRONMENT: env.APPCONFIG_ENVIRONMENT, + AWS_REGION: env.AWS_REGION, + } + Object.assign(env, { + APPCONFIG_APPLICATION: generateId(), + APPCONFIG_ENVIRONMENT: 'assistant-integration', + AWS_REGION: 'us-east-1', + }) + const appConfigEnabled = vi.spyOn(envFlags, 'isAppConfigEnabled', 'get').mockReturnValue(true) + const transport = vi + .spyOn(AppConfigDataClient.prototype, 'send') + .mockImplementation(async (command) => + command instanceof StartConfigurationSessionCommand + ? { $metadata: {}, InitialConfigurationToken: 'assistant-fixture' } + : { + $metadata: {}, + Configuration: new TextEncoder().encode( + JSON.stringify({ gmail_v2: { adminEnabled: true } }) + ), + NextPollConfigurationToken: 'assistant-fixture-next', + } + ) + try { + const [viewer] = await db.select({ role: user.role }).from(user).where(eq(user.id, ownerId)) + if (!viewer) throw new Error('Assistant fixture user is missing') + const scope = { organizationId, userId: ownerId } + const before = await loadAssistantIntegrationTools(scope) + expect(before.tools.some(({ toolId }) => toolId === 'gmail_list_labels_v2')).toBe(false) + + await db.transaction(async (tx) => { + await tx.update(user).set({ role: 'admin' }).where(eq(user.id, ownerId)) + const during = await loadAssistantIntegrationTools(scope, tx) + expect(during.tools.some(({ toolId }) => toolId === 'gmail_list_labels_v2')).toBe(true) + await tx.update(user).set({ role: viewer.role }).where(eq(user.id, ownerId)) + }) + + const after = await loadAssistantIntegrationTools(scope) + expect(after.tools.some(({ toolId }) => toolId === 'gmail_list_labels_v2')).toBe(false) + } finally { + transport.mockRestore() + appConfigEnabled.mockRestore() + Object.assign(env, originalConfig) + } + }) + + it('keeps reconnectable accounts but withdraws disabled group accounts from the action inventory', async () => { + const policyId = generateId() + await db.insert(resourcePolicy).values({ + id: policyId, + organizationId, + resourceType: 'credential_group', + resourceId: groupId, + document: { + version: 2, + resource: { type: 'credential_group', id: groupId }, + statements: [], + }, + }) + const inventory = async () => + toRecord( + JSON.parse( + await loadCopilotSearchIntegrations({ + organizationId, + userId: ownerId, + chatId, + messageId: generateId(), + }) + ) + ) + try { + expect((await inventory()).actionAccounts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ credentialId: jiraCredentialId, status: 'connected' }), + ]) + ) + await db + .update(credential) + .set({ managedOauthStatus: 'needs_reauth' }) + .where(eq(credential.id, jiraCredentialId)) + expect((await inventory()).actionAccounts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ credentialId: jiraCredentialId, status: 'reconnect_needed' }), + ]) + ) + + await db + .update(credentialGroup) + .set({ status: 'disabled' }) + .where(eq(credentialGroup.id, groupId)) + const disabled = await inventory() + expect(disabled.actionAccounts).toEqual([]) + expect(toArray(disabled.connections).map(toRecord)).toContainEqual( + expect.objectContaining({ + providerId: 'jira', + connectionStatus: 'unavailable', + action: null, + accounts: expect.arrayContaining([ + expect.objectContaining({ + credentialId: jiraCredentialId, + status: 'reconnect_needed', + action: null, + }), + ]), + }) + ) + } finally { + await db + .update(credentialGroup) + .set({ status: 'active' }) + .where(eq(credentialGroup.id, groupId)) + await db + .update(credential) + .set({ managedOauthStatus: 'active' }) + .where(eq(credential.id, jiraCredentialId)) + await db.delete(resourcePolicy).where(eq(resourcePolicy.id, policyId)) + } + }) +}) + describe('organization Assistant tool permissions', () => { it.each(['list', 'decide', 'prepare', 'claim', 'owner_claim'] as const)( 'rejects an orphaned earlier turn at the %s boundary', @@ -859,6 +1002,375 @@ function deliver(call: Awaited>, options?: Orchestrat ) } +describe('Managed account identity across Assistant approvals', () => { + const originalRedisUrl = env.REDIS_URL + + beforeAll(() => { + const redisUrl = readTestRedisUrl() + if (!redisUrl) throw new Error('Set TEST_REDIS_URL to a disposable local Redis') + Object.assign(env, { REDIS_URL: redisUrl }) + }) + + afterAll(async () => { + await closeRedisConnection() + Object.assign(env, { REDIS_URL: originalRedisUrl }) + }) + + it.each([ + { + name: 'reconnect refuses another subject', + provider: 'jira', + mode: 'reconnect', + changedSubject: true, + storedTenant: null, + returnedTenant: null, + accepted: false, + }, + { + name: 'reconnect permits an email rename for the same subject', + provider: 'jira', + mode: 'reconnect', + changedSubject: false, + storedTenant: null, + returnedTenant: null, + accepted: true, + }, + { + name: 'legacy enrollment refuses another subject before approval', + provider: 'jira', + mode: 'legacy', + changedSubject: true, + storedTenant: null, + returnedTenant: null, + accepted: false, + }, + { + name: 'create refuses another subject in a disconnected account slot', + provider: 'jira', + mode: 'restore', + changedSubject: true, + storedTenant: null, + returnedTenant: null, + accepted: false, + }, + { + name: 'create restores the same disconnected account', + provider: 'jira', + mode: 'restore', + changedSubject: false, + storedTenant: null, + returnedTenant: null, + accepted: true, + }, + { + name: 'another unused option creates a separate account without moving an approval', + provider: 'jira', + mode: 'new', + changedSubject: true, + storedTenant: null, + returnedTenant: null, + accepted: true, + }, + { + name: 'reconnect refuses another tenant for the same subject', + provider: 'salesforce', + mode: 'reconnect', + changedSubject: false, + storedTenant: 'fixture-tenant', + returnedTenant: 'another-tenant', + accepted: false, + }, + { + name: 'legacy enrollment does not treat a null tenant as a wildcard', + provider: 'salesforce', + mode: 'legacy', + changedSubject: false, + storedTenant: null, + returnedTenant: 'another-tenant', + accepted: false, + }, + { + name: 'reconnect refuses removal of the bound tenant', + provider: 'salesforce', + mode: 'reconnect', + changedSubject: false, + storedTenant: 'fixture-tenant', + returnedTenant: null, + accepted: false, + }, + { + name: 'reconnect permits an email rename within the same tenant', + provider: 'salesforce', + mode: 'reconnect', + changedSubject: false, + storedTenant: 'fixture-tenant', + returnedTenant: 'fixture-tenant', + accepted: true, + }, + ] as const)('$name', async (scenario) => { + const selectedCredentialId = + scenario.provider === 'jira' ? jiraCredentialId : salesforceCredentialId + const [originalCredential] = await db + .select() + .from(credential) + .where(eq(credential.id, selectedCredentialId)) + const [originalEnrollment] = await db + .select() + .from(credentialGroupEnrollment) + .where(eq(credentialGroupEnrollment.id, enrollmentId)) + const [group] = await db.select().from(credentialGroup).where(eq(credentialGroup.id, groupId)) + const configuredOption = group?.options.find((option) => option.id === scenario.provider) + if (!originalCredential || !originalEnrollment || !group || !configuredOption) + throw new Error('Managed account fixture is incomplete') + + const option = + scenario.mode === 'new' + ? { ...configuredOption, id: generateId(), label: 'Second Jira account' } + : configuredOption + const options = scenario.mode === 'new' ? [...group.options, option] : group.options + const policy = await getCredentialGroupProviderAdapter(scenario.provider).getPolicy( + undefined, + {} + ) + const invitationToken = generateId() + const chosenSubject = scenario.changedSubject + ? `another-subject-${generateId()}` + : selectedCredentialId + const chosenEmail = 'renamed-account@example.test' + const freshToken = `fresh-account-token-${generateId()}` + const requests: Array<{ authorization: string | null; body: unknown }> = [] + const originalFetch = globalThis.fetch + const provider = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = new Request(input, init) + if ( + request.url === 'https://auth.atlassian.com/oauth/token' || + request.url === 'https://login.salesforce.com/services/oauth2/token' + ) { + return Response.json({ + access_token: freshToken, + refresh_token: 'fresh-fixture-refresh-token', + token_type: 'Bearer', + scope: policy.requiredScopes.join(' '), + expires_in: 3600, + }) + } + if (request.url === 'https://api.atlassian.com/me') { + return Response.json({ + account_type: 'atlassian', + account_id: chosenSubject, + account_status: 'active', + email: chosenEmail, + }) + } + if (request.url === 'https://login.salesforce.com/services/oauth2/userinfo') { + return Response.json({ + user_id: chosenSubject, + email: chosenEmail, + email_verified: true, + organization_id: scenario.returnedTenant, + }) + } + if (request.url === 'https://api.atlassian.com/oauth/token/accessible-resources') { + return Response.json([ + { id: '11111111-1111-4111-8111-111111111111', url: 'https://fixture.atlassian.net' }, + ]) + } + if ( + request.url === + 'https://api.atlassian.com/ex/jira/11111111-1111-4111-8111-111111111111/rest/api/3/issue' + ) { + requests.push({ + authorization: request.headers.get('authorization'), + body: await request.json(), + }) + return Response.json({ id: '10001', key: 'SIM-1', self: request.url }, { status: 201 }) + } + const hostname = new URL(request.url).hostname + if (hostname !== 'localhost' && hostname !== '127.0.0.1') + throw new Error(`Unexpected external fixture request: ${request.url}`) + return originalFetch(input, init) + }) + + try { + await db + .update(credential) + .set({ + providerSubjectId: selectedCredentialId, + providerTenantId: scenario.storedTenant, + managedOauthStatus: 'active', + revokedAt: null, + }) + .where(eq(credential.id, selectedCredentialId)) + await db + .update(credentialGroupEnrollment) + .set({ invitationTokenHash: sha256Hex(invitationToken) }) + .where(eq(credentialGroupEnrollment.id, enrollmentId)) + if (scenario.mode === 'new') { + await db.update(credentialGroup).set({ options }).where(eq(credentialGroup.id, groupId)) + } + + let pending: + | { call: Awaited>; proposalVersion: string } + | undefined + if (scenario.provider === 'jira') { + const call = await prepare('jira_write', { + credential: jiraCredentialId, + domain: 'fixture.atlassian.net', + projectId: 'SIM', + issueType: 'Task', + summary: 'Original proposal', + }) + const proposal = (await review()).actions.find( + (action) => action.toolCallId === call.toolCallId + ) + if (!proposal) throw new Error('Expected a pending action review') + pending = { call, proposalVersion: proposal.proposalVersion } + if (scenario.mode !== 'legacy') { + await decide(call.toolCallId, proposal.proposalVersion, 'allow', { + summary: 'Reviewed summary', + }) + } + } + + if (scenario.mode === 'restore') { + await db + .update(credential) + .set({ managedOauthStatus: 'revoked', revokedAt: new Date() }) + .where(eq(credential.id, selectedCredentialId)) + } + const [beforeOAuth] = await db + .select() + .from(credential) + .where(eq(credential.id, selectedCredentialId)) + if (!beforeOAuth?.encryptedOauthTokenSet) + throw new Error('Managed credential token envelope disappeared') + const context: CredentialGroupOAuthContext = { + organizationId, + enrollmentId, + credentialGroupId: groupId, + credentialGroupName: group.name, + workspaceName: 'Assistant permission fixture', + workspaceOwnerId: null, + credentialOwnerId: ownerId, + email: originalEnrollment.email, + enrollmentStatus: originalEnrollment.status, + option, + options, + } + const connectionIntent: CredentialGroupConnectionIntent | undefined = + scenario.mode === 'legacy' + ? undefined + : scenario.mode === 'restore' || scenario.mode === 'new' + ? { kind: 'create' } + : { kind: 'reconnect', credentialId: selectedCredentialId } + const authorizationUrl = await startCredentialGroupOAuth(context, invitationToken, { + connectionIntent, + }) + const state = new URL(authorizationUrl).searchParams.get('state') + if (!state) throw new Error('Fixture authorization did not return state') + const attempt = await consumeCredentialGroupOAuthAttempt(state) + if (!attempt) throw new Error('Fixture authorization state was unavailable') + expect(await consumeCredentialGroupOAuthAttempt(state)).toBeNull() + const completion = completeCredentialGroupOAuth(context, attempt, 'fixture-code') + + if (scenario.accepted) { + const result = await completion + expect(result).toMatchObject({ + created: scenario.mode === 'new', + displayName: chosenEmail, + }) + if (scenario.mode === 'new') expect(result.credentialId).not.toBe(selectedCredentialId) + else expect(result.credentialId).toBe(selectedCredentialId) + const [connected] = await db + .select() + .from(credential) + .where(eq(credential.id, result.credentialId)) + expect(connected).toMatchObject({ + credentialGroupEnrollmentId: enrollmentId, + credentialGroupOptionId: option.id, + providerSubjectId: chosenSubject, + providerTenantId: scenario.returnedTenant, + displayName: chosenEmail, + managedOauthStatus: 'active', + revokedAt: null, + }) + if (!connected?.encryptedOauthTokenSet) + throw new Error('Connected account has no token envelope') + expect(await decryptManagedOAuthTokenSet(connected.encryptedOauthTokenSet)).toMatchObject({ + accessToken: freshToken, + }) + } else { + await expect(completion).rejects.toMatchObject({ statusCode: 409 }) + } + + if (!scenario.accepted || scenario.mode === 'new') { + const [unchanged] = await db + .select() + .from(credential) + .where(eq(credential.id, selectedCredentialId)) + expect(unchanged).toEqual(beforeOAuth) + } + + if (pending) { + if (scenario.mode === 'restore' && !scenario.accepted) { + await expect(claim(pending.call)).rejects.toThrow() + expect(requests).toEqual([]) + } else { + if (scenario.mode === 'legacy') { + await decide(pending.call.toolCallId, pending.proposalVersion, 'allow', { + summary: 'Reviewed summary', + }) + } + const delivered = await deliver(pending.call) + expect(delivered.status, JSON.stringify(delivered)).toBe('success') + const originalToken = await decryptManagedOAuthTokenSet( + beforeOAuth.encryptedOauthTokenSet + ) + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ + authorization: `Bearer ${ + scenario.accepted && scenario.mode !== 'new' ? freshToken : originalToken.accessToken + }`, + body: { + fields: { + project: { key: 'SIM' }, + summary: 'Reviewed summary', + issuetype: { name: 'Task' }, + }, + }, + }) + } + const receipts = await listOrganizationAssistantActionReceipts.execute({ + principal, + input: { organizationId, chatId, toolCallIds: [pending.call.toolCallId] }, + }) + expect(receipts.receipts[0]?.account).toMatchObject({ + credentialId: selectedCredentialId, + displayName: + scenario.accepted && scenario.mode !== 'new' ? chosenEmail : beforeOAuth.displayName, + }) + } + } finally { + provider.mockRestore() + if (scenario.mode === 'new') { + await db.delete(credential).where(eq(credential.credentialGroupOptionId, option.id)) + await db + .update(credentialGroup) + .set({ options: group.options }) + .where(eq(credentialGroup.id, groupId)) + } + await db + .update(credential) + .set(originalCredential) + .where(eq(credential.id, selectedCredentialId)) + await db + .update(credentialGroupEnrollment) + .set(originalEnrollment) + .where(eq(credentialGroupEnrollment.id, enrollmentId)) + } + }) +}) + describe('Assistant action delivery through the durable executor', () => { it.each([ 'allow', diff --git a/apps/sim/lib/core/config/block-visibility.ts b/apps/sim/lib/core/config/block-visibility.ts index a11fc5418ae..066a8e44ede 100644 --- a/apps/sim/lib/core/config/block-visibility.ts +++ b/apps/sim/lib/core/config/block-visibility.ts @@ -3,6 +3,7 @@ import type { AppConfigGateContext, AppConfigGateRule } from '@/lib/core/config/ import { matchesRule, parseGateConfig } from '@/lib/core/config/appconfig-rules' import { env } from '@/lib/core/config/env' import { getPreviewBlocksFromEnv, isAppConfigEnabled } from '@/lib/core/config/env-flags' +import type { DbOrTx } from '@/lib/db/types' /** * Name of the AppConfig configuration profile holding per-block visibility rules. @@ -54,9 +55,9 @@ function parseVisibilityConfig(json: unknown): Record * helper (and `@sim/db`) stay out of this config module's load graph for callers * that never reach an admin-gated rule. */ -async function resolveAdmin(userId: string): Promise { +async function resolveAdmin(userId: string, executor?: DbOrTx): Promise { const { isPlatformAdmin } = await import('@/lib/permissions/super-user') - return isPlatformAdmin(userId) + return isPlatformAdmin(userId, executor) } /** @@ -72,7 +73,8 @@ async function resolveAdmin(userId: string): Promise { * the caller didn't already supply `ctx.isAdmin`. */ export async function getBlockVisibility( - ctx: BlockVisibilityContext = {} + ctx: BlockVisibilityContext = {}, + executor?: DbOrTx ): Promise { if (!isAppConfigEnabled) { const revealed = new Set(getPreviewBlocksFromEnv()) @@ -94,7 +96,8 @@ export async function getBlockVisibility( ctx.isAdmin === undefined && Boolean(ctx.userId) && Object.values(rules).some((rule) => rule.adminEnabled) - const isAdmin = ctx.isAdmin ?? (needsAdmin ? await resolveAdmin(ctx.userId as string) : false) + const isAdmin = + ctx.isAdmin ?? (needsAdmin ? await resolveAdmin(ctx.userId as string, executor) : false) const revealed = new Set() const disabled = new Set() diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index f101fd60f8f..abc87f98bbc 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -291,7 +291,7 @@ export const isTriggerDevEnabled = isTruthy(env.TRIGGER_DEV_ENABLED) * `NEXT_PUBLIC_ENTERPRISE_ENABLED` twin (see {@link isBillingEnabled}). * Deployments must set both together. */ -export const isEnterpriseEnabled = +const isEnterpriseEnabled = typeof window === 'undefined' ? isTruthy(env.ENTERPRISE_ENABLED) : isTruthy(getEnv('NEXT_PUBLIC_ENTERPRISE_ENABLED')) diff --git a/apps/sim/lib/credential-groups/enrollments.ts b/apps/sim/lib/credential-groups/enrollments.ts index 5618cdce568..90f8fdc3459 100644 --- a/apps/sim/lib/credential-groups/enrollments.ts +++ b/apps/sim/lib/credential-groups/enrollments.ts @@ -1129,7 +1129,6 @@ async function buildPublicCredentialGroupEnrollment( .select({ optionId: credential.credentialGroupOptionId, status: credential.managedOauthStatus, - scopeVersion: credential.managedOauthScopeVersion, authorizationAppId: credential.authorizationAppId, grantedScopes: credential.grantedScopes, displayName: credential.displayName, @@ -1217,7 +1216,6 @@ async function buildPublicCredentialGroupEnrollment( ? ('revoked' as const) : connection.status !== 'active' || connection.authorizationAppId !== policy.authorizationAppId || - connection.scopeVersion !== policy.scopeVersion || !adapter.hasRequiredScopes( connection.grantedScopes ?? [], policy.requiredScopes diff --git a/apps/sim/lib/credential-groups/oauth.test.ts b/apps/sim/lib/credential-groups/oauth.test.ts index c14d1dc98a7..2c55dda9228 100644 --- a/apps/sim/lib/credential-groups/oauth.test.ts +++ b/apps/sim/lib/credential-groups/oauth.test.ts @@ -337,6 +337,7 @@ describe('credential group OAuth persistence', () => { { id: 'credential-1', providerSubjectId: 'google-subject-1', + providerTenantId: null, encryptedOauthTokenSet: null, refreshTokenExpiresAt: null, }, @@ -391,6 +392,7 @@ describe('credential group OAuth persistence', () => { { id: 'credential-1', providerSubjectId: 'google-subject-1', + providerTenantId: null, encryptedOauthTokenSet: null, refreshTokenExpiresAt: null, }, @@ -536,6 +538,7 @@ describe('credential group OAuth persistence', () => { id: 'credential-1', revokedAt: new Date(), providerSubjectId: 'google-subject-1', + providerTenantId: null, encryptedOauthTokenSet: null, refreshTokenExpiresAt: null, }, diff --git a/apps/sim/lib/credential-groups/oauth.ts b/apps/sim/lib/credential-groups/oauth.ts index e820cf93e0c..a037e39d44d 100644 --- a/apps/sim/lib/credential-groups/oauth.ts +++ b/apps/sim/lib/credential-groups/oauth.ts @@ -227,6 +227,7 @@ async function persistGrant( id: credential.id, revokedAt: credential.revokedAt, providerSubjectId: credential.providerSubjectId, + providerTenantId: credential.providerTenantId, encryptedOauthTokenSet: credential.encryptedOauthTokenSet, refreshTokenExpiresAt: credential.refreshTokenExpiresAt, }) @@ -250,12 +251,19 @@ async function persistGrant( ) } - let refreshToken = grant.refreshToken if ( - !refreshToken && - existing?.providerSubjectId === grant.providerSubjectId && - existing.encryptedOauthTokenSet + existing && + (existing.providerSubjectId !== grant.providerSubjectId || + existing.providerTenantId !== grant.providerTenantId) ) { + throw new CredentialGroupOAuthError( + 'Reconnect with the account originally used for this connection. Switching to a different account is not supported for this connection.', + 409 + ) + } + + let refreshToken = grant.refreshToken + if (!refreshToken && existing?.encryptedOauthTokenSet) { refreshToken = (await decryptManagedOAuthTokenSet(existing.encryptedOauthTokenSet)) .refreshToken } diff --git a/apps/sim/lib/credentials/managed-oauth.integration.ts b/apps/sim/lib/credentials/managed-oauth.integration.ts new file mode 100644 index 00000000000..c388ac04517 --- /dev/null +++ b/apps/sim/lib/credentials/managed-oauth.integration.ts @@ -0,0 +1,237 @@ +import { db } from '@sim/db' +import { + credential, + credentialGroup, + credentialGroupEnrollment, + organization, + user, +} from '@sim/db/schema' +import { sha256Hex } from '@sim/security/hash' +import { jsonResponse } from '@sim/testing/helpers/http' +import { generateId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { getAuthorizedPublicCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments' +import type { CredentialGroupProviderPolicy } from '@/lib/credential-groups/provider-adapter' +import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' +import { + encryptManagedOAuthTokenSet, + resolveManagedOAuthToken, +} from '@/lib/credentials/managed-oauth' + +vi.hoisted(() => { + process.env.SALESFORCE_CLIENT_ID = 'managed-destination-fixture-client' + process.env.SALESFORCE_CLIENT_SECRET = 'managed-destination-fixture-secret' +}) + +const organizationId = generateId() +const userId = generateId() +const groupId = generateId() +const enrollmentId = generateId() +const credentialId = generateId() +const invitationToken = generateId() +const enrollmentIdentity = { + userId, + enrollmentId, + credentialGroupId: groupId, + organizationId, + email: `${userId}@example.test`, + invitationTokenHash: sha256Hex(invitationToken), +} +const instanceUrl = 'https://managed-destination-fixture.my.salesforce.com' +let policy: CredentialGroupProviderPolicy +let accessToken: string + +const resolve = (requiredScopes = ['api']) => + resolveManagedOAuthToken({ + credentialId, + organizationId, + expectedProviderId: 'salesforce', + requiredScopes, + }) + +function stubIdentity(respond: (request: Request) => Response | Promise) { + return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = new Request(input, init) + if (request.url !== 'https://login.salesforce.com/services/oauth2/userinfo') { + throw new Error(`Unexpected provider request: ${request.url}`) + } + return respond(request) + }) +} + +beforeAll(async () => { + policy = await getCredentialGroupProviderAdapter('salesforce').getPolicy(undefined, {}) + await db.insert(user).values({ + id: userId, + name: 'Managed destination fixture', + email: `${userId}@example.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + await db.insert(organization).values({ + id: organizationId, + name: 'Managed destination fixture', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(credentialGroup).values({ + id: groupId, + organizationId, + publicId: generateId(), + name: 'Connected accounts', + options: [ + { + id: 'salesforce', + provider: 'salesforce', + label: 'Salesforce', + authorizationAppId: policy.authorizationAppId, + requiredScopes: policy.requiredScopes, + scopeVersion: policy.scopeVersion, + required: false, + status: 'active', + }, + ], + }) + await db.insert(credentialGroupEnrollment).values({ + id: enrollmentId, + credentialGroupId: groupId, + userId, + email: `${userId}@example.test`, + status: 'completed', + invitationTokenHash: sha256Hex(invitationToken), + invitationExpiresAt: new Date(Date.now() + 3_600_000), + invitedAt: new Date(), + createdBy: userId, + }) +}) + +beforeEach(async () => { + accessToken = `managed-destination-${generateId()}` + await db.insert(credential).values({ + id: credentialId, + organizationId, + type: 'managed_oauth', + createdBy: userId, + displayName: `${userId}@example.test`, + providerId: 'salesforce', + providerSubjectId: credentialId, + credentialGroupEnrollmentId: enrollmentId, + credentialGroupOptionId: 'salesforce', + authorizationAppId: policy.authorizationAppId, + managedOauthScopeVersion: policy.scopeVersion === 1 ? 2 : 1, + managedOauthStatus: 'active', + grantedScopes: policy.requiredScopes, + encryptedOauthTokenSet: await encryptManagedOAuthTokenSet({ accessToken }), + accessTokenExpiresAt: new Date(Date.now() + 3_600_000), + grantedAt: new Date(), + }) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await db.delete(credential).where(eq(credential.id, credentialId)) +}) + +afterAll(async () => { + await db.delete(organization).where(eq(organization.id, organizationId)) + await db.delete(user).where(eq(user.id, userId)) +}) + +describe('managed Salesforce destination resolution', () => { + it('shares verified destinations across concurrent calls and subsequent identity outages', async () => { + let requests = 0 + stubIdentity((request) => { + expect(request.headers.get('authorization')).toBe(`Bearer ${accessToken}`) + requests += 1 + return requests === 1 + ? jsonResponse({ urls: { rest: `${instanceUrl}/services/data/v{version}/` } }) + : jsonResponse({ error: 'temporarily_unavailable' }, 503) + }) + + const results = await Promise.all(Array.from({ length: 6 }, () => resolve())) + expect(results.map((result) => result.instanceUrl)).toEqual(Array(6).fill(instanceUrl)) + await expect(resolve()).resolves.toMatchObject({ accessToken, instanceUrl }) + expect(requests).toBe(1) + }) + + it.each(['unavailable', 'invalid_origin'] as const)( + 'allows retry after an identity response is %s', + async (failure) => { + let requests = 0 + stubIdentity(() => { + requests += 1 + if (requests === 1) { + return failure === 'unavailable' + ? jsonResponse({ error: 'temporarily_unavailable' }, 503) + : jsonResponse({ urls: { rest: 'https://untrusted.example/services/data/' } }) + } + return jsonResponse({ urls: { rest: `${instanceUrl}/services/data/v{version}/` } }) + }) + + await expect(resolve()).rejects.toMatchObject({ + code: 'MANAGED_CREDENTIAL_IDENTITY_UNAVAILABLE', + }) + await expect(resolve()).resolves.toMatchObject({ instanceUrl }) + expect(requests).toBe(2) + } + ) + + it('discovers a new destination after token rotation', async () => { + const nextToken = `managed-destination-${generateId()}` + const nextInstanceUrl = 'https://rotated-destination-fixture.my.salesforce.com' + stubIdentity((request) => { + const destination = + request.headers.get('authorization') === `Bearer ${nextToken}` + ? nextInstanceUrl + : instanceUrl + return jsonResponse({ urls: { rest: `${destination}/services/data/v{version}/` } }) + }) + await expect(resolve()).resolves.toMatchObject({ instanceUrl }) + await db + .update(credential) + .set({ + encryptedOauthTokenSet: await encryptManagedOAuthTokenSet({ accessToken: nextToken }), + }) + .where(eq(credential.id, credentialId)) + await expect(resolve()).resolves.toMatchObject({ + accessToken: nextToken, + instanceUrl: nextInstanceUrl, + }) + }) + + it.each([ + { update: { managedOauthStatus: 'revoked' as const }, code: 'MANAGED_CREDENTIAL_REVOKED' }, + { update: { grantedScopes: [] }, code: 'MANAGED_CREDENTIAL_INSUFFICIENT_SCOPE' }, + { update: { authorizationAppId: 'another-app' }, code: 'MANAGED_CREDENTIAL_NEEDS_REAUTH' }, + ])( + 'rechecks credential authority before reusing a destination: $code', + async ({ update, code }) => { + stubIdentity(() => + jsonResponse({ urls: { rest: `${instanceUrl}/services/data/v{version}/` } }) + ) + await expect(resolve()).resolves.toMatchObject({ instanceUrl }) + await db.update(credential).set(update).where(eq(credential.id, credentialId)) + await expect(resolve()).rejects.toMatchObject({ code }) + } + ) +}) + +describe('managed enrollment scope status', () => { + it('keeps a fully granted account connected when only its stored scope fingerprint differs', async () => { + const enrollment = await getAuthorizedPublicCredentialGroupEnrollment(enrollmentIdentity) + expect(enrollment?.options[0]?.connections[0]?.status).toBe('connected') + }) + + it('requests new permissions without disabling operations the existing grant already covers', async () => { + stubIdentity(() => jsonResponse({ urls: { rest: `${instanceUrl}/services/data/v{version}/` } })) + await db + .update(credential) + .set({ grantedScopes: ['api'] }) + .where(eq(credential.id, credentialId)) + const enrollment = await getAuthorizedPublicCredentialGroupEnrollment(enrollmentIdentity) + expect(enrollment?.options[0]?.connections[0]?.status).toBe('needs_reauth') + await expect(resolve()).resolves.toMatchObject({ instanceUrl }) + }) +}) diff --git a/apps/sim/lib/credentials/managed-oauth.ts b/apps/sim/lib/credentials/managed-oauth.ts index 45d2317acde..f674b09aa31 100644 --- a/apps/sim/lib/credentials/managed-oauth.ts +++ b/apps/sim/lib/credentials/managed-oauth.ts @@ -1,8 +1,10 @@ import { db } from '@sim/db' import { credential, credentialGroupEnrollment } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' +import { LRUCache } from 'lru-cache' import type { WorkspaceAuthorizationContext } from '@/lib/core/application' import { type ResourceOwner, @@ -32,6 +34,21 @@ const logger = createLogger('ManagedOAuthCredential') const MANAGED_OAUTH_TOKEN_SET_TYPE = 'managed-oauth-token-set' as const const MANAGED_OAUTH_TOKEN_SET_VERSION = 1 as const const ACCESS_TOKEN_REFRESH_WINDOW_MS = 30_000 +const SALESFORCE_INSTANCE_CACHE_TTL_MS = 5 * 60_000 +const SALESFORCE_INSTANCE_CACHE_MAX = 10_000 + +/** Only verified destinations are cached; credential authorization remains fresh on every use. */ +const salesforceInstanceCache = new LRUCache< + string, + string, + { providerId: string; accessToken: string } +>({ + max: SALESFORCE_INSTANCE_CACHE_MAX, + ttl: SALESFORCE_INSTANCE_CACHE_TTL_MS, + ignoreFetchAbort: true, + fetchMethod: (_key, _stale, { context }) => + fetchSalesforceInstanceUrl(context.providerId, context.accessToken), +}) export type ManagedOAuthCredentialErrorCode = | 'MANAGED_CREDENTIAL_NOT_FOUND' @@ -378,7 +395,10 @@ async function resolveProviderTokenContext( token: ResolvedManagedOAuthToken ): Promise { if (!isSalesforceOAuthProviderId(providerId)) return token - const instanceUrl = await fetchSalesforceInstanceUrl(providerId, token.accessToken) + const instanceUrl = await salesforceInstanceCache.fetch( + `${providerId}:${sha256Hex(token.accessToken)}`, + { context: { providerId, accessToken: token.accessToken } } + ) if (!instanceUrl) throw new ManagedOAuthCredentialError( 'MANAGED_CREDENTIAL_IDENTITY_UNAVAILABLE', diff --git a/apps/sim/lib/mothership/application/load-search-integrations.ts b/apps/sim/lib/mothership/application/load-search-integrations.ts index a0010471865..f5bb816e585 100644 --- a/apps/sim/lib/mothership/application/load-search-integrations.ts +++ b/apps/sim/lib/mothership/application/load-search-integrations.ts @@ -47,12 +47,14 @@ export async function loadCopilotSearchIntegrations( connections: connections.connections, available: connections.available, actionAccounts: connections.connections.flatMap((connection) => - connection.accounts.map((account) => ({ - credentialId: account.credentialId, - providerId: connection.providerId, - displayName: account.displayName, - status: account.status, - })) + connection.accounts + .filter((account) => account.action !== null) + .map((account) => ({ + credentialId: account.credentialId, + providerId: connection.providerId, + displayName: account.displayName, + status: account.status, + })) ), connectionGuidance: 'Offer the exact available target or account action in a terminal tag to connect or reconnect in chat. Search uses the provider APIs directly. Account inventory alone does not establish permission to search; a provider search must succeed.', diff --git a/apps/sim/lib/permissions/super-user.ts b/apps/sim/lib/permissions/super-user.ts index d4a780a928f..c2f4b3debc8 100644 --- a/apps/sim/lib/permissions/super-user.ts +++ b/apps/sim/lib/permissions/super-user.ts @@ -42,20 +42,17 @@ export async function verifyEffectiveSuperUser( } /** - * True when the user is a platform admin (`role === 'admin'`). A single-column read - * served from the replica: this gates features, not security-critical auth, so it - * tolerates the replica's bounded staleness (admin role rarely changes). Falls back - * to the primary when no replica is configured. - * - * Request-memoized: an account-settings render checks the same viewer in both the - * layout and the page. + * Reads platform-admin status from the supplied transaction or, by default, the replica. + * Request-memoized by viewer and executor so transactions never reuse another connection's result. */ -export const isPlatformAdmin = cache(async (userId: string): Promise => { - const [row] = await dbReplica - .select({ role: user.role }) - .from(user) - .where(eq(user.id, userId)) - .limit(1) +export const isPlatformAdmin = cache( + async (userId: string, executor: DbOrTx = dbReplica): Promise => { + const [row] = await executor + .select({ role: user.role }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) - return row?.role === 'admin' -}) + return row?.role === 'admin' + } +) diff --git a/apps/sim/scripts/test-assistant-actions-e2e.ts b/apps/sim/scripts/test-assistant-actions-e2e.ts index 2457d8b8d07..17410913a2b 100644 --- a/apps/sim/scripts/test-assistant-actions-e2e.ts +++ b/apps/sim/scripts/test-assistant-actions-e2e.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { mkdir, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' -import { chromium, expect } from '@playwright/test' +import { chromium, expect, type Locator } from '@playwright/test' import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' import { createLogger } from '@sim/logger' import { createDeferred } from '@sim/testing/helpers/deferred' @@ -188,6 +188,16 @@ async function capture(name: string) { await page.screenshot({ path: resolve(artifactDirectory, `${name}.png`), animations: 'disabled' }) } +async function captureExpandedReceipt(name: string, disclosure: Locator) { + await disclosure.hover() + await page.mouse.wheel(0, -1000) + await disclosure.click() + await expect(disclosure).toHaveAttribute('aria-expanded', 'true') + await expect(disclosure).toBeInViewport({ ratio: 1 }) + await capture(name) + await expect(disclosure).toBeInViewport({ ratio: 1 }) +} + function proposalArguments(summary: string) { return { credential: credentialId, @@ -598,7 +608,7 @@ try { await capture('integrations-desktop') }) await check( - 'Multiple accounts isolate permissions and disconnect the selected account only', + 'A synthetic duplicate-provider fixture isolates permissions and disconnects only the selected account', async () => { await page.goto(`${baseUrl.origin}${root}/integrations`) await page.getByRole('link', { name: 'Open Jira integration', exact: true }).click() @@ -769,6 +779,160 @@ try { } } ) + await check( + 'Email review keeps recipients and exact content safe through editing and receipts', + async () => { + const gmail = catalogAccounts.find((account) => account.option === 'gmail') + assert(gmail) + const emailChatId = generateId() + const emailRunId = generateId() + const emailStreamId = generateId() + const messages = [ + { + callId: generateId(), + subject: 'Launch update', + contentType: 'text', + body: 'The launch checklist is ready. Please confirm the final owner.', + }, + { + callId: generateId(), + subject: 'HTML launch update', + contentType: 'html', + body: `

Review the launch checklist.

`, + }, + ] + const argsFor = (message: (typeof messages)[number]) => ({ + credential: gmail.credentialId, + to: 'launch-team@example.test', + cc: 'reviewer@example.test', + bcc: 'archive@example.test', + subject: message.subject, + body: message.body, + contentType: message.contentType, + }) + await sql.begin(async (tx) => { + await tx`insert into copilot_chats (id, user_id, organization_id, type, title, conversation_id, config) + values (${emailChatId}, ${userId}, ${organizationId}, 'mothership', 'Email review', ${emailStreamId}, ${tx.json({ conversationMode: 'assistant' })})` + await tx`insert into copilot_messages (id, chat_id, message_id, role, stream_id, content, seq) + values (${generateId()}, ${emailChatId}, ${emailStreamId}, 'user', ${emailStreamId}, ${tx.json({ id: emailStreamId, role: 'user', content: 'Prepare the launch update emails.', requestId, timestamp: new Date().toISOString() })}, 1)` + await tx`insert into copilot_runs (id, chat_id, user_id, organization_id, execution_id, stream_id, tool_execution_version, status) + values (${emailRunId}, ${emailChatId}, ${userId}, ${organizationId}, ${generateId()}, ${emailStreamId}, 3, 'paused_waiting_for_tool')` + for (const message of messages) { + await tx`insert into copilot_async_tool_calls (run_id, tool_call_id, tool_name, executable_name, args, assistant_action, execution_retryable, permission_requested_at) + values (${emailRunId}, ${message.callId}, 'gmail_send_v2', 'gmail_send_v2', ${tx.json(argsFor(message))}, ${tx.json({ credentialId: gmail.credentialId, operationVersion: 1, effect: 'write' })}, false, now())` + } + }) + fixtureStreams.push({ chatId: emailChatId, streamId: emailStreamId }) + assert(await acquirePendingChatStream(emailChatId, emailStreamId, 0)) + streamPollers.push( + startAbortPoller(emailStreamId, new AbortController(), { chatId: emailChatId }) + ) + await appendStreamEvents(emailChatId, emailStreamId, [ + { type: 'session', payload: { kind: 'start' } }, + { + type: 'text', + payload: { channel: 'assistant', text: 'Review these emails before sending.' }, + }, + ...messages.map((message) => ({ + type: 'tool' as const, + payload: { + phase: 'call' as const, + executor: 'sim' as const, + mode: 'async' as const, + toolCallId: message.callId, + toolName: 'gmail_send_v2', + status: 'awaiting_approval' as const, + arguments: argsFor(message), + }, + })), + ]) + let contentRequested = false + await page.route('**/assistant-preview-probe', async (route) => { + contentRequested = true + await route.abort() + }) + await page.setViewportSize({ width: 1440, height: 1000 }) + await page.emulateMedia({ colorScheme: 'dark' }) + await page.goto(`${baseUrl.origin}${root}/search/chat/${emailChatId}`) + const emails = page.getByRole('region', { name: 'Review Send email', exact: true }) + await expect(emails).toHaveCount(2) + for (const message of messages) { + const email = emails.filter({ hasText: message.subject }).filter({ + has: page + .getByRole('region', { name: 'Message preview', exact: true }) + .filter({ hasText: message.body }), + }) + await expect(email).toHaveCount(1) + for (const address of [ + 'launch-team@example.test', + 'reviewer@example.test', + 'archive@example.test', + ]) { + await expect(email.getByText(address, { exact: true })).toBeVisible() + } + assert.equal( + await email.getByRole('region', { name: 'Message preview', exact: true }).textContent(), + message.body + ) + await email.getByRole('button', { name: 'Details', exact: true }).click() + } + await capture('email-review-desktop') + const plainIndex = (await emails.allTextContents()).findIndex((content) => + content.includes(messages[0].body) + ) + assert(plainIndex >= 0) + const plain = emails.nth(plainIndex) + await plain.getByRole('button', { name: 'Edit', exact: true }).click() + const editedBody = 'Please confirm the owner by Friday.\nThank you.' + await plain.getByRole('textbox', { name: 'Message', exact: true }).fill(editedBody) + await expect(plain.getByRole('textbox', { name: 'To', exact: true })).toHaveCount(0) + await plain.scrollIntoViewIfNeeded() + await capture('email-review-edit') + await plain.getByRole('button', { name: 'Done', exact: true }).click() + await expect(plain.getByRole('region', { name: 'Message preview', exact: true })).toHaveText( + editedBody + ) + for (const width of [320, 390]) { + await page.setViewportSize({ width, height: 844 }) + await emails.first().scrollIntoViewIfNeeded() + await capture(`email-review-${width}`) + assert( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + 'Email review overflows horizontally' + ) + } + await page.getByRole('button', { name: 'Cancel 2 shown', exact: true }).click() + await expect(emails).toHaveCount(0) + const receipts = page.getByRole('region', { name: 'Send email receipt', exact: true }) + await expect(receipts).toHaveCount(2) + for (const receipt of await receipts.all()) { + await receipt + .getByRole('button', { name: 'View Send email receipt: Cancelled', exact: true }) + .click() + } + await capture('email-receipts-mobile') + const response = await context.request.get( + `${baseUrl.origin}/api/organizations/${organizationId}/chats/${emailChatId}/actions/receipts`, + { params: { streamId: emailStreamId } } + ) + assert.equal(response.status(), 200, await response.text()) + const saved = listAssistantActionReceiptsContract.response.schema.parse(await response.json()) + for (const message of messages) { + const receipt = saved.receipts.find((entry) => entry.toolCallId === message.callId) + assert(receipt) + assert.equal(receipt.state, 'cancelled') + for (const [name, value] of Object.entries(argsFor(message))) { + if (name !== 'credential') + assert.equal(receipt.fields.find((field) => field.name === name)?.value, value) + } + } + assert.equal(contentRequested, false, 'Email markup fetched a resource') + assert.equal(await page.evaluate(() => document.body.dataset.previewExecuted), undefined) + await page.unroute('**/assistant-preview-probe') + await page.emulateMedia({ colorScheme: 'light' }) + await page.setViewportSize({ width: 1440, height: 1000 }) + } + ) await check('Personal permissions persist through the real settings route', async () => { await page.goto(`${baseUrl.origin}${root}/integrations/accounts/${credentialId}`, { waitUntil: 'domcontentloaded', @@ -784,6 +948,21 @@ try { await policy.getByRole('radio', { name: 'Ask', exact: true }).click() await expect(policy.getByRole('radio', { name: 'Ask', exact: true })).toBeChecked() await capture('tool-permissions-desktop') + const toolDescription = page + .getByRole('button', { name: 'More information', exact: true }) + .first() + await toolDescription.hover() + await expect(page.getByRole('tooltip')).toBeVisible() + await capture('tool-permission-description') + await page.mouse.move(0, 0) + await expect(page.getByRole('tooltip')).toBeHidden() + await toolDescription.focus() + await page.keyboard.press('Tab') + await page.keyboard.press('Shift+Tab') + await expect(toolDescription).toBeFocused() + await expect(page.getByRole('tooltip')).toBeVisible() + await page.keyboard.press('Tab') + await expect(page.getByRole('tooltip')).toBeHidden() await page.setViewportSize({ width: 390, height: 844 }) await capture('tool-permissions-mobile') assert( @@ -885,6 +1064,18 @@ try { .getByRole('region', { name: 'Create Jira issue receipt' }) .filter({ hasText: 'Approved historical title' }) ).toBeAttached() + const historicalReceipt = page + .getByRole('region', { name: 'Create Jira issue receipt' }) + .filter({ hasText: 'Approved historical title' }) + await captureExpandedReceipt( + 'action-receipt-completed-expanded', + historicalReceipt.getByRole('button', { + name: /^(View|Hide) Create Jira issue receipt: Completed$/, + }) + ) + await historicalReceipt + .getByRole('button', { name: 'Hide Create Jira issue receipt: Completed', exact: true }) + .click() await reviewCards.last().scrollIntoViewIfNeeded() await capture('assistant-actions-desktop') await capture('action-review-desktop') @@ -916,6 +1107,8 @@ try { await expect( page.getByRole('button', { name: 'Allow 2 shown', exact: true }) ).toBeDisabled() + await reviewCards.nth(0).scrollIntoViewIfNeeded() + await capture('action-review-account-unavailable') } finally { await sql`update credential set managed_oauth_status = 'active' where id = ${credentialId}` } @@ -933,7 +1126,12 @@ try { .nth(0) .getByRole('textbox', { name: 'Summary', exact: true }) .fill('Publish the reviewed launch checklist') + await reviewCards.nth(0).scrollIntoViewIfNeeded() + await capture('action-review-edit-mobile') + await page.setViewportSize({ width: 1440, height: 1000 }) + await reviewCards.nth(0).scrollIntoViewIfNeeded() await capture('action-review-edit') + await page.setViewportSize({ width: 390, height: 844 }) await reviewCards.nth(0).getByRole('button', { name: 'Done', exact: true }).click() await expect( reviewCards.nth(0).getByText('Publish the reviewed launch checklist', { exact: true }) @@ -954,7 +1152,14 @@ try { const approvedReceipt = page .getByRole('region', { name: 'Create Jira issue receipt' }) .filter({ hasText: 'Publish the reviewed launch checklist' }) - await expect(approvedReceipt.getByText('Approved', { exact: true })).toBeVisible() + await expect( + approvedReceipt.getByRole('button', { + name: 'View Create Jira issue receipt: Approved', + exact: true, + }) + ).toBeVisible() + await approvedReceipt.scrollIntoViewIfNeeded() + await capture('action-review-approved-and-pending') await approvedReceipt.getByRole('button', { name: 'View Create Jira issue receipt' }).click() await expect(approvedReceipt.getByRole('textbox')).toHaveCount(0) await expect(approvedReceipt.getByText('acme.atlassian.net', { exact: true })).toBeVisible() @@ -972,13 +1177,28 @@ try { const cancelledReceipt = page .getByRole('region', { name: 'Create Jira issue receipt' }) .filter({ hasText: 'Confirm the rollout owners' }) - await expect(cancelledReceipt.getByText('Cancelled', { exact: true })).toBeVisible() + await expect( + cancelledReceipt.getByRole('button', { + name: 'View Create Jira issue receipt: Cancelled', + exact: true, + }) + ).toBeVisible() await capture('action-receipts-mobile') await page.setViewportSize({ width: 1440, height: 1000 }) await capture('action-receipts-desktop') await page.reload() - await expect(approvedReceipt.getByText('Approved', { exact: true })).toBeVisible() - await expect(cancelledReceipt.getByText('Cancelled', { exact: true })).toBeVisible() + await expect( + approvedReceipt.getByRole('button', { + name: 'View Create Jira issue receipt: Approved', + exact: true, + }) + ).toBeVisible() + await expect( + cancelledReceipt.getByRole('button', { + name: 'View Create Jira issue receipt: Cancelled', + exact: true, + }) + ).toBeVisible() const rows = await sql`select tool_call_id, permission_decision, args, approved_args, execution_started_at from copilot_async_tool_calls where run_id = ${runId} order by persist_seq` assert.equal(rows[0]?.permission_decision, 'allow') @@ -1023,6 +1243,7 @@ try { timeout: 15_000, }) await expect(reviewCards).toHaveCount(0) + await capture('action-review-unavailable-retry') } finally { await page.unroute(reviewUrl) } @@ -1064,6 +1285,7 @@ try { try { await page.getByRole('button', { name: 'Allow 2 shown', exact: true }).click() await expect.poll(() => firstDecisionRequested).toBe(true) + await capture('action-review-bulk-saving') await insertProposal(lagRunId, lateCallId, 'A later proposal that was never approved') await appendStreamEvents(lagChatId, lagStreamId, [ pendingToolEvent(lateCallId, 'A later proposal that was never approved'), @@ -1139,6 +1361,7 @@ try { await expect(reviewCards.nth(0).getByRole('alert')).toBeVisible() await expect(allow).toBeEnabled() await expect(allow).toBeFocused() + await capture('action-review-decision-error') const [row] = await sql`select permission_decision, execution_started_at from copilot_async_tool_calls where tool_call_id = ${keyboardCallId}` assert.equal(row?.permission_decision, null) @@ -1437,7 +1660,15 @@ try { await expect( page.getByText('Could not load action history', { exact: true }) ).toHaveCount(index + 1, { timeout: 15_000 }) + const historyError = page + .getByText('Could not load action history', { exact: true }) + .last() + await historyError.hover() + await page.mouse.wheel(0, -1000) + await historyError.scrollIntoViewIfNeeded() + await expect(historyError).toBeInViewport({ ratio: 1 }) await capture(`receipt-history-${fixture.state.toLowerCase()}-retry`) + await expect(historyError).toBeInViewport({ ratio: 1 }) } finally { await page.unroute(`${receiptsUrl}*`) } @@ -1448,6 +1679,52 @@ try { .filter({ hasText: fixture.summary }) ).toContainText(fixture.state) } + const [savedExecution] = + await sql`select status, execution_started_at, execution_settled_at, execution_owner_token, execution_lease_expires_at, completed_at, result, assistant_action from copilot_async_tool_calls where run_id = ${bulkRunId} and tool_call_id = ${precedingCallId}` + assert(savedExecution) + try { + for (const fixture of [ + { status: 'running', label: 'Running', settled: false, unknown: false }, + { status: 'failed', label: 'Failed', settled: true, unknown: false }, + { status: 'completed', label: 'Outcome unknown', settled: true, unknown: true }, + ] as const) { + const settledAt = fixture.settled ? new Date() : null + const leaseExpiresAt = fixture.settled ? null : new Date(Date.now() + 3_600_000) + await sql`update copilot_async_tool_calls set status = ${fixture.status}, execution_started_at = now(), execution_settled_at = ${settledAt}, execution_owner_token = ${generateId()}, execution_lease_expires_at = ${leaseExpiresAt}, completed_at = ${settledAt}, result = ${sql.json({ outcomeUnknown: fixture.unknown })} where run_id = ${bulkRunId} and tool_call_id = ${precedingCallId}` + await page.reload() + const receipt = page + .getByRole('region', { name: 'Create Jira issue receipt' }) + .filter({ hasText: 'Batch issue 62' }) + await captureExpandedReceipt( + `action-receipt-${fixture.label.toLowerCase().replaceAll(' ', '-')}`, + receipt.getByRole('button', { + name: new RegExp(`^(View|Hide) Create Jira issue receipt: ${fixture.label}$`), + }) + ) + } + const incompatibleBinding = { + ...toRecord(savedExecution.assistant_action), + operationVersion: 2, + } + for (const fixture of [ + { name: 'details-unavailable', binding: incompatibleBinding }, + { + name: 'account-unavailable', + binding: { ...incompatibleBinding, credentialId: generateId() }, + }, + ]) { + await sql`update copilot_async_tool_calls set assistant_action = ${sql.json(fixture.binding)} where run_id = ${bulkRunId} and tool_call_id = ${precedingCallId}` + await page.reload() + await captureExpandedReceipt( + `action-receipt-${fixture.name}`, + page.getByRole('button', { + name: /^(View|Hide) Create Jira issue receipt: Outcome unknown$/, + }) + ) + } + } finally { + await sql`update copilot_async_tool_calls set status = ${savedExecution.status}, execution_started_at = ${savedExecution.execution_started_at}, execution_settled_at = ${savedExecution.execution_settled_at}, execution_owner_token = ${savedExecution.execution_owner_token}, execution_lease_expires_at = ${savedExecution.execution_lease_expires_at}, completed_at = ${savedExecution.completed_at}, result = ${savedExecution.result === null ? null : sql.json(savedExecution.result)}, assistant_action = ${sql.json(savedExecution.assistant_action)} where run_id = ${bulkRunId} and tool_call_id = ${precedingCallId}` + } } ) await check( diff --git a/apps/sim/scripts/test-organization-search-e2e.ts b/apps/sim/scripts/test-organization-search-e2e.ts index f537942f6a5..c5a45689522 100644 --- a/apps/sim/scripts/test-organization-search-e2e.ts +++ b/apps/sim/scripts/test-organization-search-e2e.ts @@ -813,6 +813,162 @@ try { } } }) + await check( + 'Search keeps results usable through partial coverage and a failed request', + async () => { + await page.setViewportSize({ width: 1440, height: 1000 }) + const samples = [ + { + documentId: 'launch-brief', + documentName: 'Launch brief', + connectorType: 'google_drive', + knowledgeBaseName: 'Google Drive', + sourceUrl: 'https://docs.example.test/launch', + content: 'The launch checklist is complete. Final owner sign-off is due Friday.', + }, + { + documentId: 'launch-readiness', + documentName: 'Launch readiness', + connectorType: 'confluence', + knowledgeBaseName: 'Confluence', + sourceUrl: 'https://wiki.example.test/launch', + content: 'Engineering validation is complete. The support handoff is still in progress.', + }, + ].map((sample) => ({ + ...sample, + knowledgeBaseId: 'preview-source', + author: 'Taylor', + sourceModifiedAt: '2026-10-28T18:30:00.000Z', + chunkIndex: 0, + similarity: 0.9, + })) + let unavailable = false + let partial = false + const searchRoute = '**/api/knowledge/search' + await page.route(searchRoute, async (route) => { + if (unavailable) { + await route.fulfill({ status: 503, json: { error: 'Search is temporarily unavailable' } }) + return + } + const body = toRecord(route.request().postDataJSON()) + const source = toRecord(body.filters).source + await route.fulfill({ + json: searchWorkspaceKnowledgeContract.response.schema.parse({ + success: true, + data: { + query: body.query, + results: samples.filter((sample) => !source || sample.connectorType === source), + retrieval: { status: partial ? 'partial' : 'complete', timedOutLegs: [] }, + live: { + backend: 'live', + accounts: samples.map((sample) => ({ + accountId: sample.documentId, + provider: sample.connectorType, + displayName: 'taylor@example.test', + status: partial && sample.connectorType === 'confluence' ? 'timeout' : 'ok', + })), + guidance: '', + }, + }, + }), + }) + }) + try { + await page.goto(`${baseUrl.origin}${root}/search?q=Launch%20readiness`) + await expect(page.getByText('Launch brief', { exact: true }).first()).toBeVisible() + const wikiResult = page.locator( + 'a[data-source-link][href="https://wiki.example.test/launch"]' + ) + await expect(wikiResult).toBeVisible() + await capture('search-populated-desktop') + await page.getByRole('button', { name: 'Filter by source: All sources' }).click() + await capture('search-source-filter') + await page.getByRole('menuitem', { name: 'Google Drive', exact: true }).click() + await expect(page).toHaveURL((url) => url.searchParams.get('source') === 'google_drive') + await expect(wikiResult).toHaveCount(0) + await capture('search-filtered') + await page.getByRole('button', { name: 'Filter by date: Any time' }).click() + await capture('search-date-filter') + await page.keyboard.press('Escape') + await page.setViewportSize({ width: 390, height: 844 }) + await capture('search-populated-mobile') + await page.setViewportSize({ width: 1440, height: 1000 }) + partial = true + await page.goto(`${baseUrl.origin}${root}/search?q=Partial%20coverage`) + await expect( + page.getByText('Some apps couldn’t be searched. Showing available results.') + ).toBeVisible() + await capture('search-partial-coverage') + unavailable = true + await page.goto(`${baseUrl.origin}${root}/search?q=Provider%20outage`) + await expect(page.getByText('Search couldn’t run.', { exact: true })).toBeVisible() + await capture('search-error-retry') + unavailable = false + partial = false + await page.getByRole('button', { name: 'Try again', exact: true }).click() + await expect(page.getByText('Launch brief', { exact: true }).first()).toBeVisible() + await expect(page.getByText('Search couldn’t run.', { exact: true })).toHaveCount(0) + + const citedChatId = generateId() + const citedAnswer = + 'The checklist is complete, with final owner sign-off due Friday. {"id":"document:launch-brief"}\n\nThe support handoff is still in progress. {"id":"document:launch-readiness"}' + await sql`insert into copilot_chats (id, user_id, organization_id, type, title, config) + values (${citedChatId}, ${userId}, ${organizationId}, 'mothership', 'Cited launch update', ${sql.json({ conversationMode: 'assistant' })})` + for (const [seq, role, content] of [ + [1, 'user', question], + [2, 'assistant', citedAnswer], + ] as const) { + const id = generateId() + const contentBlocks = + role === 'assistant' + ? [ + { + type: 'tool', + phase: 'call', + toolCall: { + id: 'preview-retrieval', + name: 'search_workspace', + state: 'success', + result: { + success: true, + output: { + success: true, + data: { + results: samples.map((sample) => ({ + ...sample, + citationId: `document:${sample.documentId}`, + citationUrl: sample.sourceUrl, + })), + }, + }, + }, + }, + }, + { type: 'text', channel: 'final', content }, + ] + : undefined + await sql`insert into copilot_messages (id, chat_id, message_id, role, content, seq) + values (${generateId()}, ${citedChatId}, ${id}, ${role}, ${sql.json({ id, role, content, contentBlocks, timestamp: new Date().toISOString() })}, ${seq})` + } + await page.goto(`${baseUrl.origin}${root}/search/chat/${citedChatId}`) + await expect(page.getByRole('button', { name: '2 sources', exact: true })).toBeVisible() + await capture('assistant-cited-answer') + await page.getByRole('button', { name: '2 sources', exact: true }).click() + await capture('assistant-sources-dropdown') + await page.getByRole('button', { name: 'View all sources', exact: true }).click() + const sourcesPanel = page.getByLabel('Cited sources', { exact: true }) + await expect(sourcesPanel).toBeVisible() + await expect(sourcesPanel.getByText('Launch brief', { exact: true })).toBeVisible() + await expect(sourcesPanel.getByText('Launch readiness', { exact: true })).toBeVisible() + await capture('assistant-resources-panel') + await page.setViewportSize({ width: 390, height: 844 }) + await expect(sourcesPanel).toBeVisible() + await capture('assistant-resources-mobile') + } finally { + await page.unroute(searchRoute) + } + } + ) await check( 'Search-only members can reopen assistant history without access to Home chat', async () => { diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index f45ccf80c1f..a56abb59a7a 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -28,7 +28,6 @@ interface EnvFlagsMockState { isAppConfigEnabled: boolean isSlackExtendedScopesEnabled: boolean isTriggerDevEnabled: boolean - isEnterpriseEnabled: boolean isSsoEnabled: boolean isUsageMonitoringEnabled: boolean isAccessControlEnabled: boolean @@ -82,7 +81,6 @@ const defaultEnvFlagsState: EnvFlagsMockState = { isAppConfigEnabled: false, isSlackExtendedScopesEnabled: false, isTriggerDevEnabled: false, - isEnterpriseEnabled: false, isSsoEnabled: false, /** OAuth-aware route behavior is available unless a suite overrides it. */ isUsageMonitoringEnabled: false, diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index 7e249d95cdd..ebd1ba6c5d1 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -4193,7 +4193,6 @@ "apps/sim/lib/core/async-jobs/index.ts#getCurrentBackendType", "apps/sim/lib/core/async-jobs/index.ts#resetJobQueueCache", "apps/sim/lib/core/async-jobs/types.ts#AsyncExecutionCorrelationSource", - "apps/sim/lib/core/config/env-flags.ts#isEnterpriseEnabled", "apps/sim/lib/core/config/feature-flags.ts#FeatureFlagRule", "apps/sim/lib/core/http/classes.ts#DELIVERY_CLASSES", "apps/sim/lib/core/http/classes.ts#DeliveryClass",