@open-mercato/ai-assistant 0.6.5-develop.4516.1.88e6ab71a9 → 0.6.5-develop.4534.1.b459babe6d
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-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-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-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-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/package.json +6 -6
- 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-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-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-TOOLS-EXECUTE-007-direct-tool-execution.spec.ts +97 -0
- package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +198 -0
|
@@ -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-SESSION-KEY-006-create-session-token.spec.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
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 { deleteUserAclInDb } from '@open-mercato/core/helpers/integration/dbFixtures';
|
|
12
|
+
import { getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* TC-AI-SESSION-KEY-006 — Session key generation.
|
|
16
|
+
* Source: GitHub issue #2495.
|
|
17
|
+
*
|
|
18
|
+
* Surface under test:
|
|
19
|
+
* - /api/ai_assistant/session-key (POST)
|
|
20
|
+
*
|
|
21
|
+
* Contract notes verified against the route handler (the issue's guesses were wrong):
|
|
22
|
+
* - the response body is EXACTLY `{ sessionToken, expiresAt }` — it does NOT
|
|
23
|
+
* include userId / tenantId / organizationId.
|
|
24
|
+
* - token format is `sess_` + 32 lowercase hex chars; TTL is 120 minutes.
|
|
25
|
+
* - requires `ai_assistant.view`; unauthenticated -> 401; missing feature -> 403.
|
|
26
|
+
*
|
|
27
|
+
* NOTE: the returned `sess_...` token is consumed only as the MCP `_sessionToken`
|
|
28
|
+
* tool-call argument — it is NOT accepted by the Next.js HTTP auth resolver as a
|
|
29
|
+
* Bearer/header, so the issue's "use the token to call /tools" step is invalid
|
|
30
|
+
* and intentionally omitted here.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const SESSION_KEY = '/api/ai_assistant/session-key';
|
|
34
|
+
const SESSION_TOKEN_RE = /^sess_[0-9a-f]{32}$/;
|
|
35
|
+
const TTL_MINUTES = 120;
|
|
36
|
+
|
|
37
|
+
test.describe('TC-AI-SESSION-KEY-006: Session key generation', () => {
|
|
38
|
+
test('POST mints a unique sess_ token with a ~120 minute TTL', async ({ request }) => {
|
|
39
|
+
const adminToken = await getAuthToken(request, 'admin');
|
|
40
|
+
|
|
41
|
+
const first = await apiRequest(request, 'POST', SESSION_KEY, { token: adminToken, data: {} });
|
|
42
|
+
expect(first.status(), 'session-key POST returns 200').toBe(200);
|
|
43
|
+
const body = await readJsonSafe<{ sessionToken: string; expiresAt: string }>(first);
|
|
44
|
+
expect(body?.sessionToken).toMatch(SESSION_TOKEN_RE);
|
|
45
|
+
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
const expiresAt = Date.parse(body!.expiresAt);
|
|
48
|
+
expect(Number.isNaN(expiresAt)).toBe(false);
|
|
49
|
+
// Allow generous slop for clock + request latency around the 120-minute TTL.
|
|
50
|
+
expect(expiresAt).toBeGreaterThan(now + (TTL_MINUTES - 5) * 60 * 1000);
|
|
51
|
+
expect(expiresAt).toBeLessThan(now + (TTL_MINUTES + 5) * 60 * 1000);
|
|
52
|
+
|
|
53
|
+
const second = await apiRequest(request, 'POST', SESSION_KEY, { token: adminToken, data: {} });
|
|
54
|
+
expect(second.status()).toBe(200);
|
|
55
|
+
const secondBody = await readJsonSafe<{ sessionToken: string }>(second);
|
|
56
|
+
expect(secondBody?.sessionToken).toMatch(SESSION_TOKEN_RE);
|
|
57
|
+
expect(secondBody?.sessionToken, 'each call mints a distinct token').not.toBe(body?.sessionToken);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('auth gates: unauthenticated 401 and missing ai_assistant.view 403', async ({ request, baseURL }) => {
|
|
61
|
+
test.slow();
|
|
62
|
+
const adminToken = await getAuthToken(request, 'admin');
|
|
63
|
+
const { organizationId } = getTokenScope(adminToken);
|
|
64
|
+
const stamp = randomUUID().slice(0, 8);
|
|
65
|
+
const password = 'Secret123!';
|
|
66
|
+
|
|
67
|
+
const anon = await playwrightRequest.newContext({ baseURL });
|
|
68
|
+
try {
|
|
69
|
+
const res = await anon.fetch(SESSION_KEY, { method: 'POST', data: '{}' });
|
|
70
|
+
expect(res.status(), 'unauthenticated POST is 401').toBe(401);
|
|
71
|
+
} finally {
|
|
72
|
+
await anon.dispose();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let roleId: string | null = null;
|
|
76
|
+
let userId: string | null = null;
|
|
77
|
+
try {
|
|
78
|
+
roleId = await createRoleFixture(request, adminToken, { name: `IT Session Role ${stamp}` });
|
|
79
|
+
userId = await createUserFixture(request, adminToken, {
|
|
80
|
+
email: `it-session-${stamp}@example.com`,
|
|
81
|
+
password,
|
|
82
|
+
organizationId,
|
|
83
|
+
roles: [roleId],
|
|
84
|
+
});
|
|
85
|
+
await setUserAclVisibility(request, adminToken, { userId, features: [], organizations: null });
|
|
86
|
+
const viewlessToken = await getAuthToken(request, `it-session-${stamp}@example.com`, password);
|
|
87
|
+
const denied = await apiRequest(request, 'POST', SESSION_KEY, { token: viewlessToken, data: {} });
|
|
88
|
+
expect(denied.status(), 'caller without ai_assistant.view is 403').toBe(403);
|
|
89
|
+
} finally {
|
|
90
|
+
await deleteUserAclInDb(userId ?? '').catch(() => undefined);
|
|
91
|
+
await deleteUserIfExists(request, adminToken, userId);
|
|
92
|
+
await deleteRoleIfExists(request, adminToken, roleId);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
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 { deleteTenantAllowlistInDb } from './helpers/aiAssistantFixtures';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* TC-AI-SETTINGS-ALLOWLIST-003 — Tenant model allowlist (PUT/DELETE) + settings reflection.
|
|
8
|
+
* Source: GitHub issue #2495.
|
|
9
|
+
*
|
|
10
|
+
* Surfaces under test:
|
|
11
|
+
* - /api/ai_assistant/settings/allowlist (PUT, DELETE)
|
|
12
|
+
* - /api/ai_assistant/settings (GET — reflects the snapshot)
|
|
13
|
+
*
|
|
14
|
+
* Contract notes verified against the route handlers:
|
|
15
|
+
* - PUT body fields are `allowedProviders` / `allowedModelsByProvider`
|
|
16
|
+
* (NOT `providers` / `models`); success is 200 returning the saved snapshot.
|
|
17
|
+
* - DELETE returns 200 `{ cleared: boolean }` and is idempotent
|
|
18
|
+
* (`cleared: false` when no active row exists).
|
|
19
|
+
* - GET /settings exposes `tenantAllowlist` (null when unset) + `effectiveAllowlist`.
|
|
20
|
+
* - PUT/DELETE require `ai_assistant.settings.manage`.
|
|
21
|
+
*
|
|
22
|
+
* Out of scope (documented): the `provider_not_in_env_allowlist` 400 branch only
|
|
23
|
+
* fires when the APP process has `OM_AI_AVAILABLE_PROVIDERS` set to exclude the
|
|
24
|
+
* provider. The deterministic harness does not control the app's env, so that
|
|
25
|
+
* branch is covered by the module's unit tests; here we assert the env-agnostic
|
|
26
|
+
* Zod validation (wrong type -> 400 validation_error) instead.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const ALLOWLIST = '/api/ai_assistant/settings/allowlist';
|
|
30
|
+
const SETTINGS = '/api/ai_assistant/settings';
|
|
31
|
+
|
|
32
|
+
interface TenantAllowlistSnapshot {
|
|
33
|
+
allowedProviders: string[] | null;
|
|
34
|
+
allowedModelsByProvider: Record<string, string[]>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test.describe('TC-AI-SETTINGS-ALLOWLIST-003: Tenant model allowlist', () => {
|
|
38
|
+
test('PUT persists, GET reflects, DELETE clears + is idempotent', async ({ request }) => {
|
|
39
|
+
test.slow();
|
|
40
|
+
const adminToken = await getAuthToken(request, 'admin');
|
|
41
|
+
const { tenantId } = getTokenScope(adminToken);
|
|
42
|
+
try {
|
|
43
|
+
// Start from a known-clean state.
|
|
44
|
+
const reset = await apiRequest(request, 'DELETE', ALLOWLIST, { token: adminToken });
|
|
45
|
+
expect(reset.status()).toBe(200);
|
|
46
|
+
|
|
47
|
+
const settingsBefore = await apiRequest(request, 'GET', SETTINGS, { token: adminToken });
|
|
48
|
+
expect(settingsBefore.status()).toBe(200);
|
|
49
|
+
const before = await readJsonSafe<{ tenantAllowlist: TenantAllowlistSnapshot | null; effectiveAllowlist: unknown }>(
|
|
50
|
+
settingsBefore,
|
|
51
|
+
);
|
|
52
|
+
expect(before?.tenantAllowlist).toBeNull();
|
|
53
|
+
expect(before?.effectiveAllowlist).toBeTruthy();
|
|
54
|
+
|
|
55
|
+
const put = await apiRequest(request, 'PUT', ALLOWLIST, {
|
|
56
|
+
token: adminToken,
|
|
57
|
+
data: { allowedProviders: ['openai'], allowedModelsByProvider: { openai: ['gpt-5-mini'] } },
|
|
58
|
+
});
|
|
59
|
+
expect(put.status(), 'PUT allowlist returns 200').toBe(200);
|
|
60
|
+
const saved = await readJsonSafe<TenantAllowlistSnapshot>(put);
|
|
61
|
+
expect(saved?.allowedProviders).toContain('openai');
|
|
62
|
+
expect(saved?.allowedModelsByProvider?.openai).toContain('gpt-5-mini');
|
|
63
|
+
|
|
64
|
+
const settingsAfter = await apiRequest(request, 'GET', SETTINGS, { token: adminToken });
|
|
65
|
+
expect(settingsAfter.status()).toBe(200);
|
|
66
|
+
const after = await readJsonSafe<{ tenantAllowlist: TenantAllowlistSnapshot | null }>(settingsAfter);
|
|
67
|
+
expect(after?.tenantAllowlist?.allowedProviders).toContain('openai');
|
|
68
|
+
|
|
69
|
+
const del = await apiRequest(request, 'DELETE', ALLOWLIST, { token: adminToken });
|
|
70
|
+
expect(del.status()).toBe(200);
|
|
71
|
+
expect((await readJsonSafe<{ cleared: boolean }>(del))?.cleared).toBe(true);
|
|
72
|
+
|
|
73
|
+
const settingsCleared = await apiRequest(request, 'GET', SETTINGS, { token: adminToken });
|
|
74
|
+
const cleared = await readJsonSafe<{ tenantAllowlist: TenantAllowlistSnapshot | null }>(settingsCleared);
|
|
75
|
+
expect(cleared?.tenantAllowlist).toBeNull();
|
|
76
|
+
|
|
77
|
+
const delAgain = await apiRequest(request, 'DELETE', ALLOWLIST, { token: adminToken });
|
|
78
|
+
expect(delAgain.status()).toBe(200);
|
|
79
|
+
expect((await readJsonSafe<{ cleared: boolean }>(delAgain))?.cleared, 'DELETE is idempotent').toBe(false);
|
|
80
|
+
} finally {
|
|
81
|
+
await deleteTenantAllowlistInDb(tenantId).catch(() => undefined);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('validation + RBAC gates: bad body 400, employee 403, unauthenticated 401', async ({ request, baseURL }) => {
|
|
86
|
+
const adminToken = await getAuthToken(request, 'admin');
|
|
87
|
+
|
|
88
|
+
const badType = await apiRequest(request, 'PUT', ALLOWLIST, {
|
|
89
|
+
token: adminToken,
|
|
90
|
+
data: { allowedProviders: 123 },
|
|
91
|
+
});
|
|
92
|
+
expect(badType.status()).toBe(400);
|
|
93
|
+
expect((await readJsonSafe<{ code?: string }>(badType))?.code).toBe('validation_error');
|
|
94
|
+
|
|
95
|
+
// Employee carries ai_assistant.view but NOT ai_assistant.settings.manage.
|
|
96
|
+
const employeeToken = await getAuthToken(request, 'employee');
|
|
97
|
+
const denied = await apiRequest(request, 'PUT', ALLOWLIST, {
|
|
98
|
+
token: employeeToken,
|
|
99
|
+
data: { allowedProviders: ['openai'] },
|
|
100
|
+
});
|
|
101
|
+
expect(denied.status(), 'employee lacks settings.manage -> 403').toBe(403);
|
|
102
|
+
|
|
103
|
+
const anon = await playwrightRequest.newContext({ baseURL });
|
|
104
|
+
try {
|
|
105
|
+
const res = await anon.fetch(ALLOWLIST, {
|
|
106
|
+
method: 'PUT',
|
|
107
|
+
headers: { 'Content-Type': 'application/json' },
|
|
108
|
+
data: JSON.stringify({ allowedProviders: ['openai'] }),
|
|
109
|
+
});
|
|
110
|
+
expect(res.status(), 'unauthenticated PUT is 401').toBe(401);
|
|
111
|
+
} finally {
|
|
112
|
+
await anon.dispose();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { test, expect, request as playwrightRequest } from '@playwright/test';
|
|
2
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';
|
|
3
|
+
import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TC-AI-TOOLS-EXECUTE-007 — Direct tool execution + tool listing.
|
|
7
|
+
* Source: GitHub issue #2495.
|
|
8
|
+
*
|
|
9
|
+
* Surfaces under test:
|
|
10
|
+
* - /api/ai_assistant/tools (GET)
|
|
11
|
+
* - /api/ai_assistant/tools/execute (POST)
|
|
12
|
+
*
|
|
13
|
+
* Contract notes verified against the route handlers (the issue's guesses were wrong):
|
|
14
|
+
* - execute body fields are `toolName` + `args` (NOT `name` / `input`);
|
|
15
|
+
* success is 200 `{ success: true, result }`.
|
|
16
|
+
* - a missing `toolName` -> 400 `{ error: 'toolName is required' }`.
|
|
17
|
+
* - an unknown tool -> 400 `{ success: false, error: 'Tool "<n>" not found' }`
|
|
18
|
+
* (NOT a typed `code`; the errorCode only selects 400 vs 403 internally).
|
|
19
|
+
* - both routes require `ai_assistant.view`; the per-tool `requiredFeatures` are
|
|
20
|
+
* enforced separately inside the executor.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const TOOLS = '/api/ai_assistant/tools';
|
|
24
|
+
const EXECUTE = '/api/ai_assistant/tools/execute';
|
|
25
|
+
|
|
26
|
+
interface ToolSummary {
|
|
27
|
+
name: string;
|
|
28
|
+
description: string;
|
|
29
|
+
inputSchema: { required?: unknown } & Record<string, unknown>;
|
|
30
|
+
module: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test.describe('TC-AI-TOOLS-EXECUTE-007: Direct tool execution', () => {
|
|
34
|
+
test('list tools, validate + reject unknown tools, and exercise a no-arg tool', async ({ request }) => {
|
|
35
|
+
test.slow();
|
|
36
|
+
const adminToken = await getAuthToken(request, 'admin');
|
|
37
|
+
|
|
38
|
+
const listRes = await apiRequest(request, 'GET', TOOLS, { token: adminToken });
|
|
39
|
+
expect(listRes.status()).toBe(200);
|
|
40
|
+
const list = await readJsonSafe<{ tools: ToolSummary[] }>(listRes);
|
|
41
|
+
expect(Array.isArray(list?.tools)).toBe(true);
|
|
42
|
+
expect((list?.tools.length ?? 0) > 0, 'at least one tool is visible to admin').toBe(true);
|
|
43
|
+
const sample = list!.tools[0];
|
|
44
|
+
expect(typeof sample.name).toBe('string');
|
|
45
|
+
expect(typeof sample.inputSchema).toBe('object');
|
|
46
|
+
|
|
47
|
+
// Missing toolName -> 400 with the route-level message.
|
|
48
|
+
const missing = await apiRequest(request, 'POST', EXECUTE, { token: adminToken, data: {} });
|
|
49
|
+
expect(missing.status()).toBe(400);
|
|
50
|
+
expect((await readJsonSafe<{ error?: string }>(missing))?.error).toBe('toolName is required');
|
|
51
|
+
|
|
52
|
+
// Unknown tool -> 400 { success: false, error: '... not found' }.
|
|
53
|
+
const unknown = await apiRequest(request, 'POST', EXECUTE, {
|
|
54
|
+
token: adminToken,
|
|
55
|
+
data: { toolName: 'does.not_exist_tool', args: {} },
|
|
56
|
+
});
|
|
57
|
+
expect(unknown.status()).toBe(400);
|
|
58
|
+
const unknownBody = await readJsonSafe<{ success?: boolean; error?: string }>(unknown);
|
|
59
|
+
expect(unknownBody?.success).toBe(false);
|
|
60
|
+
expect(unknownBody?.error ?? '').toContain('not found');
|
|
61
|
+
|
|
62
|
+
// Best-effort happy path: a tool whose input schema has no required fields can
|
|
63
|
+
// be invoked with empty args. The execute route runs the handler and returns
|
|
64
|
+
// 200 `{ success }`; assert the envelope only when such a tool is available.
|
|
65
|
+
const noArgTool = list!.tools.find((tool) => {
|
|
66
|
+
const required = tool.inputSchema?.required;
|
|
67
|
+
return !Array.isArray(required) || required.length === 0;
|
|
68
|
+
});
|
|
69
|
+
if (noArgTool) {
|
|
70
|
+
const exec = await apiRequest(request, 'POST', EXECUTE, {
|
|
71
|
+
token: adminToken,
|
|
72
|
+
data: { toolName: noArgTool.name, args: {} },
|
|
73
|
+
});
|
|
74
|
+
// A 200 from the execute route always carries `success: true` (only a
|
|
75
|
+
// thrown handler yields 400 `{ success: false }`), so assert the real
|
|
76
|
+
// value rather than mere presence. A no-arg tool that rejects empty input
|
|
77
|
+
// legitimately returns 400, which this guard intentionally tolerates.
|
|
78
|
+
if (exec.status() === 200) {
|
|
79
|
+
expect((await readJsonSafe<{ success?: boolean }>(exec))?.success).toBe(true);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('unauthenticated execute is rejected with 401', async ({ baseURL }) => {
|
|
85
|
+
const anon = await playwrightRequest.newContext({ baseURL });
|
|
86
|
+
try {
|
|
87
|
+
const res = await anon.fetch(EXECUTE, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'Content-Type': 'application/json' },
|
|
90
|
+
data: JSON.stringify({ toolName: 'does.not_exist_tool', args: {} }),
|
|
91
|
+
});
|
|
92
|
+
expect(res.status()).toBe(401);
|
|
93
|
+
} finally {
|
|
94
|
+
await anon.dispose();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|