* 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>
197 lines
7.3 KiB
TypeScript
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/)
|
|
})
|
|
})
|
|
})
|