Files
accounted/lib/pending-operations/__tests__/actor-and-risk.pg.test.ts
T
Jakob WennbergandClaude Opus 4.7 eb77ad50b5 feat(mcp): add create_voucher + correct_entry MCP tools (#448)
* feat(mcp): add create_voucher + correct_entry MCP tools

The MCP toolset had no way to post a journal entry outside the preset
workflows (categorize_transaction, create_invoice, …). That blocks
legitimate flows the engine already supports — K3 capitalization to BAS
1010, period-end accruals, FX adjustments, prepayments, and rättelseposter
for foreign reverse-charge VAT that landed on 2641 instead of 2614/2645.

create_voucher exposes the existing createJournalEntry() primitive:
arbitrary balanced lines, optional fiscal-period auto-resolution, staged
for human approval. correct_entry exposes correctEntry() (storno + new
corrected entry per BFL 5 kap 5§) so part of a posted verifikation can be
fixed without losing the legs that were right.

Both are HIGH risk in OPERATION_RISK_TIERS — the arbitrary account/amount/
period inputs make them compliance-critical despite being structurally
similar to uncategorize_transaction (medium). Approval flow unchanged; no
auto-commit, regardless of trust level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(mcp): address PR #448 review — voucher tools hardening

Greptile P1 + compliance bot findings, all in one pass.

commitCreateVoucher (commit.ts):
- Hardcode source_type to 'manual' instead of reading from params. A future
  direct-staging path or hand-inserted pending_operations row could
  otherwise inject 'bank_transaction'/'invoice_created'/etc. and corrupt
  the audit-trail origin.
- Re-validate balance defensively before reaching the engine, so a tampered
  params row surfaces a clean Swedish 400 instead of an opaque engine error.

gnubok_create_voucher (server.ts):
- Validate the explicit fiscal_period_id when supplied: confirm it exists,
  is open (is_closed = false), and that entry_date falls within its span.
  Without this, a closed/locked period was only caught at commit-time with
  a generic DB-trigger error.
- Throw at staging when any line targets an account that's missing from
  chart_of_accounts or marked inactive, rather than relying on the
  approver to spot the advisory flag.
- Remove source_type from the staged params blob entirely — the executor
  ignores it anyway, no point letting it travel through.
- Add a comment that the staging-time period-lock check is advisory and
  the executor is the authoritative guard, so future cleanup doesn't
  remove either as 'redundant'.

Descriptions:
- gnubok_correct_entry now explicitly notes that the storno + corrected
  entries land in the original period (defends against compliance bot's
  speculative "different period" concern recurring on future reviews).
- Both tools' tax_code field gets a note that the BAS account number
  drives momsdeklaration ruta mapping, not tax_code — guards against an
  LLM treating tax_code as the VAT-routing dial.

commitCorrectEntry (commit.ts):
- Add a comment pointing at storno-service.ts:99,102,195,198 to make the
  "uses original period and date" invariant explicit in this file.

Tests:
- +2 voucher-executors cases: source_type tampering is ignored, unbalanced
  params return 400.
- +10 new voucher-tools tests (MCP layer): unbalanced, closed explicit
  period, missing explicit period, entry_date outside period, unknown
  account, inactive account, happy path + correct_entry registration +
  unbalanced replacement.

720 tests pass in the impacted suites; full suite 2998/2998.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:16:39 +02:00

333 lines
12 KiB
TypeScript

/**
* pg-real smoke tests for the migrations introduced by the AI-native streams
* (actor model, 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 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
}>(
`INSERT INTO public.pending_operations (
user_id, company_id, operation_type, title, params, preview_data,
actor_type, actor_id, actor_label, risk_level
) VALUES ($1, $2, 'create_customer', 'pg-real test', '{}', '{}',
'api_key', NULL, 'Claude Desktop', 'low')
RETURNING id, actor_type, risk_level`,
[userId, companyId],
)
expect(result.rows[0]).toMatchObject({
actor_type: 'api_key',
risk_level: 'low',
})
})
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('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',
// Phase 4: arbitrary-line bookkeeping primitives
'create_voucher', 'correct_entry',
]
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('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)
})
})