fix(invoices): scope-check article ids in buildInvoiceWriteData so every invoice write refuses a foreign company's article (part of #2059) (#2339)
Part of #2059 (Part 2, the hardening bug). The FK on invoice_items.article_id proves the article exists, not that it belongs to the writing company: FK validation ignores RLS, and the v1 routes run on the service-role client with no RLS at all. Only the two MCP commit executors checked tenancy; the cookie POST/PATCH, v1 POST/PATCH, webshop and sales-order writers passed items[].article_id straight through the builder. Move the check to the one point every writer converges on: buildInvoiceWriteData collects the distinct article ids from product lines, runs one select scoped on company_id, and refuses with the new INVOICE_CREATE_ARTICLE_INVALID (400, Swedish message via the structured-error registry) on any miss. The MCP executor checks stay as the tamper gate for staged rows. Tests: builder unit cases (miss refused with details, dedupe + happy path, no article ids means no query, DB error surfaces as dbError), cookie PATCH and v1 POST refusal cases, and the existing v1 persist + MCP update tests now answer the builder's scoped select. Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
8313f527c9
commit
ea4da0eb07
@@ -957,9 +957,11 @@ describe('POST /api/v1/companies/:companyId/invoices', () => {
|
||||
? SWEDISH_BUSINESS_CUSTOMER
|
||||
: table === 'chart_of_accounts'
|
||||
? [{ account_number: '3041' }]
|
||||
: table === 'invoices'
|
||||
? createdInvoice
|
||||
: null
|
||||
: table === 'articles'
|
||||
? [{ id: ARTICLE_ID }]
|
||||
: table === 'invoices'
|
||||
? createdInvoice
|
||||
: null
|
||||
return (r: (v: unknown) => void) => r({ data, error: null })
|
||||
}
|
||||
return () => new Proxy({}, handler)
|
||||
@@ -1100,6 +1102,39 @@ describe('POST /api/v1/companies/:companyId/invoices', () => {
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INVOICE_CREATE_REVENUE_ACCOUNT_INVALID')
|
||||
})
|
||||
|
||||
it('rejects an article_id that belongs to another company (issue #2059)', async () => {
|
||||
// v1 runs on the service-role client with no RLS: the FK on
|
||||
// invoice_items.article_id proves existence only, so the scoped select in
|
||||
// the builder is the only tenancy guard for article linkage.
|
||||
withInvoiceWriteScope()
|
||||
const FOREIGN_ARTICLE = 'ffffffff-ffff-4fff-8fff-ffffffffffff'
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
customers: { data: SWEDISH_BUSINESS_CUSTOMER, error: null },
|
||||
articles: { data: [], error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await createInvoice(
|
||||
makePostInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices`, {
|
||||
customer_id: CUSTOMER_ID,
|
||||
invoice_date: '2026-05-12',
|
||||
due_date: '2026-06-11',
|
||||
currency: 'SEK',
|
||||
items: [
|
||||
{ description: 'x', quantity: 1, unit: 'st', unit_price: 100, article_id: FOREIGN_ARTICLE },
|
||||
],
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INVOICE_CREATE_ARTICLE_INVALID')
|
||||
expect(body.error.details).toEqual({ invalidArticleIds: [FOREIGN_ARTICLE] })
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user