@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.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -2
  3. package/dist/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.js +146 -0
  4. package/dist/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.js.map +7 -0
  5. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js +4 -8
  6. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js.map +2 -2
  7. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.js +119 -0
  8. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.js.map +7 -0
  9. package/dist/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.js +174 -0
  10. package/dist/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.js.map +7 -0
  11. package/dist/modules/ai_assistant/__integration__/TC-AI-PARTICIPANTS-002-add-remove-participants.spec.js +132 -0
  12. package/dist/modules/ai_assistant/__integration__/TC-AI-PARTICIPANTS-002-add-remove-participants.spec.js.map +7 -0
  13. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +7 -0
  14. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +2 -2
  15. package/dist/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.js +68 -0
  16. package/dist/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.js.map +7 -0
  17. package/dist/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.js +74 -0
  18. package/dist/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.js.map +7 -0
  19. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +6 -5
  20. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +2 -2
  21. package/dist/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.js +57 -0
  22. package/dist/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.js.map +7 -0
  23. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js +127 -0
  24. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js.map +7 -0
  25. package/dist/modules/ai_assistant/lib/auth.js +2 -11
  26. package/dist/modules/ai_assistant/lib/auth.js.map +2 -2
  27. package/dist/modules/ai_assistant/lib/codemode-tools.js +17 -20
  28. package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
  29. package/dist/modules/ai_assistant/lib/http-server.js +3 -2
  30. package/dist/modules/ai_assistant/lib/http-server.js.map +2 -2
  31. package/dist/modules/ai_assistant/lib/log-redaction.js +25 -0
  32. package/dist/modules/ai_assistant/lib/log-redaction.js.map +7 -0
  33. package/dist/modules/ai_assistant/lib/tool-test-runner.js +5 -3
  34. package/dist/modules/ai_assistant/lib/tool-test-runner.js.map +2 -2
  35. package/package.json +10 -11
  36. package/src/modules/ai_assistant/__integration__/TC-AI-ACTIONS-PENDING-004-confirm-cancel-mutations.spec.ts +209 -0
  37. package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.ts +6 -18
  38. package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-OVERRIDES-005-prompt-mutation-loop.spec.ts +176 -0
  39. package/src/modules/ai_assistant/__integration__/TC-AI-CONVERSATIONS-001-create-list-delete.spec.ts +222 -0
  40. package/src/modules/ai_assistant/__integration__/TC-AI-PARTICIPANTS-002-add-remove-participants.spec.ts +184 -0
  41. package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +8 -0
  42. package/src/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.ts +95 -0
  43. package/src/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.ts +115 -0
  44. package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +7 -5
  45. package/src/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.ts +97 -0
  46. package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +198 -0
  47. package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +27 -0
  48. package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +128 -0
  49. package/src/modules/ai_assistant/lib/__tests__/log-redaction.test.ts +65 -0
  50. package/src/modules/ai_assistant/lib/__tests__/tool-test-runner-pick-default-tenant.test.ts +70 -0
  51. package/src/modules/ai_assistant/lib/auth.ts +9 -15
  52. package/src/modules/ai_assistant/lib/codemode-tools.ts +21 -29
  53. package/src/modules/ai_assistant/lib/http-server.ts +3 -2
  54. package/src/modules/ai_assistant/lib/log-redaction.ts +41 -0
  55. package/src/modules/ai_assistant/lib/tool-test-runner.ts +11 -6
@@ -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
+ });
@@ -78,6 +78,8 @@ const STEP_EVENT = {
78
78
  };
79
79
 
80
80
  test.describe('TC-AI-TOKEN-USAGE-001–005: token usage stats page', () => {
81
+ test.describe.configure({ timeout: 120_000 });
82
+
81
83
  test('TC-AI-TOKEN-USAGE-001: usage page renders summary tiles for superadmin', async ({ page }) => {
82
84
  await login(page, 'superadmin');
83
85
 
@@ -97,7 +99,7 @@ test.describe('TC-AI-TOKEN-USAGE-001–005: token usage stats page', () => {
97
99
  });
98
100
  });
99
101
 
100
- await page.goto(USAGE_PAGE, { waitUntil: 'domcontentloaded' });
102
+ await page.goto(USAGE_PAGE, { waitUntil: 'commit' });
101
103
 
102
104
  const summaryTile = page.locator('p.font-semibold.text-xl', { hasText: /^(1,000|500)$/ }).first();
103
105
  await expect(summaryTile).toBeVisible({ timeout: 15_000 });
@@ -130,7 +132,7 @@ test.describe('TC-AI-TOKEN-USAGE-001–005: token usage stats page', () => {
130
132
  (response) => response.url().includes('/api/ai_assistant/usage/sessions') && response.status() === 200,
131
133
  { timeout: 15_000 },
132
134
  );
133
- await page.goto(USAGE_PAGE, { waitUntil: 'domcontentloaded' });
135
+ await page.goto(USAGE_PAGE, { waitUntil: 'commit' });
134
136
  await Promise.all([initialDailyRequest, initialSessionsRequest]);
135
137
 
136
138
  const fromInput = page.locator('#usage-from');
@@ -178,7 +180,7 @@ test.describe('TC-AI-TOKEN-USAGE-001–005: token usage stats page', () => {
178
180
  });
179
181
  });
180
182
 
181
- await page.goto(USAGE_PAGE, { waitUntil: 'domcontentloaded' });
183
+ await page.goto(USAGE_PAGE, { waitUntil: 'commit' });
182
184
 
183
185
  const sessionCell = page.getByText('00000000').first();
184
186
  await expect(sessionCell).toBeVisible({ timeout: 15_000 });
@@ -216,7 +218,7 @@ test.describe('TC-AI-TOKEN-USAGE-001–005: token usage stats page', () => {
216
218
  });
217
219
  });
218
220
 
219
- await page.goto(USAGE_PAGE, { waitUntil: 'domcontentloaded' });
221
+ await page.goto(USAGE_PAGE, { waitUntil: 'commit' });
220
222
 
221
223
  const sessionCell = page.getByText('00000000').first();
222
224
  await expect(sessionCell).toBeVisible({ timeout: 15_000 });
@@ -233,7 +235,7 @@ test.describe('TC-AI-TOKEN-USAGE-001–005: token usage stats page', () => {
233
235
  const context = await browser.newContext();
234
236
  const page = await context.newPage();
235
237
  try {
236
- await page.goto(USAGE_PAGE, { waitUntil: 'domcontentloaded' });
238
+ await page.goto(USAGE_PAGE, { waitUntil: 'commit' });
237
239
  await page.waitForURL(/\/login/, { timeout: 15_000 });
238
240
  expect(page.url()).toMatch(/\/login/);
239
241
  } finally {
@@ -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
+ });
@@ -0,0 +1,198 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import path from 'node:path';
4
+ import { Client } from 'pg';
5
+
6
+ /**
7
+ * Direct-Postgres fixtures for AI Assistant integration specs.
8
+ *
9
+ * Mirrors the sanctioned pattern in
10
+ * `@open-mercato/core/helpers/integration/dbFixtures` (raw SQL against
11
+ * `DATABASE_URL`) for the surfaces that have NO public create route:
12
+ * - `ai_pending_actions`: a pending mutation approval is only ever born from
13
+ * the internal `prepareMutation` path during a real LLM agent turn. Seeding
14
+ * the row directly keeps confirm/cancel/GET coverage deterministic and
15
+ * provider-free.
16
+ * - prompt overrides: the `prompt-override` route exposes no DELETE, so
17
+ * versioned rows must be swept via SQL after a test.
18
+ *
19
+ * The app server and this helper MUST share one `DATABASE_URL`, so specs using
20
+ * these helpers only run under the coherent app+DB harness
21
+ * (`yarn test:integration` / `:ephemeral`), never against an arbitrary dev
22
+ * server whose `DATABASE_URL` differs from `apps/mercato/.env`.
23
+ */
24
+
25
+ function resolveAppRoot(): string {
26
+ const fromEnv = process.env.OM_TEST_APP_ROOT?.trim();
27
+ return fromEnv ? path.resolve(fromEnv) : path.resolve(process.cwd(), 'apps/mercato');
28
+ }
29
+
30
+ function readEnvValue(key: string): string | undefined {
31
+ if (process.env[key]) return process.env[key];
32
+ const candidatePaths = [
33
+ path.resolve(resolveAppRoot(), '.env'),
34
+ path.resolve(process.cwd(), 'apps/mercato/.env'),
35
+ path.resolve(process.cwd(), '.env'),
36
+ ];
37
+ for (const envPath of candidatePaths) {
38
+ try {
39
+ const content = readFileSync(envPath, 'utf-8');
40
+ const match = content.match(new RegExp(`^${key}=(.+)$`, 'm'));
41
+ if (match?.[1]) return match[1].trim();
42
+ } catch {
43
+ continue;
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ function resolveDatabaseUrl(): string {
50
+ const url = readEnvValue('DATABASE_URL');
51
+ if (!url) throw new Error('[internal] DATABASE_URL is not configured for AI Assistant integration DB fixtures');
52
+ return url;
53
+ }
54
+
55
+ async function withClient<T>(run: (client: Client) => Promise<T>): Promise<T> {
56
+ const client = new Client({ connectionString: resolveDatabaseUrl() });
57
+ await client.connect();
58
+ try {
59
+ return await run(client);
60
+ } finally {
61
+ await client.end();
62
+ }
63
+ }
64
+
65
+ export type SeededPendingActionStatus =
66
+ | 'pending'
67
+ | 'confirmed'
68
+ | 'cancelled'
69
+ | 'expired'
70
+ | 'executing'
71
+ | 'failed';
72
+
73
+ export interface SeedPendingActionInput {
74
+ tenantId: string;
75
+ organizationId?: string | null;
76
+ createdByUserId: string;
77
+ status?: SeededPendingActionStatus;
78
+ agentId?: string;
79
+ toolName?: string;
80
+ /** Minutes from now until expiry. Negative => the row is already expired. */
81
+ expiresInMinutes?: number;
82
+ normalizedInput?: Record<string, unknown>;
83
+ executionResult?: Record<string, unknown> | null;
84
+ idempotencyKey?: string;
85
+ }
86
+
87
+ export interface SeededPendingAction {
88
+ id: string;
89
+ idempotencyKey: string;
90
+ }
91
+
92
+ /**
93
+ * Inserts an `ai_pending_actions` row directly. Returns the new id so the test
94
+ * can act on it and clean it up. Defaults produce an actionable `pending` row
95
+ * with a future TTL; override `status`/`expiresInMinutes`/`executionResult` to
96
+ * cover the idempotency short-circuit and 409 error branches.
97
+ */
98
+ export async function seedPendingActionInDb(input: SeedPendingActionInput): Promise<SeededPendingAction> {
99
+ const idempotencyKey = input.idempotencyKey ?? `it-pending-${randomUUID()}`;
100
+ const status = input.status ?? 'pending';
101
+ const expiresInMinutes = input.expiresInMinutes ?? 60;
102
+ const normalizedInput = JSON.stringify(input.normalizedInput ?? {});
103
+ const executionResult =
104
+ input.executionResult === undefined || input.executionResult === null
105
+ ? null
106
+ : JSON.stringify(input.executionResult);
107
+ return withClient(async (client) => {
108
+ const result = await client.query<{ id: string }>(
109
+ `insert into ai_pending_actions
110
+ (id, tenant_id, organization_id, agent_id, tool_name, normalized_input,
111
+ field_diff, attachment_ids, idempotency_key, created_by_user_id, status,
112
+ queue_mode, execution_result, created_at, expires_at)
113
+ values
114
+ (gen_random_uuid(), $1, $2, $3, $4, $5::jsonb,
115
+ '[]'::jsonb, '[]'::jsonb, $6, $7, $8,
116
+ 'inline', $9::jsonb, now(), now() + make_interval(mins => $10::int))
117
+ returning id`,
118
+ [
119
+ input.tenantId,
120
+ input.organizationId ?? null,
121
+ input.agentId ?? 'it_agent.pending_fixture',
122
+ input.toolName ?? 'it_tool.noop',
123
+ normalizedInput,
124
+ idempotencyKey,
125
+ input.createdByUserId,
126
+ status,
127
+ executionResult,
128
+ expiresInMinutes,
129
+ ],
130
+ );
131
+ return { id: result.rows[0].id, idempotencyKey };
132
+ });
133
+ }
134
+
135
+ /** Hard-deletes a seeded pending action row (best-effort test cleanup). */
136
+ export async function deletePendingActionInDb(id: string | null): Promise<void> {
137
+ if (!id) return;
138
+ await withClient(async (client) => {
139
+ await client.query('delete from ai_pending_actions where id = $1', [id]);
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Sweeps every per-agent override row for a tenant across the three override
145
+ * tables. Required for `prompt-override` cleanup (no DELETE route) and used as a
146
+ * belt-and-braces teardown for mutation-policy / loop overrides.
147
+ */
148
+ export async function deleteAgentOverridesInDb(input: { tenantId: string; agentId: string }): Promise<void> {
149
+ if (!input.tenantId || !input.agentId) return;
150
+ await withClient(async (client) => {
151
+ await client.query('delete from ai_agent_prompt_overrides where tenant_id = $1 and agent_id = $2', [
152
+ input.tenantId,
153
+ input.agentId,
154
+ ]);
155
+ await client.query('delete from ai_agent_mutation_policy_overrides where tenant_id = $1 and agent_id = $2', [
156
+ input.tenantId,
157
+ input.agentId,
158
+ ]);
159
+ await client.query('delete from ai_agent_runtime_overrides where tenant_id = $1 and agent_id = $2', [
160
+ input.tenantId,
161
+ input.agentId,
162
+ ]);
163
+ });
164
+ }
165
+
166
+ /** Hard-deletes any tenant model-allowlist rows (best-effort cleanup). */
167
+ export async function deleteTenantAllowlistInDb(tenantId: string | null): Promise<void> {
168
+ if (!tenantId) return;
169
+ await withClient(async (client) => {
170
+ await client.query('delete from ai_tenant_model_allowlists where tenant_id = $1', [tenantId]);
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Hard-deletes a conversation and its participant + message rows by the
176
+ * client-facing `conversation_id`. The DELETE route only soft-deletes, so this
177
+ * gives specs a true teardown for the rows they created.
178
+ */
179
+ export async function deleteConversationCascadeInDb(input: {
180
+ tenantId: string;
181
+ conversationId: string;
182
+ }): Promise<void> {
183
+ if (!input.tenantId || !input.conversationId) return;
184
+ await withClient(async (client) => {
185
+ await client.query('delete from ai_chat_messages where tenant_id = $1 and conversation_id = $2', [
186
+ input.tenantId,
187
+ input.conversationId,
188
+ ]);
189
+ await client.query(
190
+ 'delete from ai_chat_conversation_participants where tenant_id = $1 and conversation_id = $2',
191
+ [input.tenantId, input.conversationId],
192
+ );
193
+ await client.query('delete from ai_chat_conversations where tenant_id = $1 and conversation_id = $2', [
194
+ input.tenantId,
195
+ input.conversationId,
196
+ ]);
197
+ });
198
+ }
@@ -1,4 +1,5 @@
1
1
  import { extractApiKeyFromHeaders, hasRequiredFeatures } from '../auth'
2
+ import { hasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'
2
3
 
3
4
  describe('extractApiKeyFromHeaders', () => {
4
5
  describe('plain object headers', () => {
@@ -92,6 +93,32 @@ describe('hasRequiredFeatures', () => {
92
93
  ).toBe(false)
93
94
  })
94
95
 
96
+ it('grants a bare-segment requirement from a prefix wildcard (issue #2723)', () => {
97
+ expect(
98
+ hasRequiredFeatures(['entities'], ['entities.*'], false)
99
+ ).toBe(true)
100
+ })
101
+
102
+ it('matches the canonical matcher for bare-segment requirements with and without rbacService', () => {
103
+ const rbacService = {
104
+ hasAllFeatures: jest.fn((required: string[], granted: string[]) =>
105
+ hasAllFeatures(required, granted)
106
+ ),
107
+ }
108
+
109
+ const withService = hasRequiredFeatures(
110
+ ['entities'],
111
+ ['entities.*'],
112
+ false,
113
+ rbacService as any
114
+ )
115
+ const withoutService = hasRequiredFeatures(['entities'], ['entities.*'], false)
116
+
117
+ expect(withService).toBe(true)
118
+ expect(withoutService).toBe(true)
119
+ expect(withService).toBe(withoutService)
120
+ })
121
+
95
122
  it('requires all features (AND logic)', () => {
96
123
  expect(
97
124
  hasRequiredFeatures(