Skip to content

Commit eda8572

Browse files
dlavrenuekTuukkaa
authored andcommitted
fix: Improve chat message button handling (#26249)
1 parent 593cecc commit eda8572

2 files changed

Lines changed: 137 additions & 1 deletion

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { waitFor } from '@testing-library/vue';
2+
import { mount } from '@vue/test-utils';
3+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4+
5+
import MessageWithButtons from '../components/MessageWithButtons.vue';
6+
7+
vi.mock('../components/MarkdownRenderer.vue', () => ({
8+
default: {
9+
name: 'MarkdownRenderer',
10+
template: '<div>{{ text }}</div>',
11+
props: ['text'],
12+
},
13+
}));
14+
15+
vi.mock('@n8n/chat/composables', () => ({
16+
useOptions: () => ({
17+
options: {
18+
webhookUrl: 'https://webhook.example.com/webhook/123/chat',
19+
},
20+
}),
21+
}));
22+
23+
const editorOrigin = 'http://localhost:5678';
24+
const webhookOrigin = 'https://webhook.example.com';
25+
26+
const relativeUrlButtons = [
27+
{ text: 'Confirm', link: '/api/confirm', type: 'primary' as const },
28+
{ text: 'Cancel', link: '/api/cancel', type: 'secondary' as const },
29+
];
30+
31+
describe('MessageWithButtons', () => {
32+
beforeEach(() => {
33+
vi.stubGlobal('fetch', vi.fn());
34+
Object.defineProperty(window, 'location', {
35+
value: new URL(`${editorOrigin}/chat`),
36+
writable: true,
37+
});
38+
});
39+
40+
afterEach(() => {
41+
vi.restoreAllMocks();
42+
});
43+
44+
it('does not render buttons whose links do not match the configured webhook origin', () => {
45+
const wrapper = mount(MessageWithButtons, {
46+
props: { text: 'Please confirm', buttons: relativeUrlButtons },
47+
});
48+
49+
expect(wrapper.findAll('button')).toHaveLength(0);
50+
expect(fetch).not.toHaveBeenCalled();
51+
});
52+
53+
it('renders and fetches when the link is on the configured webhook origin', async () => {
54+
vi.mocked(fetch).mockResolvedValue({ ok: true } as Response);
55+
56+
const webhookButtons = [
57+
{
58+
text: 'Approve',
59+
link: `${webhookOrigin}/webhook/123/approve`,
60+
type: 'primary' as const,
61+
},
62+
];
63+
64+
const wrapper = mount(MessageWithButtons, {
65+
props: { text: 'Please approve', buttons: webhookButtons },
66+
});
67+
68+
expect(wrapper.find('button').exists()).toBe(true);
69+
70+
await wrapper.find('button').trigger('click');
71+
72+
await waitFor(() => expect(fetch).toHaveBeenCalledWith(`${webhookOrigin}/webhook/123/approve`));
73+
});
74+
75+
it('does not render a button when the link points to an unrecognized origin', () => {
76+
const externalButtons = [
77+
{ text: 'Go', link: 'https://broken-link.com/approve', type: 'primary' as const },
78+
];
79+
80+
const wrapper = mount(MessageWithButtons, {
81+
props: { text: 'Click me', buttons: externalButtons },
82+
});
83+
84+
expect(wrapper.find('button').exists()).toBe(false);
85+
expect(fetch).not.toHaveBeenCalled();
86+
});
87+
88+
it('does not render a button for an absolute URL on a different host with the same port', () => {
89+
const externalButtons = [
90+
{ text: 'Go', link: 'http://other-host:5678/api/confirm', type: 'primary' as const },
91+
];
92+
93+
const wrapper = mount(MessageWithButtons, {
94+
props: { text: 'Click me', buttons: externalButtons },
95+
});
96+
97+
expect(wrapper.find('button').exists()).toBe(false);
98+
expect(fetch).not.toHaveBeenCalled();
99+
});
100+
101+
it('renders only valid-origin buttons when the list contains mixed URLs', () => {
102+
const mixedButtons = [
103+
{ text: 'Editor', link: '/api/confirm', type: 'primary' as const },
104+
{
105+
text: 'Webhook',
106+
link: `${webhookOrigin}/webhook/123/approve`,
107+
type: 'secondary' as const,
108+
},
109+
{ text: 'Other', link: 'http://broken-url/approve', type: 'secondary' as const },
110+
];
111+
112+
const wrapper = mount(MessageWithButtons, {
113+
props: { text: 'Choose', buttons: mixedButtons },
114+
});
115+
116+
const rendered = wrapper.findAll('button');
117+
expect(rendered).toHaveLength(1);
118+
expect(rendered[0].text()).toBe('Webhook');
119+
});
120+
});

packages/frontend/@n8n/chat/src/components/MessageWithButtons.vue

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<script setup lang="ts">
22
import { ref } from 'vue';
33
4+
import { useOptions } from '@n8n/chat/composables';
5+
46
import Button from './Button.vue';
57
import MarkdownRenderer from './MarkdownRenderer.vue';
68
@@ -13,8 +15,22 @@ defineProps<{
1315
}>;
1416
}>();
1517
18+
const chatOptions = useOptions();
1619
const clickedButtonIndex = ref<number | null>(null);
1720
21+
const isButtonVisible = (link: string, index: number): boolean => {
22+
try {
23+
const validOrigin = new URL(chatOptions.options.webhookUrl).origin;
24+
const url = new URL(link, window.location.href);
25+
if (url.origin !== validOrigin) {
26+
return false;
27+
}
28+
return clickedButtonIndex.value === null || index === clickedButtonIndex.value;
29+
} catch {
30+
return false;
31+
}
32+
};
33+
1834
const onClick = async (link: string, index: number) => {
1935
if (clickedButtonIndex.value !== null) {
2036
return;
@@ -33,7 +49,7 @@ const onClick = async (link: string, index: number) => {
3349
<div :class="$style.buttons">
3450
<template v-for="(button, index) in buttons" :key="button.text">
3551
<Button
36-
v-if="clickedButtonIndex === null || index === clickedButtonIndex"
52+
v-if="isButtonVisible(button.link, index)"
3753
element="button"
3854
:type="button.type"
3955
:disabled="index === clickedButtonIndex"

0 commit comments

Comments
 (0)