@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,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/ai_assistant/__integration__/TC-AI-SESSION-KEY-006-create-session-token.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { test, expect, request as playwrightRequest } from '@playwright/test';\nimport { randomUUID } from 'node:crypto';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport {\n createRoleFixture,\n deleteRoleIfExists,\n createUserFixture,\n deleteUserIfExists,\n setUserAclVisibility,\n} from '@open-mercato/core/helpers/integration/authFixtures';\nimport { deleteUserAclInDb } from '@open-mercato/core/helpers/integration/dbFixtures';\nimport { getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\n\n/**\n * TC-AI-SESSION-KEY-006 \u2014 Session key generation.\n * Source: GitHub issue #2495.\n *\n * Surface under test:\n * - /api/ai_assistant/session-key (POST)\n *\n * Contract notes verified against the route handler (the issue's guesses were wrong):\n * - the response body is EXACTLY `{ sessionToken, expiresAt }` \u2014 it does NOT\n * include userId / tenantId / organizationId.\n * - token format is `sess_` + 32 lowercase hex chars; TTL is 120 minutes.\n * - requires `ai_assistant.view`; unauthenticated -> 401; missing feature -> 403.\n *\n * NOTE: the returned `sess_...` token is consumed only as the MCP `_sessionToken`\n * tool-call argument \u2014 it is NOT accepted by the Next.js HTTP auth resolver as a\n * Bearer/header, so the issue's \"use the token to call /tools\" step is invalid\n * and intentionally omitted here.\n */\n\nconst SESSION_KEY = '/api/ai_assistant/session-key';\nconst SESSION_TOKEN_RE = /^sess_[0-9a-f]{32}$/;\nconst TTL_MINUTES = 120;\n\ntest.describe('TC-AI-SESSION-KEY-006: Session key generation', () => {\n test('POST mints a unique sess_ token with a ~120 minute TTL', async ({ request }) => {\n const adminToken = await getAuthToken(request, 'admin');\n\n const first = await apiRequest(request, 'POST', SESSION_KEY, { token: adminToken, data: {} });\n expect(first.status(), 'session-key POST returns 200').toBe(200);\n const body = await readJsonSafe<{ sessionToken: string; expiresAt: string }>(first);\n expect(body?.sessionToken).toMatch(SESSION_TOKEN_RE);\n\n const now = Date.now();\n const expiresAt = Date.parse(body!.expiresAt);\n expect(Number.isNaN(expiresAt)).toBe(false);\n // Allow generous slop for clock + request latency around the 120-minute TTL.\n expect(expiresAt).toBeGreaterThan(now + (TTL_MINUTES - 5) * 60 * 1000);\n expect(expiresAt).toBeLessThan(now + (TTL_MINUTES + 5) * 60 * 1000);\n\n const second = await apiRequest(request, 'POST', SESSION_KEY, { token: adminToken, data: {} });\n expect(second.status()).toBe(200);\n const secondBody = await readJsonSafe<{ sessionToken: string }>(second);\n expect(secondBody?.sessionToken).toMatch(SESSION_TOKEN_RE);\n expect(secondBody?.sessionToken, 'each call mints a distinct token').not.toBe(body?.sessionToken);\n });\n\n test('auth gates: unauthenticated 401 and missing ai_assistant.view 403', async ({ request, baseURL }) => {\n test.slow();\n const adminToken = await getAuthToken(request, 'admin');\n const { organizationId } = getTokenScope(adminToken);\n const stamp = randomUUID().slice(0, 8);\n const password = 'Secret123!';\n\n const anon = await playwrightRequest.newContext({ baseURL });\n try {\n const res = await anon.fetch(SESSION_KEY, { method: 'POST', data: '{}' });\n expect(res.status(), 'unauthenticated POST is 401').toBe(401);\n } finally {\n await anon.dispose();\n }\n\n let roleId: string | null = null;\n let userId: string | null = null;\n try {\n roleId = await createRoleFixture(request, adminToken, { name: `IT Session Role ${stamp}` });\n userId = await createUserFixture(request, adminToken, {\n email: `it-session-${stamp}@example.com`,\n password,\n organizationId,\n roles: [roleId],\n });\n await setUserAclVisibility(request, adminToken, { userId, features: [], organizations: null });\n const viewlessToken = await getAuthToken(request, `it-session-${stamp}@example.com`, password);\n const denied = await apiRequest(request, 'POST', SESSION_KEY, { token: viewlessToken, data: {} });\n expect(denied.status(), 'caller without ai_assistant.view is 403').toBe(403);\n } finally {\n await deleteUserAclInDb(userId ?? '').catch(() => undefined);\n await deleteUserIfExists(request, adminToken, userId);\n await deleteRoleIfExists(request, adminToken, roleId);\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,MAAM,QAAQ,WAAW,yBAAyB;AAC3D,SAAS,kBAAkB;AAC3B,SAAS,YAAY,oBAAoB;AACzC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,yBAAyB;AAClC,SAAS,eAAe,oBAAoB;AAqB5C,MAAM,cAAc;AACpB,MAAM,mBAAmB;AACzB,MAAM,cAAc;AAEpB,KAAK,SAAS,iDAAiD,MAAM;AACnE,OAAK,0DAA0D,OAAO,EAAE,QAAQ,MAAM;AACpF,UAAM,aAAa,MAAM,aAAa,SAAS,OAAO;AAEtD,UAAM,QAAQ,MAAM,WAAW,SAAS,QAAQ,aAAa,EAAE,OAAO,YAAY,MAAM,CAAC,EAAE,CAAC;AAC5F,WAAO,MAAM,OAAO,GAAG,8BAA8B,EAAE,KAAK,GAAG;AAC/D,UAAM,OAAO,MAAM,aAA0D,KAAK;AAClF,WAAO,MAAM,YAAY,EAAE,QAAQ,gBAAgB;AAEnD,UAAM,MAAM,KAAK,IAAI;AACrB,UAAM,YAAY,KAAK,MAAM,KAAM,SAAS;AAC5C,WAAO,OAAO,MAAM,SAAS,CAAC,EAAE,KAAK,KAAK;AAE1C,WAAO,SAAS,EAAE,gBAAgB,OAAO,cAAc,KAAK,KAAK,GAAI;AACrE,WAAO,SAAS,EAAE,aAAa,OAAO,cAAc,KAAK,KAAK,GAAI;AAElE,UAAM,SAAS,MAAM,WAAW,SAAS,QAAQ,aAAa,EAAE,OAAO,YAAY,MAAM,CAAC,EAAE,CAAC;AAC7F,WAAO,OAAO,OAAO,CAAC,EAAE,KAAK,GAAG;AAChC,UAAM,aAAa,MAAM,aAAuC,MAAM;AACtE,WAAO,YAAY,YAAY,EAAE,QAAQ,gBAAgB;AACzD,WAAO,YAAY,cAAc,kCAAkC,EAAE,IAAI,KAAK,MAAM,YAAY;AAAA,EAClG,CAAC;AAED,OAAK,qEAAqE,OAAO,EAAE,SAAS,QAAQ,MAAM;AACxG,SAAK,KAAK;AACV,UAAM,aAAa,MAAM,aAAa,SAAS,OAAO;AACtD,UAAM,EAAE,eAAe,IAAI,cAAc,UAAU;AACnD,UAAM,QAAQ,WAAW,EAAE,MAAM,GAAG,CAAC;AACrC,UAAM,WAAW;AAEjB,UAAM,OAAO,MAAM,kBAAkB,WAAW,EAAE,QAAQ,CAAC;AAC3D,QAAI;AACF,YAAM,MAAM,MAAM,KAAK,MAAM,aAAa,EAAE,QAAQ,QAAQ,MAAM,KAAK,CAAC;AACxE,aAAO,IAAI,OAAO,GAAG,6BAA6B,EAAE,KAAK,GAAG;AAAA,IAC9D,UAAE;AACA,YAAM,KAAK,QAAQ;AAAA,IACrB;AAEA,QAAI,SAAwB;AAC5B,QAAI,SAAwB;AAC5B,QAAI;AACF,eAAS,MAAM,kBAAkB,SAAS,YAAY,EAAE,MAAM,mBAAmB,KAAK,GAAG,CAAC;AAC1F,eAAS,MAAM,kBAAkB,SAAS,YAAY;AAAA,QACpD,OAAO,cAAc,KAAK;AAAA,QAC1B;AAAA,QACA;AAAA,QACA,OAAO,CAAC,MAAM;AAAA,MAChB,CAAC;AACD,YAAM,qBAAqB,SAAS,YAAY,EAAE,QAAQ,UAAU,CAAC,GAAG,eAAe,KAAK,CAAC;AAC7F,YAAM,gBAAgB,MAAM,aAAa,SAAS,cAAc,KAAK,gBAAgB,QAAQ;AAC7F,YAAM,SAAS,MAAM,WAAW,SAAS,QAAQ,aAAa,EAAE,OAAO,eAAe,MAAM,CAAC,EAAE,CAAC;AAChG,aAAO,OAAO,OAAO,GAAG,yCAAyC,EAAE,KAAK,GAAG;AAAA,IAC7E,UAAE;AACA,YAAM,kBAAkB,UAAU,EAAE,EAAE,MAAM,MAAM,MAAS;AAC3D,YAAM,mBAAmB,SAAS,YAAY,MAAM;AACpD,YAAM,mBAAmB,SAAS,YAAY,MAAM;AAAA,IACtD;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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.js";
|
|
5
|
+
const ALLOWLIST = "/api/ai_assistant/settings/allowlist";
|
|
6
|
+
const SETTINGS = "/api/ai_assistant/settings";
|
|
7
|
+
test.describe("TC-AI-SETTINGS-ALLOWLIST-003: Tenant model allowlist", () => {
|
|
8
|
+
test("PUT persists, GET reflects, DELETE clears + is idempotent", async ({ request }) => {
|
|
9
|
+
test.slow();
|
|
10
|
+
const adminToken = await getAuthToken(request, "admin");
|
|
11
|
+
const { tenantId } = getTokenScope(adminToken);
|
|
12
|
+
try {
|
|
13
|
+
const reset = await apiRequest(request, "DELETE", ALLOWLIST, { token: adminToken });
|
|
14
|
+
expect(reset.status()).toBe(200);
|
|
15
|
+
const settingsBefore = await apiRequest(request, "GET", SETTINGS, { token: adminToken });
|
|
16
|
+
expect(settingsBefore.status()).toBe(200);
|
|
17
|
+
const before = await readJsonSafe(
|
|
18
|
+
settingsBefore
|
|
19
|
+
);
|
|
20
|
+
expect(before?.tenantAllowlist).toBeNull();
|
|
21
|
+
expect(before?.effectiveAllowlist).toBeTruthy();
|
|
22
|
+
const put = await apiRequest(request, "PUT", ALLOWLIST, {
|
|
23
|
+
token: adminToken,
|
|
24
|
+
data: { allowedProviders: ["openai"], allowedModelsByProvider: { openai: ["gpt-5-mini"] } }
|
|
25
|
+
});
|
|
26
|
+
expect(put.status(), "PUT allowlist returns 200").toBe(200);
|
|
27
|
+
const saved = await readJsonSafe(put);
|
|
28
|
+
expect(saved?.allowedProviders).toContain("openai");
|
|
29
|
+
expect(saved?.allowedModelsByProvider?.openai).toContain("gpt-5-mini");
|
|
30
|
+
const settingsAfter = await apiRequest(request, "GET", SETTINGS, { token: adminToken });
|
|
31
|
+
expect(settingsAfter.status()).toBe(200);
|
|
32
|
+
const after = await readJsonSafe(settingsAfter);
|
|
33
|
+
expect(after?.tenantAllowlist?.allowedProviders).toContain("openai");
|
|
34
|
+
const del = await apiRequest(request, "DELETE", ALLOWLIST, { token: adminToken });
|
|
35
|
+
expect(del.status()).toBe(200);
|
|
36
|
+
expect((await readJsonSafe(del))?.cleared).toBe(true);
|
|
37
|
+
const settingsCleared = await apiRequest(request, "GET", SETTINGS, { token: adminToken });
|
|
38
|
+
const cleared = await readJsonSafe(settingsCleared);
|
|
39
|
+
expect(cleared?.tenantAllowlist).toBeNull();
|
|
40
|
+
const delAgain = await apiRequest(request, "DELETE", ALLOWLIST, { token: adminToken });
|
|
41
|
+
expect(delAgain.status()).toBe(200);
|
|
42
|
+
expect((await readJsonSafe(delAgain))?.cleared, "DELETE is idempotent").toBe(false);
|
|
43
|
+
} finally {
|
|
44
|
+
await deleteTenantAllowlistInDb(tenantId).catch(() => void 0);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
test("validation + RBAC gates: bad body 400, employee 403, unauthenticated 401", async ({ request, baseURL }) => {
|
|
48
|
+
const adminToken = await getAuthToken(request, "admin");
|
|
49
|
+
const badType = await apiRequest(request, "PUT", ALLOWLIST, {
|
|
50
|
+
token: adminToken,
|
|
51
|
+
data: { allowedProviders: 123 }
|
|
52
|
+
});
|
|
53
|
+
expect(badType.status()).toBe(400);
|
|
54
|
+
expect((await readJsonSafe(badType))?.code).toBe("validation_error");
|
|
55
|
+
const employeeToken = await getAuthToken(request, "employee");
|
|
56
|
+
const denied = await apiRequest(request, "PUT", ALLOWLIST, {
|
|
57
|
+
token: employeeToken,
|
|
58
|
+
data: { allowedProviders: ["openai"] }
|
|
59
|
+
});
|
|
60
|
+
expect(denied.status(), "employee lacks settings.manage -> 403").toBe(403);
|
|
61
|
+
const anon = await playwrightRequest.newContext({ baseURL });
|
|
62
|
+
try {
|
|
63
|
+
const res = await anon.fetch(ALLOWLIST, {
|
|
64
|
+
method: "PUT",
|
|
65
|
+
headers: { "Content-Type": "application/json" },
|
|
66
|
+
data: JSON.stringify({ allowedProviders: ["openai"] })
|
|
67
|
+
});
|
|
68
|
+
expect(res.status(), "unauthenticated PUT is 401").toBe(401);
|
|
69
|
+
} finally {
|
|
70
|
+
await anon.dispose();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
//# sourceMappingURL=TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/ai_assistant/__integration__/TC-AI-SETTINGS-ALLOWLIST-003-put-delete-allowlist.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { test, expect, request as playwrightRequest } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { getTokenScope, readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\nimport { deleteTenantAllowlistInDb } from './helpers/aiAssistantFixtures';\n\n/**\n * TC-AI-SETTINGS-ALLOWLIST-003 \u2014 Tenant model allowlist (PUT/DELETE) + settings reflection.\n * Source: GitHub issue #2495.\n *\n * Surfaces under test:\n * - /api/ai_assistant/settings/allowlist (PUT, DELETE)\n * - /api/ai_assistant/settings (GET \u2014 reflects the snapshot)\n *\n * Contract notes verified against the route handlers:\n * - PUT body fields are `allowedProviders` / `allowedModelsByProvider`\n * (NOT `providers` / `models`); success is 200 returning the saved snapshot.\n * - DELETE returns 200 `{ cleared: boolean }` and is idempotent\n * (`cleared: false` when no active row exists).\n * - GET /settings exposes `tenantAllowlist` (null when unset) + `effectiveAllowlist`.\n * - PUT/DELETE require `ai_assistant.settings.manage`.\n *\n * Out of scope (documented): the `provider_not_in_env_allowlist` 400 branch only\n * fires when the APP process has `OM_AI_AVAILABLE_PROVIDERS` set to exclude the\n * provider. The deterministic harness does not control the app's env, so that\n * branch is covered by the module's unit tests; here we assert the env-agnostic\n * Zod validation (wrong type -> 400 validation_error) instead.\n */\n\nconst ALLOWLIST = '/api/ai_assistant/settings/allowlist';\nconst SETTINGS = '/api/ai_assistant/settings';\n\ninterface TenantAllowlistSnapshot {\n allowedProviders: string[] | null;\n allowedModelsByProvider: Record<string, string[]>;\n}\n\ntest.describe('TC-AI-SETTINGS-ALLOWLIST-003: Tenant model allowlist', () => {\n test('PUT persists, GET reflects, DELETE clears + is idempotent', async ({ request }) => {\n test.slow();\n const adminToken = await getAuthToken(request, 'admin');\n const { tenantId } = getTokenScope(adminToken);\n try {\n // Start from a known-clean state.\n const reset = await apiRequest(request, 'DELETE', ALLOWLIST, { token: adminToken });\n expect(reset.status()).toBe(200);\n\n const settingsBefore = await apiRequest(request, 'GET', SETTINGS, { token: adminToken });\n expect(settingsBefore.status()).toBe(200);\n const before = await readJsonSafe<{ tenantAllowlist: TenantAllowlistSnapshot | null; effectiveAllowlist: unknown }>(\n settingsBefore,\n );\n expect(before?.tenantAllowlist).toBeNull();\n expect(before?.effectiveAllowlist).toBeTruthy();\n\n const put = await apiRequest(request, 'PUT', ALLOWLIST, {\n token: adminToken,\n data: { allowedProviders: ['openai'], allowedModelsByProvider: { openai: ['gpt-5-mini'] } },\n });\n expect(put.status(), 'PUT allowlist returns 200').toBe(200);\n const saved = await readJsonSafe<TenantAllowlistSnapshot>(put);\n expect(saved?.allowedProviders).toContain('openai');\n expect(saved?.allowedModelsByProvider?.openai).toContain('gpt-5-mini');\n\n const settingsAfter = await apiRequest(request, 'GET', SETTINGS, { token: adminToken });\n expect(settingsAfter.status()).toBe(200);\n const after = await readJsonSafe<{ tenantAllowlist: TenantAllowlistSnapshot | null }>(settingsAfter);\n expect(after?.tenantAllowlist?.allowedProviders).toContain('openai');\n\n const del = await apiRequest(request, 'DELETE', ALLOWLIST, { token: adminToken });\n expect(del.status()).toBe(200);\n expect((await readJsonSafe<{ cleared: boolean }>(del))?.cleared).toBe(true);\n\n const settingsCleared = await apiRequest(request, 'GET', SETTINGS, { token: adminToken });\n const cleared = await readJsonSafe<{ tenantAllowlist: TenantAllowlistSnapshot | null }>(settingsCleared);\n expect(cleared?.tenantAllowlist).toBeNull();\n\n const delAgain = await apiRequest(request, 'DELETE', ALLOWLIST, { token: adminToken });\n expect(delAgain.status()).toBe(200);\n expect((await readJsonSafe<{ cleared: boolean }>(delAgain))?.cleared, 'DELETE is idempotent').toBe(false);\n } finally {\n await deleteTenantAllowlistInDb(tenantId).catch(() => undefined);\n }\n });\n\n test('validation + RBAC gates: bad body 400, employee 403, unauthenticated 401', async ({ request, baseURL }) => {\n const adminToken = await getAuthToken(request, 'admin');\n\n const badType = await apiRequest(request, 'PUT', ALLOWLIST, {\n token: adminToken,\n data: { allowedProviders: 123 },\n });\n expect(badType.status()).toBe(400);\n expect((await readJsonSafe<{ code?: string }>(badType))?.code).toBe('validation_error');\n\n // Employee carries ai_assistant.view but NOT ai_assistant.settings.manage.\n const employeeToken = await getAuthToken(request, 'employee');\n const denied = await apiRequest(request, 'PUT', ALLOWLIST, {\n token: employeeToken,\n data: { allowedProviders: ['openai'] },\n });\n expect(denied.status(), 'employee lacks settings.manage -> 403').toBe(403);\n\n const anon = await playwrightRequest.newContext({ baseURL });\n try {\n const res = await anon.fetch(ALLOWLIST, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n data: JSON.stringify({ allowedProviders: ['openai'] }),\n });\n expect(res.status(), 'unauthenticated PUT is 401').toBe(401);\n } finally {\n await anon.dispose();\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -57,6 +57,7 @@ const STEP_EVENT = {
|
|
|
57
57
|
updatedAt: "2026-05-01T10:00:00.000Z"
|
|
58
58
|
};
|
|
59
59
|
test.describe("TC-AI-TOKEN-USAGE-001\u2013005: token usage stats page", () => {
|
|
60
|
+
test.describe.configure({ timeout: 12e4 });
|
|
60
61
|
test("TC-AI-TOKEN-USAGE-001: usage page renders summary tiles for superadmin", async ({ page }) => {
|
|
61
62
|
await login(page, "superadmin");
|
|
62
63
|
await page.route("**/api/ai_assistant/usage/daily**", async (route) => {
|
|
@@ -73,7 +74,7 @@ test.describe("TC-AI-TOKEN-USAGE-001\u2013005: token usage stats page", () => {
|
|
|
73
74
|
body: JSON.stringify(EMPTY_SESSIONS_PAYLOAD)
|
|
74
75
|
});
|
|
75
76
|
});
|
|
76
|
-
await page.goto(USAGE_PAGE, { waitUntil: "
|
|
77
|
+
await page.goto(USAGE_PAGE, { waitUntil: "commit" });
|
|
77
78
|
const summaryTile = page.locator("p.font-semibold.text-xl", { hasText: /^(1,000|500)$/ }).first();
|
|
78
79
|
await expect(summaryTile).toBeVisible({ timeout: 15e3 });
|
|
79
80
|
});
|
|
@@ -101,7 +102,7 @@ test.describe("TC-AI-TOKEN-USAGE-001\u2013005: token usage stats page", () => {
|
|
|
101
102
|
(response) => response.url().includes("/api/ai_assistant/usage/sessions") && response.status() === 200,
|
|
102
103
|
{ timeout: 15e3 }
|
|
103
104
|
);
|
|
104
|
-
await page.goto(USAGE_PAGE, { waitUntil: "
|
|
105
|
+
await page.goto(USAGE_PAGE, { waitUntil: "commit" });
|
|
105
106
|
await Promise.all([initialDailyRequest, initialSessionsRequest]);
|
|
106
107
|
const fromInput = page.locator("#usage-from");
|
|
107
108
|
const toInput = page.locator("#usage-to");
|
|
@@ -138,7 +139,7 @@ test.describe("TC-AI-TOKEN-USAGE-001\u2013005: token usage stats page", () => {
|
|
|
138
139
|
body: JSON.stringify({ sessions: [SESSION_ROW], total: 1, limit: 50, offset: 0 })
|
|
139
140
|
});
|
|
140
141
|
});
|
|
141
|
-
await page.goto(USAGE_PAGE, { waitUntil: "
|
|
142
|
+
await page.goto(USAGE_PAGE, { waitUntil: "commit" });
|
|
142
143
|
const sessionCell = page.getByText("00000000").first();
|
|
143
144
|
await expect(sessionCell).toBeVisible({ timeout: 15e3 });
|
|
144
145
|
});
|
|
@@ -169,7 +170,7 @@ test.describe("TC-AI-TOKEN-USAGE-001\u2013005: token usage stats page", () => {
|
|
|
169
170
|
body: JSON.stringify({ sessions: [SESSION_ROW], total: 1, limit: 50, offset: 0 })
|
|
170
171
|
});
|
|
171
172
|
});
|
|
172
|
-
await page.goto(USAGE_PAGE, { waitUntil: "
|
|
173
|
+
await page.goto(USAGE_PAGE, { waitUntil: "commit" });
|
|
173
174
|
const sessionCell = page.getByText("00000000").first();
|
|
174
175
|
await expect(sessionCell).toBeVisible({ timeout: 15e3 });
|
|
175
176
|
await sessionCell.click();
|
|
@@ -182,7 +183,7 @@ test.describe("TC-AI-TOKEN-USAGE-001\u2013005: token usage stats page", () => {
|
|
|
182
183
|
const context = await browser.newContext();
|
|
183
184
|
const page = await context.newPage();
|
|
184
185
|
try {
|
|
185
|
-
await page.goto(USAGE_PAGE, { waitUntil: "
|
|
186
|
+
await page.goto(USAGE_PAGE, { waitUntil: "commit" });
|
|
186
187
|
await page.waitForURL(/\/login/, { timeout: 15e3 });
|
|
187
188
|
expect(page.url()).toMatch(/\/login/);
|
|
188
189
|
} finally {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts"],
|
|
4
|
-
"sourcesContent": ["import { test, expect } from '@playwright/test';\nimport { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth';\n\n/**\n * TC-AI-TOKEN-USAGE-001 through TC-AI-TOKEN-USAGE-005\n *\n * Integration coverage for Phase 6 (Token Usage Tracking & Stats Page) of\n * spec `2026-04-28-ai-agents-agentic-loop-controls`.\n *\n * TC-AI-TOKEN-USAGE-001 \u2014 Usage page loads and renders summary tiles (ACL gate).\n * TC-AI-TOKEN-USAGE-002 \u2014 Date filter apply re-fetches with updated params.\n * TC-AI-TOKEN-USAGE-003 \u2014 Sessions list renders when API returns session rows.\n * TC-AI-TOKEN-USAGE-004 \u2014 Clicking a session row opens the detail dialog.\n * TC-AI-TOKEN-USAGE-005 \u2014 Unauthenticated visit to usage page redirects to login.\n *\n * All API calls are intercepted via page.route() stubs \u2014 no real DB needed.\n */\n\nconst USAGE_PAGE = '/backend/config/ai-assistant/usage';\n\nconst EMPTY_DAILY_PAYLOAD = { rows: [], total: 0 };\nconst EMPTY_SESSIONS_PAYLOAD = { sessions: [], total: 0, limit: 50, offset: 0 };\n\nconst DAILY_ROW = {\n id: 'row-1',\n tenantId: 'tenant-1',\n organizationId: null,\n day: '2026-05-01',\n agentId: 'catalog.assistant',\n modelId: 'claude-haiku-4-5',\n providerId: 'anthropic',\n inputTokens: '1000',\n outputTokens: '500',\n cachedInputTokens: '0',\n reasoningTokens: '0',\n stepCount: '5',\n turnCount: '3',\n sessionCount: '2',\n createdAt: '2026-05-01T12:00:00.000Z',\n updatedAt: '2026-05-01T12:00:00.000Z',\n};\n\nconst SESSION_ROW = {\n sessionId: '00000000-0000-0000-0000-000000000001',\n agentId: 'catalog.assistant',\n moduleId: 'catalog',\n userId: 'user-1',\n startedAt: '2026-05-01T10:00:00.000Z',\n lastEventAt: '2026-05-01T10:05:00.000Z',\n stepCount: 5,\n turnCount: 3,\n inputTokens: 1000,\n outputTokens: 500,\n cachedInputTokens: 0,\n reasoningTokens: 0,\n};\n\nconst STEP_EVENT = {\n id: 'evt-1',\n tenantId: 'tenant-1',\n organizationId: null,\n userId: 'user-1',\n agentId: 'catalog.assistant',\n moduleId: 'catalog',\n sessionId: '00000000-0000-0000-0000-000000000001',\n turnId: '00000000-0000-0000-0000-000000000002',\n stepIndex: 0,\n providerId: 'anthropic',\n modelId: 'claude-haiku-4-5',\n inputTokens: 1000,\n outputTokens: 500,\n cachedInputTokens: null,\n reasoningTokens: null,\n finishReason: 'stop',\n loopAbortReason: null,\n createdAt: '2026-05-01T10:00:00.000Z',\n updatedAt: '2026-05-01T10:00:00.000Z',\n};\n\ntest.describe('TC-AI-TOKEN-USAGE-001\u2013005: token usage stats page', () => {\n test('TC-AI-TOKEN-USAGE-001: usage page renders summary tiles for superadmin', async ({ page }) => {\n await login(page, 'superadmin');\n\n await page.route('**/api/ai_assistant/usage/daily**', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({ rows: [DAILY_ROW], total: 1 }),\n });\n });\n\n await page.route('**/api/ai_assistant/usage/sessions**', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(EMPTY_SESSIONS_PAYLOAD),\n });\n });\n\n await page.goto(USAGE_PAGE, { waitUntil: '
|
|
5
|
-
"mappings": "AAAA,SAAS,MAAM,cAAc;AAC7B,SAAS,aAAa;AAiBtB,MAAM,aAAa;AAEnB,MAAM,sBAAsB,EAAE,MAAM,CAAC,GAAG,OAAO,EAAE;AACjD,MAAM,yBAAyB,EAAE,UAAU,CAAC,GAAG,OAAO,GAAG,OAAO,IAAI,QAAQ,EAAE;AAE9E,MAAM,YAAY;AAAA,EAChB,IAAI;AAAA,EACJ,UAAU;AAAA,EACV,gBAAgB;AAAA,EAChB,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,EACT,YAAY;AAAA,EACZ,aAAa;AAAA,EACb,cAAc;AAAA,EACd,mBAAmB;AAAA,EACnB,iBAAiB;AAAA,EACjB,WAAW;AAAA,EACX,WAAW;AAAA,EACX,cAAc;AAAA,EACd,WAAW;AAAA,EACX,WAAW;AACb;AAEA,MAAM,cAAc;AAAA,EAClB,WAAW;AAAA,EACX,SAAS;AAAA,EACT,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,aAAa;AAAA,EACb,WAAW;AAAA,EACX,WAAW;AAAA,EACX,aAAa;AAAA,EACb,cAAc;AAAA,EACd,mBAAmB;AAAA,EACnB,iBAAiB;AACnB;AAEA,MAAM,aAAa;AAAA,EACjB,IAAI;AAAA,EACJ,UAAU;AAAA,EACV,gBAAgB;AAAA,EAChB,QAAQ;AAAA,EACR,SAAS;AAAA,EACT,UAAU;AAAA,EACV,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,YAAY;AAAA,EACZ,SAAS;AAAA,EACT,aAAa;AAAA,EACb,cAAc;AAAA,EACd,mBAAmB;AAAA,EACnB,iBAAiB;AAAA,EACjB,cAAc;AAAA,EACd,iBAAiB;AAAA,EACjB,WAAW;AAAA,EACX,WAAW;AACb;AAEA,KAAK,SAAS,0DAAqD,MAAM;AACvE,OAAK,0EAA0E,OAAO,EAAE,KAAK,MAAM;AACjG,UAAM,MAAM,MAAM,YAAY;AAE9B,UAAM,KAAK,MAAM,qCAAqC,OAAO,UAAU;AACrE,YAAM,MAAM,QAAQ;AAAA,QAClB,QAAQ;AAAA,QACR,aAAa;AAAA,QACb,MAAM,KAAK,UAAU,EAAE,MAAM,CAAC,SAAS,GAAG,OAAO,EAAE,CAAC;AAAA,MACtD,CAAC;AAAA,IACH,CAAC;AAED,UAAM,KAAK,MAAM,wCAAwC,OAAO,UAAU;AACxE,YAAM,MAAM,QAAQ;AAAA,QAClB,QAAQ;AAAA,QACR,aAAa;AAAA,QACb,MAAM,KAAK,UAAU,sBAAsB;AAAA,MAC7C,CAAC;AAAA,IACH,CAAC;AAED,UAAM,KAAK,KAAK,YAAY,EAAE,WAAW,
|
|
4
|
+
"sourcesContent": ["import { test, expect } from '@playwright/test';\nimport { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth';\n\n/**\n * TC-AI-TOKEN-USAGE-001 through TC-AI-TOKEN-USAGE-005\n *\n * Integration coverage for Phase 6 (Token Usage Tracking & Stats Page) of\n * spec `2026-04-28-ai-agents-agentic-loop-controls`.\n *\n * TC-AI-TOKEN-USAGE-001 \u2014 Usage page loads and renders summary tiles (ACL gate).\n * TC-AI-TOKEN-USAGE-002 \u2014 Date filter apply re-fetches with updated params.\n * TC-AI-TOKEN-USAGE-003 \u2014 Sessions list renders when API returns session rows.\n * TC-AI-TOKEN-USAGE-004 \u2014 Clicking a session row opens the detail dialog.\n * TC-AI-TOKEN-USAGE-005 \u2014 Unauthenticated visit to usage page redirects to login.\n *\n * All API calls are intercepted via page.route() stubs \u2014 no real DB needed.\n */\n\nconst USAGE_PAGE = '/backend/config/ai-assistant/usage';\n\nconst EMPTY_DAILY_PAYLOAD = { rows: [], total: 0 };\nconst EMPTY_SESSIONS_PAYLOAD = { sessions: [], total: 0, limit: 50, offset: 0 };\n\nconst DAILY_ROW = {\n id: 'row-1',\n tenantId: 'tenant-1',\n organizationId: null,\n day: '2026-05-01',\n agentId: 'catalog.assistant',\n modelId: 'claude-haiku-4-5',\n providerId: 'anthropic',\n inputTokens: '1000',\n outputTokens: '500',\n cachedInputTokens: '0',\n reasoningTokens: '0',\n stepCount: '5',\n turnCount: '3',\n sessionCount: '2',\n createdAt: '2026-05-01T12:00:00.000Z',\n updatedAt: '2026-05-01T12:00:00.000Z',\n};\n\nconst SESSION_ROW = {\n sessionId: '00000000-0000-0000-0000-000000000001',\n agentId: 'catalog.assistant',\n moduleId: 'catalog',\n userId: 'user-1',\n startedAt: '2026-05-01T10:00:00.000Z',\n lastEventAt: '2026-05-01T10:05:00.000Z',\n stepCount: 5,\n turnCount: 3,\n inputTokens: 1000,\n outputTokens: 500,\n cachedInputTokens: 0,\n reasoningTokens: 0,\n};\n\nconst STEP_EVENT = {\n id: 'evt-1',\n tenantId: 'tenant-1',\n organizationId: null,\n userId: 'user-1',\n agentId: 'catalog.assistant',\n moduleId: 'catalog',\n sessionId: '00000000-0000-0000-0000-000000000001',\n turnId: '00000000-0000-0000-0000-000000000002',\n stepIndex: 0,\n providerId: 'anthropic',\n modelId: 'claude-haiku-4-5',\n inputTokens: 1000,\n outputTokens: 500,\n cachedInputTokens: null,\n reasoningTokens: null,\n finishReason: 'stop',\n loopAbortReason: null,\n createdAt: '2026-05-01T10:00:00.000Z',\n updatedAt: '2026-05-01T10:00:00.000Z',\n};\n\ntest.describe('TC-AI-TOKEN-USAGE-001\u2013005: token usage stats page', () => {\n test.describe.configure({ timeout: 120_000 });\n\n test('TC-AI-TOKEN-USAGE-001: usage page renders summary tiles for superadmin', async ({ page }) => {\n await login(page, 'superadmin');\n\n await page.route('**/api/ai_assistant/usage/daily**', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({ rows: [DAILY_ROW], total: 1 }),\n });\n });\n\n await page.route('**/api/ai_assistant/usage/sessions**', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(EMPTY_SESSIONS_PAYLOAD),\n });\n });\n\n await page.goto(USAGE_PAGE, { waitUntil: 'commit' });\n\n const summaryTile = page.locator('p.font-semibold.text-xl', { hasText: /^(1,000|500)$/ }).first();\n await expect(summaryTile).toBeVisible({ timeout: 15_000 });\n });\n\n test('TC-AI-TOKEN-USAGE-002: apply filter triggers re-fetch with new date params', async ({ page }) => {\n await login(page, 'superadmin');\n\n await page.route('**/api/ai_assistant/usage/daily**', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(EMPTY_DAILY_PAYLOAD),\n });\n });\n\n await page.route('**/api/ai_assistant/usage/sessions**', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(EMPTY_SESSIONS_PAYLOAD),\n });\n });\n\n const initialDailyRequest = page.waitForResponse(\n (response) => response.url().includes('/api/ai_assistant/usage/daily') && response.status() === 200,\n { timeout: 15_000 },\n );\n const initialSessionsRequest = page.waitForResponse(\n (response) => response.url().includes('/api/ai_assistant/usage/sessions') && response.status() === 200,\n { timeout: 15_000 },\n );\n await page.goto(USAGE_PAGE, { waitUntil: 'commit' });\n await Promise.all([initialDailyRequest, initialSessionsRequest]);\n\n const fromInput = page.locator('#usage-from');\n const toInput = page.locator('#usage-to');\n const applyButton = page.getByRole('button', { name: /apply/i });\n\n await expect(fromInput).toBeVisible({ timeout: 10_000 });\n\n await fromInput.fill('2026-04-01');\n await toInput.fill('2026-04-30');\n await expect(fromInput).toHaveValue('2026-04-01');\n await expect(toInput).toHaveValue('2026-04-30');\n const updatedDailyRequest = page.waitForResponse(\n (response) =>\n response.url().includes('/api/ai_assistant/usage/daily') &&\n response.url().includes('from=2026-04-01') &&\n response.url().includes('to=2026-04-30') &&\n response.status() === 200,\n { timeout: 10_000 },\n );\n await applyButton.click();\n await updatedDailyRequest;\n });\n\n test('TC-AI-TOKEN-USAGE-003: sessions list renders rows when API returns sessions', async ({ page }) => {\n await login(page, 'superadmin');\n\n await page.route('**/api/ai_assistant/usage/daily**', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(EMPTY_DAILY_PAYLOAD),\n });\n });\n\n await page.route('**/api/ai_assistant/usage/sessions**', async (route, request) => {\n if (request.url().includes('/sessions/')) {\n await route.continue();\n return;\n }\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({ sessions: [SESSION_ROW], total: 1, limit: 50, offset: 0 }),\n });\n });\n\n await page.goto(USAGE_PAGE, { waitUntil: 'commit' });\n\n const sessionCell = page.getByText('00000000').first();\n await expect(sessionCell).toBeVisible({ timeout: 15_000 });\n });\n\n test('TC-AI-TOKEN-USAGE-004: clicking a session row opens the detail dialog', async ({ page }) => {\n await login(page, 'superadmin');\n\n await page.route('**/api/ai_assistant/usage/daily**', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify(EMPTY_DAILY_PAYLOAD),\n });\n });\n\n await page.route('**/api/ai_assistant/usage/sessions/00000000-0000-0000-0000-000000000001', async (route) => {\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({ events: [STEP_EVENT], total: 1, sessionId: SESSION_ROW.sessionId }),\n });\n });\n\n await page.route('**/api/ai_assistant/usage/sessions**', async (route, request) => {\n if (request.url().includes('/00000000-0000-0000-0000-000000000001')) {\n // Fall back to the previously registered (more specific) handler.\n await route.fallback();\n return;\n }\n await route.fulfill({\n status: 200,\n contentType: 'application/json',\n body: JSON.stringify({ sessions: [SESSION_ROW], total: 1, limit: 50, offset: 0 }),\n });\n });\n\n await page.goto(USAGE_PAGE, { waitUntil: 'commit' });\n\n const sessionCell = page.getByText('00000000').first();\n await expect(sessionCell).toBeVisible({ timeout: 15_000 });\n await sessionCell.click();\n\n const dialogTitle = page.getByRole('dialog');\n await expect(dialogTitle).toBeVisible({ timeout: 10_000 });\n\n const modelCell = page.getByText('claude-haiku-4-5').first();\n await expect(modelCell).toBeVisible({ timeout: 5_000 });\n });\n\n test('TC-AI-TOKEN-USAGE-005: unauthenticated visit redirects to login', async ({ browser }) => {\n const context = await browser.newContext();\n const page = await context.newPage();\n try {\n await page.goto(USAGE_PAGE, { waitUntil: 'commit' });\n await page.waitForURL(/\\/login/, { timeout: 15_000 });\n expect(page.url()).toMatch(/\\/login/);\n } finally {\n await context.close();\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
const TOOLS = "/api/ai_assistant/tools";
|
|
5
|
+
const EXECUTE = "/api/ai_assistant/tools/execute";
|
|
6
|
+
test.describe("TC-AI-TOOLS-EXECUTE-007: Direct tool execution", () => {
|
|
7
|
+
test("list tools, validate + reject unknown tools, and exercise a no-arg tool", async ({ request }) => {
|
|
8
|
+
test.slow();
|
|
9
|
+
const adminToken = await getAuthToken(request, "admin");
|
|
10
|
+
const listRes = await apiRequest(request, "GET", TOOLS, { token: adminToken });
|
|
11
|
+
expect(listRes.status()).toBe(200);
|
|
12
|
+
const list = await readJsonSafe(listRes);
|
|
13
|
+
expect(Array.isArray(list?.tools)).toBe(true);
|
|
14
|
+
expect((list?.tools.length ?? 0) > 0, "at least one tool is visible to admin").toBe(true);
|
|
15
|
+
const sample = list.tools[0];
|
|
16
|
+
expect(typeof sample.name).toBe("string");
|
|
17
|
+
expect(typeof sample.inputSchema).toBe("object");
|
|
18
|
+
const missing = await apiRequest(request, "POST", EXECUTE, { token: adminToken, data: {} });
|
|
19
|
+
expect(missing.status()).toBe(400);
|
|
20
|
+
expect((await readJsonSafe(missing))?.error).toBe("toolName is required");
|
|
21
|
+
const unknown = await apiRequest(request, "POST", EXECUTE, {
|
|
22
|
+
token: adminToken,
|
|
23
|
+
data: { toolName: "does.not_exist_tool", args: {} }
|
|
24
|
+
});
|
|
25
|
+
expect(unknown.status()).toBe(400);
|
|
26
|
+
const unknownBody = await readJsonSafe(unknown);
|
|
27
|
+
expect(unknownBody?.success).toBe(false);
|
|
28
|
+
expect(unknownBody?.error ?? "").toContain("not found");
|
|
29
|
+
const noArgTool = list.tools.find((tool) => {
|
|
30
|
+
const required = tool.inputSchema?.required;
|
|
31
|
+
return !Array.isArray(required) || required.length === 0;
|
|
32
|
+
});
|
|
33
|
+
if (noArgTool) {
|
|
34
|
+
const exec = await apiRequest(request, "POST", EXECUTE, {
|
|
35
|
+
token: adminToken,
|
|
36
|
+
data: { toolName: noArgTool.name, args: {} }
|
|
37
|
+
});
|
|
38
|
+
if (exec.status() === 200) {
|
|
39
|
+
expect((await readJsonSafe(exec))?.success).toBe(true);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
test("unauthenticated execute is rejected with 401", async ({ baseURL }) => {
|
|
44
|
+
const anon = await playwrightRequest.newContext({ baseURL });
|
|
45
|
+
try {
|
|
46
|
+
const res = await anon.fetch(EXECUTE, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
data: JSON.stringify({ toolName: "does.not_exist_tool", args: {} })
|
|
50
|
+
});
|
|
51
|
+
expect(res.status()).toBe(401);
|
|
52
|
+
} finally {
|
|
53
|
+
await anon.dispose();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
//# sourceMappingURL=TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/ai_assistant/__integration__/TC-AI-TOOLS-EXECUTE-007-direct-tool-execution.spec.ts"],
|
|
4
|
+
"sourcesContent": ["import { test, expect, request as playwrightRequest } from '@playwright/test';\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api';\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures';\n\n/**\n * TC-AI-TOOLS-EXECUTE-007 \u2014 Direct tool execution + tool listing.\n * Source: GitHub issue #2495.\n *\n * Surfaces under test:\n * - /api/ai_assistant/tools (GET)\n * - /api/ai_assistant/tools/execute (POST)\n *\n * Contract notes verified against the route handlers (the issue's guesses were wrong):\n * - execute body fields are `toolName` + `args` (NOT `name` / `input`);\n * success is 200 `{ success: true, result }`.\n * - a missing `toolName` -> 400 `{ error: 'toolName is required' }`.\n * - an unknown tool -> 400 `{ success: false, error: 'Tool \"<n>\" not found' }`\n * (NOT a typed `code`; the errorCode only selects 400 vs 403 internally).\n * - both routes require `ai_assistant.view`; the per-tool `requiredFeatures` are\n * enforced separately inside the executor.\n */\n\nconst TOOLS = '/api/ai_assistant/tools';\nconst EXECUTE = '/api/ai_assistant/tools/execute';\n\ninterface ToolSummary {\n name: string;\n description: string;\n inputSchema: { required?: unknown } & Record<string, unknown>;\n module: string;\n}\n\ntest.describe('TC-AI-TOOLS-EXECUTE-007: Direct tool execution', () => {\n test('list tools, validate + reject unknown tools, and exercise a no-arg tool', async ({ request }) => {\n test.slow();\n const adminToken = await getAuthToken(request, 'admin');\n\n const listRes = await apiRequest(request, 'GET', TOOLS, { token: adminToken });\n expect(listRes.status()).toBe(200);\n const list = await readJsonSafe<{ tools: ToolSummary[] }>(listRes);\n expect(Array.isArray(list?.tools)).toBe(true);\n expect((list?.tools.length ?? 0) > 0, 'at least one tool is visible to admin').toBe(true);\n const sample = list!.tools[0];\n expect(typeof sample.name).toBe('string');\n expect(typeof sample.inputSchema).toBe('object');\n\n // Missing toolName -> 400 with the route-level message.\n const missing = await apiRequest(request, 'POST', EXECUTE, { token: adminToken, data: {} });\n expect(missing.status()).toBe(400);\n expect((await readJsonSafe<{ error?: string }>(missing))?.error).toBe('toolName is required');\n\n // Unknown tool -> 400 { success: false, error: '... not found' }.\n const unknown = await apiRequest(request, 'POST', EXECUTE, {\n token: adminToken,\n data: { toolName: 'does.not_exist_tool', args: {} },\n });\n expect(unknown.status()).toBe(400);\n const unknownBody = await readJsonSafe<{ success?: boolean; error?: string }>(unknown);\n expect(unknownBody?.success).toBe(false);\n expect(unknownBody?.error ?? '').toContain('not found');\n\n // Best-effort happy path: a tool whose input schema has no required fields can\n // be invoked with empty args. The execute route runs the handler and returns\n // 200 `{ success }`; assert the envelope only when such a tool is available.\n const noArgTool = list!.tools.find((tool) => {\n const required = tool.inputSchema?.required;\n return !Array.isArray(required) || required.length === 0;\n });\n if (noArgTool) {\n const exec = await apiRequest(request, 'POST', EXECUTE, {\n token: adminToken,\n data: { toolName: noArgTool.name, args: {} },\n });\n // A 200 from the execute route always carries `success: true` (only a\n // thrown handler yields 400 `{ success: false }`), so assert the real\n // value rather than mere presence. A no-arg tool that rejects empty input\n // legitimately returns 400, which this guard intentionally tolerates.\n if (exec.status() === 200) {\n expect((await readJsonSafe<{ success?: boolean }>(exec))?.success).toBe(true);\n }\n }\n });\n\n test('unauthenticated execute is rejected with 401', async ({ baseURL }) => {\n const anon = await playwrightRequest.newContext({ baseURL });\n try {\n const res = await anon.fetch(EXECUTE, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n data: JSON.stringify({ toolName: 'does.not_exist_tool', args: {} }),\n });\n expect(res.status()).toBe(401);\n } finally {\n await anon.dispose();\n }\n });\n});\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,MAAM,QAAQ,WAAW,yBAAyB;AAC3D,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAoB7B,MAAM,QAAQ;AACd,MAAM,UAAU;AAShB,KAAK,SAAS,kDAAkD,MAAM;AACpE,OAAK,2EAA2E,OAAO,EAAE,QAAQ,MAAM;AACrG,SAAK,KAAK;AACV,UAAM,aAAa,MAAM,aAAa,SAAS,OAAO;AAEtD,UAAM,UAAU,MAAM,WAAW,SAAS,OAAO,OAAO,EAAE,OAAO,WAAW,CAAC;AAC7E,WAAO,QAAQ,OAAO,CAAC,EAAE,KAAK,GAAG;AACjC,UAAM,OAAO,MAAM,aAAuC,OAAO;AACjE,WAAO,MAAM,QAAQ,MAAM,KAAK,CAAC,EAAE,KAAK,IAAI;AAC5C,YAAQ,MAAM,MAAM,UAAU,KAAK,GAAG,uCAAuC,EAAE,KAAK,IAAI;AACxF,UAAM,SAAS,KAAM,MAAM,CAAC;AAC5B,WAAO,OAAO,OAAO,IAAI,EAAE,KAAK,QAAQ;AACxC,WAAO,OAAO,OAAO,WAAW,EAAE,KAAK,QAAQ;AAG/C,UAAM,UAAU,MAAM,WAAW,SAAS,QAAQ,SAAS,EAAE,OAAO,YAAY,MAAM,CAAC,EAAE,CAAC;AAC1F,WAAO,QAAQ,OAAO,CAAC,EAAE,KAAK,GAAG;AACjC,YAAQ,MAAM,aAAiC,OAAO,IAAI,KAAK,EAAE,KAAK,sBAAsB;AAG5F,UAAM,UAAU,MAAM,WAAW,SAAS,QAAQ,SAAS;AAAA,MACzD,OAAO;AAAA,MACP,MAAM,EAAE,UAAU,uBAAuB,MAAM,CAAC,EAAE;AAAA,IACpD,CAAC;AACD,WAAO,QAAQ,OAAO,CAAC,EAAE,KAAK,GAAG;AACjC,UAAM,cAAc,MAAM,aAAoD,OAAO;AACrF,WAAO,aAAa,OAAO,EAAE,KAAK,KAAK;AACvC,WAAO,aAAa,SAAS,EAAE,EAAE,UAAU,WAAW;AAKtD,UAAM,YAAY,KAAM,MAAM,KAAK,CAAC,SAAS;AAC3C,YAAM,WAAW,KAAK,aAAa;AACnC,aAAO,CAAC,MAAM,QAAQ,QAAQ,KAAK,SAAS,WAAW;AAAA,IACzD,CAAC;AACD,QAAI,WAAW;AACb,YAAM,OAAO,MAAM,WAAW,SAAS,QAAQ,SAAS;AAAA,QACtD,OAAO;AAAA,QACP,MAAM,EAAE,UAAU,UAAU,MAAM,MAAM,CAAC,EAAE;AAAA,MAC7C,CAAC;AAKD,UAAI,KAAK,OAAO,MAAM,KAAK;AACzB,gBAAQ,MAAM,aAAoC,IAAI,IAAI,OAAO,EAAE,KAAK,IAAI;AAAA,MAC9E;AAAA,IACF;AAAA,EACF,CAAC;AAED,OAAK,gDAAgD,OAAO,EAAE,QAAQ,MAAM;AAC1E,UAAM,OAAO,MAAM,kBAAkB,WAAW,EAAE,QAAQ,CAAC;AAC3D,QAAI;AACF,YAAM,MAAM,MAAM,KAAK,MAAM,SAAS;AAAA,QACpC,QAAQ;AAAA,QACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,QAC9C,MAAM,KAAK,UAAU,EAAE,UAAU,uBAAuB,MAAM,CAAC,EAAE,CAAC;AAAA,MACpE,CAAC;AACD,aAAO,IAAI,OAAO,CAAC,EAAE,KAAK,GAAG;AAAA,IAC/B,UAAE;AACA,YAAM,KAAK,QAAQ;AAAA,IACrB;AAAA,EACF,CAAC;AACH,CAAC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Client } from "pg";
|
|
5
|
+
function resolveAppRoot() {
|
|
6
|
+
const fromEnv = process.env.OM_TEST_APP_ROOT?.trim();
|
|
7
|
+
return fromEnv ? path.resolve(fromEnv) : path.resolve(process.cwd(), "apps/mercato");
|
|
8
|
+
}
|
|
9
|
+
function readEnvValue(key) {
|
|
10
|
+
if (process.env[key]) return process.env[key];
|
|
11
|
+
const candidatePaths = [
|
|
12
|
+
path.resolve(resolveAppRoot(), ".env"),
|
|
13
|
+
path.resolve(process.cwd(), "apps/mercato/.env"),
|
|
14
|
+
path.resolve(process.cwd(), ".env")
|
|
15
|
+
];
|
|
16
|
+
for (const envPath of candidatePaths) {
|
|
17
|
+
try {
|
|
18
|
+
const content = readFileSync(envPath, "utf-8");
|
|
19
|
+
const match = content.match(new RegExp(`^${key}=(.+)$`, "m"));
|
|
20
|
+
if (match?.[1]) return match[1].trim();
|
|
21
|
+
} catch {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return void 0;
|
|
26
|
+
}
|
|
27
|
+
function resolveDatabaseUrl() {
|
|
28
|
+
const url = readEnvValue("DATABASE_URL");
|
|
29
|
+
if (!url) throw new Error("[internal] DATABASE_URL is not configured for AI Assistant integration DB fixtures");
|
|
30
|
+
return url;
|
|
31
|
+
}
|
|
32
|
+
async function withClient(run) {
|
|
33
|
+
const client = new Client({ connectionString: resolveDatabaseUrl() });
|
|
34
|
+
await client.connect();
|
|
35
|
+
try {
|
|
36
|
+
return await run(client);
|
|
37
|
+
} finally {
|
|
38
|
+
await client.end();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function seedPendingActionInDb(input) {
|
|
42
|
+
const idempotencyKey = input.idempotencyKey ?? `it-pending-${randomUUID()}`;
|
|
43
|
+
const status = input.status ?? "pending";
|
|
44
|
+
const expiresInMinutes = input.expiresInMinutes ?? 60;
|
|
45
|
+
const normalizedInput = JSON.stringify(input.normalizedInput ?? {});
|
|
46
|
+
const executionResult = input.executionResult === void 0 || input.executionResult === null ? null : JSON.stringify(input.executionResult);
|
|
47
|
+
return withClient(async (client) => {
|
|
48
|
+
const result = await client.query(
|
|
49
|
+
`insert into ai_pending_actions
|
|
50
|
+
(id, tenant_id, organization_id, agent_id, tool_name, normalized_input,
|
|
51
|
+
field_diff, attachment_ids, idempotency_key, created_by_user_id, status,
|
|
52
|
+
queue_mode, execution_result, created_at, expires_at)
|
|
53
|
+
values
|
|
54
|
+
(gen_random_uuid(), $1, $2, $3, $4, $5::jsonb,
|
|
55
|
+
'[]'::jsonb, '[]'::jsonb, $6, $7, $8,
|
|
56
|
+
'inline', $9::jsonb, now(), now() + make_interval(mins => $10::int))
|
|
57
|
+
returning id`,
|
|
58
|
+
[
|
|
59
|
+
input.tenantId,
|
|
60
|
+
input.organizationId ?? null,
|
|
61
|
+
input.agentId ?? "it_agent.pending_fixture",
|
|
62
|
+
input.toolName ?? "it_tool.noop",
|
|
63
|
+
normalizedInput,
|
|
64
|
+
idempotencyKey,
|
|
65
|
+
input.createdByUserId,
|
|
66
|
+
status,
|
|
67
|
+
executionResult,
|
|
68
|
+
expiresInMinutes
|
|
69
|
+
]
|
|
70
|
+
);
|
|
71
|
+
return { id: result.rows[0].id, idempotencyKey };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async function deletePendingActionInDb(id) {
|
|
75
|
+
if (!id) return;
|
|
76
|
+
await withClient(async (client) => {
|
|
77
|
+
await client.query("delete from ai_pending_actions where id = $1", [id]);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async function deleteAgentOverridesInDb(input) {
|
|
81
|
+
if (!input.tenantId || !input.agentId) return;
|
|
82
|
+
await withClient(async (client) => {
|
|
83
|
+
await client.query("delete from ai_agent_prompt_overrides where tenant_id = $1 and agent_id = $2", [
|
|
84
|
+
input.tenantId,
|
|
85
|
+
input.agentId
|
|
86
|
+
]);
|
|
87
|
+
await client.query("delete from ai_agent_mutation_policy_overrides where tenant_id = $1 and agent_id = $2", [
|
|
88
|
+
input.tenantId,
|
|
89
|
+
input.agentId
|
|
90
|
+
]);
|
|
91
|
+
await client.query("delete from ai_agent_runtime_overrides where tenant_id = $1 and agent_id = $2", [
|
|
92
|
+
input.tenantId,
|
|
93
|
+
input.agentId
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
async function deleteTenantAllowlistInDb(tenantId) {
|
|
98
|
+
if (!tenantId) return;
|
|
99
|
+
await withClient(async (client) => {
|
|
100
|
+
await client.query("delete from ai_tenant_model_allowlists where tenant_id = $1", [tenantId]);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async function deleteConversationCascadeInDb(input) {
|
|
104
|
+
if (!input.tenantId || !input.conversationId) return;
|
|
105
|
+
await withClient(async (client) => {
|
|
106
|
+
await client.query("delete from ai_chat_messages where tenant_id = $1 and conversation_id = $2", [
|
|
107
|
+
input.tenantId,
|
|
108
|
+
input.conversationId
|
|
109
|
+
]);
|
|
110
|
+
await client.query(
|
|
111
|
+
"delete from ai_chat_conversation_participants where tenant_id = $1 and conversation_id = $2",
|
|
112
|
+
[input.tenantId, input.conversationId]
|
|
113
|
+
);
|
|
114
|
+
await client.query("delete from ai_chat_conversations where tenant_id = $1 and conversation_id = $2", [
|
|
115
|
+
input.tenantId,
|
|
116
|
+
input.conversationId
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
export {
|
|
121
|
+
deleteAgentOverridesInDb,
|
|
122
|
+
deleteConversationCascadeInDb,
|
|
123
|
+
deletePendingActionInDb,
|
|
124
|
+
deleteTenantAllowlistInDb,
|
|
125
|
+
seedPendingActionInDb
|
|
126
|
+
};
|
|
127
|
+
//# sourceMappingURL=aiAssistantFixtures.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts"],
|
|
4
|
+
"sourcesContent": ["import { readFileSync } from 'node:fs';\nimport { randomUUID } from 'node:crypto';\nimport path from 'node:path';\nimport { Client } from 'pg';\n\n/**\n * Direct-Postgres fixtures for AI Assistant integration specs.\n *\n * Mirrors the sanctioned pattern in\n * `@open-mercato/core/helpers/integration/dbFixtures` (raw SQL against\n * `DATABASE_URL`) for the surfaces that have NO public create route:\n * - `ai_pending_actions`: a pending mutation approval is only ever born from\n * the internal `prepareMutation` path during a real LLM agent turn. Seeding\n * the row directly keeps confirm/cancel/GET coverage deterministic and\n * provider-free.\n * - prompt overrides: the `prompt-override` route exposes no DELETE, so\n * versioned rows must be swept via SQL after a test.\n *\n * The app server and this helper MUST share one `DATABASE_URL`, so specs using\n * these helpers only run under the coherent app+DB harness\n * (`yarn test:integration` / `:ephemeral`), never against an arbitrary dev\n * server whose `DATABASE_URL` differs from `apps/mercato/.env`.\n */\n\nfunction resolveAppRoot(): string {\n const fromEnv = process.env.OM_TEST_APP_ROOT?.trim();\n return fromEnv ? path.resolve(fromEnv) : path.resolve(process.cwd(), 'apps/mercato');\n}\n\nfunction readEnvValue(key: string): string | undefined {\n if (process.env[key]) return process.env[key];\n const candidatePaths = [\n path.resolve(resolveAppRoot(), '.env'),\n path.resolve(process.cwd(), 'apps/mercato/.env'),\n path.resolve(process.cwd(), '.env'),\n ];\n for (const envPath of candidatePaths) {\n try {\n const content = readFileSync(envPath, 'utf-8');\n const match = content.match(new RegExp(`^${key}=(.+)$`, 'm'));\n if (match?.[1]) return match[1].trim();\n } catch {\n continue;\n }\n }\n return undefined;\n}\n\nfunction resolveDatabaseUrl(): string {\n const url = readEnvValue('DATABASE_URL');\n if (!url) throw new Error('[internal] DATABASE_URL is not configured for AI Assistant integration DB fixtures');\n return url;\n}\n\nasync function withClient<T>(run: (client: Client) => Promise<T>): Promise<T> {\n const client = new Client({ connectionString: resolveDatabaseUrl() });\n await client.connect();\n try {\n return await run(client);\n } finally {\n await client.end();\n }\n}\n\nexport type SeededPendingActionStatus =\n | 'pending'\n | 'confirmed'\n | 'cancelled'\n | 'expired'\n | 'executing'\n | 'failed';\n\nexport interface SeedPendingActionInput {\n tenantId: string;\n organizationId?: string | null;\n createdByUserId: string;\n status?: SeededPendingActionStatus;\n agentId?: string;\n toolName?: string;\n /** Minutes from now until expiry. Negative => the row is already expired. */\n expiresInMinutes?: number;\n normalizedInput?: Record<string, unknown>;\n executionResult?: Record<string, unknown> | null;\n idempotencyKey?: string;\n}\n\nexport interface SeededPendingAction {\n id: string;\n idempotencyKey: string;\n}\n\n/**\n * Inserts an `ai_pending_actions` row directly. Returns the new id so the test\n * can act on it and clean it up. Defaults produce an actionable `pending` row\n * with a future TTL; override `status`/`expiresInMinutes`/`executionResult` to\n * cover the idempotency short-circuit and 409 error branches.\n */\nexport async function seedPendingActionInDb(input: SeedPendingActionInput): Promise<SeededPendingAction> {\n const idempotencyKey = input.idempotencyKey ?? `it-pending-${randomUUID()}`;\n const status = input.status ?? 'pending';\n const expiresInMinutes = input.expiresInMinutes ?? 60;\n const normalizedInput = JSON.stringify(input.normalizedInput ?? {});\n const executionResult =\n input.executionResult === undefined || input.executionResult === null\n ? null\n : JSON.stringify(input.executionResult);\n return withClient(async (client) => {\n const result = await client.query<{ id: string }>(\n `insert into ai_pending_actions\n (id, tenant_id, organization_id, agent_id, tool_name, normalized_input,\n field_diff, attachment_ids, idempotency_key, created_by_user_id, status,\n queue_mode, execution_result, created_at, expires_at)\n values\n (gen_random_uuid(), $1, $2, $3, $4, $5::jsonb,\n '[]'::jsonb, '[]'::jsonb, $6, $7, $8,\n 'inline', $9::jsonb, now(), now() + make_interval(mins => $10::int))\n returning id`,\n [\n input.tenantId,\n input.organizationId ?? null,\n input.agentId ?? 'it_agent.pending_fixture',\n input.toolName ?? 'it_tool.noop',\n normalizedInput,\n idempotencyKey,\n input.createdByUserId,\n status,\n executionResult,\n expiresInMinutes,\n ],\n );\n return { id: result.rows[0].id, idempotencyKey };\n });\n}\n\n/** Hard-deletes a seeded pending action row (best-effort test cleanup). */\nexport async function deletePendingActionInDb(id: string | null): Promise<void> {\n if (!id) return;\n await withClient(async (client) => {\n await client.query('delete from ai_pending_actions where id = $1', [id]);\n });\n}\n\n/**\n * Sweeps every per-agent override row for a tenant across the three override\n * tables. Required for `prompt-override` cleanup (no DELETE route) and used as a\n * belt-and-braces teardown for mutation-policy / loop overrides.\n */\nexport async function deleteAgentOverridesInDb(input: { tenantId: string; agentId: string }): Promise<void> {\n if (!input.tenantId || !input.agentId) return;\n await withClient(async (client) => {\n await client.query('delete from ai_agent_prompt_overrides where tenant_id = $1 and agent_id = $2', [\n input.tenantId,\n input.agentId,\n ]);\n await client.query('delete from ai_agent_mutation_policy_overrides where tenant_id = $1 and agent_id = $2', [\n input.tenantId,\n input.agentId,\n ]);\n await client.query('delete from ai_agent_runtime_overrides where tenant_id = $1 and agent_id = $2', [\n input.tenantId,\n input.agentId,\n ]);\n });\n}\n\n/** Hard-deletes any tenant model-allowlist rows (best-effort cleanup). */\nexport async function deleteTenantAllowlistInDb(tenantId: string | null): Promise<void> {\n if (!tenantId) return;\n await withClient(async (client) => {\n await client.query('delete from ai_tenant_model_allowlists where tenant_id = $1', [tenantId]);\n });\n}\n\n/**\n * Hard-deletes a conversation and its participant + message rows by the\n * client-facing `conversation_id`. The DELETE route only soft-deletes, so this\n * gives specs a true teardown for the rows they created.\n */\nexport async function deleteConversationCascadeInDb(input: {\n tenantId: string;\n conversationId: string;\n}): Promise<void> {\n if (!input.tenantId || !input.conversationId) return;\n await withClient(async (client) => {\n await client.query('delete from ai_chat_messages where tenant_id = $1 and conversation_id = $2', [\n input.tenantId,\n input.conversationId,\n ]);\n await client.query(\n 'delete from ai_chat_conversation_participants where tenant_id = $1 and conversation_id = $2',\n [input.tenantId, input.conversationId],\n );\n await client.query('delete from ai_chat_conversations where tenant_id = $1 and conversation_id = $2', [\n input.tenantId,\n input.conversationId,\n ]);\n });\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasAllFeatures } from "@open-mercato/shared/lib/auth/featureMatch";
|
|
1
2
|
async function authenticateMcpRequest(apiKeySecret, container) {
|
|
2
3
|
if (!apiKeySecret || typeof apiKeySecret !== "string") {
|
|
3
4
|
return { success: false, error: "API key is required" };
|
|
@@ -49,17 +50,7 @@ function hasRequiredFeatures(requiredFeatures, userFeatures, isSuperAdmin, rbacS
|
|
|
49
50
|
if (rbacService) {
|
|
50
51
|
return rbacService.hasAllFeatures(requiredFeatures, userFeatures);
|
|
51
52
|
}
|
|
52
|
-
return requiredFeatures
|
|
53
|
-
if (userFeatures.includes(required)) return true;
|
|
54
|
-
if (userFeatures.includes("*")) return true;
|
|
55
|
-
return userFeatures.some((feature) => {
|
|
56
|
-
if (feature.endsWith(".*")) {
|
|
57
|
-
const prefix = feature.slice(0, -2);
|
|
58
|
-
return required.startsWith(prefix + ".");
|
|
59
|
-
}
|
|
60
|
-
return false;
|
|
61
|
-
});
|
|
62
|
-
});
|
|
53
|
+
return hasAllFeatures(requiredFeatures, userFeatures);
|
|
63
54
|
}
|
|
64
55
|
function extractApiKeyFromHeaders(headers) {
|
|
65
56
|
const getHeader = (name) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/ai_assistant/lib/auth.ts"],
|
|
4
|
-
"sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\n\n/**\n * Successful authentication result.\n */\nexport type McpAuthSuccess = {\n success: true\n keyId: string\n keyName: string\n tenantId: string | null\n organizationId: string | null\n userId: string\n features: string[]\n isSuperAdmin: boolean\n}\n\n/**\n * Failed authentication result.\n */\nexport type McpAuthFailure = {\n success: false\n error: string\n}\n\n/**\n * Result from MCP authentication.\n */\nexport type McpAuthResult = McpAuthSuccess | McpAuthFailure\n\n/**\n * Authenticate an MCP request using an API key.\n *\n * This function validates the API key secret and loads the associated\n * ACL (features, organizations, super admin status) from the key's roles.\n *\n * @param apiKeySecret - The full API key secret (e.g., 'omk_xxxx.yyyy...')\n * @param container - Awilix DI container with 'em' and 'rbacService'\n * @returns Authentication result with user context or error\n */\nexport async function authenticateMcpRequest(\n apiKeySecret: string,\n container: AwilixContainer\n): Promise<McpAuthResult> {\n if (!apiKeySecret || typeof apiKeySecret !== 'string') {\n return { success: false, error: 'API key is required' }\n }\n\n const trimmedSecret = apiKeySecret.trim()\n if (!trimmedSecret) {\n return { success: false, error: 'API key is required' }\n }\n\n if (!trimmedSecret.startsWith('omk_')) {\n return { success: false, error: 'Invalid API key format' }\n }\n\n try {\n const em = container.resolve('em') as EntityManager\n\n const { findApiKeyBySecret } = await import(\n '@open-mercato/core/modules/api_keys/services/apiKeyService'\n )\n\n const apiKey = await findApiKeyBySecret(em, trimmedSecret)\n\n if (!apiKey) {\n return { success: false, error: 'Invalid or expired API key' }\n }\n\n const userId = `api_key:${apiKey.id}`\n\n const rbacService = container.resolve('rbacService') as {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null }\n ) => Promise<{\n isSuperAdmin: boolean\n features: string[]\n organizations: string[] | null\n }>\n }\n\n const acl = await rbacService.loadAcl(userId, {\n tenantId: apiKey.tenantId ?? null,\n organizationId: apiKey.organizationId ?? null,\n })\n\n try {\n apiKey.lastUsedAt = new Date()\n await em.persist(apiKey).flush()\n } catch {\n // Best-effort update; ignore write failures\n }\n\n return {\n success: true,\n keyId: apiKey.id,\n keyName: apiKey.name,\n tenantId: apiKey.tenantId ?? null,\n organizationId: apiKey.organizationId ?? null,\n userId,\n features: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n console.error('[MCP Auth] Authentication failed:', message)\n return { success: false, error: 'Authentication failed' }\n }\n}\n\n/**\n * Check if user has the required features for a resource.\n *\n * Supports:\n * - Super admin bypass (always returns true)\n * - Direct feature match (e.g., 'customers.view')\n * - Global wildcard ('*' grants all features)\n * - Prefix wildcard (e.g., 'customers.*' grants 'customers.people.view')\n *\n * @param requiredFeatures - List of features required for access\n * @param userFeatures - List of features the user has\n * @param isSuperAdmin - Whether the user is a super admin\n * @param rbacService - Optional RbacService to delegate feature matching\n * @returns True if user has access\n */\nexport function hasRequiredFeatures(\n requiredFeatures: string[] | undefined,\n userFeatures: string[],\n isSuperAdmin: boolean,\n rbacService?: RbacService\n): boolean {\n if (isSuperAdmin) return true\n if (!requiredFeatures?.length) return true\n\n // Delegate to RbacService if provided\n if (rbacService) {\n return rbacService.hasAllFeatures(requiredFeatures, userFeatures)\n }\n\n // Fallback for cases without rbacService
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { hasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'\n\n/**\n * Successful authentication result.\n */\nexport type McpAuthSuccess = {\n success: true\n keyId: string\n keyName: string\n tenantId: string | null\n organizationId: string | null\n userId: string\n features: string[]\n isSuperAdmin: boolean\n}\n\n/**\n * Failed authentication result.\n */\nexport type McpAuthFailure = {\n success: false\n error: string\n}\n\n/**\n * Result from MCP authentication.\n */\nexport type McpAuthResult = McpAuthSuccess | McpAuthFailure\n\n/**\n * Authenticate an MCP request using an API key.\n *\n * This function validates the API key secret and loads the associated\n * ACL (features, organizations, super admin status) from the key's roles.\n *\n * @param apiKeySecret - The full API key secret (e.g., 'omk_xxxx.yyyy...')\n * @param container - Awilix DI container with 'em' and 'rbacService'\n * @returns Authentication result with user context or error\n */\nexport async function authenticateMcpRequest(\n apiKeySecret: string,\n container: AwilixContainer\n): Promise<McpAuthResult> {\n if (!apiKeySecret || typeof apiKeySecret !== 'string') {\n return { success: false, error: 'API key is required' }\n }\n\n const trimmedSecret = apiKeySecret.trim()\n if (!trimmedSecret) {\n return { success: false, error: 'API key is required' }\n }\n\n if (!trimmedSecret.startsWith('omk_')) {\n return { success: false, error: 'Invalid API key format' }\n }\n\n try {\n const em = container.resolve('em') as EntityManager\n\n const { findApiKeyBySecret } = await import(\n '@open-mercato/core/modules/api_keys/services/apiKeyService'\n )\n\n const apiKey = await findApiKeyBySecret(em, trimmedSecret)\n\n if (!apiKey) {\n return { success: false, error: 'Invalid or expired API key' }\n }\n\n const userId = `api_key:${apiKey.id}`\n\n const rbacService = container.resolve('rbacService') as {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null }\n ) => Promise<{\n isSuperAdmin: boolean\n features: string[]\n organizations: string[] | null\n }>\n }\n\n const acl = await rbacService.loadAcl(userId, {\n tenantId: apiKey.tenantId ?? null,\n organizationId: apiKey.organizationId ?? null,\n })\n\n try {\n apiKey.lastUsedAt = new Date()\n await em.persist(apiKey).flush()\n } catch {\n // Best-effort update; ignore write failures\n }\n\n return {\n success: true,\n keyId: apiKey.id,\n keyName: apiKey.name,\n tenantId: apiKey.tenantId ?? null,\n organizationId: apiKey.organizationId ?? null,\n userId,\n features: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n }\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n console.error('[MCP Auth] Authentication failed:', message)\n return { success: false, error: 'Authentication failed' }\n }\n}\n\n/**\n * Check if user has the required features for a resource.\n *\n * Supports:\n * - Super admin bypass (always returns true)\n * - Direct feature match (e.g., 'customers.view')\n * - Global wildcard ('*' grants all features)\n * - Prefix wildcard (e.g., 'customers.*' grants 'customers.people.view' and the\n * bare 'customers' segment itself)\n *\n * @param requiredFeatures - List of features required for access\n * @param userFeatures - List of features the user has\n * @param isSuperAdmin - Whether the user is a super admin\n * @param rbacService - Optional RbacService to delegate feature matching\n * @returns True if user has access\n */\nexport function hasRequiredFeatures(\n requiredFeatures: string[] | undefined,\n userFeatures: string[],\n isSuperAdmin: boolean,\n rbacService?: RbacService\n): boolean {\n if (isSuperAdmin) return true\n if (!requiredFeatures?.length) return true\n\n // Delegate to RbacService if provided\n if (rbacService) {\n return rbacService.hasAllFeatures(requiredFeatures, userFeatures)\n }\n\n // Fallback for cases without rbacService: delegate to the canonical\n // wildcard-aware matcher so this path stays consistent with\n // RbacService.hasAllFeatures (which uses the same helper). The previous\n // bespoke loop rejected a bare-segment requirement (e.g. 'entities')\n // against an 'entities.*' grant, diverging from the canonical matcher.\n return hasAllFeatures(requiredFeatures, userFeatures)\n}\n\n/**\n * Extract API key from HTTP request headers.\n *\n * Supports two header formats:\n * - x-api-key: <secret>\n * - Authorization: ApiKey <secret>\n *\n * @param headers - Request headers (Map, Headers, or plain object)\n * @returns The API key secret or null if not found\n */\nexport function extractApiKeyFromHeaders(\n headers: Headers | Map<string, string> | Record<string, string | undefined>\n): string | null {\n const getHeader = (name: string): string | null => {\n if (headers instanceof Headers) {\n return headers.get(name)\n }\n if (headers instanceof Map) {\n return headers.get(name) ?? null\n }\n const value = headers[name] ?? headers[name.toLowerCase()]\n return typeof value === 'string' ? value : null\n }\n\n const xApiKey = getHeader('x-api-key')?.trim()\n if (xApiKey) {\n return xApiKey\n }\n\n const authHeader = getHeader('authorization')?.trim()\n if (authHeader && authHeader.toLowerCase().startsWith('apikey ')) {\n return authHeader.slice(7).trim()\n }\n\n return null\n}\n"],
|
|
5
|
+
"mappings": "AAGA,SAAS,sBAAsB;AAuC/B,eAAsB,uBACpB,cACA,WACwB;AACxB,MAAI,CAAC,gBAAgB,OAAO,iBAAiB,UAAU;AACrD,WAAO,EAAE,SAAS,OAAO,OAAO,sBAAsB;AAAA,EACxD;AAEA,QAAM,gBAAgB,aAAa,KAAK;AACxC,MAAI,CAAC,eAAe;AAClB,WAAO,EAAE,SAAS,OAAO,OAAO,sBAAsB;AAAA,EACxD;AAEA,MAAI,CAAC,cAAc,WAAW,MAAM,GAAG;AACrC,WAAO,EAAE,SAAS,OAAO,OAAO,yBAAyB;AAAA,EAC3D;AAEA,MAAI;AACF,UAAM,KAAK,UAAU,QAAQ,IAAI;AAEjC,UAAM,EAAE,mBAAmB,IAAI,MAAM,OACnC,4DACF;AAEA,UAAM,SAAS,MAAM,mBAAmB,IAAI,aAAa;AAEzD,QAAI,CAAC,QAAQ;AACX,aAAO,EAAE,SAAS,OAAO,OAAO,6BAA6B;AAAA,IAC/D;AAEA,UAAM,SAAS,WAAW,OAAO,EAAE;AAEnC,UAAM,cAAc,UAAU,QAAQ,aAAa;AAWnD,UAAM,MAAM,MAAM,YAAY,QAAQ,QAAQ;AAAA,MAC5C,UAAU,OAAO,YAAY;AAAA,MAC7B,gBAAgB,OAAO,kBAAkB;AAAA,IAC3C,CAAC;AAED,QAAI;AACF,aAAO,aAAa,oBAAI,KAAK;AAC7B,YAAM,GAAG,QAAQ,MAAM,EAAE,MAAM;AAAA,IACjC,QAAQ;AAAA,IAER;AAEA,WAAO;AAAA,MACL,SAAS;AAAA,MACT,OAAO,OAAO;AAAA,MACd,SAAS,OAAO;AAAA,MAChB,UAAU,OAAO,YAAY;AAAA,MAC7B,gBAAgB,OAAO,kBAAkB;AAAA,MACzC;AAAA,MACA,UAAU,IAAI;AAAA,MACd,cAAc,IAAI;AAAA,IACpB;AAAA,EACF,SAAS,OAAO;AACd,UAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AACrE,YAAQ,MAAM,qCAAqC,OAAO;AAC1D,WAAO,EAAE,SAAS,OAAO,OAAO,wBAAwB;AAAA,EAC1D;AACF;AAkBO,SAAS,oBACd,kBACA,cACA,cACA,aACS;AACT,MAAI,aAAc,QAAO;AACzB,MAAI,CAAC,kBAAkB,OAAQ,QAAO;AAGtC,MAAI,aAAa;AACf,WAAO,YAAY,eAAe,kBAAkB,YAAY;AAAA,EAClE;AAOA,SAAO,eAAe,kBAAkB,YAAY;AACtD;AAYO,SAAS,yBACd,SACe;AACf,QAAM,YAAY,CAAC,SAAgC;AACjD,QAAI,mBAAmB,SAAS;AAC9B,aAAO,QAAQ,IAAI,IAAI;AAAA,IACzB;AACA,QAAI,mBAAmB,KAAK;AAC1B,aAAO,QAAQ,IAAI,IAAI,KAAK;AAAA,IAC9B;AACA,UAAM,QAAQ,QAAQ,IAAI,KAAK,QAAQ,KAAK,YAAY,CAAC;AACzD,WAAO,OAAO,UAAU,WAAW,QAAQ;AAAA,EAC7C;AAEA,QAAM,UAAU,UAAU,WAAW,GAAG,KAAK;AAC7C,MAAI,SAAS;AACX,WAAO;AAAA,EACT;AAEA,QAAM,aAAa,UAAU,eAAe,GAAG,KAAK;AACpD,MAAI,cAAc,WAAW,YAAY,EAAE,WAAW,SAAS,GAAG;AAChE,WAAO,WAAW,MAAM,CAAC,EAAE,KAAK;AAAA,EAClC;AAEA,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|