3ac80edc96
* 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>
52 lines
2.5 KiB
TypeScript
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/)
|
|
})
|
|
})
|