From 5e3a77fc70606529742b63b2bda70a9d271713b7 Mon Sep 17 00:00:00 2001 From: belviskhoremk Date: Sat, 8 Aug 2026 13:01:08 +0000 Subject: [PATCH] feat: add booked_slots sync trigger and pgTAP RLS/trigger tests Includes explicit GRANTs for anon/authenticated/service_role on all public tables: the Supabase CLI's current default no longer auto-exposes new tables, so RLS policies from the initial schema were unreachable without them (every request failed at the privilege check before RLS was evaluated). --- .../20260808000002_booked_slots_trigger.sql | 39 ++++++++ supabase/tests/database/rls.test.sql | 90 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 supabase/migrations/20260808000002_booked_slots_trigger.sql create mode 100644 supabase/tests/database/rls.test.sql diff --git a/supabase/migrations/20260808000002_booked_slots_trigger.sql b/supabase/migrations/20260808000002_booked_slots_trigger.sql new file mode 100644 index 0000000..959ebf6 --- /dev/null +++ b/supabase/migrations/20260808000002_booked_slots_trigger.sql @@ -0,0 +1,39 @@ +-- Base table grants for the API roles. The Supabase CLI's current default +-- (auto_expose_new_tables unset) no longer auto-grants table privileges to +-- anon/authenticated/service_role, so without these GRANTs the RLS policies +-- from the initial schema migration are unreachable (every request fails at +-- the privilege check before RLS is even evaluated). This is the documented +-- "explicit grants + RLS" pattern Supabase recommends going forward; the +-- actual access is still fully governed by each table's RLS policies. +grant select, insert, update, delete on + public.products, + public.services, + public.schedule_template, + public.schedule_exceptions, + public.bookings, + public.booked_slots, + public.inquiries +to anon, authenticated, service_role; + +-- Keeps the public, PII-free booked_slots table in sync whenever a booking +-- is created. security definer + explicit search_path per Supabase's +-- documented safe pattern for definer functions. + +create or replace function public.sync_booked_slot() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + insert into public.booked_slots (date, time) + values (new.date, new.time) + on conflict do nothing; + return new; +end; +$$; + +create trigger booking_created_sync_slot + after insert on public.bookings + for each row + execute function public.sync_booked_slot(); diff --git a/supabase/tests/database/rls.test.sql b/supabase/tests/database/rls.test.sql new file mode 100644 index 0000000..c4ceab3 --- /dev/null +++ b/supabase/tests/database/rls.test.sql @@ -0,0 +1,90 @@ +begin; +select plan(11); + +-- Seed reference data as service_role (bypasses RLS) +set local role service_role; +insert into public.services (id, name, duration_minutes, price_from, description, image) +values ('knotless', 'Knotless Braids', 240, 150, 'Tension-free braids.', 'styles/knotless.jpg'); +insert into public.products (id, name, category, price, image, description, in_stock) +values ('edge-control', 'Silk Edge Control', 'hair-care', 12, 'products/edge-control.jpg', 'Hold with shine.', true); +reset role; + +-- Anyone can read services +set local role anon; +select isnt_empty( + $$ select * from public.services $$, + 'anon can read services' +); + +-- Anyone can read products +select isnt_empty( + $$ select * from public.products $$, + 'anon can read products' +); + +-- Anon cannot write to services +select throws_ok( + $$ insert into public.services (id, name, duration_minutes, price_from, description, image) + values ('hack', 'Hack', 1, 1, 'x', 'x') $$, + '42501', + null, + 'anon cannot insert into services' +); + +-- Anyone can create a booking +select lives_ok( + $$ insert into public.bookings (service_id, date, time, contact_name, contact_phone, contact_email, contact_notes) + values ('knotless', '2026-09-15', '10:00 AM', 'Jane Doe', '555-0100', 'jane@example.com', '') $$, + 'anon can create a booking' +); + +-- Anon cannot read bookings (PII protection) +select is_empty( + $$ select * from public.bookings $$, + 'anon cannot read bookings' +); + +-- Anyone can create an inquiry +select lives_ok( + $$ insert into public.inquiries (product_id, contact_name, contact_email, contact_notes) + values ('edge-control', 'Jane Doe', 'jane@example.com', 'Is this in stock?') $$, + 'anon can create an inquiry' +); + +-- Anon cannot read inquiries (PII protection) +select is_empty( + $$ select * from public.inquiries $$, + 'anon cannot read inquiries' +); + +-- Anyone can read booked_slots (no PII there) +select isnt_empty( + $$ select * from public.booked_slots $$, + 'anon can read booked_slots (populated by the trigger from the booking above)' +); + +-- Anon cannot write booked_slots directly +select throws_ok( + $$ insert into public.booked_slots (date, time) values ('2026-09-16', '1:00 PM') $$, + '42501', + null, + 'anon cannot insert into booked_slots directly' +); + +-- The admin (matching UID) can read bookings +set local role authenticated; +set local request.jwt.claim.sub = '00000000-0000-0000-0000-000000000001'; +select isnt_empty( + $$ select * from public.bookings $$, + 'admin can read bookings' +); + +-- The admin can write to services +select lives_ok( + $$ insert into public.services (id, name, duration_minutes, price_from, description, image) + values ('cornrows', 'Cornrows', 120, 90, 'Classic style.', 'styles/cornrows.jpg') $$, + 'admin can insert into services' +); + +select * from finish(); +rollback;