Files
accounted/tests/pg/match-batch-allocate-service-actor.pg.test.ts
T
e70718eea3 fix(matching): make match_batch_allocate commit from the MCP approval path (#1645)
The pending-operations commit path runs on the cookieless service client,
where auth.uid() is NULL, so every MCP-approved batch allocation returned
BATCH_UNAUTHORIZED since 20260601122000 dropped the p_user_id argument.
The web /pending path only worked because it carries a cookie session.

Re-add p_user_id gated exactly like undo_sie_import (20260727121000):
honored only when auth.role() = 'service_role', every other caller is
pinned to its own auth.uid(), so an authenticated PostgREST caller cannot
impersonate. The commit handler now passes the approving user through,
which also attributes the journal entry and payment rows to the human
who approved instead of failing outright. The 3-arg signature is dropped
(the new 4th arg has a DEFAULT, so the HTTP twin's 3-arg call still
resolves); grants re-asserted: no PUBLIC/anon, authenticated +
service_role only.

Reported via gnubok_feedback 2026-07-24 (codex/hermes) and 2026-08-06.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 22:20:52 +02:00

202 lines
7.5 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
insertAuthUser,
insertCompany,
insertCompanyMember,
insertFiscalPeriod,
} from '@/tests/pg/fixtures'
import { getPool, runAsServiceRole, withUserContext } from '@/tests/pg/setup'
/**
* Covers 20260817150000_match_batch_allocate_service_actor:
* - service_role caller + p_user_id of a member: allocation commits and
* the journal entry / payment rows are attributed to that user. This is
* the pending-operations commit path (createServiceClientNoCookies),
* which before the migration ALWAYS got BATCH_UNAUTHORIZED because
* auth.uid() is NULL on the service client.
* - p_user_id is an assertion, honored ONLY under auth.role() =
* 'service_role': an authenticated non-member spoofing an owner's UUID
* and a caller with no JWT at all both stay BATCH_UNAUTHORIZED.
* - Grants: PUBLIC/anon revoked, authenticated + service_role kept; the
* old 3-arg signature is gone (the 4th arg has a DEFAULT, so 3-arg
* call sites still resolve).
*/
let arrivalSeq = 0
async function insertSupplier(params: { userId: string; companyId: string }): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.suppliers
(id, user_id, company_id, name, supplier_type, country, default_payment_terms, default_currency)
VALUES ($1, $2, $3, 'Leverantör AB', 'swedish_business', 'SE', 30, 'SEK')`,
[id, params.userId, params.companyId],
)
return id
}
async function insertSupplierInvoice(params: {
userId: string
companyId: string
supplierId: string
total: number
}): Promise<string> {
const id = randomUUID()
const arrivalNumber = (Date.now() % 1_000_000) * 1000 + arrivalSeq++
await getPool().query(
`INSERT INTO public.supplier_invoices
(id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number,
invoice_date, due_date, received_date, status, currency,
subtotal, vat_amount, total, paid_amount, remaining_amount,
vat_treatment, reverse_charge, is_credit_note)
VALUES ($1, $2, $3, $4, $5, $6, '2026-06-01', '2026-07-01', '2026-06-01', 'approved', 'SEK',
$7, 0, $7, 0, $7, 'standard_25', false, false)`,
[id, params.userId, params.companyId, params.supplierId, arrivalNumber, `LF-${arrivalNumber}`, params.total],
)
return id
}
async function insertTransaction(params: {
userId: string
companyId: string
amount: number
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.transactions
(id, user_id, company_id, date, description, amount, currency, category)
VALUES ($1, $2, $3, '2026-06-05', 'Bank transfer', $4, 'SEK', 'uncategorized')`,
[id, params.userId, params.companyId, params.amount],
)
return id
}
async function seedTenant() {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
await insertFiscalPeriod({
userId,
companyId,
periodStart: '2026-01-01',
periodEnd: '2026-12-31',
})
const supplierId = await insertSupplier({ userId, companyId })
const invoiceId = await insertSupplierInvoice({ userId, companyId, supplierId, total: 2000 })
const txId = await insertTransaction({ userId, companyId, amount: -2000 })
return { userId, companyId, invoiceId, txId }
}
interface RpcResult {
ok: boolean
code?: string
journal_entry_id?: string
}
const CALL = `SELECT match_batch_allocate($1, $2::jsonb, $3, $4) AS result`
describe('match_batch_allocate service actor', () => {
it('commits for a service_role caller with p_user_id of a member and attributes rows to that user', async () => {
const { userId, companyId, invoiceId, txId } = await seedTenant()
const allocations = [{ kind: 'supplier_invoice', supplier_invoice_id: invoiceId, amount: 2000 }]
const result = await runAsServiceRole(async (client) => {
const r = await client.query<{ result: RpcResult }>(CALL, [
txId,
JSON.stringify(allocations),
companyId,
userId,
])
return r.rows[0]!.result
})
expect(result.ok).toBe(true)
expect(result.journal_entry_id).toBeTruthy()
const je = await getPool().query<{ user_id: string }>(
`SELECT user_id FROM public.journal_entries WHERE id = $1`,
[result.journal_entry_id],
)
expect(je.rows[0]!.user_id).toBe(userId)
const payment = await getPool().query<{ user_id: string }>(
`SELECT user_id FROM public.supplier_invoice_payments WHERE journal_entry_id = $1`,
[result.journal_entry_id],
)
expect(payment.rows).toHaveLength(1)
expect(payment.rows[0]!.user_id).toBe(userId)
})
it('still rejects a service_role caller that passes no p_user_id', async () => {
const { companyId, invoiceId, txId } = await seedTenant()
const allocations = [{ kind: 'supplier_invoice', supplier_invoice_id: invoiceId, amount: 2000 }]
const result = await runAsServiceRole(async (client) => {
const r = await client.query<{ result: RpcResult }>(CALL, [
txId,
JSON.stringify(allocations),
companyId,
null,
])
return r.rows[0]!.result
})
expect(result.ok).toBe(false)
expect(result.code).toBe('BATCH_UNAUTHORIZED')
})
it('ignores a spoofed p_user_id from an authenticated non-member', async () => {
const { userId, companyId, invoiceId, txId } = await seedTenant()
const stranger = await insertAuthUser()
const allocations = [{ kind: 'supplier_invoice', supplier_invoice_id: invoiceId, amount: 2000 }]
const result = await withUserContext(stranger, async (client) => {
const r = await client.query<{ result: RpcResult }>(CALL, [
txId,
JSON.stringify(allocations),
companyId,
userId,
])
return r.rows[0]!.result
})
expect(result.ok).toBe(false)
expect(result.code).toBe('BATCH_UNAUTHORIZED')
})
it('ignores p_user_id when there is no JWT context at all', async () => {
const { userId, companyId, invoiceId, txId } = await seedTenant()
const allocations = [{ kind: 'supplier_invoice', supplier_invoice_id: invoiceId, amount: 2000 }]
const r = await getPool().query<{ result: RpcResult }>(CALL, [
txId,
JSON.stringify(allocations),
companyId,
userId,
])
expect(r.rows[0]!.result.ok).toBe(false)
expect(r.rows[0]!.result.code).toBe('BATCH_UNAUTHORIZED')
})
it('keeps least-privilege grants and drops the 3-arg overload', async () => {
const { rows } = await getPool().query<{
anon_can: boolean
authenticated_can: boolean
service_role_can: boolean
overloads: string
}>(
`SELECT has_function_privilege('anon', 'public.match_batch_allocate(uuid,jsonb,uuid,uuid)', 'EXECUTE') AS anon_can,
has_function_privilege('authenticated', 'public.match_batch_allocate(uuid,jsonb,uuid,uuid)', 'EXECUTE') AS authenticated_can,
has_function_privilege('service_role', 'public.match_batch_allocate(uuid,jsonb,uuid,uuid)', 'EXECUTE') AS service_role_can,
(SELECT count(*) FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public' AND p.proname = 'match_batch_allocate')::text AS overloads`,
)
expect(rows[0]!.anon_can).toBe(false)
expect(rows[0]!.authenticated_can).toBe(true)
expect(rows[0]!.service_role_can).toBe(true)
expect(rows[0]!.overloads).toBe('1')
})
})