Skip to content
Merged
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
Expand Up @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
remove: vi.fn(),
install: vi.fn(),
refetch: vi.fn(),
copy: vi.fn(),
removeError: null as Error | null,
}))
vi.mock('nuqs', () => ({ useQueryState: () => [null, vi.fn()] }))
Expand Down Expand Up @@ -53,6 +54,8 @@ let container: HTMLDivElement
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
vi.stubGlobal('navigator', { clipboard: { writeText: mocks.copy } })
mocks.copy.mockReset().mockResolvedValue(undefined)
mocks.context.mockReturnValue({ organization: { id: 'org-1' }, viewer: { isAdmin: true } })
mocks.list.mockReturnValue({ data: { installations: [], bots: [] } })
mocks.manifest.mockReturnValue({
Expand Down Expand Up @@ -104,11 +107,14 @@ async function action(label: string) {
}

describe('Slack Search settings and shared wizard', () => {
it('starts with one setup action and the prefilled manifest, with no name or token form', async () => {
it('starts with one setup action and a Slack app link, with no manifest preview or form', async () => {
await render()
expect(container.querySelectorAll('button')).toHaveLength(1)
await click('Set up')
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('App manifest')
expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('App manifest')
expect(document.querySelector('a[href="https://api.slack.com/apps"]')).toHaveTextContent(
'Create app in Slack'
)
expect(document.querySelectorAll('input')).toHaveLength(0)
expect(mocks.manifest).toHaveBeenCalledWith('org-1', 'Sim Search')
expect(mocks.install).not.toHaveBeenCalled()
Expand All @@ -135,7 +141,11 @@ describe('Slack Search settings and shared wizard', () => {
expect(container.textContent).toContain('Enabled')
await action('Reconnect')
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Reconnect Slack Search')
await click('Copy app configuration')
expect(mocks.copy).toHaveBeenCalledExactlyOnceWith('{}')
expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).not.toBeNull()
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Configuration copied')
expect(document.querySelector('pre')).toBeNull()
await click('Continue')
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Leave fields blank')
await click('Continue')
Expand All @@ -151,6 +161,40 @@ describe('Slack Search settings and shared wizard', () => {
expect(mocks.install.mock.calls[0][0]).not.toHaveProperty('clientSecret')
})

it('keeps the update action available when clipboard access fails', async () => {
mocks.copy.mockRejectedValueOnce(new Error('Clipboard access denied'))
await render(true)
await action('Reconnect')
await click('Copy app configuration')
expect(document.querySelector('[role="alert"]')).toHaveTextContent('Allow clipboard access')
expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).toBeNull()
expect(button('Copy app configuration')).toBeDefined()
await click('Copy app configuration')
expect(document.querySelector('[role="alert"]')).toBeNull()
expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).not.toBeNull()
})

it('offers the same configuration update for an app shared with Slack sources', async () => {
mocks.manifest.mockReturnValue({
data: {
manifest: '{"display_information":{"name":"Shared Slack app"}}',
existingApp: { appId: 'A2' },
createAppUrl: 'https://api.slack.com/apps',
},
isPending: false,
refetch: mocks.refetch,
})
await render()
await click('Set up')
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Update your Slack app')
await click('Copy app configuration')
expect(mocks.copy).toHaveBeenCalledExactlyOnceWith(
'{"display_information":{"name":"Shared Slack app"}}'
)
expect(document.querySelector('a[href="https://api.slack.com/apps/A2"]')).not.toBeNull()
expect(document.querySelector('pre')).toBeNull()
})

it('disables the selected connection from the actions menu', async () => {
await render(true)
await action('Disable')
Expand Down
50 changes: 33 additions & 17 deletions apps/sim/components/integrations/slack-search-setup-wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
writeTextToClipboard,
} from '@sim/emcn'
import { SlackIcon } from '@/components/icons'
import { SlackAppManifest } from '@/components/integrations/slack-app-manifest'
import {
SLACK_SEARCH_DEFAULT_DESCRIPTION,
SLACK_SEARCH_DEFAULT_NAME,
Expand Down Expand Up @@ -43,11 +43,26 @@ export function SlackSearchSetupWizard({
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
const [signingSecret, setSigningSecret] = useState('')
const error = prepare.error ?? oauth.error
const [configurationCopied, setConfigurationCopied] = useState(false)
const [copyError, setCopyError] = useState<Error | null>(null)
const error = prepare.error ?? oauth.error ?? copyError
const busy = oauth.isPending
const stepNumber = step === 'manifest' ? 1 : step === 'credentials' ? 2 : 3
const configuredAppId = appId ?? prepare.data?.existingApp?.appId

async function copyConfiguration() {
if (!prepare.data) throw new Error('Slack app configuration is not ready')
setCopyError(null)
try {
await writeTextToClipboard(prepare.data.manifest)
setConfigurationCopied(true)
} catch {
setCopyError(
new Error('Could not copy the app configuration. Allow clipboard access and try again.')
)
}
}

function advance() {
if (step === 'manifest') {
setStep('credentials')
Expand Down Expand Up @@ -101,11 +116,20 @@ export function SlackSearchSetupWizard({
</p>
)}
{step === 'manifest' && prepare.data && (
<>
<ChipModalField
type='custom'
title={installationId ? 'Update your Slack app' : 'Create your Slack app'}
>
<ChipModalField
type='custom'
title={configuredAppId ? 'Update your Slack app' : 'Create your Slack app'}
hint={
configuredAppId
? configurationCopied
? 'Configuration copied. In Slack, open App Manifest, select JSON, replace the configuration, and save your changes before continuing.'
: 'Copy the updated configuration, then open your app in Slack to apply it.'
: undefined
}
>
{configuredAppId && !configurationCopied ? (
<Chip onClick={() => void copyConfiguration()}>Copy app configuration</Chip>
) : (
<ChipLink
href={
configuredAppId
Expand All @@ -117,16 +141,8 @@ export function SlackSearchSetupWizard({
>
{configuredAppId ? 'Open Slack app settings' : 'Create app in Slack'}
</ChipLink>
<p className='text-[var(--text-secondary)] text-caption'>
{configuredAppId
? 'Open App Manifest in your existing app and apply the updated configuration.'
: 'Choose your Slack workspace, review the prepared manifest, then create the app.'}
</p>
</ChipModalField>
<ChipModalField type='custom' title='App manifest'>
<SlackAppManifest manifest={prepare.data.manifest} />
</ChipModalField>
</>
)}
</ChipModalField>
)}
{step === 'credentials' && (
<>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @vitest-environment jsdom */
import { act } from 'react'
import { toast } from '@sim/emcn'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
Comment thread
TheodoreSpeaks marked this conversation as resolved.
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
Expand Down Expand Up @@ -44,6 +45,8 @@ describe('Slack member access selection', () => {
let root: Root
let container: HTMLDivElement
let client: QueryClient
let channels: Array<{ onmessage: ((event: MessageEvent<unknown>) => void) | null }>
let popup: { location: { href: string }; closed: boolean; close: ReturnType<typeof vi.fn> }
const bot: WorkspaceCredential = {
id: '11111111-1111-4111-8111-111111111111',
workspaceId: 'workspace-1',
Expand All @@ -62,6 +65,8 @@ describe('Slack member access selection', () => {

beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(toast, 'error').mockReturnValue('toast')
vi.spyOn(toast, 'success').mockReturnValue('toast')
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
mocks.create.mockResolvedValue(undefined)
mocks.apps.mockReturnValue({
Expand All @@ -81,17 +86,23 @@ describe('Slack member access selection', () => {
state: 'state',
authorizationUrl: 'https://slack.com/oauth/v2/authorize',
})
channels = []
vi.stubGlobal(
'BroadcastChannel',
class {
onmessage: ((event: MessageEvent<unknown>) => void) | null = null
constructor() {
channels.push(this)
}
close() {}
}
)
vi.spyOn(window, 'open').mockReturnValue({
popup = {
location: { href: '' },
closed: false,
close: vi.fn(),
} as unknown as Window)
}
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
Expand All @@ -103,6 +114,7 @@ describe('Slack member access selection', () => {
client.clear()
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.useRealTimers()
})

async function render(
Expand Down Expand Up @@ -166,6 +178,145 @@ describe('Slack member access selection', () => {
)
}

async function completeAuthorization(state = 'state') {
await act(async () => {
for (const channel of channels) {
channel.onmessage?.(
new MessageEvent('message', {
data: {
type: 'slack-managed-users',
ok: true,
state,
credentialGroupId: 'group-1',
slackBotCredentialId: bot.id,
},
})
)
}
})
}

it('accepts authorization after browser isolation reports a live popup as closed', async () => {
vi.useFakeTimers()
await render()
await submit()
popup.closed = true
await act(async () => vi.advanceTimersByTimeAsync(1_000))

expect(toast.error).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('Waiting for Slack...')
await completeAuthorization('unrelated-state')
expect(toast.success).not.toHaveBeenCalled()
await completeAuthorization()
expect(toast.success).toHaveBeenCalledWith('Slack configured')
expect(mocks.onOpenChange).toHaveBeenCalledWith(false)
await act(async () => vi.advanceTimersByTimeAsync(10 * 60 * 1_000))
expect(toast.error).not.toHaveBeenCalled()
})

it('expires only after the authorization deadline and ignores a late callback', async () => {
vi.useFakeTimers()
await render()
await submit()
await act(async () => vi.advanceTimersByTimeAsync(10 * 60 * 1_000 - 1))
expect(toast.error).not.toHaveBeenCalled()
await act(async () => vi.advanceTimersByTimeAsync(1))
expect(toast.error).toHaveBeenCalledExactlyOnceWith(
'Slack authorization expired. Please try again.'
)
expect(popup.close).toHaveBeenCalledOnce()
await completeAuthorization()
expect(toast.success).not.toHaveBeenCalled()
})

it('lets the user cancel an abandoned popup without reporting expiry', async () => {
vi.useFakeTimers()
await render()
await submit()
await clickButton('Cancel')
expect(popup.close).toHaveBeenCalledOnce()
expect(mocks.onOpenChange).toHaveBeenCalledWith(false)
await completeAuthorization()
await act(async () => vi.advanceTimersByTimeAsync(10 * 60 * 1_000))
expect(toast.error).not.toHaveBeenCalled()
expect(toast.success).not.toHaveBeenCalled()
})

it('keeps a new authorization intact if an old deadline callback runs', async () => {
vi.useFakeTimers()
const timeouts = vi.spyOn(window, 'setTimeout')
await render()
await submit()
const oldDeadline = timeouts.mock.calls.find(([, delay]) => delay === 10 * 60 * 1_000)?.[0]
if (typeof oldDeadline !== 'function') throw new Error('Authorization deadline was not set')
await clickButton('Cancel')

const nextPopup = { location: { href: '' }, closed: false, close: vi.fn() }
vi.mocked(window.open).mockReturnValueOnce(nextPopup as unknown as Window)
mocks.start.mockResolvedValueOnce({
state: 'new-state',
authorizationUrl: 'https://slack.com/oauth/v2/authorize',
})
await submit()
await act(async () => oldDeadline())

expect(toast.error).not.toHaveBeenCalled()
expect(nextPopup.close).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('Waiting for Slack...')
await completeAuthorization('new-state')
expect(toast.success).toHaveBeenCalledExactlyOnceWith('Slack configured')
await act(async () => vi.advanceTimersByTimeAsync(10 * 60 * 1_000))
expect(toast.error).not.toHaveBeenCalled()
})

it('does not navigate or start a timeout when authorization startup finishes after cancel', async () => {
vi.useFakeTimers()
let finishStartup!: (value: { state: string; authorizationUrl: string }) => void
mocks.start.mockReturnValueOnce(
new Promise((resolve) => {
finishStartup = resolve
})
)
await render()
await submit()
await clickButton('Cancel')
await act(async () => {
finishStartup({ state: 'state', authorizationUrl: 'https://slack.com/oauth/v2/authorize' })
})
expect(popup.location.href).toBe('')
await act(async () => vi.advanceTimersByTimeAsync(10 * 60 * 1_000))
expect(toast.error).not.toHaveBeenCalled()
})

it.each(['resolve', 'reject'] as const)(
'ignores authorization startup that completes with %s after unmount',
async (outcome) => {
vi.useFakeTimers()
let finishStartup!: () => void
mocks.start.mockReturnValueOnce(
new Promise((resolve, reject) => {
finishStartup = () =>
outcome === 'resolve'
? resolve({
state: 'state',
authorizationUrl: 'https://slack.com/oauth/v2/authorize',
})
: reject(new Error('Authorization startup failed'))
})
)
await render()
await submit()
await act(async () => root.render(null))
await act(async () => finishStartup())

expect(popup.close).toHaveBeenCalledOnce()
expect(popup.location.href).toBe('')
await act(async () => vi.advanceTimersByTimeAsync(10 * 60 * 1_000))
expect(toast.error).not.toHaveBeenCalled()
expect(toast.success).not.toHaveBeenCalled()
}
)

it('opens Slack app setup inline and returns to member setup when canceled', async () => {
await render(undefined, [])
expect(document.querySelector('a')).toBeNull()
Expand Down Expand Up @@ -199,7 +350,8 @@ describe('Slack member access selection', () => {
const dialog = appSetupDialog(true)
expect(dialog).toBeDefined()
expect(dialog?.textContent).toContain('Step 1 of 3')
expect(dialog?.textContent).toContain('App manifest')
expect(dialog?.textContent).not.toContain('App manifest')
expect(dialog?.textContent).toContain('Create app in Slack')
expect(mocks.manifest).toHaveBeenCalledWith('org-1', 'Sim Search')
expect(mocks.start).not.toHaveBeenCalled()
expect(mocks.create).not.toHaveBeenCalled()
Expand Down
Loading
Loading