Files
accounted/extensions/general/mcp-server/__tests__/search-tools.test.ts
T
Mattsson 072aedeaf9 Fix/supp ag fb (#1023)
* fix: prevent credit notes from entering payment flow

* fix: persist and display customer personal numbers

* feat: configure automatic invoice reminder days

* fix: issue credit notes through send flow

* chore: add repository agent guidance

* feat(mcp): route tools across user companies

* fix(articles): delete unused register entries

* feat(invoices): improve issued invoice actions

* feat(supplier-invoices): retain uploaded source documents

* docs: record implementation decisions

* feat: enhance customer personal number handling and validation

- Updated CustomerForm to allow personal numbers in the format of "********-1234" for individual customers.
- Added validation to ensure personal numbers are only accepted for individual customers in CreateCustomerSchema.
- Implemented masking and encryption for personal numbers to enhance data protection.
- Introduced new utility functions for masking and encrypting personal numbers.
- Added database migration to enforce unique constraints on credit note relationships and prevent duplicate entries.
- Enhanced error handling and logging for credit note issuance and invoice processing.
- Updated tests to cover new credit note creation guards and personal number handling.

* test: enhance list companies test with supabase query mocks
2026-07-15 15:53:15 +02:00

178 lines
7.2 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { tools } from '../server'
import { ALL_SCOPES } from '@/lib/auth/api-keys'
const searchTool = tools.find((t) => t.name === 'gnubok_search_tools')!
async function call(args: Record<string, unknown>, keyScopes: string[] = ALL_SCOPES as unknown as string[]) {
// Mirror the dispatcher: inject __keyScopes the way handleMcpRequest does.
const argsWithScopes = { ...args, __keyScopes: keyScopes }
return (await searchTool.execute(
argsWithScopes,
'company-id',
'user-id',
{} as never,
{ type: 'api_key' }
)) as {
tools: Array<{ name: string; description?: string; scope: string | null; inputSchema?: unknown; outputSchema?: unknown }>
count: number
total_matched: number
detail: 'name' | 'summary' | 'full'
}
}
describe('gnubok_search_tools', () => {
it('is registered as a tool', () => {
expect(searchTool).toBeDefined()
expect(searchTool.annotations.readOnlyHint).toBe(true)
})
it('returns all tools when query is empty (default summary detail)', async () => {
const result = await call({})
expect(result.detail).toBe('summary')
expect(result.tools.length).toBeGreaterThan(0)
expect(result.tools.length).toBeLessThanOrEqual(20) // default limit
// summary entries should have name + description but not full schema
expect(result.tools[0]).toHaveProperty('name')
expect(result.tools[0]).toHaveProperty('description')
expect(result.tools[0]).not.toHaveProperty('inputSchema')
})
it('detail=name returns only names + scope', async () => {
const result = await call({ detail: 'name', limit: 5 })
expect(result.detail).toBe('name')
for (const t of result.tools) {
expect(t).not.toHaveProperty('description')
expect(t).not.toHaveProperty('inputSchema')
expect(t).toHaveProperty('name')
expect(t).toHaveProperty('scope')
}
})
it('detail=full returns inputSchema and outputSchema', async () => {
const result = await call({ detail: 'full', query: 'list_uncategorized', limit: 5 })
expect(result.tools.length).toBeGreaterThan(0)
const tool = result.tools[0]
expect(tool).toHaveProperty('inputSchema')
expect(tool).toHaveProperty('outputSchema')
expect(
(tool.inputSchema as { properties: Record<string, unknown> }).properties
).toHaveProperty('company_id')
})
it('does not add company_id to company-independent discovery tools', async () => {
const result = await call({ detail: 'full', query: 'search tools', limit: 5 })
const tool = result.tools.find((candidate) => candidate.name === 'gnubok_search_tools')
expect(tool).toBeDefined()
expect(
(tool!.inputSchema as { properties: Record<string, unknown> }).properties
).not.toHaveProperty('company_id')
})
it('filters by query keyword', async () => {
const result = await call({ query: 'vat' })
expect(result.tools.length).toBeGreaterThan(0)
for (const t of result.tools) {
const haystack = `${t.name} ${t.description ?? ''}`.toLowerCase()
expect(haystack).toContain('vat')
}
})
it('ranks by relevance: the most on-point tool comes first', async () => {
// "vat" should surface the canonical report tool ahead of other vat-* tools,
// not whichever is defined earliest by accident.
const vat = await call({ query: 'vat' })
expect(vat.tools[0].name).toBe('gnubok_get_vat_report')
// Multi-word query: every term must match (name or description), and the
// tool whose NAME carries both terms ranks first.
const paid = await call({ query: 'invoice paid' })
expect(paid.tools.length).toBeGreaterThan(0)
expect(paid.tools[0].name).toBe('gnubok_mark_invoice_as_paid')
})
it('respects limit (1-50, default 20, clamps over-50)', async () => {
const overLimit = await call({ limit: 100 })
expect(overLimit.tools.length).toBeLessThanOrEqual(50)
})
it('filters out tools the caller cannot invoke based on scopes', async () => {
// Caller has only reports:read: should not see invoices:write tools.
const result = await call({ query: '', limit: 50 }, ['reports:read'])
const names = result.tools.map((t) => t.name)
expect(names).not.toContain('gnubok_create_invoice')
expect(names).not.toContain('gnubok_send_invoice')
// But should see reports:read tools.
expect(names).toContain('gnubok_get_trial_balance')
// And unscoped tools (like search itself) are always available.
expect(names).toContain('gnubok_search_tools')
})
it('scope filter narrows results to a single scope', async () => {
const result = await call({ scope: 'invoices:write', limit: 50 })
for (const t of result.tools) {
expect(t.scope).toBe('invoices:write')
}
})
it('total_matched reflects pre-limit candidates', async () => {
const limited = await call({ query: '', limit: 3 })
expect(limited.tools.length).toBe(3)
expect(limited.total_matched).toBeGreaterThan(3)
})
// Security: when the dispatcher fails to inject __keyScopes (rename, refactor
// regression, direct invocation outside the dispatcher), the search MUST fall
// back to a fail-closed default: only unscoped tools visible. The earlier
// permissive default leaked the full inventory.
it('fail-closed: hides scoped tools when __keyScopes is not injected', async () => {
// Bypass the helper which always injects __keyScopes: call execute() directly.
const result = (await searchTool.execute(
{ limit: 50 }, // no __keyScopes
'company-id',
'user-id',
{} as never,
{ type: 'api_key' }
)) as { tools: Array<{ name: string; scope: string | null }> }
const names = result.tools.map((t) => t.name)
// Only unscoped (discovery / skill) tools should appear.
expect(names).toContain('gnubok_search_tools')
expect(names).toContain('gnubok_list_skills')
expect(names).toContain('gnubok_load_skill')
// No scoped tool should leak: pick representatives from each scope domain.
expect(names).not.toContain('gnubok_create_invoice') // invoices:write
expect(names).not.toContain('gnubok_get_trial_balance') // reports:read
expect(names).not.toContain('gnubok_list_uncategorized_transactions') // transactions:read
expect(names).not.toContain('gnubok_create_salary_run') // payroll:write
expect(names).not.toContain('gnubok_close_period') // bookkeeping:write
// Sanity: every returned tool truly is unscoped.
for (const t of result.tools) {
expect(t.scope).toBeNull()
}
})
it('fail-closed: explicitly empty __keyScopes also hides scoped tools', async () => {
// The "scopes were checked, granted set is empty" case must behave the same
// as "scopes were not injected at all". Both indicate no scoped access.
const result = (await searchTool.execute(
{ __keyScopes: [], limit: 50 },
'company-id',
'user-id',
{} as never,
{ type: 'api_key' }
)) as { tools: Array<{ name: string; scope: string | null }> }
const names = result.tools.map((t) => t.name)
expect(names).not.toContain('gnubok_create_invoice')
expect(names).not.toContain('gnubok_get_trial_balance')
for (const t of result.tools) {
expect(t.scope).toBeNull()
}
})
})