* 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
184 lines
5.9 KiB
TypeScript
184 lines
5.9 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
addCompanyToNextHint,
|
|
addCompanyToTopLevelNext,
|
|
assertMcpCompanyWriteAccess,
|
|
extractRequestedCompany,
|
|
isCompanyDependentTool,
|
|
projectToolInputSchema,
|
|
resolveMcpCompanyContext,
|
|
} from '../company-routing'
|
|
|
|
const DEFAULT_COMPANY_ID = '11111111-1111-4111-8111-111111111111'
|
|
const OTHER_COMPANY_ID = '22222222-2222-4222-8222-222222222222'
|
|
|
|
function membershipClient(result: { data: unknown; error: unknown }) {
|
|
const chain: Record<string, ReturnType<typeof vi.fn>> = {
|
|
select: vi.fn(() => chain),
|
|
eq: vi.fn(() => chain),
|
|
is: vi.fn(() => chain),
|
|
maybeSingle: vi.fn().mockResolvedValue(result),
|
|
}
|
|
return {
|
|
client: { from: vi.fn(() => chain) },
|
|
chain,
|
|
}
|
|
}
|
|
|
|
describe('MCP company routing', () => {
|
|
it('projects company_id onto company-dependent tool schemas without mutating the source', () => {
|
|
const inputSchema = {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: { invoice_id: { type: 'string' } },
|
|
required: ['invoice_id'],
|
|
}
|
|
|
|
const projected = projectToolInputSchema({ name: 'gnubok_send_invoice', inputSchema })
|
|
|
|
expect(projected).not.toBe(inputSchema)
|
|
expect(projected.properties).toEqual({
|
|
invoice_id: { type: 'string' },
|
|
company_id: expect.objectContaining({ type: 'string', format: 'uuid' }),
|
|
})
|
|
expect(inputSchema.properties).not.toHaveProperty('company_id')
|
|
expect(projected.additionalProperties).toBe(false)
|
|
})
|
|
|
|
it.each(['gnubok_search_tools', 'gnubok_load_skill', 'gnubok_list_companies'])(
|
|
'keeps the company-independent schema unchanged for %s',
|
|
(name) => {
|
|
const inputSchema = {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {},
|
|
}
|
|
|
|
expect(isCompanyDependentTool(name)).toBe(false)
|
|
expect(projectToolInputSchema({ name, inputSchema })).toBe(inputSchema)
|
|
}
|
|
)
|
|
|
|
it('extracts and strips a valid company_id before tool execution', () => {
|
|
expect(
|
|
extractRequestedCompany({ company_id: OTHER_COMPANY_ID, invoice_id: 'invoice-1' })
|
|
).toEqual({
|
|
requestedCompanyId: OTHER_COMPANY_ID,
|
|
toolArgs: { invoice_id: 'invoice-1' },
|
|
})
|
|
})
|
|
|
|
it('rejects a malformed company_id', () => {
|
|
expect(() => extractRequestedCompany({ company_id: 'not-a-uuid' })).toThrow(
|
|
expect.objectContaining({ code: 'VALIDATION_ERROR' })
|
|
)
|
|
})
|
|
|
|
it('checks membership and resolves the requested company role', async () => {
|
|
const { client, chain } = membershipClient({
|
|
data: { company_id: OTHER_COMPANY_ID, role: 'admin' },
|
|
error: null,
|
|
})
|
|
|
|
await expect(
|
|
resolveMcpCompanyContext({
|
|
supabase: client as never,
|
|
userId: 'user-1',
|
|
defaultCompanyId: DEFAULT_COMPANY_ID,
|
|
requestedCompanyId: OTHER_COMPANY_ID,
|
|
})
|
|
).resolves.toEqual({
|
|
companyId: OTHER_COMPANY_ID,
|
|
role: 'admin',
|
|
isDefault: false,
|
|
})
|
|
expect(chain.eq).toHaveBeenCalledWith('user_id', 'user-1')
|
|
expect(chain.eq).toHaveBeenCalledWith('company_id', OTHER_COMPANY_ID)
|
|
expect(chain.is).toHaveBeenCalledWith('companies.archived_at', null)
|
|
})
|
|
|
|
it('checks the API key default company when company_id is omitted', async () => {
|
|
const { client, chain } = membershipClient({
|
|
data: { company_id: DEFAULT_COMPANY_ID, role: 'owner' },
|
|
error: null,
|
|
})
|
|
|
|
await expect(
|
|
resolveMcpCompanyContext({
|
|
supabase: client as never,
|
|
userId: 'user-1',
|
|
defaultCompanyId: DEFAULT_COMPANY_ID,
|
|
})
|
|
).resolves.toEqual({
|
|
companyId: DEFAULT_COMPANY_ID,
|
|
role: 'owner',
|
|
isDefault: true,
|
|
})
|
|
expect(chain.eq).toHaveBeenCalledWith('company_id', DEFAULT_COMPANY_ID)
|
|
})
|
|
|
|
it('rejects companies without a current non-archived membership', async () => {
|
|
const { client } = membershipClient({ data: null, error: null })
|
|
|
|
await expect(
|
|
resolveMcpCompanyContext({
|
|
supabase: client as never,
|
|
userId: 'user-1',
|
|
defaultCompanyId: DEFAULT_COMPANY_ID,
|
|
requestedCompanyId: OTHER_COMPANY_ID,
|
|
})
|
|
).rejects.toMatchObject({ code: 'NOT_FOUND' })
|
|
})
|
|
|
|
it('fails closed when the membership lookup fails', async () => {
|
|
const { client } = membershipClient({
|
|
data: null,
|
|
error: { message: 'database unavailable' },
|
|
})
|
|
|
|
await expect(
|
|
resolveMcpCompanyContext({
|
|
supabase: client as never,
|
|
userId: 'user-1',
|
|
defaultCompanyId: DEFAULT_COMPANY_ID,
|
|
})
|
|
).rejects.toMatchObject({ code: 'INTERNAL_ERROR' })
|
|
})
|
|
|
|
it('allows viewer reads but rejects viewer writes, approvals, and management', () => {
|
|
const context = { companyId: OTHER_COMPANY_ID, role: 'viewer' as const, isDefault: false }
|
|
|
|
expect(() => assertMcpCompanyWriteAccess(context, 'reports:read')).not.toThrow()
|
|
expect(() => assertMcpCompanyWriteAccess(context, undefined)).not.toThrow()
|
|
expect(() => assertMcpCompanyWriteAccess(context, 'invoices:write')).toThrow(
|
|
expect.objectContaining({ code: 'FORBIDDEN' })
|
|
)
|
|
expect(() => assertMcpCompanyWriteAccess(context, 'pending_operations:approve')).toThrow(
|
|
expect.objectContaining({ code: 'FORBIDDEN' })
|
|
)
|
|
expect(() => assertMcpCompanyWriteAccess(context, 'webhooks:manage')).toThrow(
|
|
expect.objectContaining({ code: 'FORBIDDEN' })
|
|
)
|
|
})
|
|
|
|
it('keeps company context in follow-up tool hints', () => {
|
|
const next = {
|
|
tool: 'gnubok_approve_pending_operation',
|
|
description: 'Approve the operation',
|
|
args: { operation_id: 'operation-1' },
|
|
}
|
|
|
|
expect(addCompanyToNextHint(next, OTHER_COMPANY_ID)).toEqual({
|
|
...next,
|
|
args: { operation_id: 'operation-1', company_id: OTHER_COMPANY_ID },
|
|
})
|
|
expect(addCompanyToTopLevelNext({ data: {}, next }, OTHER_COMPANY_ID)).toEqual({
|
|
data: {},
|
|
next: {
|
|
...next,
|
|
args: { operation_id: 'operation-1', company_id: OTHER_COMPANY_ID },
|
|
},
|
|
})
|
|
})
|
|
})
|