Files
accounted/extensions/general/mcp-server/__tests__/search-tools.test.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

165 lines
6.6 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')
})
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()
}
})
})