Supabase RLS Security Smoke Test
Validate all security policies after v5.1 patch deployment
✓ Creates test data: Users (student, teacher, admin, tutor), classes, enrollments, idioms, circles
✓ Tests anon access: Can only read public content (approved idioms, active tutors)
✓ Tests student access: Can read own data, enrolled classes, own sessions
✓ Tests teacher access: Can manage own classes, insert/update/delete enrollments
✓ Tests tutor access: Can read own sessions, not other tutors' sessions
✓ Tests admin access: Can read/write all tables, including admin-only tables
✓ Tests helper functions: current_user_email(), current_user_is_admin(), get_user_role(), is_enrolled_in_class()
✓ Reports results: Shows ✅ for pass, ❌ for fail with clear messages
Step 1: Copy the SQL below
Step 2: Open Supabase SQL Editor
Step 3: Paste and run the entire script
Step 4: Review the NOTICE messages in the output
Step 5: Look for any ❌ warnings - these indicate security issues
Step 6: Optionally uncomment cleanup section to remove test data
✅ All tests should show green checkmarks (✅)
✅ Anon users see only public content
✅ Students see only own data and enrolled classes
✅ Teachers can manage own classes and enrollments
✅ Tutors see only own sessions
✅ Admins have full access
✅ Helper functions return correct values
⚠️ Any ❌ warnings indicate security policy issues that need fixing
-- =====================================================
-- Snappy English - RLS Security Smoke Test
-- Run AFTER v5.1 patch to validate all security policies
-- Tests anon, student, teacher, tutor, and admin access
-- =====================================================
-- =====================================================
-- SETUP: Create test users and data
-- =====================================================
-- Insert test users (run as admin/service role)
INSERT INTO public.users (id, email, full_name, role, is_educator)
VALUES
('11111111-1111-1111-1111-111111111111', 'student@test.com', 'Test Student', 'user', false),
('22222222-2222-2222-2222-222222222222', 'teacher@test.com', 'Test Teacher', 'user', true),
('33333333-3333-3333-3333-333333333333', 'admin@test.com', 'Test Admin', 'admin', false),
('44444444-4444-4444-4444-444444444444', 'tutor@test.com', 'Test Tutor', 'user', false)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
role = EXCLUDED.role,
is_educator = EXCLUDED.is_educator;
-- Create test class
INSERT INTO public.classes (id, class_name, teacher_email, class_code)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Test Class', 'teacher@test.com', 'TEST123')
ON CONFLICT (id) DO UPDATE SET teacher_email = EXCLUDED.teacher_email;
-- Enroll student
INSERT INTO public.class_enrollment (class_id, student_email, status)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'student@test.com', 'active')
ON CONFLICT DO NOTHING;
-- Create test idiom
INSERT INTO public.idioms (id, phrase, meaning, example_sentence, category, difficulty, is_approved)
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Test Idiom', 'Test meaning', 'Test sentence', 'general', 'beginner', true)
ON CONFLICT (id) DO UPDATE SET is_approved = EXCLUDED.is_approved;
-- Create unapproved idiom (should only be visible to admin)
INSERT INTO public.idioms (id, phrase, meaning, example_sentence, category, difficulty, is_approved)
VALUES ('cccccccc-cccc-cccc-cccc-cccccccccccc', 'Hidden Idiom', 'Hidden meaning', 'Hidden sentence', 'general', 'beginner', false)
ON CONFLICT (id) DO UPDATE SET is_approved = EXCLUDED.is_approved;
-- Create test study circle
INSERT INTO public.study_circles (id, name, creator_email)
VALUES ('dddddddd-dddd-dddd-dddd-dddddddddddd', 'Test Circle', 'student@test.com')
ON CONFLICT (id) DO UPDATE SET creator_email = EXCLUDED.creator_email;
-- Add circle membership
INSERT INTO public.circle_membership (circle_id, user_email)
VALUES ('dddddddd-dddd-dddd-dddd-dddddddddddd', 'student@test.com')
ON CONFLICT DO NOTHING;
-- Create test tutor
INSERT INTO public.tutors (id, full_name, email, hourly_rate_gbp, status)
VALUES ('eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', 'Test Tutor', 'tutor@test.com', 50.00, 'active')
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email;
-- Create test tutoring session
INSERT INTO public.tutoring_sessions (id, student_email, tutor_id, session_date, duration_minutes, cost_golden_eggs)
VALUES ('ffffffff-ffff-ffff-ffff-ffffffffffff', 'student@test.com', 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', NOW() + INTERVAL '1 day', 45, 30)
ON CONFLICT (id) DO UPDATE SET student_email = EXCLUDED.student_email;
RAISE NOTICE '✅ Test data created';
-- =====================================================
-- TEST 1: ANON USER ACCESS (Public Content Only)
-- =====================================================
DO $$
DECLARE
test_count INT;
BEGIN
-- Simulate anon by clearing session
PERFORM set_config('request.jwt.claims', NULL, true);
RAISE NOTICE '---';
RAISE NOTICE 'TEST 1: ANON USER ACCESS';
RAISE NOTICE '---';
-- Can read approved idioms
SELECT COUNT(*) INTO test_count FROM public.idioms WHERE is_approved = true;
IF test_count > 0 THEN
RAISE NOTICE '✅ Anon can read approved idioms (count: %)', test_count;
ELSE
RAISE WARNING '❌ Anon cannot read approved idioms';
END IF;
-- Cannot read unapproved idioms
SELECT COUNT(*) INTO test_count FROM public.idioms WHERE is_approved = false;
IF test_count = 0 THEN
RAISE NOTICE '✅ Anon cannot read unapproved idioms';
ELSE
RAISE WARNING '❌ Anon can read unapproved idioms (should be hidden)';
END IF;
-- Can read active tutors
SELECT COUNT(*) INTO test_count FROM public.tutors WHERE status = 'active';
IF test_count > 0 THEN
RAISE NOTICE '✅ Anon can read active tutors (count: %)', test_count;
ELSE
RAISE WARNING '❌ Anon cannot read active tutors';
END IF;
-- Cannot read users
SELECT COUNT(*) INTO test_count FROM public.users;
IF test_count = 0 THEN
RAISE NOTICE '✅ Anon cannot read users table';
ELSE
RAISE WARNING '❌ Anon can read users (should be blocked)';
END IF;
-- Cannot read circle memberships
SELECT COUNT(*) INTO test_count FROM public.circle_membership;
IF test_count = 0 THEN
RAISE NOTICE '✅ Anon cannot read circle memberships';
ELSE
RAISE WARNING '❌ Anon can read circle memberships (should be blocked)';
END IF;
END $$;
-- =====================================================
-- TEST 2: STUDENT USER ACCESS
-- =====================================================
DO $$
DECLARE
test_count INT;
student_id UUID := '11111111-1111-1111-1111-111111111111';
BEGIN
-- Simulate student auth
PERFORM set_config('request.jwt.claims', json_build_object('sub', student_id)::text, true);
RAISE NOTICE '---';
RAISE NOTICE 'TEST 2: STUDENT USER ACCESS (student@test.com)';
RAISE NOTICE '---';
-- Can read own user record
SELECT COUNT(*) INTO test_count FROM public.users WHERE id = student_id;
IF test_count = 1 THEN
RAISE NOTICE '✅ Student can read own user record';
ELSE
RAISE WARNING '❌ Student cannot read own user record';
END IF;
-- Cannot read other users
SELECT COUNT(*) INTO test_count FROM public.users WHERE id != student_id;
IF test_count = 0 THEN
RAISE NOTICE '✅ Student cannot read other users';
ELSE
RAISE WARNING '❌ Student can read other users (count: %)', test_count;
END IF;
-- Can read approved idioms
SELECT COUNT(*) INTO test_count FROM public.idioms WHERE is_approved = true;
IF test_count > 0 THEN
RAISE NOTICE '✅ Student can read approved idioms';
ELSE
RAISE WARNING '❌ Student cannot read approved idioms';
END IF;
-- Cannot read unapproved idioms
SELECT COUNT(*) INTO test_count FROM public.idioms WHERE is_approved = false;
IF test_count = 0 THEN
RAISE NOTICE '✅ Student cannot read unapproved idioms';
ELSE
RAISE WARNING '❌ Student can read unapproved idioms (should be hidden)';
END IF;
-- Can read own circle membership
SELECT COUNT(*) INTO test_count FROM public.circle_membership WHERE user_email = 'student@test.com';
IF test_count = 1 THEN
RAISE NOTICE '✅ Student can read own circle membership';
ELSE
RAISE WARNING '❌ Student cannot read own circle membership';
END IF;
-- Can read classes they are enrolled in
SELECT COUNT(*) INTO test_count
FROM public.classes c
WHERE EXISTS (
SELECT 1 FROM public.class_enrollment e
WHERE e.class_id = c.id AND e.student_email = 'student@test.com'
);
IF test_count > 0 THEN
RAISE NOTICE '✅ Student can read classes they are enrolled in';
ELSE
RAISE WARNING '❌ Student cannot read enrolled classes';
END IF;
-- Can read own tutoring sessions
SELECT COUNT(*) INTO test_count FROM public.tutoring_sessions WHERE student_email = 'student@test.com';
IF test_count > 0 THEN
RAISE NOTICE '✅ Student can read own tutoring sessions';
ELSE
RAISE WARNING '❌ Student cannot read own tutoring sessions';
END IF;
-- Cannot manage other students' enrollments
BEGIN
DELETE FROM public.class_enrollment WHERE student_email = 'other@test.com';
RAISE WARNING '❌ Student can delete other enrollments (should be blocked)';
EXCEPTION WHEN insufficient_privilege THEN
RAISE NOTICE '✅ Student cannot delete other enrollments';
END;
END $$;
-- =====================================================
-- TEST 3: TEACHER USER ACCESS
-- =====================================================
DO $$
DECLARE
test_count INT;
teacher_id UUID := '22222222-2222-2222-2222-222222222222';
BEGIN
-- Simulate teacher auth
PERFORM set_config('request.jwt.claims', json_build_object('sub', teacher_id)::text, true);
RAISE NOTICE '---';
RAISE NOTICE 'TEST 3: TEACHER USER ACCESS (teacher@test.com)';
RAISE NOTICE '---';
-- Can read own classes
SELECT COUNT(*) INTO test_count FROM public.classes WHERE teacher_email = 'teacher@test.com';
IF test_count > 0 THEN
RAISE NOTICE '✅ Teacher can read own classes (count: %)', test_count;
ELSE
RAISE WARNING '❌ Teacher cannot read own classes';
END IF;
-- Cannot read other teachers' classes
SELECT COUNT(*) INTO test_count FROM public.classes WHERE teacher_email != 'teacher@test.com';
IF test_count = 0 THEN
RAISE NOTICE '✅ Teacher cannot read other teachers classes';
ELSE
RAISE WARNING '❌ Teacher can read other teachers classes (count: %)', test_count;
END IF;
-- Can read enrollment for own classes
SELECT COUNT(*) INTO test_count
FROM public.class_enrollment e
WHERE EXISTS (
SELECT 1 FROM public.classes c
WHERE c.id = e.class_id AND c.teacher_email = 'teacher@test.com'
);
IF test_count > 0 THEN
RAISE NOTICE '✅ Teacher can read enrollments for own classes';
ELSE
RAISE WARNING '❌ Teacher cannot read enrollments';
END IF;
-- Can insert enrollment for own class
BEGIN
INSERT INTO public.class_enrollment (class_id, student_email, status)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'newstudent@test.com', 'active');
RAISE NOTICE '✅ Teacher can insert enrollment for own class';
DELETE FROM public.class_enrollment WHERE student_email = 'newstudent@test.com';
EXCEPTION WHEN OTHERS THEN
RAISE WARNING '❌ Teacher cannot insert enrollment: %', SQLERRM;
END;
-- Can update enrollment for own class
BEGIN
UPDATE public.class_enrollment
SET status = 'inactive'
WHERE class_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
AND student_email = 'student@test.com';
RAISE NOTICE '✅ Teacher can update enrollment for own class';
-- Reset
UPDATE public.class_enrollment SET status = 'active'
WHERE class_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
AND student_email = 'student@test.com';
EXCEPTION WHEN OTHERS THEN
RAISE WARNING '❌ Teacher cannot update enrollment: %', SQLERRM;
END;
-- Can delete enrollment for own class
BEGIN
INSERT INTO public.class_enrollment (class_id, student_email, status)
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'temp@test.com', 'active');
DELETE FROM public.class_enrollment
WHERE class_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
AND student_email = 'temp@test.com';
RAISE NOTICE '✅ Teacher can delete enrollment for own class';
EXCEPTION WHEN OTHERS THEN
RAISE WARNING '❌ Teacher cannot delete enrollment: %', SQLERRM;
END;
END $$;
-- =====================================================
-- TEST 4: TUTOR USER ACCESS
-- =====================================================
DO $$
DECLARE
test_count INT;
tutor_id UUID := '44444444-4444-4444-4444-444444444444';
BEGIN
-- Simulate tutor auth
PERFORM set_config('request.jwt.claims', json_build_object('sub', tutor_id)::text, true);
RAISE NOTICE '---';
RAISE NOTICE 'TEST 4: TUTOR USER ACCESS (tutor@test.com)';
RAISE NOTICE '---';
-- Can read own tutoring sessions
SELECT COUNT(*) INTO test_count
FROM public.tutoring_sessions ts
WHERE EXISTS (
SELECT 1 FROM public.tutors t
WHERE t.id = ts.tutor_id AND t.email = 'tutor@test.com'
);
IF test_count > 0 THEN
RAISE NOTICE '✅ Tutor can read own sessions (count: %)', test_count;
ELSE
RAISE WARNING '❌ Tutor cannot read own sessions';
END IF;
-- Cannot read other tutors' sessions
SELECT COUNT(*) INTO test_count
FROM public.tutoring_sessions ts
WHERE NOT EXISTS (
SELECT 1 FROM public.tutors t
WHERE t.id = ts.tutor_id AND t.email = 'tutor@test.com'
);
IF test_count = 0 THEN
RAISE NOTICE '✅ Tutor cannot read other tutors sessions';
ELSE
RAISE WARNING '❌ Tutor can read other tutors sessions (count: %)', test_count;
END IF;
END $$;
-- =====================================================
-- TEST 5: ADMIN USER ACCESS
-- =====================================================
DO $$
DECLARE
test_count INT;
admin_id UUID := '33333333-3333-3333-3333-333333333333';
BEGIN
-- Simulate admin auth
PERFORM set_config('request.jwt.claims', json_build_object('sub', admin_id)::text, true);
RAISE NOTICE '---';
RAISE NOTICE 'TEST 5: ADMIN USER ACCESS (admin@test.com)';
RAISE NOTICE '---';
-- Can read all users
SELECT COUNT(*) INTO test_count FROM public.users;
IF test_count >= 4 THEN
RAISE NOTICE '✅ Admin can read all users (count: %)', test_count;
ELSE
RAISE WARNING '❌ Admin cannot read all users';
END IF;
-- Can read unapproved idioms
SELECT COUNT(*) INTO test_count FROM public.idioms WHERE is_approved = false;
IF test_count > 0 THEN
RAISE NOTICE '✅ Admin can read unapproved idioms (count: %)', test_count;
ELSE
RAISE WARNING '❌ Admin cannot read unapproved idioms';
END IF;
-- Can read all classes
SELECT COUNT(*) INTO test_count FROM public.classes;
IF test_count > 0 THEN
RAISE NOTICE '✅ Admin can read all classes (count: %)', test_count;
ELSE
RAISE WARNING '❌ Admin cannot read all classes';
END IF;
-- Can read all circle memberships
SELECT COUNT(*) INTO test_count FROM public.circle_membership;
IF test_count > 0 THEN
RAISE NOTICE '✅ Admin can read all circle memberships (count: %)', test_count;
ELSE
RAISE WARNING '❌ Admin cannot read all circle memberships';
END IF;
-- Can read flagged accounts (admin-only table)
SELECT COUNT(*) INTO test_count FROM public.flagged_accounts;
RAISE NOTICE '✅ Admin can read flagged_accounts (count: %)', test_count;
-- Can manage content pricing
SELECT COUNT(*) INTO test_count FROM public.content_pricing;
RAISE NOTICE '✅ Admin can read content_pricing (count: %)', test_count;
END $$;
-- =====================================================
-- TEST 6: HELPER FUNCTIONS
-- =====================================================
DO $$
DECLARE
email TEXT;
is_admin BOOLEAN;
role TEXT;
is_enrolled BOOLEAN;
student_id UUID := '11111111-1111-1111-1111-111111111111';
BEGIN
-- Simulate student auth
PERFORM set_config('request.jwt.claims', json_build_object('sub', student_id)::text, true);
RAISE NOTICE '---';
RAISE NOTICE 'TEST 6: HELPER FUNCTIONS';
RAISE NOTICE '---';
-- Test current_user_email()
SELECT public.current_user_email() INTO email;
IF email = 'student@test.com' THEN
RAISE NOTICE '✅ current_user_email() returns correct email: %', email;
ELSE
RAISE WARNING '❌ current_user_email() failed. Got: %', COALESCE(email, 'NULL');
END IF;
-- Test current_user_is_admin() as student
SELECT public.current_user_is_admin() INTO is_admin;
IF is_admin = false THEN
RAISE NOTICE '✅ current_user_is_admin() returns false for student';
ELSE
RAISE WARNING '❌ current_user_is_admin() returned true for student';
END IF;
-- Test get_user_role()
SELECT public.get_user_role(student_id) INTO role;
IF role = 'user' THEN
RAISE NOTICE '✅ get_user_role() returns correct role: %', role;
ELSE
RAISE WARNING '❌ get_user_role() failed. Got: %', COALESCE(role, 'NULL');
END IF;
-- Test is_enrolled_in_class()
SELECT public.is_enrolled_in_class('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'student@test.com') INTO is_enrolled;
IF is_enrolled = true THEN
RAISE NOTICE '✅ is_enrolled_in_class() correctly identifies enrollment';
ELSE
RAISE WARNING '❌ is_enrolled_in_class() failed';
END IF;
-- Test current_user_is_admin() as admin
PERFORM set_config('request.jwt.claims', json_build_object('sub', '33333333-3333-3333-3333-333333333333')::text, true);
SELECT public.current_user_is_admin() INTO is_admin;
IF is_admin = true THEN
RAISE NOTICE '✅ current_user_is_admin() returns true for admin';
ELSE
RAISE WARNING '❌ current_user_is_admin() returned false for admin';
END IF;
END $$;
-- =====================================================
-- CLEANUP TEST DATA (OPTIONAL)
-- =====================================================
-- Uncomment to clean up test data after validation
-- DELETE FROM public.circle_membership WHERE circle_id = 'dddddddd-dddd-dddd-dddd-dddddddddddd';
-- DELETE FROM public.study_circles WHERE id = 'dddddddd-dddd-dddd-dddd-dddddddddddd';
-- DELETE FROM public.tutoring_sessions WHERE id = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
-- DELETE FROM public.tutors WHERE id = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee';
-- DELETE FROM public.class_enrollment WHERE class_id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
-- DELETE FROM public.classes WHERE id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
-- DELETE FROM public.idioms WHERE id IN ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'cccccccc-cccc-cccc-cccc-cccccccccccc');
-- DELETE FROM public.users WHERE id IN ('11111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222', '33333333-3333-3333-3333-333333333333', '44444444-4444-4444-4444-444444444444');
RAISE NOTICE '---';
RAISE NOTICE '✅ SMOKE TEST COMPLETED';
RAISE NOTICE 'Review warnings above - any ❌ indicates a security policy issue';
RAISE NOTICE '---';If anon can read unapproved content:
Check that idioms policy uses is_approved OR public.current_user_is_admin()
If student can read other users:
Verify users SELECT policy uses id = auth.uid() in USING clause
If teacher can't manage enrollments:
Check enrollment policies include teacher_email checks in both USING and WITH CHECK
If helper functions return NULL:
Ensure public.users table has matching records for auth.uid()
If tutor sees all sessions:
Verify tutoring_tutor_read policy checks tutors.email = current_user_email()