@open-mercato/ai-assistant 0.6.3-develop.3901.1.ddad60693a → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-06-deep-link.spec.js +87 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-06-deep-link.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-07-owner-label.spec.js +119 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-sharing-07-owner-label.spec.js.map +7 -0
- package/dist/modules/ai_assistant/acl.js +1 -0
- package/dist/modules/ai_assistant/acl.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/chat/route.js +3 -0
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/[userId]/route.js +128 -0
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/[userId]/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/route.js +271 -0
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js +9 -1
- package/dist/modules/ai_assistant/api/ai/conversations/[conversationId]/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/conversations/route.js +4 -1
- package/dist/modules/ai_assistant/api/ai/conversations/route.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +5 -1
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
- package/dist/modules/ai_assistant/components/ConversationShareButton.js +5 -0
- package/dist/modules/ai_assistant/components/ConversationShareButton.js.map +7 -0
- package/dist/modules/ai_assistant/components/ConversationShareDialog.js +5 -0
- package/dist/modules/ai_assistant/components/ConversationShareDialog.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities.js +3 -0
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js +235 -5
- package/dist/modules/ai_assistant/data/repositories/AiChatConversationRepository.js.map +2 -2
- package/dist/modules/ai_assistant/events.js +14 -0
- package/dist/modules/ai_assistant/events.js.map +2 -2
- package/dist/modules/ai_assistant/i18n/de.json +17 -0
- package/dist/modules/ai_assistant/i18n/en.json +17 -0
- package/dist/modules/ai_assistant/i18n/es.json +17 -0
- package/dist/modules/ai_assistant/i18n/pl.json +17 -0
- package/dist/modules/ai_assistant/lib/conversation-storage.js +12 -3
- package/dist/modules/ai_assistant/lib/conversation-storage.js.map +2 -2
- package/dist/modules/ai_assistant/migrations/Migration20260522120000_ai_assistant.js +15 -0
- package/dist/modules/ai_assistant/migrations/Migration20260522120000_ai_assistant.js.map +7 -0
- package/dist/modules/ai_assistant/notifications.client.js +30 -0
- package/dist/modules/ai_assistant/notifications.client.js.map +7 -0
- package/dist/modules/ai_assistant/notifications.js +27 -0
- package/dist/modules/ai_assistant/notifications.js.map +7 -0
- package/dist/modules/ai_assistant/setup.js +2 -1
- package/dist/modules/ai_assistant/setup.js.map +2 -2
- package/dist/modules/ai_assistant/subscribers/conversation-shared-notify.js +59 -0
- package/dist/modules/ai_assistant/subscribers/conversation-shared-notify.js.map +7 -0
- package/dist/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.js +123 -0
- package/dist/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.js.map +7 -0
- package/generated/entities/ai_chat_conversation_participant/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -8
- package/src/modules/ai_assistant/__integration__/TC-AI-sharing-06-deep-link.spec.ts +117 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-sharing-07-owner-label.spec.ts +159 -0
- package/src/modules/ai_assistant/__tests__/integration/ai-chat-sharing.test.ts +406 -0
- package/src/modules/ai_assistant/acl.ts +1 -0
- package/src/modules/ai_assistant/api/ai/chat/route.ts +3 -0
- package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/[userId]/route.ts +149 -0
- package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/participants/route.ts +314 -0
- package/src/modules/ai_assistant/api/ai/conversations/[conversationId]/route.ts +9 -1
- package/src/modules/ai_assistant/api/ai/conversations/route.ts +4 -1
- package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +4 -0
- package/src/modules/ai_assistant/components/ConversationShareButton.tsx +1 -0
- package/src/modules/ai_assistant/components/ConversationShareDialog.tsx +1 -0
- package/src/modules/ai_assistant/data/entities.ts +4 -0
- package/src/modules/ai_assistant/data/repositories/AiChatConversationRepository.ts +270 -7
- package/src/modules/ai_assistant/data/repositories/__tests__/AiChatConversationRepository.test.ts +297 -3
- package/src/modules/ai_assistant/events.ts +31 -0
- package/src/modules/ai_assistant/i18n/__tests__/conversation-share-translations.test.ts +59 -0
- package/src/modules/ai_assistant/i18n/de.json +17 -0
- package/src/modules/ai_assistant/i18n/en.json +17 -0
- package/src/modules/ai_assistant/i18n/es.json +17 -0
- package/src/modules/ai_assistant/i18n/pl.json +17 -0
- package/src/modules/ai_assistant/lib/conversation-storage.ts +22 -1
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +25 -0
- package/src/modules/ai_assistant/migrations/Migration20260522120000_ai_assistant.ts +15 -0
- package/src/modules/ai_assistant/notifications.client.ts +29 -0
- package/src/modules/ai_assistant/notifications.ts +25 -0
- package/src/modules/ai_assistant/setup.ts +2 -1
- package/src/modules/ai_assistant/subscribers/__tests__/conversation-shared-notify.test.ts +116 -0
- package/src/modules/ai_assistant/subscribers/conversation-shared-notify.ts +78 -0
- package/src/modules/ai_assistant/widgets/notifications/ConversationSharedRenderer.tsx +121 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TC-AI-sharing-06: Deep-link auto-open via ?openAiConversation=<id>
|
|
6
|
+
*
|
|
7
|
+
* Regression test for the bug where navigating to
|
|
8
|
+
* `/backend?openAiConversation=<id>` failed to open the AiAssistantLauncher
|
|
9
|
+
* chat sheet — because the launcher read window.location.search only on mount
|
|
10
|
+
* and AppShell never remounts during client-side navigation.
|
|
11
|
+
*
|
|
12
|
+
* Fix: replaced mount-only useEffect with useSearchParams() from next/navigation,
|
|
13
|
+
* which is reactive to URL changes in both hard and client-side navigations.
|
|
14
|
+
*
|
|
15
|
+
* Also verifies the URL-cleanup behaviour: after the deep-link is handled the
|
|
16
|
+
* launcher strips ?openAiConversation from the URL via router.replace() so
|
|
17
|
+
* subsequent normal chat opens don't inherit the shared conversation.
|
|
18
|
+
*
|
|
19
|
+
* API routes are fully stubbed — no live LLM is called.
|
|
20
|
+
*/
|
|
21
|
+
test.describe('TC-AI-sharing-06: deep-link auto-open from ?openAiConversation', () => {
|
|
22
|
+
const STUB_CONV_ID = 'test-shared-conv-01';
|
|
23
|
+
const STUB_AGENT_ID = 'customers.account_assistant';
|
|
24
|
+
|
|
25
|
+
const agentsStub = {
|
|
26
|
+
agents: [
|
|
27
|
+
{
|
|
28
|
+
id: STUB_AGENT_ID,
|
|
29
|
+
moduleId: 'customers',
|
|
30
|
+
label: 'Account assistant',
|
|
31
|
+
description: 'Helps with customer accounts.',
|
|
32
|
+
executionMode: 'chat',
|
|
33
|
+
mutationPolicy: 'read-only',
|
|
34
|
+
allowedTools: [],
|
|
35
|
+
requiredFeatures: [],
|
|
36
|
+
acceptedMediaTypes: [],
|
|
37
|
+
hasOutputSchema: false,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
total: 1,
|
|
41
|
+
aiConfigured: true,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const conversationStub = {
|
|
45
|
+
conversation: {
|
|
46
|
+
conversationId: STUB_CONV_ID,
|
|
47
|
+
agentId: STUB_AGENT_ID,
|
|
48
|
+
title: 'Shared conversation',
|
|
49
|
+
status: 'open',
|
|
50
|
+
visibility: 'shared',
|
|
51
|
+
pageContext: null,
|
|
52
|
+
createdAt: new Date().toISOString(),
|
|
53
|
+
updatedAt: new Date().toISOString(),
|
|
54
|
+
lastMessageAt: null,
|
|
55
|
+
importedFromLocalAt: null,
|
|
56
|
+
isOwner: false,
|
|
57
|
+
},
|
|
58
|
+
messages: [],
|
|
59
|
+
nextCursor: null,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
async function setupStubs(page: import('@playwright/test').Page) {
|
|
63
|
+
await page.route('**/api/ai_assistant/health', (route) =>
|
|
64
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ healthy: true }) }),
|
|
65
|
+
);
|
|
66
|
+
await page.route('**/api/ai_assistant/ai/agents', (route) =>
|
|
67
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(agentsStub) }),
|
|
68
|
+
);
|
|
69
|
+
await page.route(`**/api/ai_assistant/ai/conversations/${STUB_CONV_ID}**`, (route) =>
|
|
70
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(conversationStub) }),
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test('chat sheet opens when page loads with ?openAiConversation param', async ({ page }) => {
|
|
75
|
+
await login(page, 'superadmin');
|
|
76
|
+
await setupStubs(page);
|
|
77
|
+
|
|
78
|
+
await page.goto(`/backend?openAiConversation=${STUB_CONV_ID}`, { waitUntil: 'domcontentloaded' });
|
|
79
|
+
|
|
80
|
+
await expect(page.locator('[data-ai-launcher-sheet]')).toBeVisible({ timeout: 10_000 });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('URL param is stripped after deep-link is handled', async ({ page }) => {
|
|
84
|
+
await login(page, 'superadmin');
|
|
85
|
+
await setupStubs(page);
|
|
86
|
+
|
|
87
|
+
await page.goto(`/backend?openAiConversation=${STUB_CONV_ID}`, { waitUntil: 'domcontentloaded' });
|
|
88
|
+
|
|
89
|
+
// Wait for the chat to open (deep-link was handled).
|
|
90
|
+
await expect(page.locator('[data-ai-launcher-sheet]')).toBeVisible({ timeout: 10_000 });
|
|
91
|
+
|
|
92
|
+
// The launcher calls router.replace(pathname) after opening, stripping the
|
|
93
|
+
// param. The URL should no longer contain openAiConversation.
|
|
94
|
+
await expect(page).not.toHaveURL(/openAiConversation/, { timeout: 5_000 });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('deep-link is ignored when conversation fetch returns 403', async ({ page }) => {
|
|
98
|
+
await login(page, 'superadmin');
|
|
99
|
+
|
|
100
|
+
await page.route('**/api/ai_assistant/health', (route) =>
|
|
101
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ healthy: true }) }),
|
|
102
|
+
);
|
|
103
|
+
await page.route('**/api/ai_assistant/ai/agents', (route) =>
|
|
104
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(agentsStub) }),
|
|
105
|
+
);
|
|
106
|
+
// Simulate conversation that the viewer has no access to.
|
|
107
|
+
await page.route(`**/api/ai_assistant/ai/conversations/${STUB_CONV_ID}**`, (route) =>
|
|
108
|
+
route.fulfill({ status: 403, contentType: 'application/json', body: JSON.stringify({ error: 'forbidden' }) }),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
await page.goto(`/backend?openAiConversation=${STUB_CONV_ID}`, { waitUntil: 'domcontentloaded' });
|
|
112
|
+
|
|
113
|
+
// Launcher should be visible but chat sheet must NOT auto-open.
|
|
114
|
+
await expect(page.locator('[data-ai-launcher-trigger]')).toBeVisible({ timeout: 10_000 });
|
|
115
|
+
await expect(page.locator('[data-ai-launcher-sheet]')).not.toBeVisible();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TC-AI-sharing-07: Owner message label in shared-conversation viewer mode
|
|
6
|
+
*
|
|
7
|
+
* Regression test for the bug where user-role messages in a shared
|
|
8
|
+
* conversation always rendered as "You" for the viewer, even when they were
|
|
9
|
+
* written by the conversation owner and had no senderUserId (common for
|
|
10
|
+
* conversations imported from local storage).
|
|
11
|
+
*
|
|
12
|
+
* Root cause: `isOtherUsersMessage` included `&& message.senderUserId != null`,
|
|
13
|
+
* but imported messages don't carry senderUserId. Since viewers can never send
|
|
14
|
+
* messages (the composer is hidden), ALL user-role messages when isOwner===false
|
|
15
|
+
* belong to the owner.
|
|
16
|
+
*
|
|
17
|
+
* Fix: removed the senderUserId guard from MessageRow in AiChat.tsx.
|
|
18
|
+
*
|
|
19
|
+
* Assertions:
|
|
20
|
+
* - [data-ai-chat-read-only-notice] is visible (confirms isOwner===false path)
|
|
21
|
+
* - user-role messages show "Owner" label, not "You" / "Ty"
|
|
22
|
+
* - assistant messages show "Assistant"
|
|
23
|
+
* - composer is hidden (read-only viewer mode)
|
|
24
|
+
*/
|
|
25
|
+
test.describe('TC-AI-sharing-07: viewer sees Owner label on owner messages', () => {
|
|
26
|
+
const STUB_CONV_ID = 'test-shared-conv-02';
|
|
27
|
+
const STUB_AGENT_ID = 'customers.account_assistant';
|
|
28
|
+
|
|
29
|
+
const agentsStub = {
|
|
30
|
+
agents: [
|
|
31
|
+
{
|
|
32
|
+
id: STUB_AGENT_ID,
|
|
33
|
+
moduleId: 'customers',
|
|
34
|
+
label: 'Account assistant',
|
|
35
|
+
description: 'Helps with customer accounts.',
|
|
36
|
+
executionMode: 'chat',
|
|
37
|
+
mutationPolicy: 'read-only',
|
|
38
|
+
allowedTools: [],
|
|
39
|
+
requiredFeatures: [],
|
|
40
|
+
acceptedMediaTypes: [],
|
|
41
|
+
hasOutputSchema: false,
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
total: 1,
|
|
45
|
+
aiConfigured: true,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Conversation where isOwner===false (viewer). Messages have senderUserId:null
|
|
49
|
+
// because they were imported from local storage — this was the triggering
|
|
50
|
+
// condition of the bug.
|
|
51
|
+
const conversationStub = {
|
|
52
|
+
conversation: {
|
|
53
|
+
conversationId: STUB_CONV_ID,
|
|
54
|
+
agentId: STUB_AGENT_ID,
|
|
55
|
+
title: 'Shared conversation',
|
|
56
|
+
status: 'open',
|
|
57
|
+
visibility: 'shared',
|
|
58
|
+
pageContext: null,
|
|
59
|
+
createdAt: new Date().toISOString(),
|
|
60
|
+
updatedAt: new Date().toISOString(),
|
|
61
|
+
lastMessageAt: null,
|
|
62
|
+
importedFromLocalAt: new Date().toISOString(),
|
|
63
|
+
isOwner: false,
|
|
64
|
+
},
|
|
65
|
+
messages: [
|
|
66
|
+
{
|
|
67
|
+
id: 'msg-u1',
|
|
68
|
+
clientMessageId: 'cmsg-u1',
|
|
69
|
+
role: 'user',
|
|
70
|
+
content: 'What are the open deals for Acme?',
|
|
71
|
+
uiParts: [],
|
|
72
|
+
attachmentIds: [],
|
|
73
|
+
files: [],
|
|
74
|
+
model: null,
|
|
75
|
+
metadata: null,
|
|
76
|
+
createdAt: new Date().toISOString(),
|
|
77
|
+
// Deliberately null — imported messages never have senderUserId.
|
|
78
|
+
senderUserId: null,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'msg-a1',
|
|
82
|
+
clientMessageId: 'cmsg-a1',
|
|
83
|
+
role: 'assistant',
|
|
84
|
+
content: 'I found 3 open deals for Acme Corp.',
|
|
85
|
+
uiParts: [],
|
|
86
|
+
attachmentIds: [],
|
|
87
|
+
files: [],
|
|
88
|
+
model: 'claude-haiku-4-5',
|
|
89
|
+
metadata: null,
|
|
90
|
+
createdAt: new Date().toISOString(),
|
|
91
|
+
senderUserId: null,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
nextCursor: null,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
test('viewer sees Owner/Assistant labels and read-only notice', async ({ page }) => {
|
|
98
|
+
await login(page, 'superadmin');
|
|
99
|
+
|
|
100
|
+
await page.route('**/api/ai_assistant/health', (route) =>
|
|
101
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ healthy: true }) }),
|
|
102
|
+
);
|
|
103
|
+
await page.route('**/api/ai_assistant/ai/agents', (route) =>
|
|
104
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(agentsStub) }),
|
|
105
|
+
);
|
|
106
|
+
await page.route(`**/api/ai_assistant/ai/conversations/${STUB_CONV_ID}**`, (route) =>
|
|
107
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(conversationStub) }),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await page.goto(`/backend?openAiConversation=${STUB_CONV_ID}`, { waitUntil: 'domcontentloaded' });
|
|
111
|
+
|
|
112
|
+
const sheet = page.locator('[data-ai-launcher-sheet]');
|
|
113
|
+
await expect(sheet).toBeVisible({ timeout: 10_000 });
|
|
114
|
+
|
|
115
|
+
// Read-only notice must be present for a viewer.
|
|
116
|
+
await expect(sheet.locator('[data-ai-chat-read-only-notice]')).toBeVisible();
|
|
117
|
+
|
|
118
|
+
// User-role messages (from owner, senderUserId=null) must show "Owner", not "You".
|
|
119
|
+
const userMessages = sheet.locator('[data-role="user"]');
|
|
120
|
+
await expect(userMessages.first()).toBeVisible();
|
|
121
|
+
// The label is the first text-xs div inside the message header.
|
|
122
|
+
const userLabel = userMessages.first().locator('div.text-xs').first();
|
|
123
|
+
await expect(userLabel).toHaveText(/owner/i);
|
|
124
|
+
// Regression guard: must NOT show "You" or "Ty".
|
|
125
|
+
await expect(userLabel).not.toHaveText(/^you$/i);
|
|
126
|
+
|
|
127
|
+
// Assistant messages must still show "Assistant".
|
|
128
|
+
const assistantMessages = sheet.locator('[data-role="assistant"]');
|
|
129
|
+
await expect(assistantMessages.first()).toBeVisible();
|
|
130
|
+
const assistantLabel = assistantMessages.first().locator('div.text-xs').first();
|
|
131
|
+
await expect(assistantLabel).toHaveText(/assistant/i);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('viewer composer is hidden (cannot send messages)', async ({ page }) => {
|
|
135
|
+
await login(page, 'superadmin');
|
|
136
|
+
|
|
137
|
+
await page.route('**/api/ai_assistant/health', (route) =>
|
|
138
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ healthy: true }) }),
|
|
139
|
+
);
|
|
140
|
+
await page.route('**/api/ai_assistant/ai/agents', (route) =>
|
|
141
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(agentsStub) }),
|
|
142
|
+
);
|
|
143
|
+
await page.route(`**/api/ai_assistant/ai/conversations/${STUB_CONV_ID}**`, (route) =>
|
|
144
|
+
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(conversationStub) }),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
await page.goto(`/backend?openAiConversation=${STUB_CONV_ID}`, { waitUntil: 'domcontentloaded' });
|
|
148
|
+
|
|
149
|
+
const sheet = page.locator('[data-ai-launcher-sheet]');
|
|
150
|
+
await expect(sheet).toBeVisible({ timeout: 10_000 });
|
|
151
|
+
|
|
152
|
+
// The composer form is hidden via className when isOwner===false.
|
|
153
|
+
// It should not be visible to the user.
|
|
154
|
+
const composer = sheet.locator('form[data-ai-chat-composer], [data-ai-chat-composer]');
|
|
155
|
+
// Either not present or hidden.
|
|
156
|
+
const isComposerVisible = await composer.isVisible().catch(() => false);
|
|
157
|
+
expect(isComposerVisible).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AiChatConversation,
|
|
3
|
+
AiChatConversationParticipant,
|
|
4
|
+
AiChatMessage,
|
|
5
|
+
} from '../../data/entities'
|
|
6
|
+
import {
|
|
7
|
+
AiChatConversationRepository,
|
|
8
|
+
} from '../../data/repositories/AiChatConversationRepository'
|
|
9
|
+
|
|
10
|
+
// ─── In-memory ORM mock ────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
type ConvRow = {
|
|
13
|
+
id: string
|
|
14
|
+
tenantId: string
|
|
15
|
+
organizationId: string | null
|
|
16
|
+
conversationId: string
|
|
17
|
+
agentId: string
|
|
18
|
+
ownerUserId: string
|
|
19
|
+
title: string | null
|
|
20
|
+
status: 'open' | 'closed'
|
|
21
|
+
visibility: 'private' | 'shared' | 'organization'
|
|
22
|
+
pageContext: Record<string, unknown> | null
|
|
23
|
+
lastMessageAt: Date | null
|
|
24
|
+
importedFromLocalAt: Date | null
|
|
25
|
+
createdAt: Date
|
|
26
|
+
updatedAt: Date
|
|
27
|
+
deletedAt: Date | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type ParticipantRow = {
|
|
31
|
+
id: string
|
|
32
|
+
tenantId: string
|
|
33
|
+
organizationId: string | null
|
|
34
|
+
conversationId: string
|
|
35
|
+
userId: string
|
|
36
|
+
role: 'owner' | 'viewer' | 'commenter'
|
|
37
|
+
lastReadAt: Date | null
|
|
38
|
+
deletedAt: Date | null
|
|
39
|
+
createdAt: Date
|
|
40
|
+
updatedAt: Date
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let idCounter = 0
|
|
44
|
+
|
|
45
|
+
function matchesWhere(row: Record<string, any>, where: any): boolean {
|
|
46
|
+
if (!where) return true
|
|
47
|
+
for (const key of Object.keys(where)) {
|
|
48
|
+
if (key === '$or') {
|
|
49
|
+
const branches: any[] = where.$or
|
|
50
|
+
if (!branches.some((branch) => matchesWhere(row, branch))) return false
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
const expected = where[key]
|
|
54
|
+
const actual = row[key] ?? null
|
|
55
|
+
if (expected && typeof expected === 'object') {
|
|
56
|
+
if ('$lt' in expected) {
|
|
57
|
+
const lt = expected.$lt as Date
|
|
58
|
+
if (!(actual instanceof Date) || !(actual.getTime() < lt.getTime())) return false
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if ('$in' in expected) {
|
|
62
|
+
const list = expected.$in as unknown[]
|
|
63
|
+
if (!list.includes(actual)) return false
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
if ('$ne' in expected) {
|
|
67
|
+
if (actual === expected.$ne) return false
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (expected === null) {
|
|
72
|
+
if (actual !== null && actual !== undefined) return false
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
if (actual !== expected) return false
|
|
76
|
+
}
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function entityKey(entity: unknown): 'conv' | 'participant' | 'message' | null {
|
|
81
|
+
if (entity === AiChatConversation) return 'conv'
|
|
82
|
+
if (entity === AiChatConversationParticipant) return 'participant'
|
|
83
|
+
if (entity === AiChatMessage) return 'message'
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function mockEm() {
|
|
88
|
+
const stores: Record<'conv' | 'participant' | 'message', any[]> = {
|
|
89
|
+
conv: [],
|
|
90
|
+
participant: [],
|
|
91
|
+
message: [],
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const find = async (entity: unknown, where: any, options?: any): Promise<any[]> => {
|
|
95
|
+
const key = entityKey(entity)
|
|
96
|
+
if (!key) return []
|
|
97
|
+
let rows = stores[key].filter((row) => matchesWhere(row, where))
|
|
98
|
+
if (typeof options?.limit === 'number') rows = rows.slice(0, options.limit)
|
|
99
|
+
return rows
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const pendingPersist: any[] = []
|
|
103
|
+
|
|
104
|
+
const em: any = {
|
|
105
|
+
find,
|
|
106
|
+
findOne: async (entity: unknown, where: any) => {
|
|
107
|
+
const rows = await find(entity, where)
|
|
108
|
+
return rows[0] ?? null
|
|
109
|
+
},
|
|
110
|
+
count: async (entity: unknown, where: any): Promise<number> => {
|
|
111
|
+
const rows = await find(entity, where)
|
|
112
|
+
return rows.length
|
|
113
|
+
},
|
|
114
|
+
create: (entity: unknown, data: any) => {
|
|
115
|
+
idCounter += 1
|
|
116
|
+
const key = entityKey(entity)
|
|
117
|
+
if (key === 'conv') {
|
|
118
|
+
const row: ConvRow = {
|
|
119
|
+
id: `conv-${idCounter}`,
|
|
120
|
+
tenantId: data.tenantId,
|
|
121
|
+
organizationId: data.organizationId ?? null,
|
|
122
|
+
conversationId: data.conversationId,
|
|
123
|
+
agentId: data.agentId ?? 'test-agent',
|
|
124
|
+
ownerUserId: data.ownerUserId,
|
|
125
|
+
title: data.title ?? null,
|
|
126
|
+
status: data.status ?? 'open',
|
|
127
|
+
visibility: data.visibility ?? 'private',
|
|
128
|
+
pageContext: data.pageContext ?? null,
|
|
129
|
+
lastMessageAt: data.lastMessageAt ?? null,
|
|
130
|
+
importedFromLocalAt: data.importedFromLocalAt ?? null,
|
|
131
|
+
createdAt: new Date(),
|
|
132
|
+
updatedAt: new Date(),
|
|
133
|
+
deletedAt: data.deletedAt ?? null,
|
|
134
|
+
}
|
|
135
|
+
return row
|
|
136
|
+
}
|
|
137
|
+
if (key === 'participant') {
|
|
138
|
+
const row: ParticipantRow = {
|
|
139
|
+
id: `part-${idCounter}`,
|
|
140
|
+
tenantId: data.tenantId,
|
|
141
|
+
organizationId: data.organizationId ?? null,
|
|
142
|
+
conversationId: data.conversationId,
|
|
143
|
+
userId: data.userId,
|
|
144
|
+
role: data.role ?? 'owner',
|
|
145
|
+
lastReadAt: data.lastReadAt ?? null,
|
|
146
|
+
deletedAt: data.deletedAt ?? null,
|
|
147
|
+
createdAt: new Date(),
|
|
148
|
+
updatedAt: new Date(),
|
|
149
|
+
}
|
|
150
|
+
return row
|
|
151
|
+
}
|
|
152
|
+
throw new Error(`Unknown entity in mock EM`)
|
|
153
|
+
},
|
|
154
|
+
persist: (row: any) => {
|
|
155
|
+
pendingPersist.push(row)
|
|
156
|
+
return em
|
|
157
|
+
},
|
|
158
|
+
flush: async () => {
|
|
159
|
+
while (pendingPersist.length > 0) {
|
|
160
|
+
const row = pendingPersist.shift()
|
|
161
|
+
if (!row) continue
|
|
162
|
+
const key: 'conv' | 'participant' | 'message' = row.id.startsWith('conv-')
|
|
163
|
+
? 'conv'
|
|
164
|
+
: row.id.startsWith('part-')
|
|
165
|
+
? 'participant'
|
|
166
|
+
: 'message'
|
|
167
|
+
const store = stores[key]
|
|
168
|
+
const idx = store.findIndex((c) => c.id === row.id)
|
|
169
|
+
if (idx >= 0) store[idx] = row
|
|
170
|
+
else store.push(row)
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
transactional: async (fn: (tx: any) => Promise<unknown>) => fn(em),
|
|
174
|
+
__stores: stores,
|
|
175
|
+
}
|
|
176
|
+
return em
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
function seedConversation(
|
|
182
|
+
em: ReturnType<typeof mockEm>,
|
|
183
|
+
overrides: Partial<ConvRow> & { conversationId: string; tenantId: string; ownerUserId: string },
|
|
184
|
+
) {
|
|
185
|
+
idCounter += 1
|
|
186
|
+
const row: ConvRow = {
|
|
187
|
+
id: `conv-${idCounter}`,
|
|
188
|
+
organizationId: null,
|
|
189
|
+
agentId: 'test-agent',
|
|
190
|
+
title: null,
|
|
191
|
+
status: 'open',
|
|
192
|
+
visibility: 'private',
|
|
193
|
+
pageContext: null,
|
|
194
|
+
lastMessageAt: null,
|
|
195
|
+
importedFromLocalAt: null,
|
|
196
|
+
createdAt: new Date(),
|
|
197
|
+
updatedAt: new Date(),
|
|
198
|
+
deletedAt: null,
|
|
199
|
+
...overrides,
|
|
200
|
+
}
|
|
201
|
+
em.__stores.conv.push(row)
|
|
202
|
+
return row
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function seedParticipant(
|
|
206
|
+
em: ReturnType<typeof mockEm>,
|
|
207
|
+
overrides: Partial<ParticipantRow> & { conversationId: string; tenantId: string; userId: string },
|
|
208
|
+
) {
|
|
209
|
+
idCounter += 1
|
|
210
|
+
const row: ParticipantRow = {
|
|
211
|
+
id: `part-${idCounter}`,
|
|
212
|
+
organizationId: null,
|
|
213
|
+
role: 'viewer',
|
|
214
|
+
lastReadAt: null,
|
|
215
|
+
deletedAt: null,
|
|
216
|
+
createdAt: new Date(),
|
|
217
|
+
updatedAt: new Date(),
|
|
218
|
+
...overrides,
|
|
219
|
+
}
|
|
220
|
+
em.__stores.participant.push(row)
|
|
221
|
+
return row
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── Test suite ───────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
const TENANT_A = 'tenant-alpha'
|
|
227
|
+
const TENANT_B = 'tenant-beta'
|
|
228
|
+
const CONV_ID = 'conv-share-001'
|
|
229
|
+
const OWNER_ID = 'user-owner'
|
|
230
|
+
const VIEWER_ID = 'user-viewer'
|
|
231
|
+
const STRANGER_ID = 'user-stranger'
|
|
232
|
+
|
|
233
|
+
describe('TC-AI-sharing-01: owner access baseline', () => {
|
|
234
|
+
it('owner can retrieve their own conversation via getById', async () => {
|
|
235
|
+
const em = mockEm()
|
|
236
|
+
seedConversation(em, {
|
|
237
|
+
conversationId: CONV_ID,
|
|
238
|
+
tenantId: TENANT_A,
|
|
239
|
+
ownerUserId: OWNER_ID,
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const repo = new AiChatConversationRepository(em)
|
|
243
|
+
const result = await repo.getById(CONV_ID, {
|
|
244
|
+
tenantId: TENANT_A,
|
|
245
|
+
organizationId: null,
|
|
246
|
+
userId: OWNER_ID,
|
|
247
|
+
canManageConversations: false,
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
expect(result).not.toBeNull()
|
|
251
|
+
expect(result?.conversationId).toBe(CONV_ID)
|
|
252
|
+
expect(result?.ownerUserId).toBe(OWNER_ID)
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
describe('TC-AI-sharing-02: participant access', () => {
|
|
257
|
+
it('viewer participant can access a shared conversation via getById', async () => {
|
|
258
|
+
const em = mockEm()
|
|
259
|
+
seedConversation(em, {
|
|
260
|
+
conversationId: CONV_ID,
|
|
261
|
+
tenantId: TENANT_A,
|
|
262
|
+
ownerUserId: OWNER_ID,
|
|
263
|
+
visibility: 'shared',
|
|
264
|
+
})
|
|
265
|
+
seedParticipant(em, {
|
|
266
|
+
conversationId: CONV_ID,
|
|
267
|
+
tenantId: TENANT_A,
|
|
268
|
+
userId: VIEWER_ID,
|
|
269
|
+
role: 'viewer',
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
const repo = new AiChatConversationRepository(em)
|
|
273
|
+
const result = await repo.getById(CONV_ID, {
|
|
274
|
+
tenantId: TENANT_A,
|
|
275
|
+
organizationId: null,
|
|
276
|
+
userId: VIEWER_ID,
|
|
277
|
+
canManageConversations: false,
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
expect(result).not.toBeNull()
|
|
281
|
+
expect(result?.conversationId).toBe(CONV_ID)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('participant also sees shared conversation in list', async () => {
|
|
285
|
+
const em = mockEm()
|
|
286
|
+
seedConversation(em, {
|
|
287
|
+
conversationId: CONV_ID,
|
|
288
|
+
tenantId: TENANT_A,
|
|
289
|
+
ownerUserId: OWNER_ID,
|
|
290
|
+
visibility: 'shared',
|
|
291
|
+
})
|
|
292
|
+
seedParticipant(em, {
|
|
293
|
+
conversationId: CONV_ID,
|
|
294
|
+
tenantId: TENANT_A,
|
|
295
|
+
userId: VIEWER_ID,
|
|
296
|
+
role: 'viewer',
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
const repo = new AiChatConversationRepository(em)
|
|
300
|
+
const { items } = await repo.list(
|
|
301
|
+
{ tenantId: TENANT_A, organizationId: null, userId: VIEWER_ID, canManageConversations: false },
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
expect(items.some((c) => c.conversationId === CONV_ID)).toBe(true)
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
describe('TC-AI-sharing-03: non-participant denial', () => {
|
|
309
|
+
it('user without participant row cannot access another user\'s conversation', async () => {
|
|
310
|
+
const em = mockEm()
|
|
311
|
+
seedConversation(em, {
|
|
312
|
+
conversationId: CONV_ID,
|
|
313
|
+
tenantId: TENANT_A,
|
|
314
|
+
ownerUserId: OWNER_ID,
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
const repo = new AiChatConversationRepository(em)
|
|
318
|
+
const result = await repo.getById(CONV_ID, {
|
|
319
|
+
tenantId: TENANT_A,
|
|
320
|
+
organizationId: null,
|
|
321
|
+
userId: STRANGER_ID,
|
|
322
|
+
canManageConversations: false,
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
expect(result).toBeNull()
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('revoked participant (deletedAt set) cannot access the conversation', async () => {
|
|
329
|
+
const em = mockEm()
|
|
330
|
+
seedConversation(em, {
|
|
331
|
+
conversationId: CONV_ID,
|
|
332
|
+
tenantId: TENANT_A,
|
|
333
|
+
ownerUserId: OWNER_ID,
|
|
334
|
+
visibility: 'shared',
|
|
335
|
+
})
|
|
336
|
+
seedParticipant(em, {
|
|
337
|
+
conversationId: CONV_ID,
|
|
338
|
+
tenantId: TENANT_A,
|
|
339
|
+
userId: VIEWER_ID,
|
|
340
|
+
role: 'viewer',
|
|
341
|
+
deletedAt: new Date(),
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
const repo = new AiChatConversationRepository(em)
|
|
345
|
+
const result = await repo.getById(CONV_ID, {
|
|
346
|
+
tenantId: TENANT_A,
|
|
347
|
+
organizationId: null,
|
|
348
|
+
userId: VIEWER_ID,
|
|
349
|
+
canManageConversations: false,
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
expect(result).toBeNull()
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
describe('TC-AI-sharing-04: manager override', () => {
|
|
357
|
+
it('manager (canManageConversations=true) can access any conversation in their tenant', async () => {
|
|
358
|
+
const em = mockEm()
|
|
359
|
+
seedConversation(em, {
|
|
360
|
+
conversationId: CONV_ID,
|
|
361
|
+
tenantId: TENANT_A,
|
|
362
|
+
ownerUserId: OWNER_ID,
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
const repo = new AiChatConversationRepository(em)
|
|
366
|
+
const result = await repo.getById(CONV_ID, {
|
|
367
|
+
tenantId: TENANT_A,
|
|
368
|
+
organizationId: null,
|
|
369
|
+
userId: STRANGER_ID,
|
|
370
|
+
canManageConversations: true,
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
expect(result).not.toBeNull()
|
|
374
|
+
expect(result?.conversationId).toBe(CONV_ID)
|
|
375
|
+
})
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
describe('TC-AI-sharing-05: cross-tenant denial', () => {
|
|
379
|
+
it('participant row from a different tenant does not grant access', async () => {
|
|
380
|
+
const em = mockEm()
|
|
381
|
+
seedConversation(em, {
|
|
382
|
+
conversationId: CONV_ID,
|
|
383
|
+
tenantId: TENANT_A,
|
|
384
|
+
ownerUserId: OWNER_ID,
|
|
385
|
+
})
|
|
386
|
+
// Malicious scenario: participant row for VIEWER_ID but with TENANT_B tenant scope
|
|
387
|
+
seedParticipant(em, {
|
|
388
|
+
conversationId: CONV_ID,
|
|
389
|
+
tenantId: TENANT_B,
|
|
390
|
+
userId: VIEWER_ID,
|
|
391
|
+
role: 'viewer',
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
const repo = new AiChatConversationRepository(em)
|
|
395
|
+
// VIEWER_ID attempts access via TENANT_B context — should fail because the
|
|
396
|
+
// conversation belongs to TENANT_A, so findOneAccessibleConversation returns null.
|
|
397
|
+
const result = await repo.getById(CONV_ID, {
|
|
398
|
+
tenantId: TENANT_B,
|
|
399
|
+
organizationId: null,
|
|
400
|
+
userId: VIEWER_ID,
|
|
401
|
+
canManageConversations: false,
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
expect(result).toBeNull()
|
|
405
|
+
})
|
|
406
|
+
})
|
|
@@ -2,6 +2,7 @@ export const features = [
|
|
|
2
2
|
{ id: 'ai_assistant.view', title: 'View AI Assistant Settings', module: 'ai_assistant' },
|
|
3
3
|
{ id: 'ai_assistant.settings.manage', title: 'Manage AI Assistant Settings', module: 'ai_assistant' },
|
|
4
4
|
{ id: 'ai_assistant.conversations.manage', title: 'Manage AI Assistant Conversations', module: 'ai_assistant' },
|
|
5
|
+
{ id: 'ai_assistant.conversations.share', title: 'Share AI Assistant Conversations', module: 'ai_assistant' },
|
|
5
6
|
{ id: 'ai_assistant.mcp.serve', title: 'Start MCP Server', module: 'ai_assistant' },
|
|
6
7
|
{ id: 'ai_assistant.tools.list', title: 'List MCP Tools', module: 'ai_assistant' },
|
|
7
8
|
{ id: 'ai_assistant.mcp_servers.view', title: 'View MCP Server Configurations', module: 'ai_assistant' },
|
|
@@ -650,6 +650,9 @@ export async function POST(req: NextRequest): Promise<Response> {
|
|
|
650
650
|
attachmentIds: bodyResult.data.attachmentIds,
|
|
651
651
|
})
|
|
652
652
|
} catch (error) {
|
|
653
|
+
if (error instanceof Error && error.name === 'AiChatConversationOrgNotFoundError') {
|
|
654
|
+
return jsonError(400, error.message, 'organization_not_found')
|
|
655
|
+
}
|
|
653
656
|
console.error('[AI Chat Agent] Failed to persist user message:', error)
|
|
654
657
|
}
|
|
655
658
|
|