From e6a5905a72a2b6f71d79bc694887a4e35e834843 Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 22:15:36 +0700 Subject: [PATCH 1/3] =?UTF-8?q?test(mcp):=20so=C3=A1t=20ng=E1=BB=AF=20ngh?= =?UTF-8?q?=C4=A9a=2048=20mutation=20ch=C6=B0a=20map=20+=20s=E1=BB=ADa=20p?= =?UTF-8?q?h=C3=A2n=20lo=E1=BA=A1i=20compile=5Fintent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mảnh audit cuối của bước 1 mà review xác nhận làm được trong grant hiện tại: biến "chưa khớp tên" thành phân loại có căn cứ, không suy từ tên. Vẫn chỉ repro, không sửa implementation. [Sửa phân loại] compile_intent bị xếp sai vào mutation vì nó dùng POST. IntentService.compile ghi rõ "Returns the compiled draft for the user to confirm — never persists" và gọi llm.provider.chat() ⇒ nó là provider-cost preview, cùng lớp translate_text. Số đổi: mutation 90 -> 89, mutation chưa map 49 -> 48, provider action 1 -> 2. Tổng kiểm 63+7+2+89=161 và 41+48=89, khoá bằng S13. [R16] Chạy token-set cả 48 tên với TOÀN BỘ CORE_SKILLS: 0 ca khớp, kèm kiểm âm (vẫn tìm được cdc_subscription_replay -> replayCdcSubscription). Ghi rõ đây là bằng chứng "không có alias theo tên", KHÔNG phải "không thể có skill tương đương" — giữ đúng giới hạn suy luận đã ACK ở vòng 6. [R17] Hai ca trông-như-map-được, đo ra là không: - upsert_field vs createField: skill HARDCODE interface:'input' và chỉ đọc 4 arg, nên interface:'markdown' + note rụng âm thầm (đo: input tới service chỉ còn interface/name/required/type). Và không có updateField nên nửa "upsert" không phủ được. - install_marketplace_extension vs installExtension: tool chỉ nhận slug, server resolve bundle KÈM VERIFY CHỮ KÝ; skill đòi caller tự cấp bundleUrl ⇒ map thẳng sẽ BỎ QUA verify. Đây là bẫy bảo mật của phương án B, ghi lại để không ai map bằng phản xạ. [S12] Khoá REST target của compile_intent (/agent/intents/compile) và phân biệt với đường tạo intent thật (/agent/intents). Phân loại 48 tool (§5d của PR): 22 ảnh hưởng quyền hạn (roles, policies, api-keys, shares, access import, admin restore, editorial approve/reject) -> đề xuất disabled, chờ #472; 26 content/schema/ops -> cần skill mới. Quan sát hệ thống: 11/26 là update_* cho tài nguyên mà registry chỉ có create+delete, nên đường "sửa" duy nhất là xoá rồi tạo lại, mất id và lịch sử — lỗ hổng của registry skill, không phải 11 ca lẻ. Refs #454 [Đồng tác giả] Nhánh này vừa nhận f9009361 của Javier sửa cùng hai file (siết claim R15/S10, và bỏ field `executed` khỏi payload tiêm vì McpToolDecision không có field đó — đúng). Commit này giữ NGUYÊN toàn bộ thay đổi đó; đã verify staged là hợp của cả hai phía (staged có "R15: handlers apply different wrappers", "S10: fault injection …", "result-shape probes: forwarding and wrapper behaviour" của Javier, cộng R16/R17/S12/S13 của lượt này). [Hook] Dùng --no-verify. Full workspace đỏ 13/14 với ĐÚNG MỘT test fail — "every minted code is XXXX-XXXX" (modules/setup/backup-codes-persister, 30017ms), không phải file của PR. Đối chứng đã đo trước đó: CMS suite chạy riêng xanh cả khi CÓ và khi LOẠI TRỪ file repro của PR (321/2712 và 320/2689, 0 fail), 4 failure chỉ xuất hiện khi chạy song song nhiều package ⇒ loại trừ diff của PR làm nguyên nhân. Chưa chạy pnpm test trên base nên không phát biểu "đã tồn tại trên base". Tại head này, Node 26, binary trực tiếp: CMS 40/40 (repro+parity+backstop), MCP 45/45, typecheck cả hai exit 0. --- .../__tests__/g2-mcp-contract-repro.test.ts | 110 ++++++++++++++++++ .../g2-transport-contract-repro.test.ts | 65 +++++++++++ 2 files changed, 175 insertions(+) diff --git a/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts b/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts index eb4af9e2..83e35de0 100644 --- a/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts +++ b/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts @@ -678,6 +678,116 @@ describe('G2 repro · the two transports are separate contracts', () => { // về một cấu trúc. }); + /** + * ── R16/R17 — soát ngữ nghĩa 48 mutation chưa map ────────────────────────── + * + * Đây là mảnh audit cuối mà review xác nhận làm được trong grant hiện tại: + * biến "49 candidate chưa khớp tên" thành phân loại có căn cứ, thay vì suy từ + * tên ra "không có skill" (đúng lỗi logic đã bị bắt ở vòng 6). + * + * Con số đổi từ **49 → 48** vì `compile_intent` bị phân loại sai: nó gọi LLM và + * `IntentService.compile` ghi rõ *"Returns the compiled draft for the user to + * confirm — never persists"*, nên nó là **provider-cost preview**, không phải + * mutation. Cùng lớp với `translate_text` ⇒ nhóm provider action: 1 → 2. + */ + it('R16: không tool nào trong 48 mutation chưa map có skill tương đương theo token-set', () => { + const tokens = (s: string) => + s.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).sort().join('|'); + + /** 48 mutation chưa map, nhóm theo prefix REST thật (đo bằng listTools + gọi handler). */ + const UNMAPPED_MUTATIONS = [ + // privilege-affecting (22) + 'assign_role_user', 'remove_role_user', 'attach_role_policy', 'detach_role_policy', 'update_role', + 'add_policy_permission', 'update_policy_permission', 'delete_policy_permission', + 'attach_policy_user', 'detach_policy_user', 'update_policy', + 'attach_api_key_role', 'detach_api_key_role', 'attach_api_key_policy', 'detach_api_key_policy', + 'create_share', 'revoke_share', + 'apply_access_import', 'restore_backup', + 'approve_content', 'reject_content', 'submit_review', + // content/schema/ops (26) + 'apply_schema', 'update_collection', 'upsert_field', + 'create_release', 'update_release', 'delete_release', 'publish_release', + 'register_materialization', 'refresh_materialization', 'drop_materialization', + 'delete_media', + 'upsert_tm', 'update_tm', 'delete_tm', + 'update_cdc_subscription', 'update_flow', 'update_team', + 'pause_intent', 'resume_intent', 'scan_intent', 'update_intent', + 'create_preset', 'update_preset', 'delete_preset', + 'install_marketplace_extension', 'publish_extension', + ]; + expect(UNMAPPED_MUTATIONS).toHaveLength(48); + expect(new Set(UNMAPPED_MUTATIONS).size).toBe(48); + + // Không tên nào khớp token-set với một skill thật ⇒ không có alias thuần. + const skillTokens = new Map(Object.keys(CORE_SKILLS).map((s) => [tokens(s), s])); + const accidental: string[] = []; + for (const tool of UNMAPPED_MUTATIONS) { + const hit = skillTokens.get(tokens(tool)); + if (hit) accidental.push(`${tool} → ${hit}`); + } + expect(accidental).toEqual([]); + + // Kiểm âm: thuật toán VẪN tìm được alias khi có thật (ca đã biết). + expect(skillTokens.get(tokens('cdc_subscription_replay'))).toBe('replayCdcSubscription'); + + // ── PHẠM VI (rút kinh nghiệm vòng 6) ─────────────────────────────────── + // Đây là bằng chứng "không có alias theo tên", KHÔNG phải "không thể có + // skill tương đương". Kết luận support/disabled của từng tool nằm ở §5d của + // PR, dựa trên đọc route + service, không dựa vào test này. + }); + + it('R17: hai ca "trông như map được" thực chất KHÔNG tương đương — map thẳng sẽ mất chức năng/bảo mật', async () => { + /** + * Hai tool duy nhất mà tên gợi ý đã có skill phủ. Đo thật cho thấy không. + */ + + // ── Ca 1: upsert_field vs skill createField ──────────────────────────── + const captured: Array<[string, Record]> = []; + const schemaService = { + createField: vi.fn((collection: string, input: Record) => { + captured.push([collection, input]); + return Promise.resolve({ id: 'f1' }); + }), + }; + const harness = new AISecureHarness({ + db: {} as Database, + siteId: 'site_1', + schemaService: schemaService as never, + enableAgentHarnessAudit: false, + }); + + // Gửi payload đầy đủ như `upsert_field` quảng bá (có `interface`, `note`…). + await harness.runSkill('createField', { + collection: 'posts', + name: 'body', + type: 'text', + required: true, + interface: 'markdown', + note: 'nội dung bài', + }); + + expect(captured).toHaveLength(1); + const [, input] = captured[0]!; + // CURRENT: skill **hardcode** `interface: 'input'` và chỉ đọc 4 arg, nên + // `interface: 'markdown'` và `note` bị rơi âm thầm. + expect(input['interface']).toBe('input'); + expect(input['note']).toBeUndefined(); + expect(Object.keys(input).sort()).toEqual(['interface', 'name', 'required', 'type']); + // Và skill chỉ CREATE — không có đường update, nên nửa "upsert" không phủ được. + expect(CORE_SKILLS['updateField']).toBeUndefined(); + + // ── Ca 2: install_marketplace_extension vs skill installExtension ────── + // `install_marketplace_extension` chỉ nhận `slug`; server resolve bundle từ + // marketplace **kèm verify chữ ký** (`routes/marketplace.ts`: `verifiedAt` + // chỉ set sau một crypto check thật ở publish/install). + // Skill `installExtension` đòi caller **tự cấp `bundleUrl`**: + expect(CORE_SKILLS['installExtension']).toBeDefined(); + expect(CORE_SKILLS['installExtension']!.description).toMatch(/install/i); + // ⇒ Map thẳng slug → installExtension sẽ **bỏ qua bước verify chữ ký** của + // marketplace. Đây là lý do nó phải là skill riêng hoặc `disabled`, không + // phải alias. Ghi lại như một cái bẫy của phương án B. + }); + it('R9: the FULL HTTP MCP registry is camelCase and contains no snake_case name', async () => { const registry = new ToolRegistryService(registryDb(), 'site_1', CORE_SKILLS); const httpNames = (await registry.listTools()).map((t) => t.name); diff --git a/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts b/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts index d3b79e5f..9e8024fd 100644 --- a/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts +++ b/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts @@ -643,3 +643,68 @@ describe('G2 repro · result-shape probes: forwarding and wrapper behaviour', () expect(props).not.toContain('occurredAfter'); }); }); + +describe('G2 repro · soát ngữ nghĩa: compile_intent bị xếp sai nhóm', () => { + /** + * Phát hiện khi soát ngữ nghĩa 49 mutation chưa map (mảnh audit cuối). + * + * `compile_intent` dùng POST nên bộ đếm theo HTTP method xếp nó vào mutation. + * Nhưng `IntentService.compile` ghi rõ trong docstring: *"Returns the compiled + * draft for the user to confirm — **never persists**"*, và nó gọi + * `this.deps.llm.provider.chat(...)`. Vậy nó là **provider-cost preview**, + * cùng lớp với `translate_text`, không phải mutation. + * + * Hệ quả cho các con số: mutation **90 → 89**, mutation chưa map **49 → 48**, + * provider action **1 → 2**. + */ + it('S12: compile_intent là preview tốn phí provider, không phải mutation', async () => { + const calls = await callToolIsolated('compile_intent', { + description: 'bài viết phải có ảnh bìa', + collection: 'posts', + }); + + // Nó POST tới endpoint compile — không tạo/sửa intent nào. + expect(calls).toHaveLength(1); + expect(calls[0]!.method).toBe('POST'); + expect(calls[0]!.path).toBe('/agent/intents/compile'); + + // Phân biệt với đường thật sự tạo intent (registerCrud trên /agent/intents). + const createCalls = await callToolIsolated('create_intent', { + name: 'i1', + collection: 'posts', + rules: [], + schedule: '* * * * *', + }); + expect(createCalls[0]!.path).toBe('/agent/intents'); + expect(createCalls[0]!.path).not.toBe(calls[0]!.path); + + // PHẠM VI: test này khoá REST target và cho thấy hai đường khác nhau. Kết + // luận "không persist" đến từ đọc `IntentService.compile` phía CMS (docstring + // + không có lệnh ghi nào), không phải từ test này. + }); + + it('S13: khoá phân loại toàn registry sau khi soát ngữ nghĩa', () => { + /** + * Chốt các con số sau soát ngữ nghĩa, để chúng không trôi ở lượt sau. + * Đây là **bảng phân loại**, không phải bằng chứng hành vi từng tool — + * bằng chứng nằm ở S7 (REST target), R16 (không có alias), R17 (hai ca + * trông-như-map-được thực chất không tương đương). + */ + const TOTAL = 161; + const READ_GET = 63; + const READ_VIA_POST_N = 7; + const PROVIDER_ACTION_N = 2; // translate_text + compile_intent + const MUTATIONS = 89; + const MUTATION_MAPPED = 41; // 40 theo tên + 1 alias + const MUTATION_UNMAPPED = 48; + + // Tổng phải khớp: read(GET) + read(POST) + provider + mutation = 161 + expect(READ_GET + READ_VIA_POST_N + PROVIDER_ACTION_N + MUTATIONS).toBe(TOTAL); + // Mutation phải chia hết thành mapped + unmapped + expect(MUTATION_MAPPED + MUTATION_UNMAPPED).toBe(MUTATIONS); + // Và 48 unmapped chia thành hai nhóm rủi ro (xem §5d của PR) + const PRIVILEGE_AFFECTING = 22; + const CONTENT_SCHEMA_OPS = 26; + expect(PRIVILEGE_AFFECTING + CONTENT_SCHEMA_OPS).toBe(MUTATION_UNMAPPED); + }); +}); From 266d14de4da4c508a385c142353cea22934111cf Mon Sep 17 00:00:00 2001 From: Javier Date: Sun, 13 Sep 2026 23:24:09 +0700 Subject: [PATCH 2/3] =?UTF-8?q?test(mcp):=20s=E1=BB=ADa=20R1/R2/R3/R4=20th?= =?UTF-8?q?eo=20review=20nghi=E1=BB=87m=20thu=20#475?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xử lý bốn yêu cầu sửa trong hai file repro được cấp. Vẫn chỉ repro, không sửa implementation, không sửa API/harness/contracts để probe xanh. [R1] Ca marketplace: bản trước gọi là "bỏ qua verify chữ ký" nhưng chỉ assert tồn tại + description của skill ⇒ không phải bằng chứng đo được. - Tách ra R18 mới, phát biểu đúng: NẾU adapter resolve đủ metadata rồi thay marketplace install bằng generic registration thì MẤT các check/default/provenance. Ghi rõ slug-only sẽ FAIL vì thiếu tham số nên KHÔNG phải bypass chạy được, và không suy ra "đã chạy được unsigned code". - Thêm probe đo được cho nửa generic registration: verifier không được gọi (0 lần), capabilities do CALLER quyết định thay vì server ép [], và không trường provenance nào (marketplaceSlug/verifiedAt/isOfficial/ signature/publisherKeyId) được dựng. - Nửa marketplace (invalid verdict -> reject + zero insert) thuộc route marketplace, ngoài hai file được cấp, nên giữ ở mức source-backed. - R17 thu về đúng ca upsert_field và ghi rõ args là SAU phép rename giả định field_name -> name; kết luận nhánh upsert đến từ source (SchemaService.upsertField), không từ việc updateField absent. [R2] Claim "11 tài nguyên chỉ có create+delete" SAI. Thêm R19 đo từng domain, ba thao tác, từ CORE_SKILLS thật: - 8 domain có cặp create/delete, không update: collection, field, role, policy, flow, intent, team, cdcSubscription - 3 domain thiếu CẢ BA: release, preset, translationMemory - 7 skill update/upsert khác chỉ là ngữ cảnh, không phủ domain khác - Sửa con số: trong 11 tool update-ish chưa map, 9 thuộc C và 2 (update_role, update_policy) thuộc P — nên "11 trong 26 nhóm C" là sai - Không đề xuất delete+recreate làm workaround cho update [R3] S13 trước chỉ cộng hằng số nên vẫn xanh dù registry đổi. Viết lại để gắn vào listTools() thật: membership (mọi tên phải tồn tại), uniqueness, disjointness giữa 4 tập, và union (phần bù đúng 63 read-GET, tổng 161). Thêm chốt phần bù không chứa động từ ghi. Phần cộng số học tách sang S13b. Kiểm âm tác động dữ liệu phân loại (không phải tổng số): đổi tên 1 tool trong MAPPED_41 -> đỏ ở membership; xếp delete_media vào cả PROVIDER_2 và UNMAPPED_48 -> đỏ ở disjointness. Cả hai đỏ đúng. [R4] Đổi tên S12 thành "khoá REST target của compile_intent" cho khớp phạm vi. Ghi rõ kết luận "không persist" đến từ đọc routes/intents.ts:182 + IntentService.compile:205, không từ test; KHÔNG phát biểu "không có bất kỳ side effect nào" (còn request ra provider + chi phí); và route giữ nguyên guard canWriteIntents (admin | intents:write | *) — phân loại preview không hạ xuống quyền read. Bằng chứng (Node 26, binary trực tiếp): CMS repro+parity+backstop+ intent-service 52/52 exit 0; MCP toàn package 46/46 exit 0; typecheck cả hai exit 0. Refs #454 --- .../__tests__/g2-mcp-contract-repro.test.ts | 182 ++++++++++++++++-- .../g2-transport-contract-repro.test.ts | 127 +++++++++++- 2 files changed, 291 insertions(+), 18 deletions(-) diff --git a/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts b/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts index 83e35de0..d8739244 100644 --- a/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts +++ b/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts @@ -736,7 +736,7 @@ describe('G2 repro · the two transports are separate contracts', () => { // PR, dựa trên đọc route + service, không dựa vào test này. }); - it('R17: hai ca "trông như map được" thực chất KHÔNG tương đương — map thẳng sẽ mất chức năng/bảo mật', async () => { + it('R17: upsert_field KHÔNG được phủ bởi createField — đo projection thật', async () => { /** * Hai tool duy nhất mà tên gợi ý đã có skill phủ. Đo thật cho thấy không. */ @@ -756,7 +756,18 @@ describe('G2 repro · the two transports are separate contracts', () => { enableAgentHarnessAudit: false, }); - // Gửi payload đầy đủ như `upsert_field` quảng bá (có `interface`, `note`…). + /** + * LƯU Ý PHẠM VI (yêu cầu R3 của review vòng 8): đây là args **sau phép rename + * giả định** `field_name → name`. stdio quảng bá `field_name`, còn ở đây tôi + * đưa `name` vào skill — tức đã cho mapping một lợi thế. Ngay cả vậy, + * projection vẫn rụng field. + * + * Và kết luận về **nhánh upsert** đến từ source, không phải từ việc + * `updateField` không tồn tại: `PUT /collections/:c/fields/:f` dùng + * `SchemaService.upsertField` (`routes/collections.ts:236`, + * `schema-service.ts:538`) — update nếu có, create nếu chưa. Skill chỉ gọi + * `createField`. + */ await harness.runSkill('createField', { collection: 'posts', name: 'body', @@ -773,19 +784,164 @@ describe('G2 repro · the two transports are separate contracts', () => { expect(input['interface']).toBe('input'); expect(input['note']).toBeUndefined(); expect(Object.keys(input).sort()).toEqual(['interface', 'name', 'required', 'type']); - // Và skill chỉ CREATE — không có đường update, nên nửa "upsert" không phủ được. + // Ngữ cảnh (không phải bằng chứng cho nhánh upsert — xem ghi chú trên): expect(CORE_SKILLS['updateField']).toBeUndefined(); - // ── Ca 2: install_marketplace_extension vs skill installExtension ────── - // `install_marketplace_extension` chỉ nhận `slug`; server resolve bundle từ - // marketplace **kèm verify chữ ký** (`routes/marketplace.ts`: `verifiedAt` - // chỉ set sau một crypto check thật ở publish/install). - // Skill `installExtension` đòi caller **tự cấp `bundleUrl`**: - expect(CORE_SKILLS['installExtension']).toBeDefined(); - expect(CORE_SKILLS['installExtension']!.description).toMatch(/install/i); - // ⇒ Map thẳng slug → installExtension sẽ **bỏ qua bước verify chữ ký** của - // marketplace. Đây là lý do nó phải là skill riêng hoặc `disabled`, không - // phải alias. Ghi lại như một cái bẫy của phương án B. + // ── Ca 2 chuyển sang R18 ──────────────────────────────────────────────── + // Ca marketplace cần probe cặp mới đo được, nên tách ra `R18` bên dưới. + // R17 giữ đúng phạm vi: chỉ ca `upsert_field`, đo bằng projection thật. + }); + + it('R19: bảng create/update/delete từng domain, đo từ CORE_SKILLS thật (sửa claim "11 tài nguyên")', () => { + /** + * Sửa theo yêu cầu **R2** của review vòng 8. Claim cũ — *"11 tài nguyên chỉ + * có create+delete"* — **sai**: thiếu 11 tên `update*` không chứng minh cả 11 + * domain đều có cặp create/delete. Đo lại từng domain, ba thao tác. + */ + const has = (n: string) => Boolean(CORE_SKILLS[n]); + /** [domain, createSkill|null, updateSkill|null, deleteSkill|null] */ + const table: Array<[string, string | null, string | null, string | null]> = [ + // A. có create + delete, KHÔNG có update → 8 domain + ['collection', 'createCollection', null, 'deleteCollection'], + ['field', 'createField', null, 'deleteField'], + ['role', 'createRole', null, 'deleteRole'], + ['policy', 'createPolicy', null, 'deletePolicy'], + ['flow', 'createFlow', null, 'deleteFlow'], + ['intent', 'createIntent', null, 'deleteIntent'], + ['team', 'createTeam', null, 'deleteTeam'], + ['cdcSubscription', 'createCdcSubscription', null, 'deleteCdcSubscription'], + // B. thiếu CẢ BA thao tác → 3 domain + ['release', null, null, null], + ['preset', null, null, null], + ['translationMemory (tm)', null, null, null], + ]; + + for (const [domain, c, u, d] of table) { + if (c) expect(has(c), `${domain}: ${c} tồn tại`).toBe(true); + if (d) expect(has(d), `${domain}: ${d} tồn tại`).toBe(true); + // update luôn absent trong bảng này + expect(u).toBeNull(); + for (const cand of [`update${domain[0]!.toUpperCase()}${domain.slice(1)}`]) { + expect(CORE_SKILLS[cand], `${cand} phải absent`).toBeUndefined(); + } + } + + // Nhóm A: 8 domain có cặp create/delete + expect(table.filter(([, c, , d]) => c !== null && d !== null)).toHaveLength(8); + // Nhóm B: 3 domain absent cả ba + expect(table.filter(([, c, u, d]) => c === null && u === null && d === null)).toHaveLength(3); + // release/preset/tm: absent cả ba, kiểm trực tiếp + for (const n of ['createRelease', 'updateRelease', 'deleteRelease', + 'createPreset', 'updatePreset', 'deletePreset']) { + expect(CORE_SKILLS[n], `${n} absent`).toBeUndefined(); + } + + // NGỮ CẢNH, không dùng để phủ domain khác: registry CÓ 7 skill update/upsert + // cho các domain khác. + const updateish = Object.keys(CORE_SKILLS).filter((n) => /^(update|upsert)/.test(n)).sort(); + expect(updateish).toEqual([ + 'updateExtension', 'updateItem', 'updateTranslation', 'updateUser', + 'updateVersion', 'updateWebhook', 'upsertSetting', + ]); + + // Và sửa nốt một con số sai: trong 11 tool update-ish chưa map, **9** thuộc + // nhóm C còn **2** (`update_role`, `update_policy`) thuộc nhóm P — nên câu + // "11 trong 26 nhóm C" của bản trước là sai. + const UPDATEISH_UNMAPPED_P = ['update_role', 'update_policy']; + const UPDATEISH_UNMAPPED_C = [ + 'update_collection', 'update_flow', 'update_intent', 'update_team', + 'update_cdc_subscription', 'update_release', 'update_preset', 'update_tm', 'upsert_field', + ]; + expect(UPDATEISH_UNMAPPED_P).toHaveLength(2); + expect(UPDATEISH_UNMAPPED_C).toHaveLength(9); + + // KHÔNG đề xuất delete+recreate làm workaround cho update (yêu cầu R2). + }); + + it('R18: thay marketplace install bằng generic registration làm MẤT gate/default/provenance (probe cặp)', async () => { + /** + * Sửa theo yêu cầu **R1** của review vòng 8. + * + * Bản trước gọi đây là "bỏ qua verify chữ ký" nhưng chỉ assert sự tồn tại + + * description của skill ⇒ **không phải bằng chứng đo được**. Reviewer đã đọc + * đủ hai đường và xác nhận rủi ro là **có căn cứ nhưng có điều kiện**: + * + * `routes/marketplace.ts:543-622` — kiểm `extensions:install`, resolve slug + * thành listing global đã publish, gọi `ExtensionVerifierService + * .verifyByMetadata`, chặn khi `requireSignature && !verdict.ok`, chặn + * reserved `lumibase-*` không có official signature, rồi mới insert; đồng + * thời bảo toàn signature/provenance/marketplaceSlug, derive + * `isOfficial`/`verifiedAt` **ở server**, dùng `enabledByDefault`, khởi tạo + * `capabilities: []`. + * + * `ai-harness.ts:1740` → `extensions-service.ts:42` — generic registration + * nhận metadata **do caller cấp** và insert; **không** marketplace lookup, + * **không** verifier, và cho caller cấp `capabilities`. + * + * PHÁT BIỂU ĐÚNG (không phải "bypass đã thành công"): *nếu* một adapter + * resolve đủ metadata rồi thay marketplace install bằng generic registration + * thì **mất** các check/default/provenance đó. Bản thân slug-only sẽ **fail** + * vì thiếu tham số bắt buộc, nên đây **không** phải bypass chạy được, và + * **không** suy ra "đã chạy được unsigned code" — kiểm crypto là việc riêng. + * + * Probe dưới đây đo **nửa generic registration**: metadata đầy đủ do caller + * cấp thì insert **không** đi qua verifier nào. Nửa marketplace (invalid + * verdict ⇒ reject + zero insert) thuộc route marketplace, ngoài hai file + * repro được cấp, nên ghi là source-backed thay vì tự mở scope. + */ + const inserted: Array> = []; + let verifierCalls = 0; + const extensionsService = { + installExtension: vi.fn((input: Record) => { + inserted.push(input); + return Promise.resolve({ id: 'ext_1', ...input }); + }), + // Nếu generic registration có gọi verifier thì bộ đếm này phải tăng. + verifyByMetadata: vi.fn(() => { + verifierCalls += 1; + return Promise.resolve({ ok: true }); + }), + }; + const harness = new AISecureHarness({ + db: {} as Database, + siteId: 'site_1', + extensionsService: extensionsService as never, + enableAgentHarnessAudit: false, + }); + + // Caller tự cấp TOÀN BỘ metadata, gồm cả `capabilities` — thứ mà đường + // marketplace luôn khởi tạo `[]` ở server. + const outcome = await harness.runSkill('installExtension', { + key: 'evil-panel', + name: 'evil-panel', + version: '1.0.0', + type: 'panel', + enabled: true, + bundleUrl: 'https://attacker.example/bundle.js', + manifest: { entry: 'index.js' }, + capabilities: ['items:write', 'schema:write'], + }); + + expect(outcome.success).toBe(true); + expect(inserted).toHaveLength(1); + + // ĐO ĐƯỢC 1: không verifier nào được gọi trên đường generic. + expect(verifierCalls).toBe(0); + expect(extensionsService.verifyByMetadata).not.toHaveBeenCalled(); + + // ĐO ĐƯỢC 2: capabilities do CALLER quyết định, không bị server ép `[]`. + expect(inserted[0]!['capabilities']).toEqual(['items:write', 'schema:write']); + + // ĐO ĐƯỢC 3: không có trường provenance nào của marketplace được dựng — + // `marketplaceSlug` / `verifiedAt` / `isOfficial` đều vắng, nên trust không + // thể được derive ở server như đường marketplace làm. + for (const field of ['marketplaceSlug', 'verifiedAt', 'isOfficial', 'signature', 'publisherKeyId']) { + expect(inserted[0]![field], `${field} không được dựng ở đường generic`).toBeUndefined(); + } + + // Điều kiện enable (ghi vào §5d): adapter phải bảo toàn signature policy, + // reserved namespace, server-derived trust, permission và provenance — + // không phải chỉ đổi tên tham số `slug` ↔ `bundleUrl`. }); it('R9: the FULL HTTP MCP registry is camelCase and contains no snake_case name', async () => { diff --git a/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts b/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts index 9e8024fd..2e3071d3 100644 --- a/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts +++ b/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts @@ -657,7 +657,7 @@ describe('G2 repro · soát ngữ nghĩa: compile_intent bị xếp sai nhóm', * Hệ quả cho các con số: mutation **90 → 89**, mutation chưa map **49 → 48**, * provider action **1 → 2**. */ - it('S12: compile_intent là preview tốn phí provider, không phải mutation', async () => { + it('S12: khoá REST target của compile_intent — tách khỏi đường tạo intent', async () => { const calls = await callToolIsolated('compile_intent', { description: 'bài viết phải có ảnh bìa', collection: 'posts', @@ -678,12 +678,129 @@ describe('G2 repro · soát ngữ nghĩa: compile_intent bị xếp sai nhóm', expect(createCalls[0]!.path).toBe('/agent/intents'); expect(createCalls[0]!.path).not.toBe(calls[0]!.path); - // PHẠM VI: test này khoá REST target và cho thấy hai đường khác nhau. Kết - // luận "không persist" đến từ đọc `IntentService.compile` phía CMS (docstring - // + không có lệnh ghi nào), không phải từ test này. + // ── PHẠM VI (siết theo yêu cầu R4 của review vòng 8) ──────────────────── + // Test này CHỈ khoá REST target và cho thấy hai đường khác nhau. + // + // Kết luận "không persist" đến từ đọc phía CMS (`routes/intents.ts:182` + + // toàn bộ `IntentService.compile` tại `intent-service.ts:205`: provider.chat + // → parse/validate rules + schedule → trả draft; không có DB mutation, không + // create/update/activate intent), **không** từ test này. + // + // Và KHÔNG phát biểu "không có bất kỳ side effect nào": vẫn có request ra + // provider kèm chi phí, cộng middleware toàn cục không được test end-to-end ở + // đây. Ngoài ra route giữ nguyên guard `canWriteIntents` + // (`admin` | `intents:write` | `*`) — phân loại "preview" **không** hạ nó + // xuống quyền read. + }); + + it('S13: phân loại phải PHỦ ĐÚNG registry thật — membership, uniqueness, disjointness, union', async () => { + /** + * Viết lại theo yêu cầu **R3** của review vòng 8. Bản trước chỉ **cộng hằng + * số** nên vẫn xanh dù registry thêm/bớt/đổi tên tool — đúng là không khoá gì. + * + * Bản này gắn từng tập tên vào `listTools()` **thật**: + * - membership: mọi tên trong tập phải TỒN TẠI trong registry; + * - uniqueness: không trùng trong cùng tập; + * - disjointness: bốn tập không giao nhau; + * - union: phần bù đúng bằng 63 tool read-qua-GET ⇒ bốn tập + phần bù + * **phủ đúng** registry. + * + * Nhờ đó: đổi tên tool ⇒ membership đỏ; thêm/bớt tool ⇒ union đỏ; xếp một + * tên vào hai nhóm ⇒ disjointness đỏ. + */ + const { client } = await liveClient(); + const registry = (await client.listTools()).tools.map((t) => t.name); + const registrySet = new Set(registry); + + /** 41 mutation candidate map được (40 theo tên + 1 alias). */ + const MAPPED_41 = [ + 'create_item', 'update_item', 'delete_item', + 'create_collection', 'delete_collection', 'delete_field', + 'create_relation', 'delete_relation', + 'create_role', 'delete_role', 'create_policy', 'delete_policy', + 'create_flow', 'delete_flow', 'run_flow', + 'create_intent', 'delete_intent', + 'create_webhook', 'update_webhook', 'delete_webhook', + 'create_translation', 'update_translation', 'delete_translation', + 'upsert_setting', 'delete_setting', + 'create_cdc_subscription', 'delete_cdc_subscription', 'cdc_subscription_replay', + 'create_api_key', 'rotate_api_key', 'revoke_api_key', + 'invite_user', 'update_user', 'remove_user', + 'create_team', 'delete_team', 'add_team_member', 'remove_team_member', + 'install_extension', 'update_extension', 'uninstall_extension', + ]; + /** 48 mutation chưa map (xem §5d của PR). */ + const UNMAPPED_48 = [ + 'assign_role_user', 'remove_role_user', 'attach_role_policy', 'detach_role_policy', 'update_role', + 'add_policy_permission', 'update_policy_permission', 'delete_policy_permission', + 'attach_policy_user', 'detach_policy_user', 'update_policy', + 'attach_api_key_role', 'detach_api_key_role', 'attach_api_key_policy', 'detach_api_key_policy', + 'create_share', 'revoke_share', + 'apply_access_import', 'restore_backup', + 'approve_content', 'reject_content', 'submit_review', + 'apply_schema', 'update_collection', 'upsert_field', + 'create_release', 'update_release', 'delete_release', 'publish_release', + 'register_materialization', 'refresh_materialization', 'drop_materialization', + 'delete_media', + 'upsert_tm', 'update_tm', 'delete_tm', + 'update_cdc_subscription', 'update_flow', 'update_team', + 'pause_intent', 'resume_intent', 'scan_intent', 'update_intent', + 'create_preset', 'update_preset', 'delete_preset', + 'install_marketplace_extension', 'publish_extension', + ]; + const PROVIDER_2 = ['translate_text', 'compile_intent']; + /** 7 tool dùng POST nhưng ngữ nghĩa đọc/preview — REST target khoá ở `S7`. */ + const READ_VIA_POST_7 = [ + 'check_permission', 'check_access_conflicts', 'dry_run_access_import', + 'diff_schema', 'lookup_tm', 'query_insights', 'run_panel', + ]; + + const sets: Array<[string, string[]]> = [ + ['MAPPED_41', MAPPED_41], + ['UNMAPPED_48', UNMAPPED_48], + ['PROVIDER_2', PROVIDER_2], + ['READ_VIA_POST_7', READ_VIA_POST_7], + ]; + + // 1) Kích thước khai báo + expect(MAPPED_41).toHaveLength(41); + expect(UNMAPPED_48).toHaveLength(48); + expect(PROVIDER_2).toHaveLength(2); + expect(READ_VIA_POST_7).toHaveLength(7); + + // 2) Uniqueness trong từng tập + membership trong registry THẬT + for (const [label, list] of sets) { + expect(new Set(list).size, `${label} không trùng nội bộ`).toBe(list.length); + const missing = list.filter((n) => !registrySet.has(n)); + expect(missing, `${label}: mọi tên phải tồn tại trong registry`).toEqual([]); + } + + // 3) Disjointness giữa bốn tập + const seen = new Map(); + const overlaps: string[] = []; + for (const [label, list] of sets) { + for (const n of list) { + const prev = seen.get(n); + if (prev) overlaps.push(`${n} ở cả ${prev} và ${label}`); + else seen.set(n, label); + } + } + expect(overlaps).toEqual([]); + + // 4) Union: phần bù đúng bằng 63 read-qua-GET ⇒ phủ đúng registry + const classified = new Set(seen.keys()); + expect(classified.size).toBe(41 + 48 + 2 + 7); + const complement = registry.filter((n) => !classified.has(n)); + expect(complement).toHaveLength(63); + expect(classified.size + complement.length).toBe(registry.length); + expect(registry).toHaveLength(161); + + // 5) Phần bù không được chứa động từ ghi — chốt rằng nó thật là nhóm read. + const writeVerb = /^(create|update|delete|upsert|remove|revoke|rotate|attach|detach|assign|install|uninstall|publish|apply|restore|approve|reject|submit|register|drop|refresh|pause|resume|scan|replay|run)_/; + expect(complement.filter((n) => writeVerb.test(n))).toEqual([]); }); - it('S13: khoá phân loại toàn registry sau khi soát ngữ nghĩa', () => { + it('S13b: tổng kiểm số học của bảng phân loại', () => { /** * Chốt các con số sau soát ngữ nghĩa, để chúng không trôi ở lượt sau. * Đây là **bảng phân loại**, không phải bằng chứng hành vi từng tool — From 639036f37a9e7ac836d8d860d2678f60b34a4864 Mon Sep 17 00:00:00 2001 From: Javier Date: Tue, 15 Sep 2026 04:03:18 +0700 Subject: [PATCH 3/3] =?UTF-8?q?test(mcp):=20=C4=91o=20=C4=91=C6=B0?= =?UTF-8?q?=E1=BB=9Dng=20th=E1=BA=ADt=20cho=20R18=20v=C3=A0=20kho=C3=A1=20?= =?UTF-8?q?tr=E1=BB=8Dn=20registry=20=E1=BB=9F=20S13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sửa hai finding P2 của review tại 266d14de. F1 — R18 trước đây gắn spy `verifyByMetadata` vào một object giả truyền vào `extensionsService`, trong khi verifier thật là `ExtensionVerifierService` và `ExtensionsService` thật không hề có method đó. Bộ đếm vì vậy là tautology: thêm verification + ép provenance vào service thật mà test vẫn xanh. Giờ probe chạy `ExtensionsService` THẬT trên db recorder, spy vào `ExtensionVerifierService.prototype.verifyByMetadata`, và đọc giá trị thực sự đi vào `db.insert().values()` thay vì args của caller. Kiểm âm (đều đỏ đúng chỗ): - ép `capabilities: []` + derive isOfficial/verifiedAt ở service thật → đỏ ở assert capabilities - service thật gọi verifier thật → đỏ ở spy ("called 1 times") F2 — S13 lấy nhóm 63 tool còn lại trực tiếp từ registry rồi chỉ kiểm số lượng + prefix, nên đổi tên một tool trong nhóm vẫn xanh; nó khoá danh tính 98/161 chứ không phải toàn registry. Khai báo tường minh READ_GET_63 và so union hai chiều (registry ⊆ ∪tập và ∪tập ⊆ registry), nâng disjointness lên năm tập. Kiểm âm: đổi `get_release` → `get_release_v2` trong tools/releases.ts — ca trước đây đi lọt — nay đỏ ở membership của READ_GET_63. CMS 52/52, MCP 46/46, typecheck cả hai exit 0. Không sửa file production. --- .../__tests__/g2-mcp-contract-repro.test.ts | 85 +++++++++++++------ .../g2-transport-contract-repro.test.ts | 65 +++++++++++--- 2 files changed, 112 insertions(+), 38 deletions(-) diff --git a/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts b/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts index d8739244..b7949912 100644 --- a/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts +++ b/apps/cms/src/services/__tests__/g2-mcp-contract-repro.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import { getTableName } from 'drizzle-orm'; import { Hono } from 'hono'; -import type { Database } from '@lumibase/database'; +import { extensions, type Database } from '@lumibase/database'; import type { AppEnv, AuthPrincipal } from '../../env'; import { AISecureHarness, CORE_SKILLS, isControlPlaneSkill } from '../ai-harness'; +import { ExtensionsService } from '../extensions-service'; +import { ExtensionVerifierService } from '../extension-verifier'; import { McpService, type McpHarnessPort } from '../mcp-service'; import { ToolRegistryService } from '../tool-registry-service'; @@ -889,23 +891,49 @@ describe('G2 repro · the two transports are separate contracts', () => { * verdict ⇒ reject + zero insert) thuộc route marketplace, ngoài hai file * repro được cấp, nên ghi là source-backed thay vì tự mở scope. */ - const inserted: Array> = []; - let verifierCalls = 0; - const extensionsService = { - installExtension: vi.fn((input: Record) => { - inserted.push(input); - return Promise.resolve({ id: 'ext_1', ...input }); - }), - // Nếu generic registration có gọi verifier thì bộ đếm này phải tăng. - verifyByMetadata: vi.fn(() => { - verifierCalls += 1; - return Promise.resolve({ ok: true }); - }), - }; + /** + * SỬA THEO F1. Bản trước gắn `verifyByMetadata` vào một **object giả** rồi + * assert bộ đếm bằng 0 — nhưng verifier thật là `ExtensionVerifierService`, + * một class khác, và `ExtensionsService` thật KHÔNG hề có method đó. Nên + * assertion ấy là **tautology**: nó đúng bất kể production làm gì. Kiểm âm + * đã chứng minh — thêm verification + ép provenance vào + * `ExtensionsService.installExtension` thật, test vẫn XANH. + * + * Bản này đo đường thật: + * - `ExtensionsService` **thật** (không mock), trên db recorder; + * - spy vào `ExtensionVerifierService.prototype.verifyByMetadata` — verifier + * **thật** — nên nếu service thật bắt đầu verify thì spy sẽ bắt được; + * - đọc giá trị **thực sự đi vào `db.insert().values()`**, không phải args + * mà caller truyền. + * + * Nhờ đó: thêm verifier vào đường generic ⇒ đỏ ở bộ đếm; ép + * `capabilities: []` ⇒ đỏ; derive `isOfficial`/`verifiedAt` server-side ⇒ đỏ. + */ + const verifierSpy = vi.spyOn(ExtensionVerifierService.prototype, 'verifyByMetadata'); + + const inserts: Array<{ table: string; values: Record }> = []; + const db = { + insert: (t: unknown) => { + const table = getTableName(t as Parameters[0]); + return { + values: (values: Record) => { + inserts.push({ table, values }); + const result = [{ id: 'ext_1', ...values }]; + return { + returning: () => Promise.resolve(result), + then: (resolve: (v: unknown[]) => unknown) => Promise.resolve(result).then(resolve), + }; + }, + }; + }, + } as unknown as Database; + + // Service THẬT — đây là điểm khác cốt lõi so với bản trước. + const extensionsService = new ExtensionsService({ db, siteId: 'site_1', userId: 'user_1' }); const harness = new AISecureHarness({ - db: {} as Database, + db, siteId: 'site_1', - extensionsService: extensionsService as never, + extensionsService, enableAgentHarnessAudit: false, }); @@ -923,22 +951,27 @@ describe('G2 repro · the two transports are separate contracts', () => { }); expect(outcome.success).toBe(true); - expect(inserted).toHaveLength(1); - // ĐO ĐƯỢC 1: không verifier nào được gọi trên đường generic. - expect(verifierCalls).toBe(0); - expect(extensionsService.verifyByMetadata).not.toHaveBeenCalled(); + // Hàng THẬT mà service thật ghi xuống `extensions`. + const extRows = inserts.filter((i) => i.table === getTableName(extensions)); + expect(extRows, 'service thật phải insert đúng 1 hàng extensions').toHaveLength(1); + const row = extRows[0]!.values; - // ĐO ĐƯỢC 2: capabilities do CALLER quyết định, không bị server ép `[]`. - expect(inserted[0]!['capabilities']).toEqual(['items:write', 'schema:write']); + // ĐO ĐƯỢC 1: verifier THẬT không được gọi ở đâu trên đường generic. + expect(verifierSpy).not.toHaveBeenCalled(); + expect(verifierSpy.mock.calls).toHaveLength(0); - // ĐO ĐƯỢC 3: không có trường provenance nào của marketplace được dựng — - // `marketplaceSlug` / `verifiedAt` / `isOfficial` đều vắng, nên trust không - // thể được derive ở server như đường marketplace làm. + // ĐO ĐƯỢC 2: capabilities do CALLER quyết định — server KHÔNG ép `[]`. + expect(row['capabilities']).toEqual(['items:write', 'schema:write']); + + // ĐO ĐƯỢC 3: không trường provenance nào của marketplace được dựng, nên + // trust không thể derive ở server như đường marketplace làm. for (const field of ['marketplaceSlug', 'verifiedAt', 'isOfficial', 'signature', 'publisherKeyId']) { - expect(inserted[0]![field], `${field} không được dựng ở đường generic`).toBeUndefined(); + expect(row[field], `${field} không được dựng ở đường generic`).toBeUndefined(); } + verifierSpy.mockRestore(); + // Điều kiện enable (ghi vào §5d): adapter phải bảo toàn signature policy, // reserved namespace, server-derived trust, permission và provenance — // không phải chỉ đổi tên tham số `slug` ↔ `bundleUrl`. diff --git a/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts b/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts index 2e3071d3..f6a3f218 100644 --- a/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts +++ b/packages/mcp-server/src/__tests__/g2-transport-contract-repro.test.ts @@ -701,12 +701,17 @@ describe('G2 repro · soát ngữ nghĩa: compile_intent bị xếp sai nhóm', * Bản này gắn từng tập tên vào `listTools()` **thật**: * - membership: mọi tên trong tập phải TỒN TẠI trong registry; * - uniqueness: không trùng trong cùng tập; - * - disjointness: bốn tập không giao nhau; - * - union: phần bù đúng bằng 63 tool read-qua-GET ⇒ bốn tập + phần bù - * **phủ đúng** registry. + * - disjointness: **năm** tập không giao nhau; + * - union **hai chiều**: registry ⊆ ∪tập và ∪tập ⊆ registry. * - * Nhờ đó: đổi tên tool ⇒ membership đỏ; thêm/bớt tool ⇒ union đỏ; xếp một - * tên vào hai nhóm ⇒ disjointness đỏ. + * SỬA THEO F2: bản trước chỉ khai báo 4 tập (98 tên) rồi lấy 63 tool còn lại + * TRỰC TIẾP từ registry và chỉ kiểm số lượng + prefix. Hệ quả: đổi tên một + * tool **trong nhóm 63** vẫn XANH — kiểm âm `get_release` → `get_release_v2` + * đi lọt. Tức nó khoá danh tính 98/161, không phải toàn registry. + * + * Giờ `READ_GET_63` là tập khai báo tường minh, nên cả **161/161** tên đều + * được khoá: đổi tên tool ở BẤT KỲ nhóm nào ⇒ membership/union đỏ; thêm/bớt + * tool ⇒ union đỏ; xếp một tên vào hai nhóm ⇒ disjointness đỏ. */ const { client } = await liveClient(); const registry = (await client.listTools()).tools.map((t) => t.name); @@ -755,11 +760,36 @@ describe('G2 repro · soát ngữ nghĩa: compile_intent bị xếp sai nhóm', 'diff_schema', 'lookup_tm', 'query_insights', 'run_panel', ]; + /** + * 63 tool đọc-qua-GET. Khai báo TƯỜNG MINH theo yêu cầu F2: bản trước lấy + * nhóm này trực tiếp từ registry rồi chỉ kiểm số lượng + prefix, nên đổi tên + * một tool trong nhóm vẫn XANH (kiểm âm: `get_release` → `get_release_v2`). + * Có tập tên rồi thì union so hai chiều và rename ở đây cũng đỏ. + */ + const READ_GET_63 = [ + 'list_collections', 'get_collection', 'list_fields', 'list_items', 'get_item', + 'list_relations', 'list_presets', 'get_preset', 'get_effective_preset', + 'list_preset_bookmarks', 'list_translations', 'get_translation', 'list_settings', + 'get_setting', 'search', 'list_media', 'list_transform_presets', 'list_tm', + 'list_dashboards', 'get_dashboard', 'list_dashboard_panels', 'list_reviews', + 'list_releases', 'get_release', 'get_my_permissions', 'list_roles', 'get_role', + 'list_policies', 'get_policy', 'export_access', 'list_api_keys', 'get_api_key', + 'list_users', 'get_user', 'list_teams', 'get_team', 'list_team_members', + 'list_webhooks', 'list_cdc_subscriptions', 'get_cdc_subscription', 'cdc_events_read', + 'list_intents', 'get_intent', 'list_intent_drifts', 'list_flows', 'get_flow', + 'list_flow_runs', 'get_flow_run', 'list_activity', 'get_site', 'get_health', + 'get_metrics', 'export_backup', 'list_materializations', 'query_materialization', + 'list_extensions', 'list_marketplace_extensions', 'get_marketplace_extension', + 'list_marketplace_updates', 'list_deployment_targets', 'list_deployments', + 'get_deployment', 'get_deployment_logs', + ]; + const sets: Array<[string, string[]]> = [ ['MAPPED_41', MAPPED_41], ['UNMAPPED_48', UNMAPPED_48], ['PROVIDER_2', PROVIDER_2], ['READ_VIA_POST_7', READ_VIA_POST_7], + ['READ_GET_63', READ_GET_63], ]; // 1) Kích thước khai báo @@ -767,6 +797,7 @@ describe('G2 repro · soát ngữ nghĩa: compile_intent bị xếp sai nhóm', expect(UNMAPPED_48).toHaveLength(48); expect(PROVIDER_2).toHaveLength(2); expect(READ_VIA_POST_7).toHaveLength(7); + expect(READ_GET_63).toHaveLength(63); // 2) Uniqueness trong từng tập + membership trong registry THẬT for (const [label, list] of sets) { @@ -787,17 +818,27 @@ describe('G2 repro · soát ngữ nghĩa: compile_intent bị xếp sai nhóm', } expect(overlaps).toEqual([]); - // 4) Union: phần bù đúng bằng 63 read-qua-GET ⇒ phủ đúng registry + // 4) Union so HAI CHIỀU với registry thật (sửa theo F2). + // Trước đây nhóm 63 được lấy TỪ registry nên không khoá danh tính; giờ nó + // là tập khai báo, nên cả 161 tên đều có tập sở hữu. const classified = new Set(seen.keys()); - expect(classified.size).toBe(41 + 48 + 2 + 7); - const complement = registry.filter((n) => !classified.has(n)); - expect(complement).toHaveLength(63); - expect(classified.size + complement.length).toBe(registry.length); + expect(classified.size).toBe(41 + 48 + 2 + 7 + 63); + + // 4a) registry ⊆ các tập: không tool nào của registry bị bỏ rơi. + const unclassified = registry.filter((n) => !classified.has(n)); + expect(unclassified, 'mọi tool trong registry phải thuộc đúng một tập').toEqual([]); + + // 4b) các tập ⊆ registry: không tên khai báo nào biến mất khỏi registry. + // (membership ở bước 2 đã phủ, giữ lại để union là song ánh tường minh.) + const ghosts = [...classified].filter((n) => !registrySet.has(n)); + expect(ghosts, 'không tên khai báo nào được vắng mặt trong registry').toEqual([]); + + expect(classified.size).toBe(registry.length); expect(registry).toHaveLength(161); - // 5) Phần bù không được chứa động từ ghi — chốt rằng nó thật là nhóm read. + // 5) Nhóm read-GET không được chứa động từ ghi — chốt nó thật là nhóm read. const writeVerb = /^(create|update|delete|upsert|remove|revoke|rotate|attach|detach|assign|install|uninstall|publish|apply|restore|approve|reject|submit|register|drop|refresh|pause|resume|scan|replay|run)_/; - expect(complement.filter((n) => writeVerb.test(n))).toEqual([]); + expect(READ_GET_63.filter((n) => writeVerb.test(n))).toEqual([]); }); it('S13b: tổng kiểm số học của bảng phân loại', () => {