Files
accounted/tests/pg/peppol-access.pg.test.ts
Jakob Wennberg 3ac80edc96 feat(peppol): gate Peppol per company: request access, operator enables with a sending cap (#1794)
* feat(peppol): gate Peppol per company: request access, operator enables with a sending cap

Peppol is no longer available to every company by default. Each transmission
is billed per document by the access point and each receiving identifier
consumes a contracted tenant slot, so the product now works like this:

- peppol_access (new table, RLS read-only for members, service-role writes):
  status requested | enabled | disabled, max_sends (null = no cap),
  receive_enabled as a separate grant, who asked and who enabled.
- POST /api/settings/peppol/access: the company asks from Settings >
  Fakturering; the row is written and the operators are e-mailed (best effort,
  the row is the source of truth).
- scripts/peppol/access.ts list | enable <company|orgnr> [--max-sends N]
  [--receive] | disable | show: the operator side.
- POST /api/invoices/[id]/peppol/send refuses PEPPOL_ACCESS_REQUIRED /
  PEPPOL_SEND_LIMIT_REACHED before touching the invoice; the invoice page's
  send item says so instead of pretending. Registration for receiving refuses
  PEPPOL_ACCESS_REQUIRED / PEPPOL_RECEIVING_NOT_ENABLED.
- Settings UI: access status row with "Begär åtkomst", sends used of cap,
  receiving switch only once receiving is granted.

Refs #546

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

* test(peppol): pass route params to the settings handlers; baseline-align the access row

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

* fix(peppol): revoke default table privileges from authenticated on the access and receiving tables

Supabase grants ALL on new tables to authenticated by default; the earlier
REVOKE covered PUBLIC and anon only, so a member's UPDATE on peppol_access was
an RLS-filtered no-op instead of a permission error (pg-real caught it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqFpxeWqbpR7bcwUJLRERQ

---------

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

52 lines
2.5 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { getPool, runAsServiceRole, withUserContext } from './setup'
import { seedCompany } from './fixtures'
describe('peppol_access', () => {
it('is readable by the company members only and never writable by authenticated users', async () => {
const own = await seedCompany()
const other = await seedCompany()
await getPool().query(
`INSERT INTO public.peppol_access (company_id, status, max_sends, enabled_at, enabled_by)
VALUES ($1, 'enabled', 25, now(), 'test'), ($2, 'requested', NULL, NULL, NULL)`,
[own.companyId, other.companyId],
)
const visible = await withUserContext(own.userId, async (client) => {
const { rows } = await client.query(`SELECT company_id, status, max_sends FROM public.peppol_access`)
return rows
})
expect(visible).toEqual([{ company_id: own.companyId, status: 'enabled', max_sends: 25 }])
await expect(withUserContext(own.userId, (client) =>
client.query(`UPDATE public.peppol_access SET max_sends = 1000000 WHERE company_id = $1`, [own.companyId]),
)).rejects.toThrow(/permission denied|row-level security/)
await expect(withUserContext(other.userId, (client) =>
client.query(
`INSERT INTO public.peppol_access (company_id, status, enabled_at) VALUES ($1, 'enabled', now())
ON CONFLICT (company_id) DO UPDATE SET status = 'enabled', enabled_at = now()`,
[other.companyId],
),
)).rejects.toThrow(/permission denied|row-level security/)
const serviceView = await runAsServiceRole(async (client) => {
const { rows } = await client.query(`SELECT count(*)::int AS n FROM public.peppol_access`)
return rows[0].n as number
})
expect(serviceView).toBeGreaterThanOrEqual(2)
})
it('keeps the status shape honest: enabled needs enabled_at, disabled needs disabled_at', async () => {
const seeded = await seedCompany()
await expect(getPool().query(
`INSERT INTO public.peppol_access (company_id, status) VALUES ($1, 'enabled')`, [seeded.companyId],
)).rejects.toThrow(/peppol_access_status_shape/)
await expect(getPool().query(
`INSERT INTO public.peppol_access (company_id, status, disabled_at) VALUES ($1, 'disabled', now())`, [seeded.companyId],
)).resolves.toBeTruthy()
await expect(getPool().query(
`UPDATE public.peppol_access SET max_sends = -1 WHERE company_id = $1`, [seeded.companyId],
)).rejects.toThrow(/peppol_access_max_sends_check/)
})
})