@open-mercato/ai-assistant 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4
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/AGENTS.md +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.js +146 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js +4 -8
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.js +119 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.js +174 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-PARTICIPANTS-002-add-remove-participants.spec.js +132 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-PARTICIPANTS-002-add-remove-participants.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.js +68 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.js +74 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +6 -5
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.js +57 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js +127 -0
- package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js.map +7 -0
- package/dist/modules/ai_assistant/lib/auth.js +2 -11
- package/dist/modules/ai_assistant/lib/auth.js.map +2 -2
- package/dist/modules/ai_assistant/lib/codemode-tools.js +17 -20
- package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
- package/dist/modules/ai_assistant/lib/http-server.js +3 -2
- package/dist/modules/ai_assistant/lib/http-server.js.map +2 -2
- package/dist/modules/ai_assistant/lib/log-redaction.js +25 -0
- package/dist/modules/ai_assistant/lib/log-redaction.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-test-runner.js +5 -3
- package/dist/modules/ai_assistant/lib/tool-test-runner.js.map +2 -2
- package/package.json +10 -11
- package/src/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.ts +209 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.ts +6 -18
- package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.ts +176 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.ts +222 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-PARTICIPANTS-002-add-remove-participants.spec.ts +184 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +8 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.ts +95 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.ts +115 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +7 -5
- package/src/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.ts +97 -0
- package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +198 -0
- package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +27 -0
- package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +128 -0
- package/src/modules/ai_assistant/lib/__tests__/log-redaction.test.ts +65 -0
- package/src/modules/ai_assistant/lib/__tests__/tool-test-runner-pick-default-tenant.test.ts +70 -0
- package/src/modules/ai_assistant/lib/auth.ts +9 -15
- package/src/modules/ai_assistant/lib/codemode-tools.ts +21 -29
- package/src/modules/ai_assistant/lib/http-server.ts +3 -2
- package/src/modules/ai_assistant/lib/log-redaction.ts +41 -0
- package/src/modules/ai_assistant/lib/tool-test-runner.ts +11 -6
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { test, expect, request as playwrightRequest } from '@playwright/test';
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';
|
|
3
|
+
import { getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';
|
|
4
|
+
import { deleteAgentOverridesInDb } from './helpers/aiAssistantFixtures';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* TC-AI-AGENT-OVERRIDES-005 — Per-agent runtime overrides (prompt / mutation-policy / loop).
|
|
8
|
+
* Source: GitHub issue #2495.
|
|
9
|
+
*
|
|
10
|
+
* Surfaces under test:
|
|
11
|
+
* - /api/ai_assistant/ai/agents/{agentId}/prompt-override (GET, POST)
|
|
12
|
+
* - /api/ai_assistant/ai/agents/{agentId}/mutation-policy (GET, POST, DELETE)
|
|
13
|
+
* - /api/ai_assistant/ai/agents/{agentId}/loop-override (GET, PUT, DELETE)
|
|
14
|
+
*
|
|
15
|
+
* Contract notes verified against the route handlers (the issue's guesses were wrong):
|
|
16
|
+
* - prompt-override + mutation-policy use POST (NOT PUT); loop-override uses PUT.
|
|
17
|
+
* - prompt-override has no DELETE route (versioned) -> swept via SQL in teardown.
|
|
18
|
+
* - mutation-policy escalation beyond the agent's declared ceiling -> 400
|
|
19
|
+
* `escalation_not_allowed` (NOT `policy_escalation_not_allowed`); an invalid
|
|
20
|
+
* policy value -> 400 `validation_error` (NOT `invalid_mutation_policy`).
|
|
21
|
+
* - a malformed agentId -> 400 `validation_error`; an unknown one -> 404 `agent_unknown`.
|
|
22
|
+
* - writes require `ai_assistant.settings.manage`.
|
|
23
|
+
*
|
|
24
|
+
* The agent id is discovered dynamically from GET /ai/agents — never hard-coded.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const AGENTS = '/api/ai_assistant/ai/agents';
|
|
28
|
+
|
|
29
|
+
interface AgentSummary {
|
|
30
|
+
id: string;
|
|
31
|
+
mutationPolicy: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const POLICY_RANK: Record<string, number> = {
|
|
35
|
+
'read-only': 0,
|
|
36
|
+
'destructive-confirm-required': 1,
|
|
37
|
+
'confirm-required': 2,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
test.describe('TC-AI-AGENT-OVERRIDES-005: Per-agent runtime overrides', () => {
|
|
41
|
+
test('prompt + mutation-policy + loop override CRUD, validation, escalation, RBAC', async ({
|
|
42
|
+
request,
|
|
43
|
+
baseURL,
|
|
44
|
+
}) => {
|
|
45
|
+
test.slow();
|
|
46
|
+
const adminToken = await getAuthToken(request, 'admin');
|
|
47
|
+
const { tenantId } = getTokenScope(adminToken);
|
|
48
|
+
|
|
49
|
+
const agentsRes = await apiRequest(request, 'GET', AGENTS, { token: adminToken });
|
|
50
|
+
expect(agentsRes.status()).toBe(200);
|
|
51
|
+
const agentsBody = await readJsonSafe<{ agents: AgentSummary[] }>(agentsRes);
|
|
52
|
+
expect(Array.isArray(agentsBody?.agents) && (agentsBody?.agents.length ?? 0) > 0, 'at least one agent is registered').toBe(
|
|
53
|
+
true,
|
|
54
|
+
);
|
|
55
|
+
// Intentionally targets the first registered agent (whichever modules are
|
|
56
|
+
// enabled). Policy assertions are computed relative to that agent's declared
|
|
57
|
+
// ceiling (`codeDeclared`) so the test is correct for any agent.
|
|
58
|
+
const agentId = agentsBody!.agents[0].id;
|
|
59
|
+
|
|
60
|
+
const promptOverride = `${AGENTS}/${encodeURIComponent(agentId)}/prompt-override`;
|
|
61
|
+
const mutationPolicy = `${AGENTS}/${encodeURIComponent(agentId)}/mutation-policy`;
|
|
62
|
+
const loopOverride = `${AGENTS}/${encodeURIComponent(agentId)}/loop-override`;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// --- prompt-override (GET + POST; reserved-key validation) ---
|
|
66
|
+
const savePrompt = await apiRequest(request, 'POST', promptOverride, {
|
|
67
|
+
token: adminToken,
|
|
68
|
+
data: { sections: { tone: 'Be concise and helpful.' } },
|
|
69
|
+
});
|
|
70
|
+
expect(savePrompt.status(), 'POST prompt-override returns 200').toBe(200);
|
|
71
|
+
const savedPrompt = await readJsonSafe<{ ok: boolean; version: number }>(savePrompt);
|
|
72
|
+
expect(savedPrompt?.ok).toBe(true);
|
|
73
|
+
expect(typeof savedPrompt?.version).toBe('number');
|
|
74
|
+
|
|
75
|
+
const getPrompt = await apiRequest(request, 'GET', promptOverride, { token: adminToken });
|
|
76
|
+
expect(getPrompt.status()).toBe(200);
|
|
77
|
+
const promptBody = await readJsonSafe<{ override: { sections: Record<string, string> } | null }>(getPrompt);
|
|
78
|
+
expect(promptBody?.override?.sections?.tone).toBe('Be concise and helpful.');
|
|
79
|
+
|
|
80
|
+
const reserved = await apiRequest(request, 'POST', promptOverride, {
|
|
81
|
+
token: adminToken,
|
|
82
|
+
data: { sections: { mutationPolicy: 'confirm-required' } },
|
|
83
|
+
});
|
|
84
|
+
expect(reserved.status()).toBe(400);
|
|
85
|
+
expect((await readJsonSafe<{ code?: string }>(reserved))?.code).toBe('reserved_key');
|
|
86
|
+
|
|
87
|
+
// --- mutation-policy (GET + POST + DELETE; escalation + invalid value) ---
|
|
88
|
+
const getPolicy = await apiRequest(request, 'GET', mutationPolicy, { token: adminToken });
|
|
89
|
+
expect(getPolicy.status()).toBe(200);
|
|
90
|
+
const policyBody = await readJsonSafe<{ codeDeclared: string }>(getPolicy);
|
|
91
|
+
const codeDeclared = policyBody?.codeDeclared ?? 'read-only';
|
|
92
|
+
|
|
93
|
+
// 'read-only' is the most restrictive policy, so saving it is never an escalation.
|
|
94
|
+
const savePolicy = await apiRequest(request, 'POST', mutationPolicy, {
|
|
95
|
+
token: adminToken,
|
|
96
|
+
data: { mutationPolicy: 'read-only' },
|
|
97
|
+
});
|
|
98
|
+
expect(savePolicy.status(), 'saving a non-escalating policy returns 200').toBe(200);
|
|
99
|
+
|
|
100
|
+
const getPolicyAfter = await apiRequest(request, 'GET', mutationPolicy, { token: adminToken });
|
|
101
|
+
expect((await readJsonSafe<{ override: { mutationPolicy: string } | null }>(getPolicyAfter))?.override?.mutationPolicy).toBe(
|
|
102
|
+
'read-only',
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const invalidPolicy = await apiRequest(request, 'POST', mutationPolicy, {
|
|
106
|
+
token: adminToken,
|
|
107
|
+
data: { mutationPolicy: 'not-a-valid-policy' },
|
|
108
|
+
});
|
|
109
|
+
expect(invalidPolicy.status()).toBe(400);
|
|
110
|
+
expect((await readJsonSafe<{ code?: string }>(invalidPolicy))?.code).toBe('validation_error');
|
|
111
|
+
|
|
112
|
+
// Escalation (widening the agent's declared ceiling) is rejected. Only
|
|
113
|
+
// assert when 'confirm-required' is strictly less restrictive than declared.
|
|
114
|
+
if ((POLICY_RANK[codeDeclared] ?? 0) < POLICY_RANK['confirm-required']) {
|
|
115
|
+
const escalation = await apiRequest(request, 'POST', mutationPolicy, {
|
|
116
|
+
token: adminToken,
|
|
117
|
+
data: { mutationPolicy: 'confirm-required' },
|
|
118
|
+
});
|
|
119
|
+
expect(escalation.status()).toBe(400);
|
|
120
|
+
expect((await readJsonSafe<{ code?: string }>(escalation))?.code).toBe('escalation_not_allowed');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const deletePolicy = await apiRequest(request, 'DELETE', mutationPolicy, { token: adminToken });
|
|
124
|
+
expect(deletePolicy.status()).toBe(200);
|
|
125
|
+
const getPolicyCleared = await apiRequest(request, 'GET', mutationPolicy, { token: adminToken });
|
|
126
|
+
expect((await readJsonSafe<{ override: unknown }>(getPolicyCleared))?.override, 'override cleared (null, not 404)').toBeNull();
|
|
127
|
+
|
|
128
|
+
// --- loop-override (GET + PUT + DELETE) ---
|
|
129
|
+
const putLoop = await apiRequest(request, 'PUT', loopOverride, {
|
|
130
|
+
token: adminToken,
|
|
131
|
+
data: { loopMaxSteps: 5 },
|
|
132
|
+
});
|
|
133
|
+
expect(putLoop.status(), 'PUT loop-override returns 200').toBe(200);
|
|
134
|
+
|
|
135
|
+
const getLoop = await apiRequest(request, 'GET', loopOverride, { token: adminToken });
|
|
136
|
+
expect(getLoop.status()).toBe(200);
|
|
137
|
+
expect((await readJsonSafe<{ override: { loopMaxSteps: number } | null }>(getLoop))?.override?.loopMaxSteps).toBe(5);
|
|
138
|
+
|
|
139
|
+
const deleteLoop = await apiRequest(request, 'DELETE', loopOverride, { token: adminToken });
|
|
140
|
+
expect(deleteLoop.status()).toBe(200);
|
|
141
|
+
const getLoopCleared = await apiRequest(request, 'GET', loopOverride, { token: adminToken });
|
|
142
|
+
expect((await readJsonSafe<{ override: unknown }>(getLoopCleared))?.override).toBeNull();
|
|
143
|
+
|
|
144
|
+
// --- agent-id validation ---
|
|
145
|
+
const malformed = await apiRequest(request, 'GET', `${AGENTS}/BadAgentId/prompt-override`, { token: adminToken });
|
|
146
|
+
expect(malformed.status()).toBe(400);
|
|
147
|
+
expect((await readJsonSafe<{ code?: string }>(malformed))?.code).toBe('validation_error');
|
|
148
|
+
|
|
149
|
+
const unknown = await apiRequest(request, 'GET', `${AGENTS}/does.not_exist/prompt-override`, { token: adminToken });
|
|
150
|
+
expect(unknown.status()).toBe(404);
|
|
151
|
+
expect((await readJsonSafe<{ code?: string }>(unknown))?.code).toBe('agent_unknown');
|
|
152
|
+
|
|
153
|
+
// --- RBAC: employee lacks settings.manage; unauthenticated is rejected ---
|
|
154
|
+
const employeeToken = await getAuthToken(request, 'employee');
|
|
155
|
+
const denied = await apiRequest(request, 'POST', promptOverride, {
|
|
156
|
+
token: employeeToken,
|
|
157
|
+
data: { sections: { tone: 'nope' } },
|
|
158
|
+
});
|
|
159
|
+
expect(denied.status(), 'employee lacks settings.manage -> 403').toBe(403);
|
|
160
|
+
|
|
161
|
+
const anon = await playwrightRequest.newContext({ baseURL });
|
|
162
|
+
try {
|
|
163
|
+
const res = await anon.fetch(promptOverride, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: { 'Content-Type': 'application/json' },
|
|
166
|
+
data: JSON.stringify({ sections: { tone: 'nope' } }),
|
|
167
|
+
});
|
|
168
|
+
expect(res.status(), 'unauthenticated POST is 401').toBe(401);
|
|
169
|
+
} finally {
|
|
170
|
+
await anon.dispose();
|
|
171
|
+
}
|
|
172
|
+
} finally {
|
|
173
|
+
await deleteAgentOverridesInDb({ tenantId, agentId }).catch(() => undefined);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
package/src/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { test, expect, request as playwrightRequest } from '@playwright/test';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';
|
|
4
|
+
import {
|
|
5
|
+
createRoleFixture,
|
|
6
|
+
deleteRoleIfExists,
|
|
7
|
+
createUserFixture,
|
|
8
|
+
deleteUserIfExists,
|
|
9
|
+
setUserAclVisibility,
|
|
10
|
+
} from '@open-mercato/core/helpers/integration/authFixtures';
|
|
11
|
+
import {
|
|
12
|
+
createOrganizationInDb,
|
|
13
|
+
deleteOrganizationInDb,
|
|
14
|
+
deleteUserAclInDb,
|
|
15
|
+
} from '@open-mercato/core/helpers/integration/dbFixtures';
|
|
16
|
+
import { getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';
|
|
17
|
+
import { deleteConversationCascadeInDb } from './helpers/aiAssistantFixtures';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* TC-AI-CONVERSATIONS-001 — Conversation CRUD lifecycle (create, list, patch, delete).
|
|
21
|
+
* Source: GitHub issue #2495.
|
|
22
|
+
*
|
|
23
|
+
* Surfaces under test:
|
|
24
|
+
* - /api/ai_assistant/ai/conversations (POST, GET)
|
|
25
|
+
* - /api/ai_assistant/ai/conversations/{id} (PATCH, DELETE)
|
|
26
|
+
*
|
|
27
|
+
* Contract notes verified against the route handlers (not the issue's guesses):
|
|
28
|
+
* - create body field is `agentId` (required); response is a single serialized
|
|
29
|
+
* conversation with `conversationId/agentId/title/status/visibility/isOwner`.
|
|
30
|
+
* - re-creating the same `conversationId` is idempotent -> 200 (vs 201 first time).
|
|
31
|
+
* - DELETE is a SOFT delete returning 200 `{ ok: true }` (NOT 204).
|
|
32
|
+
* - item routes are scoped by tenant+organization+ownership; a caller in a
|
|
33
|
+
* different organization sees the row as absent -> 404 `conversation_not_found`
|
|
34
|
+
* (NOT 403).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const CONVERSATIONS = '/api/ai_assistant/ai/conversations';
|
|
38
|
+
|
|
39
|
+
interface SerializedConversation {
|
|
40
|
+
conversationId: string;
|
|
41
|
+
agentId: string;
|
|
42
|
+
title: string | null;
|
|
43
|
+
status: string;
|
|
44
|
+
visibility: string;
|
|
45
|
+
isOwner: boolean | null;
|
|
46
|
+
participantCount: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test.describe('TC-AI-CONVERSATIONS-001: Conversation CRUD lifecycle + org scoping', () => {
|
|
50
|
+
test('create (idempotent) -> list -> patch -> soft-delete hides from list', async ({ request }) => {
|
|
51
|
+
test.slow();
|
|
52
|
+
const adminToken = await getAuthToken(request, 'admin');
|
|
53
|
+
const { tenantId } = getTokenScope(adminToken);
|
|
54
|
+
const agentId = `it_conv.agent_${randomUUID().slice(0, 8)}`;
|
|
55
|
+
const conversationId = `it-conv-${randomUUID()}`;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const createRes = await apiRequest(request, 'POST', CONVERSATIONS, {
|
|
59
|
+
token: adminToken,
|
|
60
|
+
data: { agentId, conversationId, title: 'Original title' },
|
|
61
|
+
});
|
|
62
|
+
expect(createRes.status(), 'create returns 201').toBe(201);
|
|
63
|
+
const created = await readJsonSafe<SerializedConversation>(createRes);
|
|
64
|
+
expect(created?.conversationId).toBe(conversationId);
|
|
65
|
+
expect(created?.agentId).toBe(agentId);
|
|
66
|
+
expect(created?.title).toBe('Original title');
|
|
67
|
+
expect(created?.status).toBe('open');
|
|
68
|
+
expect(created?.visibility).toBe('private');
|
|
69
|
+
// `isOwner` is only enriched on the item GET (which passes callerUserId);
|
|
70
|
+
// on the create/list responses it is intentionally null.
|
|
71
|
+
expect(created?.isOwner).toBeNull();
|
|
72
|
+
|
|
73
|
+
const recreate = await apiRequest(request, 'POST', CONVERSATIONS, {
|
|
74
|
+
token: adminToken,
|
|
75
|
+
data: { agentId, conversationId, title: 'Original title' },
|
|
76
|
+
});
|
|
77
|
+
expect(recreate.status(), 're-create with same id is idempotent (200)').toBe(200);
|
|
78
|
+
|
|
79
|
+
// Item GET enriches ownership: the creator is the owner.
|
|
80
|
+
const getItem = await apiRequest(
|
|
81
|
+
request,
|
|
82
|
+
'GET',
|
|
83
|
+
`${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,
|
|
84
|
+
{ token: adminToken },
|
|
85
|
+
);
|
|
86
|
+
expect(getItem.status()).toBe(200);
|
|
87
|
+
const item = await readJsonSafe<{ conversation: SerializedConversation }>(getItem);
|
|
88
|
+
expect(item?.conversation.conversationId).toBe(conversationId);
|
|
89
|
+
expect(item?.conversation.isOwner).toBe(true);
|
|
90
|
+
|
|
91
|
+
const listRes = await apiRequest(
|
|
92
|
+
request,
|
|
93
|
+
'GET',
|
|
94
|
+
`${CONVERSATIONS}?agent=${encodeURIComponent(agentId)}`,
|
|
95
|
+
{ token: adminToken },
|
|
96
|
+
);
|
|
97
|
+
expect(listRes.status()).toBe(200);
|
|
98
|
+
const list = await readJsonSafe<{ items: SerializedConversation[]; nextCursor: string | null }>(listRes);
|
|
99
|
+
expect(Array.isArray(list?.items)).toBe(true);
|
|
100
|
+
expect(list?.items.some((c) => c.conversationId === conversationId)).toBe(true);
|
|
101
|
+
|
|
102
|
+
const patchRes = await apiRequest(
|
|
103
|
+
request,
|
|
104
|
+
'PATCH',
|
|
105
|
+
`${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,
|
|
106
|
+
{ token: adminToken, data: { title: 'Renamed title', status: 'closed' } },
|
|
107
|
+
);
|
|
108
|
+
expect(patchRes.status()).toBe(200);
|
|
109
|
+
const patched = await readJsonSafe<SerializedConversation>(patchRes);
|
|
110
|
+
expect(patched?.title).toBe('Renamed title');
|
|
111
|
+
expect(patched?.status).toBe('closed');
|
|
112
|
+
|
|
113
|
+
const delRes = await apiRequest(
|
|
114
|
+
request,
|
|
115
|
+
'DELETE',
|
|
116
|
+
`${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,
|
|
117
|
+
{ token: adminToken },
|
|
118
|
+
);
|
|
119
|
+
expect(delRes.status(), 'delete returns 200 (soft delete)').toBe(200);
|
|
120
|
+
expect((await readJsonSafe<{ ok: boolean }>(delRes))?.ok).toBe(true);
|
|
121
|
+
|
|
122
|
+
const listAfter = await apiRequest(
|
|
123
|
+
request,
|
|
124
|
+
'GET',
|
|
125
|
+
`${CONVERSATIONS}?agent=${encodeURIComponent(agentId)}`,
|
|
126
|
+
{ token: adminToken },
|
|
127
|
+
);
|
|
128
|
+
expect(listAfter.status()).toBe(200);
|
|
129
|
+
const listAfterBody = await readJsonSafe<{ items: SerializedConversation[] }>(listAfter);
|
|
130
|
+
expect(listAfterBody?.items.some((c) => c.conversationId === conversationId)).toBe(false);
|
|
131
|
+
} finally {
|
|
132
|
+
await deleteConversationCascadeInDb({ tenantId, conversationId }).catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('validation + auth gates: missing agentId -> 400, unauthenticated -> 401', async ({ request, baseURL }) => {
|
|
137
|
+
const adminToken = await getAuthToken(request, 'admin');
|
|
138
|
+
|
|
139
|
+
const badCreate = await apiRequest(request, 'POST', CONVERSATIONS, {
|
|
140
|
+
token: adminToken,
|
|
141
|
+
data: { title: 'no agent id' },
|
|
142
|
+
});
|
|
143
|
+
expect(badCreate.status()).toBe(400);
|
|
144
|
+
expect((await readJsonSafe<{ code?: string }>(badCreate))?.code).toBe('validation_error');
|
|
145
|
+
|
|
146
|
+
const anon = await playwrightRequest.newContext({ baseURL });
|
|
147
|
+
try {
|
|
148
|
+
const res = await anon.fetch(CONVERSATIONS, { method: 'GET' });
|
|
149
|
+
expect(res.status(), 'unauthenticated list is 401').toBe(401);
|
|
150
|
+
} finally {
|
|
151
|
+
await anon.dispose();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('cross-org caller cannot GET/PATCH/DELETE another org conversation (404)', async ({ request }) => {
|
|
156
|
+
test.slow();
|
|
157
|
+
const adminToken = await getAuthToken(request, 'admin');
|
|
158
|
+
const { tenantId } = getTokenScope(adminToken);
|
|
159
|
+
const stamp = randomUUID().slice(0, 8);
|
|
160
|
+
const password = 'Secret123!';
|
|
161
|
+
const agentId = `it_conv.xorg_${stamp}`;
|
|
162
|
+
const conversationId = `it-conv-xorg-${randomUUID()}`;
|
|
163
|
+
|
|
164
|
+
let orgBId: string | null = null;
|
|
165
|
+
let roleId: string | null = null;
|
|
166
|
+
let userId: string | null = null;
|
|
167
|
+
try {
|
|
168
|
+
orgBId = await createOrganizationInDb({ name: `IT Conv OrgB ${stamp}`, tenantId });
|
|
169
|
+
roleId = await createRoleFixture(request, adminToken, { name: `IT Conv Role ${stamp}` });
|
|
170
|
+
userId = await createUserFixture(request, adminToken, {
|
|
171
|
+
email: `it-conv-${stamp}@example.com`,
|
|
172
|
+
password,
|
|
173
|
+
organizationId: orgBId,
|
|
174
|
+
roles: [roleId],
|
|
175
|
+
});
|
|
176
|
+
await setUserAclVisibility(request, adminToken, {
|
|
177
|
+
userId,
|
|
178
|
+
features: ['ai_assistant.view', 'ai_assistant.conversations.share'],
|
|
179
|
+
organizations: [orgBId],
|
|
180
|
+
});
|
|
181
|
+
const otherToken = await getAuthToken(request, `it-conv-${stamp}@example.com`, password);
|
|
182
|
+
expect(getTokenScope(otherToken).organizationId, 'fixture user is homed in org B').toBe(orgBId);
|
|
183
|
+
|
|
184
|
+
const createRes = await apiRequest(request, 'POST', CONVERSATIONS, {
|
|
185
|
+
token: adminToken,
|
|
186
|
+
data: { agentId, conversationId, title: 'Org A private' },
|
|
187
|
+
});
|
|
188
|
+
expect(createRes.status()).toBe(201);
|
|
189
|
+
|
|
190
|
+
const getRes = await apiRequest(
|
|
191
|
+
request,
|
|
192
|
+
'GET',
|
|
193
|
+
`${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,
|
|
194
|
+
{ token: otherToken },
|
|
195
|
+
);
|
|
196
|
+
expect(getRes.status(), 'cross-org GET is 404 (not 403)').toBe(404);
|
|
197
|
+
expect((await readJsonSafe<{ code?: string }>(getRes))?.code).toBe('conversation_not_found');
|
|
198
|
+
|
|
199
|
+
const patchRes = await apiRequest(
|
|
200
|
+
request,
|
|
201
|
+
'PATCH',
|
|
202
|
+
`${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,
|
|
203
|
+
{ token: otherToken, data: { title: 'hijack' } },
|
|
204
|
+
);
|
|
205
|
+
expect(patchRes.status(), 'cross-org PATCH is 404').toBe(404);
|
|
206
|
+
|
|
207
|
+
const delRes = await apiRequest(
|
|
208
|
+
request,
|
|
209
|
+
'DELETE',
|
|
210
|
+
`${CONVERSATIONS}/${encodeURIComponent(conversationId)}`,
|
|
211
|
+
{ token: otherToken },
|
|
212
|
+
);
|
|
213
|
+
expect(delRes.status(), 'cross-org DELETE is 404').toBe(404);
|
|
214
|
+
} finally {
|
|
215
|
+
await deleteConversationCascadeInDb({ tenantId, conversationId }).catch(() => undefined);
|
|
216
|
+
await deleteUserAclInDb(userId ?? '').catch(() => undefined);
|
|
217
|
+
await deleteUserIfExists(request, adminToken, userId);
|
|
218
|
+
await deleteRoleIfExists(request, adminToken, roleId);
|
|
219
|
+
await deleteOrganizationInDb(orgBId).catch(() => undefined);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';
|
|
4
|
+
import {
|
|
5
|
+
createRoleFixture,
|
|
6
|
+
deleteRoleIfExists,
|
|
7
|
+
createUserFixture,
|
|
8
|
+
deleteUserIfExists,
|
|
9
|
+
setUserAclVisibility,
|
|
10
|
+
} from '@open-mercato/core/helpers/integration/authFixtures';
|
|
11
|
+
import {
|
|
12
|
+
createOrganizationInDb,
|
|
13
|
+
deleteOrganizationInDb,
|
|
14
|
+
deleteUserAclInDb,
|
|
15
|
+
} from '@open-mercato/core/helpers/integration/dbFixtures';
|
|
16
|
+
import { getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';
|
|
17
|
+
import { deleteConversationCascadeInDb } from './helpers/aiAssistantFixtures';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* TC-AI-PARTICIPANTS-002 — Conversation participants (add / list / remove).
|
|
21
|
+
* Source: GitHub issue #2495.
|
|
22
|
+
*
|
|
23
|
+
* Surfaces under test:
|
|
24
|
+
* - /api/ai_assistant/ai/conversations/{id}/participants (POST, GET)
|
|
25
|
+
* - /api/ai_assistant/ai/conversations/{id}/participants/{userId} (DELETE)
|
|
26
|
+
*
|
|
27
|
+
* Contract notes verified against the route handlers:
|
|
28
|
+
* - add requires `ai_assistant.conversations.share`; only the owner may add.
|
|
29
|
+
* - the only wire role is `viewer` (the owner row is implicit).
|
|
30
|
+
* - add returns 201 `{ participant: { userId, role, lastReadAt, addedAt } }`.
|
|
31
|
+
* - removing a participant returns 204; removing the OWNER is 403; a missing
|
|
32
|
+
* participant is 404 `participant_not_found`.
|
|
33
|
+
* - self-add is 400 `self_share_not_allowed`; a duplicate is 409
|
|
34
|
+
* `duplicate_participant`; a target in another org is 400 `user_not_found`.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const CONVERSATIONS = '/api/ai_assistant/ai/conversations';
|
|
38
|
+
|
|
39
|
+
interface ParticipantRow {
|
|
40
|
+
userId: string;
|
|
41
|
+
role: string;
|
|
42
|
+
lastReadAt: string | null;
|
|
43
|
+
addedAt: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test.describe('TC-AI-PARTICIPANTS-002: Conversation participants', () => {
|
|
47
|
+
test('add -> list -> remove lifecycle plus owner/duplicate/self/cross-org/RBAC guards', async ({ request }) => {
|
|
48
|
+
test.slow();
|
|
49
|
+
const adminToken = await getAuthToken(request, 'admin');
|
|
50
|
+
const { tenantId, organizationId: orgAId, userId: ownerId } = getTokenScope(adminToken);
|
|
51
|
+
const stamp = randomUUID().slice(0, 8);
|
|
52
|
+
const password = 'Secret123!';
|
|
53
|
+
const agentId = `it_part.agent_${stamp}`;
|
|
54
|
+
const conversationId = `it-part-${randomUUID()}`;
|
|
55
|
+
|
|
56
|
+
let roleId: string | null = null;
|
|
57
|
+
let memberId: string | null = null;
|
|
58
|
+
let foreignOrgId: string | null = null;
|
|
59
|
+
let foreignUserId: string | null = null;
|
|
60
|
+
try {
|
|
61
|
+
roleId = await createRoleFixture(request, adminToken, { name: `IT Part Role ${stamp}` });
|
|
62
|
+
// Member lives in the SAME org as the owner and carries only view -> valid
|
|
63
|
+
// share target AND a deterministic "lacks conversations.share" caller.
|
|
64
|
+
memberId = await createUserFixture(request, adminToken, {
|
|
65
|
+
email: `it-part-member-${stamp}@example.com`,
|
|
66
|
+
password,
|
|
67
|
+
organizationId: orgAId,
|
|
68
|
+
roles: [roleId],
|
|
69
|
+
});
|
|
70
|
+
await setUserAclVisibility(request, adminToken, {
|
|
71
|
+
userId: memberId,
|
|
72
|
+
features: ['ai_assistant.view'],
|
|
73
|
+
organizations: [orgAId],
|
|
74
|
+
});
|
|
75
|
+
// Foreign user lives in another org -> cannot be shared into this conversation.
|
|
76
|
+
foreignOrgId = await createOrganizationInDb({ name: `IT Part OrgB ${stamp}`, tenantId });
|
|
77
|
+
foreignUserId = await createUserFixture(request, adminToken, {
|
|
78
|
+
email: `it-part-foreign-${stamp}@example.com`,
|
|
79
|
+
password,
|
|
80
|
+
organizationId: foreignOrgId,
|
|
81
|
+
roles: [roleId],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const createRes = await apiRequest(request, 'POST', CONVERSATIONS, {
|
|
85
|
+
token: adminToken,
|
|
86
|
+
data: { agentId, conversationId, title: 'Shareable conversation' },
|
|
87
|
+
});
|
|
88
|
+
expect(createRes.status()).toBe(201);
|
|
89
|
+
|
|
90
|
+
const participantsPath = `${CONVERSATIONS}/${encodeURIComponent(conversationId)}/participants`;
|
|
91
|
+
|
|
92
|
+
// Add member -> 201 viewer
|
|
93
|
+
const addRes = await apiRequest(request, 'POST', participantsPath, {
|
|
94
|
+
token: adminToken,
|
|
95
|
+
data: { userId: memberId },
|
|
96
|
+
});
|
|
97
|
+
expect(addRes.status(), 'add participant returns 201').toBe(201);
|
|
98
|
+
const added = await readJsonSafe<{ participant: ParticipantRow }>(addRes);
|
|
99
|
+
expect(added?.participant.userId).toBe(memberId);
|
|
100
|
+
expect(added?.participant.role).toBe('viewer');
|
|
101
|
+
|
|
102
|
+
// List -> owner + member
|
|
103
|
+
const listRes = await apiRequest(request, 'GET', participantsPath, { token: adminToken });
|
|
104
|
+
expect(listRes.status()).toBe(200);
|
|
105
|
+
const list = await readJsonSafe<{ ownerUserId: string; participants: ParticipantRow[] }>(listRes);
|
|
106
|
+
expect(list?.ownerUserId).toBe(ownerId);
|
|
107
|
+
expect(list?.participants.some((p) => p.userId === ownerId && p.role === 'owner')).toBe(true);
|
|
108
|
+
expect(list?.participants.some((p) => p.userId === memberId && p.role === 'viewer')).toBe(true);
|
|
109
|
+
|
|
110
|
+
// Self-add -> 400 self_share_not_allowed
|
|
111
|
+
const selfAdd = await apiRequest(request, 'POST', participantsPath, {
|
|
112
|
+
token: adminToken,
|
|
113
|
+
data: { userId: ownerId },
|
|
114
|
+
});
|
|
115
|
+
expect(selfAdd.status()).toBe(400);
|
|
116
|
+
expect((await readJsonSafe<{ code?: string }>(selfAdd))?.code).toBe('self_share_not_allowed');
|
|
117
|
+
|
|
118
|
+
// Duplicate -> 409 duplicate_participant
|
|
119
|
+
const dup = await apiRequest(request, 'POST', participantsPath, {
|
|
120
|
+
token: adminToken,
|
|
121
|
+
data: { userId: memberId },
|
|
122
|
+
});
|
|
123
|
+
expect(dup.status()).toBe(409);
|
|
124
|
+
expect((await readJsonSafe<{ code?: string }>(dup))?.code).toBe('duplicate_participant');
|
|
125
|
+
|
|
126
|
+
// Foreign-org target -> 400 user_not_found
|
|
127
|
+
const foreign = await apiRequest(request, 'POST', participantsPath, {
|
|
128
|
+
token: adminToken,
|
|
129
|
+
data: { userId: foreignUserId },
|
|
130
|
+
});
|
|
131
|
+
expect(foreign.status()).toBe(400);
|
|
132
|
+
expect((await readJsonSafe<{ code?: string }>(foreign))?.code).toBe('user_not_found');
|
|
133
|
+
|
|
134
|
+
// Caller lacking conversations.share -> 403 (feature gate fires first)
|
|
135
|
+
const memberToken = await getAuthToken(request, `it-part-member-${stamp}@example.com`, password);
|
|
136
|
+
const denied = await apiRequest(request, 'POST', participantsPath, {
|
|
137
|
+
token: memberToken,
|
|
138
|
+
data: { userId: randomUUID() },
|
|
139
|
+
});
|
|
140
|
+
expect(denied.status(), 'caller without conversations.share is 403').toBe(403);
|
|
141
|
+
|
|
142
|
+
// Remove the OWNER -> 403
|
|
143
|
+
const removeOwner = await apiRequest(
|
|
144
|
+
request,
|
|
145
|
+
'DELETE',
|
|
146
|
+
`${participantsPath}/${encodeURIComponent(ownerId)}`,
|
|
147
|
+
{ token: adminToken },
|
|
148
|
+
);
|
|
149
|
+
expect(removeOwner.status(), 'cannot revoke the owner').toBe(403);
|
|
150
|
+
|
|
151
|
+
// Remove the member -> 204
|
|
152
|
+
const removeMember = await apiRequest(
|
|
153
|
+
request,
|
|
154
|
+
'DELETE',
|
|
155
|
+
`${participantsPath}/${encodeURIComponent(memberId)}`,
|
|
156
|
+
{ token: adminToken },
|
|
157
|
+
);
|
|
158
|
+
expect(removeMember.status(), 'revoke participant returns 204').toBe(204);
|
|
159
|
+
|
|
160
|
+
// List -> member gone
|
|
161
|
+
const listAfter = await apiRequest(request, 'GET', participantsPath, { token: adminToken });
|
|
162
|
+
expect(listAfter.status()).toBe(200);
|
|
163
|
+
const after = await readJsonSafe<{ participants: ParticipantRow[] }>(listAfter);
|
|
164
|
+
expect(after?.participants.some((p) => p.userId === memberId)).toBe(false);
|
|
165
|
+
|
|
166
|
+
// Remove again -> 404 participant_not_found
|
|
167
|
+
const removeAgain = await apiRequest(
|
|
168
|
+
request,
|
|
169
|
+
'DELETE',
|
|
170
|
+
`${participantsPath}/${encodeURIComponent(memberId)}`,
|
|
171
|
+
{ token: adminToken },
|
|
172
|
+
);
|
|
173
|
+
expect(removeAgain.status()).toBe(404);
|
|
174
|
+
expect((await readJsonSafe<{ code?: string }>(removeAgain))?.code).toBe('participant_not_found');
|
|
175
|
+
} finally {
|
|
176
|
+
await deleteConversationCascadeInDb({ tenantId, conversationId }).catch(() => undefined);
|
|
177
|
+
await deleteUserAclInDb(memberId ?? '').catch(() => undefined);
|
|
178
|
+
await deleteUserIfExists(request, adminToken, memberId);
|
|
179
|
+
await deleteUserIfExists(request, adminToken, foreignUserId);
|
|
180
|
+
await deleteRoleIfExists(request, adminToken, roleId);
|
|
181
|
+
await deleteOrganizationInDb(foreignOrgId).catch(() => undefined);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts
CHANGED
|
@@ -178,6 +178,14 @@ test.describe('TC-AI-RUNTIME-OVERRIDES-006: runtime model overrides', () => {
|
|
|
178
178
|
});
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
+
await page.route('**/api/ai_assistant/ai/agents', async (route) => {
|
|
182
|
+
await route.fulfill({
|
|
183
|
+
status: 200,
|
|
184
|
+
contentType: 'application/json',
|
|
185
|
+
body: JSON.stringify(agentsPayload),
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
181
189
|
await page.route('**/api/ai_assistant/health', async (route) => {
|
|
182
190
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', url: 'http://localhost', mcpUrl: 'http://localhost:3001' }) });
|
|
183
191
|
});
|