072aedeaf9
* 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
178 lines
7.2 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|