* feat(branding): implement dynamic branding in service worker and reports * feat(auth): enhance API key scopes and add bookkeeping write scope - Updated transaction write scope description to include additional tools. - Enhanced reports read scope description to reflect new functionality. - Introduced bookkeeping write scope with relevant description. - Updated SCOPE_GROUPS to include bookkeeping domain. - Modified TOOL_SCOPE_MAP to include new bookkeeping operations. - Updated validateApiKey function to return api_key_id and api_key_name for better actor attribution. feat(tests): add unit tests for MCP resource registry - Created tests for data resources to ensure all required fields are present. - Added tests for resource query parsing and retrieval. feat(resources): implement MCP resources for company and accounting data - Added capabilities resource to expose API key capabilities based on granted scopes. - Implemented chart of accounts resource to retrieve active BAS chart. - Created company current resource to fetch active company details. - Developed active fiscal period resource to check posting eligibility. - Implemented recent activity resource to fetch latest journal entries, invoices, and transactions. - Added VAT treatments resource to provide available VAT rates per customer type. feat(pending-operations): introduce risk tiers for operations - Added risk level classification for pending operations to determine auto-commit eligibility. - Implemented functions to classify operation risk levels and identify high-risk operations. feat(migrations): add actor model and risk tier to pending operations - Updated pending_operations table to include actor type and risk level columns. - Enhanced audit_log to mirror actor information for compliance. - Modified validate_and_increment_api_key function to return actor details. - Expanded operation types in pending_operations to include new high-risk operations. * feat: add auto-commit functionality for low-risk pending operations - Implemented shouldAutoCommit function to determine eligibility for auto-commit based on operation type, actor type, and company settings. - Created commitPendingOperation function to handle execution of pending operations with consistent status updates. - Added tests for shouldAutoCommit to cover various scenarios including high-risk operations, user actors, company opt-in status, and monetary thresholds. - Introduced new columns in company_settings for agent_auto_commit_enabled and agent_auto_commit_max_amount to allow companies to opt-in for auto-commit functionality. - Added SQL migration to update the database schema for new auto-commit settings. * feat(idempotency): implement idempotency key handling for safe retries and cleanup * feat: expand API key scopes and pending operations for bookkeeping - Added 'suppliers:write' scope to API key scopes for supplier invoice management. - Updated SCOPE_GROUPS to include the new 'suppliers:write' scope. - Introduced new pending operation types for bookkeeping: close_period, lock_period, run_year_end, set_opening_balances, run_currency_revaluation, explain_voucher_gap, uncategorize_transaction, approve_supplier_invoice, credit_supplier_invoice, and convert_invoice. - Implemented corresponding commit functions for the new operations in the pending operations module. - Enhanced PendingOperation type to include actor model and risk level attributes. - Added tests for new functionality, ensuring proper behavior and constraints in the database. * feat: implement unlockPeriod functionality and related tests * feat: add agent auto-commit settings and related functionality * feat: add attention resource with comprehensive summary of outstanding tasks * feat: enhance pending operations with 'committing' status and immutability checks, improve idempotency handling, and add original voucher reference for credit notes
379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
/**
|
|
* pg-real smoke tests for the four migrations introduced by the
|
|
* AI-native streams (actor model, auto-commit, expanded op types, idempotency).
|
|
*
|
|
* These don't replicate the unit-test coverage — they prove the schema and
|
|
* constraints behave as the application code assumes when running against a
|
|
* real Postgres with the migrations applied.
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import { getPool } from '@/tests/pg/setup'
|
|
import { seedCompany } from '@/tests/pg/fixtures'
|
|
|
|
describe('pending_operations: actor model + risk + auto-commit columns', () => {
|
|
it('accepts the expanded actor_type and risk_level enums', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const pool = getPool()
|
|
|
|
const result = await pool.query<{
|
|
id: string
|
|
actor_type: string
|
|
risk_level: string
|
|
auto_commit_eligible: boolean
|
|
}>(
|
|
`INSERT INTO public.pending_operations (
|
|
user_id, company_id, operation_type, title, params, preview_data,
|
|
actor_type, actor_id, actor_label, risk_level, auto_commit_eligible
|
|
) VALUES ($1, $2, 'create_customer', 'pg-real test', '{}', '{}',
|
|
'api_key', NULL, 'Claude Desktop', 'low', true)
|
|
RETURNING id, actor_type, risk_level, auto_commit_eligible`,
|
|
[userId, companyId],
|
|
)
|
|
|
|
expect(result.rows[0]).toMatchObject({
|
|
actor_type: 'api_key',
|
|
risk_level: 'low',
|
|
auto_commit_eligible: true,
|
|
})
|
|
})
|
|
|
|
it('rejects invalid actor_type via CHECK constraint', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
await expect(
|
|
getPool().query(
|
|
`INSERT INTO public.pending_operations (
|
|
user_id, company_id, operation_type, title, params, preview_data, actor_type, risk_level
|
|
) VALUES ($1, $2, 'create_customer', 'x', '{}', '{}', 'martian', 'low')`,
|
|
[userId, companyId],
|
|
),
|
|
).rejects.toThrow(/check constraint|actor_type/i)
|
|
})
|
|
|
|
it('rejects invalid risk_level via CHECK constraint', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
await expect(
|
|
getPool().query(
|
|
`INSERT INTO public.pending_operations (
|
|
user_id, company_id, operation_type, title, params, preview_data, actor_type, risk_level
|
|
) VALUES ($1, $2, 'create_customer', 'x', '{}', '{}', 'user', 'critical')`,
|
|
[userId, companyId],
|
|
),
|
|
).rejects.toThrow(/check constraint|risk_level/i)
|
|
})
|
|
|
|
it('blocks auto_committed_at when status is still pending', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
await expect(
|
|
getPool().query(
|
|
`INSERT INTO public.pending_operations (
|
|
user_id, company_id, operation_type, title, params, preview_data,
|
|
actor_type, risk_level, auto_committed_at
|
|
) VALUES ($1, $2, 'create_customer', 'x', '{}', '{}',
|
|
'api_key', 'low', now())`,
|
|
[userId, companyId],
|
|
),
|
|
).rejects.toThrow(/pending_ops_auto_commit_status|check constraint/i)
|
|
})
|
|
|
|
it('accepts the expanded operation_type enum (close_period, run_year_end, …)', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const expandedTypes = [
|
|
'close_period', 'lock_period', 'unlock_period', 'run_year_end', 'set_opening_balances',
|
|
'run_currency_revaluation', 'explain_voucher_gap', 'uncategorize_transaction',
|
|
'approve_supplier_invoice', 'credit_supplier_invoice',
|
|
'credit_invoice', 'convert_invoice', 'import_sie',
|
|
]
|
|
|
|
for (const op of expandedTypes) {
|
|
const result = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.pending_operations (
|
|
user_id, company_id, operation_type, title, params, preview_data
|
|
) VALUES ($1, $2, $3, 'pg-real test', '{}', '{}')
|
|
RETURNING id`,
|
|
[userId, companyId, op],
|
|
)
|
|
expect(result.rows[0]?.id).toBeTruthy()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('audit_log: actor_type + actor_label columns', () => {
|
|
it('accepts INSERT with actor_type and actor_label', async () => {
|
|
const { userId } = await seedCompany()
|
|
const result = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.audit_log (
|
|
user_id, action, table_name, actor_type, actor_label, description
|
|
) VALUES ($1, 'INSERT', 'pending_operations', 'api_key', 'Claude Desktop', 'pg-real test')
|
|
RETURNING id`,
|
|
[userId],
|
|
)
|
|
expect(result.rows[0]?.id).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
describe('company_settings: auto_commit columns', () => {
|
|
it('exposes agent_auto_commit_enabled (default false) and agent_auto_commit_max_amount (NULL)', async () => {
|
|
const { companyId } = await seedCompany()
|
|
|
|
const settings = await getPool().query<{
|
|
enabled: boolean
|
|
max_amount: string | null
|
|
}>(
|
|
`SELECT agent_auto_commit_enabled AS enabled,
|
|
agent_auto_commit_max_amount AS max_amount
|
|
FROM public.company_settings
|
|
WHERE company_id = $1`,
|
|
[companyId],
|
|
)
|
|
|
|
// company_settings may or may not have a default row; if it doesn't, the
|
|
// columns still exist on the table and we can inspect the catalog.
|
|
if (settings.rows.length === 0) {
|
|
const catalog = await getPool().query<{ name: string }>(
|
|
`SELECT column_name AS name FROM information_schema.columns
|
|
WHERE table_schema = 'public' AND table_name = 'company_settings'
|
|
AND column_name IN ('agent_auto_commit_enabled', 'agent_auto_commit_max_amount')`,
|
|
)
|
|
expect(catalog.rowCount).toBe(2)
|
|
return
|
|
}
|
|
|
|
expect(settings.rows[0]?.enabled).toBe(false)
|
|
expect(settings.rows[0]?.max_amount).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('idempotency_keys table', () => {
|
|
it('enforces unique (user_id, key) and 24h default expiry', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
|
|
await getPool().query(
|
|
`INSERT INTO public.idempotency_keys
|
|
(user_id, company_id, key, request_hash, scope, response_status, response_body)
|
|
VALUES ($1, $2, 'dup-key', 'hash-1', 'mcp_tool', 'success', '{}')`,
|
|
[userId, companyId],
|
|
)
|
|
|
|
await expect(
|
|
getPool().query(
|
|
`INSERT INTO public.idempotency_keys
|
|
(user_id, company_id, key, request_hash, scope, response_status, response_body)
|
|
VALUES ($1, $2, 'dup-key', 'hash-2', 'mcp_tool', 'success', '{}')`,
|
|
[userId, companyId],
|
|
),
|
|
).rejects.toThrow(/duplicate key|unique/i)
|
|
|
|
const expiry = await getPool().query<{ expires_at: string; created_at: string }>(
|
|
`SELECT expires_at, created_at FROM public.idempotency_keys
|
|
WHERE user_id = $1 AND key = 'dup-key'`,
|
|
[userId],
|
|
)
|
|
const created = new Date(expiry.rows[0]!.created_at).getTime()
|
|
const expires = new Date(expiry.rows[0]!.expires_at).getTime()
|
|
const hoursDelta = (expires - created) / 3_600_000
|
|
// Expect the default ~24h gap (allow ±1 minute for clock skew).
|
|
expect(hoursDelta).toBeGreaterThan(23.95)
|
|
expect(hoursDelta).toBeLessThan(24.05)
|
|
})
|
|
|
|
it('rejects invalid response_status', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
await expect(
|
|
getPool().query(
|
|
`INSERT INTO public.idempotency_keys
|
|
(user_id, company_id, key, request_hash, scope, response_status, response_body)
|
|
VALUES ($1, $2, 'bad-status', 'hash-x', 'mcp_tool', 'maybe', '{}')`,
|
|
[userId, companyId],
|
|
),
|
|
).rejects.toThrow(/check constraint|response_status/i)
|
|
})
|
|
|
|
it('rejects NULL company_id (multi-tenant scoping)', async () => {
|
|
const { userId } = await seedCompany()
|
|
await expect(
|
|
getPool().query(
|
|
`INSERT INTO public.idempotency_keys
|
|
(user_id, company_id, key, request_hash, scope, response_status, response_body)
|
|
VALUES ($1, NULL, 'k1', 'h1', 'mcp_tool', 'success', '{}')`,
|
|
[userId],
|
|
),
|
|
).rejects.toThrow(/null value|not.null/i)
|
|
})
|
|
|
|
it('allows the same key across two companies (scoped uniqueness)', async () => {
|
|
const { userId, companyId: company1 } = await seedCompany()
|
|
const { companyId: company2 } = await seedCompany()
|
|
const sameKey = 'shared-key-abc'
|
|
|
|
await getPool().query(
|
|
`INSERT INTO public.idempotency_keys
|
|
(user_id, company_id, key, request_hash, scope, response_status, response_body)
|
|
VALUES ($1, $2, $3, 'h1', 'mcp_tool', 'success', '{}')`,
|
|
[userId, company1, sameKey],
|
|
)
|
|
// Same user + same key but different company must NOT collide.
|
|
await expect(
|
|
getPool().query(
|
|
`INSERT INTO public.idempotency_keys
|
|
(user_id, company_id, key, request_hash, scope, response_status, response_body)
|
|
VALUES ($1, $2, $3, 'h2', 'mcp_tool', 'success', '{}')`,
|
|
[userId, company2, sameKey],
|
|
),
|
|
).resolves.toBeTruthy()
|
|
})
|
|
})
|
|
|
|
describe('pending_operations: CAS + post-commit immutability', () => {
|
|
it('accepts the new committing transient status', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const result = await getPool().query<{ id: string; status: string }>(
|
|
`INSERT INTO public.pending_operations
|
|
(user_id, company_id, operation_type, status, title, params, preview_data)
|
|
VALUES ($1, $2, 'create_customer', 'committing', 'pg-real', '{}', '{}')
|
|
RETURNING id, status`,
|
|
[userId, companyId],
|
|
)
|
|
expect(result.rows[0]?.status).toBe('committing')
|
|
})
|
|
|
|
it('CAS pattern (UPDATE … WHERE status=pending) only claims unclaimed rows', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const ins = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.pending_operations
|
|
(user_id, company_id, operation_type, status, title, params, preview_data)
|
|
VALUES ($1, $2, 'create_customer', 'pending', 'cas-test', '{}', '{}')
|
|
RETURNING id`,
|
|
[userId, companyId],
|
|
)
|
|
const id = ins.rows[0]!.id
|
|
|
|
// First claim succeeds.
|
|
const first = await getPool().query(
|
|
`UPDATE public.pending_operations SET status = 'committing'
|
|
WHERE id = $1 AND status = 'pending' RETURNING id`,
|
|
[id],
|
|
)
|
|
expect(first.rowCount).toBe(1)
|
|
|
|
// Second concurrent claim sees status='committing' and returns 0 rows.
|
|
const second = await getPool().query(
|
|
`UPDATE public.pending_operations SET status = 'committing'
|
|
WHERE id = $1 AND status = 'pending' RETURNING id`,
|
|
[id],
|
|
)
|
|
expect(second.rowCount).toBe(0)
|
|
})
|
|
|
|
it('blocks UPDATE on rows in terminal status (committed)', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const ins = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.pending_operations
|
|
(user_id, company_id, operation_type, status, title, params, preview_data, resolved_at)
|
|
VALUES ($1, $2, 'create_customer', 'committed', 'imm-test', '{}', '{}', now())
|
|
RETURNING id`,
|
|
[userId, companyId],
|
|
)
|
|
const id = ins.rows[0]!.id
|
|
|
|
await expect(
|
|
getPool().query(
|
|
`UPDATE public.pending_operations SET title = 'tampered' WHERE id = $1`,
|
|
[id],
|
|
),
|
|
).rejects.toThrow(/terminal state|BFL 7/i)
|
|
})
|
|
|
|
it('blocks UPDATE on rows in terminal status (rejected)', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const ins = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.pending_operations
|
|
(user_id, company_id, operation_type, status, title, params, preview_data, resolved_at)
|
|
VALUES ($1, $2, 'create_customer', 'rejected', 'imm-test', '{}', '{}', now())
|
|
RETURNING id`,
|
|
[userId, companyId],
|
|
)
|
|
const id = ins.rows[0]!.id
|
|
|
|
await expect(
|
|
getPool().query(
|
|
`UPDATE public.pending_operations SET params = '{"x":1}' WHERE id = $1`,
|
|
[id],
|
|
),
|
|
).rejects.toThrow(/terminal state|BFL 7/i)
|
|
})
|
|
|
|
it('blocks DELETE on rows in terminal status', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const ins = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.pending_operations
|
|
(user_id, company_id, operation_type, status, title, params, preview_data, resolved_at)
|
|
VALUES ($1, $2, 'create_customer', 'committed', 'del-test', '{}', '{}', now())
|
|
RETURNING id`,
|
|
[userId, companyId],
|
|
)
|
|
const id = ins.rows[0]!.id
|
|
|
|
await expect(
|
|
getPool().query(`DELETE FROM public.pending_operations WHERE id = $1`, [id]),
|
|
).rejects.toThrow(/terminal state|BFL 7/i)
|
|
})
|
|
|
|
it('blocks UPDATE of params on non-terminal rows (BFL 7 underlag-immutability)', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const ins = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.pending_operations
|
|
(user_id, company_id, operation_type, status, title, params, preview_data)
|
|
VALUES ($1, $2, 'create_customer', 'pending', 'frozen-test', '{"name":"original"}', '{}')
|
|
RETURNING id`,
|
|
[userId, companyId],
|
|
)
|
|
const id = ins.rows[0]!.id
|
|
|
|
await expect(
|
|
getPool().query(
|
|
`UPDATE public.pending_operations SET params = '{"name":"tampered"}' WHERE id = $1`,
|
|
[id],
|
|
),
|
|
).rejects.toThrow(/frozen|underlag/i)
|
|
})
|
|
|
|
it('blocks UPDATE of operation_type on non-terminal rows', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const ins = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.pending_operations
|
|
(user_id, company_id, operation_type, status, title, params, preview_data)
|
|
VALUES ($1, $2, 'create_customer', 'pending', 'op-type-frozen', '{}', '{}')
|
|
RETURNING id`,
|
|
[userId, companyId],
|
|
)
|
|
const id = ins.rows[0]!.id
|
|
|
|
await expect(
|
|
getPool().query(
|
|
`UPDATE public.pending_operations SET operation_type = 'send_invoice' WHERE id = $1`,
|
|
[id],
|
|
),
|
|
).rejects.toThrow(/frozen/i)
|
|
})
|
|
|
|
it('allows committing → committed transition', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const ins = await getPool().query<{ id: string }>(
|
|
`INSERT INTO public.pending_operations
|
|
(user_id, company_id, operation_type, status, title, params, preview_data)
|
|
VALUES ($1, $2, 'create_customer', 'committing', 'transition-test', '{}', '{}')
|
|
RETURNING id`,
|
|
[userId, companyId],
|
|
)
|
|
const id = ins.rows[0]!.id
|
|
|
|
const upd = await getPool().query(
|
|
`UPDATE public.pending_operations
|
|
SET status = 'committed', resolved_at = now(), result_data = '{"ok":true}'
|
|
WHERE id = $1 RETURNING id`,
|
|
[id],
|
|
)
|
|
expect(upd.rowCount).toBe(1)
|
|
})
|
|
})
|