Files
accounted/tests/pg/booking-template-hidden.pg.test.ts
T
MattssonandClaude Fable 5 57d4359d1a feat(booking-templates): per-company opt-in hiding of system templates (#2004)
* feat(booking-templates): per-company opt-in hiding of system templates

Users cannot delete or hide the 26 standard konteringspaket, which clutter
the settings panel and every template picker. Deletion stays off the table
(shared global rows); instead a company can now hide individual system
templates for itself only.

- New booking_template_hidden table (insert=hide, delete=unhide), RLS gated
  on active company + write role; nothing hidden by default
- POST/DELETE /api/settings/booking-templates/[id]/hide (system templates
  only; company/team templates keep their real delete path)
- List route decorates rows with per-company is_hidden; pickers filter them
  out; the settings panel shows hidden ones in a collapsed restore section
  so hiding is never silent
- Classified in full-archive-export exclusions (UI preference, not
  rakenskapsinformation)

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

* fix(booking-templates): idempotent re-hide, system-only RLS insert, hidden filter in bulk-book

Skeptic + CodeRabbit findings on #2004, one pass:

- hide upsert now passes ignoreDuplicates (DO NOTHING): the table has no
  UPDATE policy on purpose, so the DO UPDATE conflict arm turned a
  concurrent re-hide into an RLS 42501/500; pg test pins the conflict shape
- bth_insert policy additionally requires the referenced template to be an
  active system template (migration is unmerged, edited in place); negative
  pg test for company templates
- BulkBookDialog excludes templates hidden by the company (was reading the
  table directly and ignoring hides)
- panel shows the failure toast when the hide/unhide fetch itself rejects
- picker category chips built from the hidden-filtered list

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 12:12:47 +02:00

197 lines
7.3 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { getPool, withUserContext } from './setup'
import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
/**
* `booking_template_hidden` (migration 20260828100000).
*
* Per-company opt-in hiding of system templates. The RLS matters here: a hide
* row written by company A must never leak to, or be writable from, company B,
* and viewers must not be able to hide anything. All writes go through the
* authenticated role so the policies are what is actually under test.
*/
async function setActiveCompany(userId: string, companyId: string) {
await getPool().query(
`INSERT INTO public.user_preferences (user_id, active_company_id)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id`,
[userId, companyId],
)
}
async function systemTemplateId(): Promise<string> {
const { rows } = await getPool().query(
`SELECT id FROM public.booking_template_library WHERE is_system AND is_active LIMIT 1`,
)
return rows[0].id as string
}
describe('booking_template_hidden RLS', () => {
it('lets a write-role member hide and unhide for the active company', async () => {
const { userId, companyId } = await seedCompany()
await setActiveCompany(userId, companyId)
const templateId = await systemTemplateId()
await withUserContext(userId, async (client) => {
await client.query(
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
VALUES ($1, $2, $3)`,
[templateId, companyId, userId],
)
const { rows } = await client.query(
`SELECT template_id FROM public.booking_template_hidden WHERE company_id = $1`,
[companyId],
)
expect(rows).toHaveLength(1)
expect(rows[0].template_id).toBe(templateId)
const del = await client.query(
`DELETE FROM public.booking_template_hidden
WHERE template_id = $1 AND company_id = $2`,
[templateId, companyId],
)
expect(del.rowCount).toBe(1)
})
})
it('blocks hiding for a company that is not the active one', async () => {
const { userId, companyId } = await seedCompany()
const other = await seedCompany()
// Member of both, but acting in their own company.
await insertCompanyMember({ companyId: other.companyId, userId, role: 'owner' })
await setActiveCompany(userId, companyId)
const templateId = await systemTemplateId()
await withUserContext(userId, async (client) => {
await expect(
client.query(
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
VALUES ($1, $2, $3)`,
[templateId, other.companyId, userId],
),
).rejects.toThrow(/row-level security/)
})
})
it('blocks a viewer from hiding', async () => {
const { companyId } = await seedCompany()
const viewerId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: viewerId, role: 'viewer' })
await setActiveCompany(viewerId, companyId)
const templateId = await systemTemplateId()
await withUserContext(viewerId, async (client) => {
await expect(
client.query(
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
VALUES ($1, $2, $3)`,
[templateId, companyId, viewerId],
),
).rejects.toThrow(/row-level security/)
})
})
it("does not leak another company's hide rows", async () => {
const a = await seedCompany()
const b = await seedCompany()
const templateId = await systemTemplateId()
// Seed A's hide row on the superuser connection so it persists for B's read.
await getPool().query(
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[templateId, a.companyId, a.userId],
)
await setActiveCompany(b.userId, b.companyId)
await withUserContext(b.userId, async (client) => {
const { rows } = await client.query(
`SELECT template_id FROM public.booking_template_hidden`,
)
expect(rows).toEqual([])
})
// Cleanup the persisted seed row.
await getPool().query(
`DELETE FROM public.booking_template_hidden WHERE company_id = $1`,
[a.companyId],
)
})
it('re-hide via ON CONFLICT DO NOTHING succeeds despite no UPDATE policy', async () => {
// The route upserts with ignoreDuplicates (DO NOTHING). A DO UPDATE arm
// would be rejected by RLS here (no UPDATE policy on purpose), so this
// pins the exact conflict shape the route sends: second hide = no-op 0
// rows, not an error.
const { userId, companyId } = await seedCompany()
await setActiveCompany(userId, companyId)
const templateId = await systemTemplateId()
await withUserContext(userId, async (client) => {
const first = await client.query(
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
VALUES ($1, $2, $3)
ON CONFLICT (template_id, company_id) DO NOTHING`,
[templateId, companyId, userId],
)
expect(first.rowCount).toBe(1)
const second = await client.query(
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
VALUES ($1, $2, $3)
ON CONFLICT (template_id, company_id) DO NOTHING`,
[templateId, companyId, userId],
)
expect(second.rowCount).toBe(0)
})
})
it('blocks hiding a non-system (company) template even via direct insert', async () => {
const { userId, companyId } = await seedCompany()
await setActiveCompany(userId, companyId)
// A company-scoped template: has a real delete path, must not be hideable.
const { rows } = await getPool().query(
`INSERT INTO public.booking_template_library
(company_id, created_by, name, description, category, entity_type, is_system, lines)
VALUES ($1, $2, 'Egen mall', '', 'other', 'all', FALSE, '[]'::jsonb)
RETURNING id`,
[companyId, userId],
)
const companyTemplateId = rows[0].id as string
await withUserContext(userId, async (client) => {
await expect(
client.query(
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
VALUES ($1, $2, $3)`,
[companyTemplateId, companyId, userId],
),
).rejects.toThrow(/row-level security/)
})
await getPool().query(`DELETE FROM public.booking_template_library WHERE id = $1`, [
companyTemplateId,
])
})
it('enforces one hide row per (template, company)', async () => {
const { userId, companyId } = await seedCompany()
await setActiveCompany(userId, companyId)
const templateId = await systemTemplateId()
await withUserContext(userId, async (client) => {
await client.query(
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
VALUES ($1, $2, $3)`,
[templateId, companyId, userId],
)
await expect(
client.query(
`INSERT INTO public.booking_template_hidden (template_id, company_id, hidden_by)
VALUES ($1, $2, $3)`,
[templateId, companyId, userId],
),
).rejects.toThrow(/duplicate key/)
})
})
})