diff --git a/supabase/migrations/20260920174841_debate_workspace_foundation.sql b/supabase/migrations/20260920174841_debate_workspace_foundation.sql new file mode 100644 index 000000000..a170957d3 --- /dev/null +++ b/supabase/migrations/20260920174841_debate_workspace_foundation.sql @@ -0,0 +1,685 @@ +CREATE SCHEMA IF NOT EXISTS private; +REVOKE ALL ON SCHEMA private FROM PUBLIC, anon, authenticated; + +CREATE TABLE public.workspaces ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + motion text NOT NULL CHECK (length(btrim(motion)) BETWEEN 1 AND 2000), + title text CHECK (title IS NULL OR length(btrim(title)) BETWEEN 1 AND 200), + initial_language text NOT NULL CHECK (initial_language IN ('en', 'es', 'hu')), + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived', 'deleted')), + deleted_at timestamptz, + access_epoch bigint NOT NULL DEFAULT 0 CHECK (access_epoch >= 0), + revision bigint NOT NULL DEFAULT 0 CHECK (revision >= 0), + create_request_id uuid NOT NULL, + pending_owner_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT workspaces_owner_create_request_key UNIQUE (owner_id, create_request_id), + CONSTRAINT workspaces_pending_owner_check CHECK (pending_owner_id IS DISTINCT FROM owner_id), + CONSTRAINT workspaces_deleted_state_check CHECK ((status = 'deleted') = (deleted_at IS NOT NULL)) +); + +CREATE TABLE public.workspace_members ( + workspace_id uuid NOT NULL REFERENCES public.workspaces(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + joined_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (workspace_id, user_id) +); + +CREATE TABLE public.workspace_documents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id uuid NOT NULL REFERENCES public.workspaces(id) ON DELETE CASCADE, + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + side text NOT NULL CHECK (side IN ('pro', 'con')), + title text NOT NULL CHECK (length(btrim(title)) BETWEEN 1 AND 200), + position integer NOT NULL CHECK (position >= 0), + assignee_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + state text NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'archived', 'trashed')), + trashed_at timestamptz, + trash_expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT workspace_documents_workspace_id_id_key UNIQUE (workspace_id, id), + CONSTRAINT workspace_documents_trash_state_check CHECK ( + (state = 'trashed') = (trashed_at IS NOT NULL) + AND (state = 'trashed') = (trash_expires_at IS NOT NULL) + ) +); + +CREATE TABLE public.workspace_audit_events ( + operation_id uuid PRIMARY KEY, + request_fingerprint bytea NOT NULL, + workspace_id uuid NOT NULL REFERENCES public.workspaces(id) ON DELETE CASCADE, + document_id uuid, + actor_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + action text NOT NULL, + before_revision bigint CHECK (before_revision IS NULL OR before_revision >= 0), + after_revision bigint CHECK (after_revision IS NULL OR after_revision >= 0), + target_user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT workspace_audit_events_document_fkey + FOREIGN KEY (workspace_id, document_id) + REFERENCES public.workspace_documents(workspace_id, id) + ON DELETE CASCADE, + CONSTRAINT workspace_audit_events_revision_check CHECK ( + before_revision IS NULL OR after_revision IS NULL OR after_revision >= before_revision + ) +); + +CREATE INDEX workspaces_owner_id_idx ON public.workspaces(owner_id); +CREATE INDEX workspaces_pending_owner_id_idx ON public.workspaces(pending_owner_id) + WHERE pending_owner_id IS NOT NULL; +CREATE INDEX workspace_members_user_workspace_idx + ON public.workspace_members(user_id, workspace_id); +CREATE INDEX workspace_documents_workspace_side_position_idx + ON public.workspace_documents(workspace_id, side, position); +CREATE INDEX workspace_documents_created_by_idx ON public.workspace_documents(created_by) + WHERE created_by IS NOT NULL; +CREATE INDEX workspace_documents_assignee_id_idx ON public.workspace_documents(assignee_id) + WHERE assignee_id IS NOT NULL; +CREATE INDEX workspace_audit_events_workspace_created_idx + ON public.workspace_audit_events(workspace_id, created_at); +CREATE INDEX workspace_audit_events_document_idx ON public.workspace_audit_events(workspace_id, document_id) + WHERE document_id IS NOT NULL; +CREATE INDEX workspace_audit_events_actor_id_idx ON public.workspace_audit_events(actor_id) + WHERE actor_id IS NOT NULL; +CREATE INDEX workspace_audit_events_target_user_id_idx ON public.workspace_audit_events(target_user_id) + WHERE target_user_id IS NOT NULL; + +ALTER TABLE public.workspaces ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.workspace_members ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.workspace_documents ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.workspace_audit_events ENABLE ROW LEVEL SECURITY; + +CREATE FUNCTION private.workspace_request_fingerprint(p_payload jsonb) +RETURNS bytea +LANGUAGE sql +IMMUTABLE +SET search_path = '' +AS $$ + SELECT extensions.digest(pg_catalog.convert_to(p_payload::text, 'UTF8'), 'sha256'); +$$; + +REVOKE ALL ON FUNCTION private.workspace_request_fingerprint(jsonb) + FROM PUBLIC, anon, authenticated; + +CREATE FUNCTION private.is_current_workspace_member(p_workspace_id uuid) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT (SELECT auth.uid()) IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM public.workspace_members + WHERE workspace_id = p_workspace_id + AND user_id = (SELECT auth.uid()) + ); +$$; + +REVOKE ALL ON FUNCTION private.is_current_workspace_member(uuid) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION private.is_current_workspace_member(uuid) TO authenticated; + +CREATE POLICY workspaces_select_member ON public.workspaces + FOR SELECT TO authenticated + USING ((SELECT private.is_current_workspace_member(id))); + +CREATE POLICY workspace_members_select_member ON public.workspace_members + FOR SELECT TO authenticated + USING ((SELECT private.is_current_workspace_member(workspace_id))); + +CREATE POLICY workspace_documents_select_member ON public.workspace_documents + FOR SELECT TO authenticated + USING ((SELECT private.is_current_workspace_member(workspace_id))); + +CREATE POLICY workspace_audit_events_select_member ON public.workspace_audit_events + FOR SELECT TO authenticated + USING ((SELECT private.is_current_workspace_member(workspace_id))); + +REVOKE ALL ON TABLE + public.workspaces, + public.workspace_members, + public.workspace_documents, + public.workspace_audit_events +FROM PUBLIC, anon, authenticated; + +GRANT SELECT ON TABLE + public.workspaces, + public.workspace_members, + public.workspace_documents, + public.workspace_audit_events +TO authenticated; + +CREATE FUNCTION public.create_workspace( + p_motion text, + p_title text, + p_language text, + p_request_id uuid +) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid := auth.uid(); + v_motion text := btrim(p_motion); + v_title text := nullif(btrim(p_title), ''); + v_fingerprint bytea; + v_existing public.workspace_audit_events%ROWTYPE; + v_workspace_id uuid; +BEGIN + IF v_user_id IS NULL OR p_request_id IS NULL THEN + RAISE EXCEPTION 'authentication required'; + END IF; + + v_fingerprint := private.workspace_request_fingerprint(jsonb_build_object( + 'rpc', 'create_workspace', + 'actor', v_user_id, + 'motion', v_motion, + 'title', v_title, + 'language', p_language + )); + + PERFORM pg_advisory_xact_lock(hashtextextended(p_request_id::text, 0)); + SELECT * INTO v_existing + FROM public.workspace_audit_events + WHERE operation_id = p_request_id; + + IF FOUND THEN + IF v_existing.request_fingerprint IS DISTINCT FROM v_fingerprint THEN + RAISE EXCEPTION 'operation ID conflict'; + END IF; + RETURN v_existing.workspace_id; + END IF; + + IF v_motion IS NULL OR length(v_motion) NOT BETWEEN 1 AND 2000 + OR p_language IS NULL OR p_language NOT IN ('en', 'es', 'hu') + OR (v_title IS NOT NULL AND length(v_title) > 200) THEN + RAISE EXCEPTION 'invalid workspace request'; + END IF; + + INSERT INTO public.workspaces ( + owner_id, motion, title, initial_language, create_request_id + ) + VALUES (v_user_id, v_motion, v_title, p_language, p_request_id) + ON CONFLICT (owner_id, create_request_id) DO NOTHING + RETURNING id INTO v_workspace_id; + + IF v_workspace_id IS NULL THEN + SELECT id INTO v_workspace_id + FROM public.workspaces + WHERE owner_id = v_user_id + AND create_request_id = p_request_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'operation ID conflict'; + END IF; + ELSE + INSERT INTO public.workspace_members (workspace_id, user_id) + VALUES (v_workspace_id, v_user_id); + + INSERT INTO public.workspace_documents ( + workspace_id, created_by, side, title, position + ) + SELECT v_workspace_id, v_user_id, defaults.side, defaults.title, defaults.position + FROM (VALUES + ('pro', 'Intro', 0), + ('pro', 'R1', 1), + ('pro', 'R2', 2), + ('pro', CASE p_language WHEN 'es' THEN 'Conclusión' WHEN 'hu' THEN 'Összegzés' ELSE 'Conclusion' END, 3), + ('con', 'Intro', 0), + ('con', 'R1', 1), + ('con', 'R2', 2), + ('con', CASE p_language WHEN 'es' THEN 'Conclusión' WHEN 'hu' THEN 'Összegzés' ELSE 'Conclusion' END, 3) + ) AS defaults(side, title, position); + + INSERT INTO public.workspace_audit_events ( + operation_id, request_fingerprint, workspace_id, actor_id, action, after_revision + ) + VALUES (p_request_id, v_fingerprint, v_workspace_id, v_user_id, 'workspace_create', 0); + END IF; + + RETURN v_workspace_id; +END; +$$; + +CREATE FUNCTION public.update_workspace_document( + p_workspace_id uuid, + p_document_id uuid, + p_title text, + p_side text, + p_ordered_ids uuid[], + p_assignee_id uuid, + p_assignee_changed boolean, + p_state text, + p_expected_revision bigint, + p_request_id uuid +) +RETURNS bigint +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid := auth.uid(); + v_title text := CASE WHEN p_title IS NULL THEN NULL ELSE btrim(p_title) END; + v_fingerprint bytea; + v_existing public.workspace_audit_events%ROWTYPE; + v_workspace public.workspaces%ROWTYPE; + v_document public.workspace_documents%ROWTYPE; + v_side text; + v_state text; + v_new_revision bigint; +BEGIN + IF v_user_id IS NULL OR p_request_id IS NULL OR p_expected_revision IS NULL THEN + RAISE EXCEPTION 'authentication required'; + END IF; + + v_fingerprint := private.workspace_request_fingerprint(jsonb_build_object( + 'rpc', 'update_workspace_document', + 'actor', v_user_id, + 'workspace', p_workspace_id, + 'document', p_document_id, + 'title', v_title, + 'side', p_side, + 'ordered_ids', to_jsonb(p_ordered_ids), + 'assignee', p_assignee_id, + 'assignee_changed', p_assignee_changed, + 'state', p_state, + 'expected_revision', p_expected_revision + )); + + PERFORM pg_advisory_xact_lock(hashtextextended(p_request_id::text, 0)); + SELECT * INTO v_existing + FROM public.workspace_audit_events + WHERE operation_id = p_request_id; + + IF FOUND THEN + IF v_existing.request_fingerprint IS DISTINCT FROM v_fingerprint THEN + RAISE EXCEPTION 'operation ID conflict'; + END IF; + RETURN v_existing.after_revision; + END IF; + + IF p_workspace_id IS NULL OR p_document_id IS NULL OR p_expected_revision < 0 + OR p_assignee_changed IS NULL + OR (NOT p_assignee_changed AND p_assignee_id IS NOT NULL) + OR (p_title IS NOT NULL AND (v_title = '' OR length(v_title) > 200)) + OR (p_side IS NOT NULL AND p_side NOT IN ('pro', 'con')) + OR (p_state IS NOT NULL AND p_state NOT IN ('active', 'archived', 'trashed')) THEN + RAISE EXCEPTION 'invalid document request'; + END IF; + + SELECT w.* INTO v_workspace + FROM public.workspaces AS w + WHERE w.id = p_workspace_id + AND EXISTS ( + SELECT 1 FROM public.workspace_members AS m + WHERE m.workspace_id = w.id AND m.user_id = v_user_id + ) + FOR UPDATE; + + IF NOT FOUND OR v_workspace.status <> 'active' THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + IF v_workspace.revision <> p_expected_revision THEN + RAISE EXCEPTION 'workspace conflict'; + END IF; + + SELECT d.* INTO v_document + FROM public.workspace_documents AS d + WHERE d.workspace_id = p_workspace_id + AND d.id = p_document_id + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + IF p_assignee_changed AND p_assignee_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM public.workspace_members + WHERE workspace_id = p_workspace_id AND user_id = p_assignee_id + ) THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + + v_side := coalesce(p_side, v_document.side); + v_state := coalesce(p_state, v_document.state); + + IF v_document.state = 'trashed' AND v_state <> 'trashed' + AND v_document.trash_expires_at <= now() THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + IF v_state = 'active' + AND (v_document.state <> 'active' OR v_side <> v_document.side) + AND p_ordered_ids IS NULL THEN + RAISE EXCEPTION 'invalid document order'; + END IF; + + UPDATE public.workspace_documents + SET title = coalesce(v_title, v_document.title), + side = v_side, + assignee_id = CASE + WHEN p_assignee_changed THEN p_assignee_id + ELSE v_document.assignee_id + END, + state = v_state, + trashed_at = CASE + WHEN v_state = 'trashed' AND v_document.state <> 'trashed' THEN now() + WHEN v_state = 'trashed' THEN v_document.trashed_at + ELSE NULL + END, + trash_expires_at = CASE + WHEN v_state = 'trashed' AND v_document.state <> 'trashed' THEN now() + interval '30 days' + WHEN v_state = 'trashed' THEN v_document.trash_expires_at + ELSE NULL + END, + updated_at = now() + WHERE id = p_document_id; + + IF p_ordered_ids IS NOT NULL THEN + IF cardinality(p_ordered_ids) <> ( + SELECT count(*) FROM public.workspace_documents + WHERE workspace_id = p_workspace_id AND side = v_side AND state = 'active' + ) + OR cardinality(p_ordered_ids) <> ( + SELECT count(DISTINCT ordered_id) FROM unnest(p_ordered_ids) AS ordered_id + ) + OR EXISTS ( + SELECT 1 FROM unnest(p_ordered_ids) AS ordered_id + WHERE NOT EXISTS ( + SELECT 1 FROM public.workspace_documents + WHERE workspace_id = p_workspace_id + AND id = ordered_id + AND side = v_side + AND state = 'active' + ) + ) THEN + RAISE EXCEPTION 'invalid document order'; + END IF; + + UPDATE public.workspace_documents AS d + SET position = ordered.position - 1, + updated_at = now() + FROM unnest(p_ordered_ids) WITH ORDINALITY AS ordered(id, position) + WHERE d.workspace_id = p_workspace_id + AND d.id = ordered.id; + END IF; + + v_new_revision := v_workspace.revision + 1; + UPDATE public.workspaces + SET revision = v_new_revision, updated_at = now() + WHERE id = p_workspace_id; + + INSERT INTO public.workspace_audit_events ( + operation_id, request_fingerprint, workspace_id, document_id, actor_id, + action, before_revision, after_revision, target_user_id + ) + VALUES ( + p_request_id, v_fingerprint, p_workspace_id, p_document_id, v_user_id, + 'document_update', v_workspace.revision, v_new_revision, + CASE WHEN p_assignee_changed THEN p_assignee_id ELSE NULL END + ); + + RETURN v_new_revision; +END; +$$; + +CREATE FUNCTION public.change_workspace_access( + p_workspace_id uuid, + p_action text, + p_target_user_id uuid, + p_expected_revision bigint, + p_request_id uuid +) +RETURNS bigint +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid := auth.uid(); + v_fingerprint bytea; + v_existing public.workspace_audit_events%ROWTYPE; + v_workspace public.workspaces%ROWTYPE; + v_new_revision bigint; +BEGIN + IF v_user_id IS NULL OR p_request_id IS NULL OR p_expected_revision IS NULL THEN + RAISE EXCEPTION 'authentication required'; + END IF; + + v_fingerprint := private.workspace_request_fingerprint(jsonb_build_object( + 'rpc', 'change_workspace_access', + 'actor', v_user_id, + 'workspace', p_workspace_id, + 'action', p_action, + 'target', p_target_user_id, + 'expected_revision', p_expected_revision + )); + + PERFORM pg_advisory_xact_lock(hashtextextended(p_request_id::text, 0)); + SELECT * INTO v_existing + FROM public.workspace_audit_events + WHERE operation_id = p_request_id; + + IF FOUND THEN + IF v_existing.request_fingerprint IS DISTINCT FROM v_fingerprint THEN + RAISE EXCEPTION 'operation ID conflict'; + END IF; + RETURN v_existing.after_revision; + END IF; + + IF p_workspace_id IS NULL OR p_target_user_id IS NULL OR p_expected_revision < 0 + OR p_action IS NULL OR p_action NOT IN ('remove', 'propose_transfer', 'accept_transfer') THEN + RAISE EXCEPTION 'invalid access request'; + END IF; + + SELECT w.* INTO v_workspace + FROM public.workspaces AS w + WHERE w.id = p_workspace_id + AND EXISTS ( + SELECT 1 FROM public.workspace_members AS m + WHERE m.workspace_id = w.id AND m.user_id = v_user_id + ) + FOR UPDATE; + + IF NOT FOUND OR v_workspace.status = 'deleted' THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + IF v_workspace.revision <> p_expected_revision THEN + RAISE EXCEPTION 'workspace conflict'; + END IF; + + v_new_revision := v_workspace.revision + 1; + + CASE p_action + WHEN 'remove' THEN + IF p_target_user_id = v_workspace.owner_id OR NOT EXISTS ( + SELECT 1 FROM public.workspace_members + WHERE workspace_id = p_workspace_id AND user_id = p_target_user_id + ) THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + + UPDATE public.workspace_documents + SET assignee_id = NULL, updated_at = now() + WHERE workspace_id = p_workspace_id AND assignee_id = p_target_user_id; + + DELETE FROM public.workspace_members + WHERE workspace_id = p_workspace_id AND user_id = p_target_user_id; + + UPDATE public.workspaces + SET pending_owner_id = CASE + WHEN pending_owner_id = p_target_user_id THEN NULL + ELSE pending_owner_id + END, + revision = v_new_revision, + access_epoch = access_epoch + 1, + updated_at = now() + WHERE id = p_workspace_id; + + WHEN 'propose_transfer' THEN + IF v_user_id <> v_workspace.owner_id OR p_target_user_id = v_workspace.owner_id + OR NOT EXISTS ( + SELECT 1 FROM public.workspace_members + WHERE workspace_id = p_workspace_id AND user_id = p_target_user_id + ) THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + + UPDATE public.workspaces + SET pending_owner_id = p_target_user_id, + revision = v_new_revision, + updated_at = now() + WHERE id = p_workspace_id; + + WHEN 'accept_transfer' THEN + IF v_user_id <> p_target_user_id + OR v_workspace.pending_owner_id IS DISTINCT FROM v_user_id + OR NOT EXISTS ( + SELECT 1 FROM public.workspace_members + WHERE workspace_id = p_workspace_id AND user_id = v_user_id + ) THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + + UPDATE public.workspaces + SET owner_id = v_user_id, + pending_owner_id = NULL, + revision = v_new_revision, + access_epoch = access_epoch + 1, + updated_at = now() + WHERE id = p_workspace_id; + END CASE; + + INSERT INTO public.workspace_audit_events ( + operation_id, request_fingerprint, workspace_id, actor_id, action, + before_revision, after_revision, target_user_id + ) + VALUES ( + p_request_id, v_fingerprint, p_workspace_id, v_user_id, p_action, + v_workspace.revision, v_new_revision, p_target_user_id + ); + + RETURN v_new_revision; +END; +$$; + +CREATE FUNCTION public.change_workspace_state( + p_workspace_id uuid, + p_action text, + p_expected_revision bigint, + p_request_id uuid +) +RETURNS bigint +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid := auth.uid(); + v_fingerprint bytea; + v_existing public.workspace_audit_events%ROWTYPE; + v_workspace public.workspaces%ROWTYPE; + v_new_revision bigint; +BEGIN + IF v_user_id IS NULL OR p_request_id IS NULL OR p_expected_revision IS NULL THEN + RAISE EXCEPTION 'authentication required'; + END IF; + + v_fingerprint := private.workspace_request_fingerprint(jsonb_build_object( + 'rpc', 'change_workspace_state', + 'actor', v_user_id, + 'workspace', p_workspace_id, + 'action', p_action, + 'expected_revision', p_expected_revision + )); + + PERFORM pg_advisory_xact_lock(hashtextextended(p_request_id::text, 0)); + SELECT * INTO v_existing + FROM public.workspace_audit_events + WHERE operation_id = p_request_id; + + IF FOUND THEN + IF v_existing.request_fingerprint IS DISTINCT FROM v_fingerprint THEN + RAISE EXCEPTION 'operation ID conflict'; + END IF; + RETURN v_existing.after_revision; + END IF; + + IF p_workspace_id IS NULL OR p_expected_revision < 0 + OR p_action IS NULL OR p_action NOT IN ('archive', 'reopen', 'delete', 'restore') THEN + RAISE EXCEPTION 'invalid state request'; + END IF; + + SELECT w.* INTO v_workspace + FROM public.workspaces AS w + WHERE w.id = p_workspace_id + AND EXISTS ( + SELECT 1 FROM public.workspace_members AS m + WHERE m.workspace_id = w.id AND m.user_id = v_user_id + ) + FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + IF p_action IN ('delete', 'restore') AND v_workspace.owner_id <> v_user_id THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + IF v_workspace.revision <> p_expected_revision THEN + RAISE EXCEPTION 'workspace conflict'; + END IF; + + IF (p_action = 'archive' AND v_workspace.status <> 'active') + OR (p_action = 'reopen' AND v_workspace.status <> 'archived') + OR (p_action = 'delete' AND v_workspace.status = 'deleted') + OR (p_action = 'restore' AND ( + v_workspace.status <> 'deleted' + OR v_workspace.deleted_at < now() - interval '30 days' + )) THEN + RAISE EXCEPTION 'workspace unavailable'; + END IF; + + v_new_revision := v_workspace.revision + 1; + UPDATE public.workspaces + SET status = CASE p_action + WHEN 'archive' THEN 'archived' + WHEN 'reopen' THEN 'active' + WHEN 'delete' THEN 'deleted' + WHEN 'restore' THEN 'active' + END, + deleted_at = CASE WHEN p_action = 'delete' THEN now() ELSE NULL END, + revision = v_new_revision, + updated_at = now() + WHERE id = p_workspace_id; + + INSERT INTO public.workspace_audit_events ( + operation_id, request_fingerprint, workspace_id, actor_id, action, + before_revision, after_revision + ) + VALUES ( + p_request_id, v_fingerprint, p_workspace_id, v_user_id, p_action, + v_workspace.revision, v_new_revision + ); + + RETURN v_new_revision; +END; +$$; + +REVOKE ALL ON FUNCTION public.create_workspace(text, text, text, uuid) + FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION public.update_workspace_document(uuid, uuid, text, text, uuid[], uuid, boolean, text, bigint, uuid) + FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION public.change_workspace_access(uuid, text, uuid, bigint, uuid) + FROM PUBLIC, anon; +REVOKE ALL ON FUNCTION public.change_workspace_state(uuid, text, bigint, uuid) + FROM PUBLIC, anon; + +GRANT EXECUTE ON FUNCTION public.create_workspace(text, text, text, uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION public.update_workspace_document(uuid, uuid, text, text, uuid[], uuid, boolean, text, bigint, uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION public.change_workspace_access(uuid, text, uuid, bigint, uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION public.change_workspace_state(uuid, text, bigint, uuid) TO authenticated; diff --git a/supabase/tests/workspace_lifecycle_test.sql b/supabase/tests/workspace_lifecycle_test.sql new file mode 100644 index 000000000..9198490ee --- /dev/null +++ b/supabase/tests/workspace_lifecycle_test.sql @@ -0,0 +1,918 @@ +BEGIN; +SELECT plan(82); + +INSERT INTO auth.users (id) +VALUES + ('51111111-1111-4111-8111-111111111111'), + ('52222222-2222-4222-8222-222222222222'), + ('53333333-3333-4333-8333-333333333333'), + ('54444444-4444-4444-8444-444444444444'), + ('55555555-5555-4555-8555-555555555555'), + ('56666666-6666-4666-8666-666666666666'); + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '51111111-1111-4111-8111-111111111111', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); + +SELECT lives_ok( + $$SELECT public.create_workspace( + 'English motion', 'English workspace', 'en', 'baaaaaaa-0000-4000-8000-000000000001' + )$$, + 'an authenticated owner creates an English workspace' +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '54444444-4444-4444-8444-444444444444', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT lives_ok( + $$SELECT public.create_workspace( + 'Spanish motion', 'Spanish workspace', 'es', 'baaaaaaa-0000-4000-8000-000000000002' + )$$, + 'another owner creates a Spanish workspace' +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '56666666-6666-4666-8666-666666666666', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT lives_ok( + $$SELECT public.create_workspace( + 'Hungarian motion', 'Hungarian workspace', 'hu', 'baaaaaaa-0000-4000-8000-000000000003' + )$$, + 'another owner creates a Hungarian workspace' +); + +RESET ROLE; +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspace_members$$, + ARRAY[3::bigint], + 'creation atomically inserts one owner membership per workspace' +); +SELECT results_eq( + $$SELECT w.initial_language, count(d.*)::bigint + FROM public.workspaces w + JOIN public.workspace_documents d ON d.workspace_id = w.id + GROUP BY w.initial_language + ORDER BY w.initial_language$$, + $$VALUES ('en'::text, 8::bigint), ('es'::text, 8::bigint), ('hu'::text, 8::bigint)$$, + 'creation atomically inserts exactly eight documents per language' +); +SELECT results_eq( + $$SELECT w.initial_language, d.side, string_agg(d.title, ',' ORDER BY d.position) + FROM public.workspace_documents d + JOIN public.workspaces w ON w.id = d.workspace_id + GROUP BY w.initial_language, d.side + ORDER BY w.initial_language, d.side$$, + $$VALUES + ('en'::text, 'con'::text, 'Intro,R1,R2,Conclusion'::text), + ('en'::text, 'pro'::text, 'Intro,R1,R2,Conclusion'::text), + ('es'::text, 'con'::text, 'Intro,R1,R2,Conclusión'::text), + ('es'::text, 'pro'::text, 'Intro,R1,R2,Conclusión'::text), + ('hu'::text, 'con'::text, 'Intro,R1,R2,Összegzés'::text), + ('hu'::text, 'pro'::text, 'Intro,R1,R2,Összegzés'::text)$$, + 'default documents use canonical sides, positions, and localized final titles' +); +SELECT results_eq( + $$SELECT side, min(position), max(position), count(DISTINCT position)::bigint + FROM public.workspace_documents d + JOIN public.workspaces w ON w.id = d.workspace_id + WHERE w.initial_language = 'en' + GROUP BY side ORDER BY side$$, + $$VALUES ('con'::text, 0, 3, 4::bigint), ('pro'::text, 0, 3, 4::bigint)$$, + 'each side starts with positions zero through three' +); + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '51111111-1111-4111-8111-111111111111', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT results_eq( + $$SELECT public.create_workspace( + 'English motion', 'English workspace', 'en', 'baaaaaaa-0000-4000-8000-000000000001' + )$$, + $$SELECT id FROM public.workspaces WHERE initial_language = 'en'$$, + 'an identical create replay returns the original workspace' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspaces$$, + ARRAY[1::bigint], + 'an identical create replay adds no workspace visible to the owner' +); +SELECT throws_ok( + $$SELECT public.create_workspace( + 'Changed motion', 'English workspace', 'en', 'baaaaaaa-0000-4000-8000-000000000001' + )$$, + 'P0001', 'operation ID conflict', + 'a create request ID cannot be reused with different arguments' +); +SELECT throws_ok( + $$SELECT public.create_workspace( + ' ', NULL, 'en', 'baaaaaaa-0000-4000-8000-000000000004' + )$$, + 'P0001', 'invalid workspace request', + 'workspace creation rejects an empty motion' +); + +RESET ROLE; +INSERT INTO public.workspace_members (workspace_id, user_id) +SELECT id, member_id +FROM public.workspaces +CROSS JOIN unnest(ARRAY[ + '52222222-2222-4222-8222-222222222222'::uuid, + '53333333-3333-4333-8333-333333333333'::uuid, + '55555555-5555-4555-8555-555555555555'::uuid +]) AS member_id +WHERE initial_language = 'en'; + +INSERT INTO public.workspace_members (workspace_id, user_id) +SELECT id, '55555555-5555-4555-8555-555555555555' +FROM public.workspaces +WHERE initial_language = 'es'; + +INSERT INTO public.workspace_members (workspace_id, user_id) +SELECT id, '52222222-2222-4222-8222-222222222222' +FROM public.workspaces +WHERE initial_language = 'hu'; + +UPDATE public.workspace_documents AS d +SET assignee_id = '55555555-5555-4555-8555-555555555555' +FROM public.workspaces AS w +WHERE w.id = d.workspace_id + AND w.initial_language = 'en' + AND d.side = 'con' + AND d.position = 1; + +SELECT set_config( + 'test.clear_assignment_document_id', + (SELECT d.id::text + FROM public.workspace_documents d + JOIN public.workspaces w ON w.id = d.workspace_id + WHERE w.initial_language = 'en' AND d.side = 'con' AND d.position = 1), + true +); + +SELECT set_config( + 'test.foreign_document_id', + (SELECT d.id::text + FROM public.workspace_documents d + JOIN public.workspaces w ON w.id = d.workspace_id + WHERE w.initial_language = 'es' + LIMIT 1), + true +); + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '55555555-5555-4555-8555-555555555555', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT results_eq( + $$SELECT public.change_workspace_access( + (SELECT id FROM public.workspaces WHERE initial_language = 'es'), + 'remove', '55555555-5555-4555-8555-555555555555', 0, + 'baaaaaaa-0000-4000-8000-000000000005' + )$$, + ARRAY[1::bigint], + 'a non-owner member can remove themselves' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspaces WHERE initial_language = 'es'$$, + ARRAY[0::bigint], + 'self-removal revokes reads immediately' +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '56666666-6666-4666-8666-666666666666', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT results_eq( + $$SELECT public.change_workspace_access( + (SELECT id FROM public.workspaces WHERE initial_language = 'hu'), + 'propose_transfer', '52222222-2222-4222-8222-222222222222', 0, + 'baaaaaaa-0000-4000-8000-000000000006' + )$$, + ARRAY[1::bigint], + 'an owner can set a pending owner only to an existing member' +); +SELECT results_eq( + $$SELECT public.change_workspace_access( + (SELECT id FROM public.workspaces WHERE initial_language = 'hu'), + 'remove', '52222222-2222-4222-8222-222222222222', 1, + 'baaaaaaa-0000-4000-8000-000000000007' + )$$, + ARRAY[2::bigint], + 'the owner can remove a pending nominee safely' +); +SELECT results_eq( + $$SELECT pending_owner_id, revision, access_epoch + FROM public.workspaces WHERE initial_language = 'hu'$$, + $$VALUES (NULL::uuid, 2::bigint, 1::bigint)$$, + 'removing a pending nominee clears pending ownership atomically' +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '51111111-1111-4111-8111-111111111111', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); + +SELECT results_eq( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND side = 'pro' AND position = 0), + NULL, NULL, NULL, '52222222-2222-4222-8222-222222222222', true, NULL, + 0, 'baaaaaaa-0000-4000-8000-000000000010' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + ARRAY[1::bigint], + 'a document update assigns a member and increments the workspace revision once' +); +SELECT results_eq( + $$SELECT d.title, d.assignee_id + FROM public.workspace_documents d + JOIN public.workspaces w ON w.id = d.workspace_id + WHERE w.initial_language = 'en' AND d.side = 'pro' AND d.position = 0$$, + $$VALUES ('Intro'::text, '52222222-2222-4222-8222-222222222222'::uuid)$$, + 'the document update assigns the member without changing unrelated metadata' +); +SELECT results_eq( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND side = 'pro' AND position = 0), + NULL, NULL, NULL, '52222222-2222-4222-8222-222222222222', true, NULL, + 0, 'baaaaaaa-0000-4000-8000-000000000010' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + ARRAY[1::bigint], + 'an identical document operation returns its original revision' +); +SELECT throws_ok( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND side = 'pro' AND position = 0), + NULL, NULL, NULL, '52222222-2222-4222-8222-222222222222', false, NULL, + 0, 'baaaaaaa-0000-4000-8000-000000000010' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + 'P0001', 'operation ID conflict', + 'the assignment-presence flag participates in the request fingerprint' +); +SELECT throws_ok( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND side = 'pro' AND position = 0), + 'Stale', NULL, NULL, NULL, false, NULL, + 0, 'baaaaaaa-0000-4000-8000-000000000011' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + 'P0001', 'workspace conflict', + 'a stale document revision changes nothing' +); +SELECT results_eq( + $$SELECT d.title, w.revision + FROM public.workspace_documents d + JOIN public.workspaces w ON w.id = d.workspace_id + WHERE w.initial_language = 'en' AND d.side = 'pro' AND d.position = 0$$, + $$VALUES ('Intro'::text, 1::bigint)$$, + 'the stale document update leaves metadata and revision unchanged' +); +SELECT throws_ok( + $$SELECT public.update_workspace_document( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + current_setting('test.foreign_document_id')::uuid, + 'Cross workspace', NULL, NULL, NULL, false, NULL, + 1, 'baaaaaaa-0000-4000-8000-000000000012' + )$$, + 'P0001', 'workspace unavailable', + 'a cross-workspace document reference returns the uniform unavailable error' +); +SELECT throws_ok( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND side = 'pro' AND position = 0), + NULL, NULL, NULL, '54444444-4444-4444-8444-444444444444', true, NULL, + 1, 'baaaaaaa-0000-4000-8000-000000000013' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + 'P0001', 'workspace unavailable', + 'a nonmember cannot be assigned to a document' +); +SELECT results_eq( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND side = 'pro' AND position = 0), + 'Opening', NULL, NULL, NULL, false, NULL, + 1, 'baaaaaaa-0000-4000-8000-000000000038' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + ARRAY[2::bigint], + 'an unrelated rename increments the workspace revision once' +); +SELECT results_eq( + $$SELECT d.title, d.assignee_id, w.revision + FROM public.workspace_documents d + JOIN public.workspaces w ON w.id = d.workspace_id + WHERE w.initial_language = 'en' AND d.side = 'pro' AND d.position = 0$$, + $$VALUES ('Opening'::text, '52222222-2222-4222-8222-222222222222'::uuid, 2::bigint)$$, + 'an unrelated rename preserves assignment' +); +SELECT results_eq( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND side = 'pro' AND position = 0), + NULL, NULL, + (SELECT array_agg(id ORDER BY position DESC) FROM public.workspace_documents WHERE workspace_id = w.id AND side = 'pro' AND state = 'active'), + NULL, false, NULL, 2, 'baaaaaaa-0000-4000-8000-000000000014' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + ARRAY[3::bigint], + 'an exact side list reorders documents and increments one revision' +); +SELECT results_eq( + $$SELECT string_agg(d.title, ',' ORDER BY d.position) + FROM public.workspace_documents d + JOIN public.workspaces w ON w.id = d.workspace_id + WHERE w.initial_language = 'en' AND d.side = 'pro'$$, + ARRAY['Conclusion,R2,R1,Opening'::text], + 'the exact reorder persists every supplied position' +); +SELECT results_eq( + $$SELECT assignee_id FROM public.workspace_documents WHERE title = 'Opening'$$, + ARRAY['52222222-2222-4222-8222-222222222222'::uuid], + 'an unrelated reorder preserves assignment' +); +SELECT throws_ok( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND side = 'pro' LIMIT 1), + NULL, NULL, + ARRAY[(SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND side = 'pro' LIMIT 1), + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND side = 'pro' LIMIT 1)], + NULL, false, NULL, 3, 'baaaaaaa-0000-4000-8000-000000000015' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + 'P0001', 'invalid document order', + 'a duplicate or incomplete reorder list is rejected atomically' +); +SELECT throws_ok( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND side = 'pro' LIMIT 1), + NULL, NULL, + ARRAY(SELECT id FROM public.workspace_documents + WHERE workspace_id = w.id AND side = 'pro' ORDER BY position LIMIT 3) + || ARRAY[current_setting('test.foreign_document_id')::uuid], + NULL, false, NULL, 3, 'baaaaaaa-0000-4000-8000-000000000034' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + 'P0001', 'invalid document order', + 'a reorder list containing a foreign document is rejected atomically' +); + +SELECT results_eq( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND title = 'Opening'), + NULL, NULL, NULL, NULL, false, 'trashed', + 3, 'baaaaaaa-0000-4000-8000-000000000016' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + ARRAY[4::bigint], + 'a member can trash a document through the metadata RPC' +); +SELECT ok( + (SELECT trashed_at IS NOT NULL + AND trash_expires_at BETWEEN now() + interval '29 days' AND now() + interval '31 days' + AND assignee_id = '52222222-2222-4222-8222-222222222222' + FROM public.workspace_documents WHERE title = 'Opening'), + 'trash records a 30-day recovery deadline and preserves assignment' +); +SELECT throws_ok( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND title = 'Opening'), + NULL, NULL, NULL, NULL, false, 'active', + 4, 'baaaaaaa-0000-4000-8000-000000000039' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + 'P0001', 'invalid document order', + 'restoring an inactive document without an exact side order fails atomically' +); +SELECT results_eq( + $$SELECT d.state, d.position, d.assignee_id, w.revision, + (SELECT string_agg(active.title || ':' || active.position, ',' ORDER BY active.position) + FROM public.workspace_documents active + WHERE active.workspace_id = w.id AND active.side = 'pro' AND active.state = 'active') + FROM public.workspace_documents d + JOIN public.workspaces w ON w.id = d.workspace_id + WHERE d.title = 'Opening'$$, + $$VALUES ( + 'trashed'::text, + 3, + '52222222-2222-4222-8222-222222222222'::uuid, + 4::bigint, + 'Conclusion:0,R2:1,R1:2'::text + )$$, + 'failed restore preserves document state, assignment, side order, and revision' +); +SELECT results_eq( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND title = 'Opening'), + NULL, NULL, + (SELECT array_agg(id ORDER BY position) + FROM public.workspace_documents WHERE workspace_id = w.id AND side = 'pro' AND state = 'active') + || ARRAY[(SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND title = 'Opening')], + NULL, false, 'active', + 4, 'baaaaaaa-0000-4000-8000-000000000017' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + ARRAY[5::bigint], + 'a trashed document can be restored' +); +SELECT results_eq( + $$SELECT state, trashed_at, trash_expires_at, assignee_id + FROM public.workspace_documents WHERE title = 'Opening'$$, + $$VALUES ('active'::text, NULL::timestamptz, NULL::timestamptz, '52222222-2222-4222-8222-222222222222'::uuid)$$, + 'restore clears recovery timestamps and retains a current member assignment' +); +SELECT results_eq( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND title = 'Opening'), + NULL, NULL, NULL, NULL, false, 'archived', + 5, 'baaaaaaa-0000-4000-8000-000000000029' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + ARRAY[6::bigint], + 'a document can be archived through the metadata RPC' +); +SELECT results_eq( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND title = 'Opening'), + NULL, NULL, + (SELECT array_agg(id ORDER BY position) + FROM public.workspace_documents WHERE workspace_id = w.id AND side = 'pro' AND state = 'active') + || ARRAY[(SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND title = 'Opening')], + NULL, false, 'active', + 6, 'baaaaaaa-0000-4000-8000-000000000030' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + ARRAY[7::bigint], + 'an archived document can be restored with its current assignment' +); +SELECT throws_ok( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND title = 'Opening'), + NULL, 'con', NULL, NULL, false, NULL, + 7, 'baaaaaaa-0000-4000-8000-000000000040' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + 'P0001', 'invalid document order', + 'moving an active document without an exact destination order fails atomically' +); +SELECT results_eq( + $$SELECT d.side, d.position, d.assignee_id, w.revision, + (SELECT string_agg(active.title || ':' || active.position, ',' ORDER BY active.position) + FROM public.workspace_documents active + WHERE active.workspace_id = w.id AND active.side = 'con' AND active.state = 'active') + FROM public.workspace_documents d + JOIN public.workspaces w ON w.id = d.workspace_id + WHERE d.title = 'Opening'$$, + $$VALUES ( + 'pro'::text, + 3, + '52222222-2222-4222-8222-222222222222'::uuid, + 7::bigint, + 'Intro:0,R1:1,R2:2,Conclusion:3'::text + )$$, + 'failed side move preserves document metadata, destination order, and revision' +); +SELECT results_eq( + $$SELECT public.update_workspace_document( + w.id, + (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND title = 'Opening'), + NULL, 'con', + (SELECT array_agg(id ORDER BY position) FROM public.workspace_documents + WHERE workspace_id = w.id AND side = 'con' AND state = 'active') + || ARRAY[(SELECT id FROM public.workspace_documents WHERE workspace_id = w.id AND title = 'Opening')], + NULL, false, NULL, + 7, 'baaaaaaa-0000-4000-8000-000000000035' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + ARRAY[8::bigint], + 'a document moves sides with an exact destination order' +); +SELECT results_eq( + $$SELECT side, position, assignee_id FROM public.workspace_documents WHERE title = 'Opening'$$, + $$VALUES ('con'::text, 4, '52222222-2222-4222-8222-222222222222'::uuid)$$, + 'the side move persists destination position and assignment' +); +SELECT set_config( + 'test.english_workspace_id', + (SELECT id::text FROM public.workspaces WHERE initial_language = 'en'), + true +); +SELECT set_config( + 'test.english_document_id', + (SELECT id::text FROM public.workspace_documents WHERE title = 'Opening'), + true +); +SELECT results_eq( + $$SELECT public.update_workspace_document( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + current_setting('test.clear_assignment_document_id')::uuid, + NULL, NULL, NULL, NULL, true, NULL, + 8, 'baaaaaaa-0000-4000-8000-000000000041' + )$$, + ARRAY[9::bigint], + 'an explicit assignment change can clear an assignee' +); +SELECT results_eq( + $$SELECT d.assignee_id, w.revision + FROM public.workspace_documents d + JOIN public.workspaces w ON w.id = d.workspace_id + WHERE d.id = current_setting('test.clear_assignment_document_id')::uuid$$, + $$VALUES (NULL::uuid, 9::bigint)$$, + 'explicit clear stores NULL without affecting another document assignment' +); +SELECT results_eq( + $$SELECT public.update_workspace_document( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + current_setting('test.english_document_id')::uuid, + NULL, NULL, NULL, NULL, false, 'trashed', + 9, 'baaaaaaa-0000-4000-8000-000000000042' + )$$, + ARRAY[10::bigint], + 'trash after a side move preserves assignment and records a recovery deadline' +); + +RESET ROLE; +UPDATE public.workspace_documents +SET trash_expires_at = now() - interval '1 second' +WHERE id = current_setting('test.english_document_id')::uuid; +SELECT set_config( + 'test.expired_document_trashed_at', + (SELECT trashed_at::text FROM public.workspace_documents + WHERE id = current_setting('test.english_document_id')::uuid), + true +); +SELECT set_config( + 'test.expired_document_expires_at', + (SELECT trash_expires_at::text FROM public.workspace_documents + WHERE id = current_setting('test.english_document_id')::uuid), + true +); + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '51111111-1111-4111-8111-111111111111', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT throws_ok( + $$SELECT public.update_workspace_document( + w.id, + current_setting('test.english_document_id')::uuid, + NULL, NULL, + (SELECT array_agg(id ORDER BY position) + FROM public.workspace_documents WHERE workspace_id = w.id AND side = 'con' AND state = 'active') + || ARRAY[current_setting('test.english_document_id')::uuid], + NULL, false, 'active', + 10, 'baaaaaaa-0000-4000-8000-000000000043' + ) + FROM public.workspaces w WHERE initial_language = 'en'$$, + 'P0001', 'workspace unavailable', + 'expired trash cannot be restored even with an exact destination order' +); +SELECT results_eq( + $$SELECT d.state, d.trashed_at, d.trash_expires_at, d.assignee_id, w.revision, + (SELECT string_agg(active.title || ':' || active.position, ',' ORDER BY active.position) + FROM public.workspace_documents active + WHERE active.workspace_id = w.id AND active.side = 'con' AND active.state = 'active') + FROM public.workspace_documents d + JOIN public.workspaces w ON w.id = d.workspace_id + WHERE d.id = current_setting('test.english_document_id')::uuid$$, + $$VALUES ( + 'trashed'::text, + current_setting('test.expired_document_trashed_at')::timestamptz, + current_setting('test.expired_document_expires_at')::timestamptz, + '52222222-2222-4222-8222-222222222222'::uuid, + 10::bigint, + 'Intro:0,R1:1,R2:2,Conclusion:3'::text + )$$, + 'expired restore failure preserves state, timestamps, assignment, order, and revision' +); +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '52222222-2222-4222-8222-222222222222', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT results_eq( + $$SELECT public.change_workspace_state( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'archive', 10, 'baaaaaaa-0000-4000-8000-000000000020' + )$$, + ARRAY[11::bigint], + 'a current member can archive a workspace' +); +SELECT results_eq( + $$SELECT public.change_workspace_state( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'archive', 10, 'baaaaaaa-0000-4000-8000-000000000020' + )$$, + ARRAY[11::bigint], + 'an identical member archive replay returns its original outcome' +); +SELECT throws_ok( + $$SELECT public.change_workspace_state( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'reopen', 11, 'baaaaaaa-0000-4000-8000-000000000020' + )$$, + 'P0001', 'operation ID conflict', + 'a workspace state operation ID cannot be reused with different arguments' +); +SELECT results_eq( + $$SELECT public.change_workspace_state( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'reopen', 11, 'baaaaaaa-0000-4000-8000-000000000021' + )$$, + ARRAY[12::bigint], + 'a current member can reopen an archived workspace' +); +SELECT throws_ok( + $$SELECT public.change_workspace_access( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'remove', '51111111-1111-4111-8111-111111111111', 12, + 'baaaaaaa-0000-4000-8000-000000000019' + )$$, + 'P0001', 'workspace unavailable', + 'no member can remove the owner' +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '51111111-1111-4111-8111-111111111111', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT results_eq( + $$SELECT public.change_workspace_access( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'remove', '52222222-2222-4222-8222-222222222222', 12, + 'baaaaaaa-0000-4000-8000-000000000022' + )$$, + ARRAY[13::bigint], + 'member removal increments revision and returns the committed outcome' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspace_members + WHERE user_id = '52222222-2222-4222-8222-222222222222'$$, + ARRAY[0::bigint], + 'member removal deletes access immediately' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspace_documents + WHERE assignee_id = '52222222-2222-4222-8222-222222222222'$$, + ARRAY[0::bigint], + 'member removal clears every assignment atomically' +); +SELECT results_eq( + $$SELECT revision, access_epoch FROM public.workspaces WHERE initial_language = 'en'$$, + $$VALUES (13::bigint, 1::bigint)$$, + 'member removal increments the metadata revision and access epoch' +); +SELECT results_eq( + $$SELECT public.change_workspace_access( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'remove', '52222222-2222-4222-8222-222222222222', 12, + 'baaaaaaa-0000-4000-8000-000000000022' + )$$, + ARRAY[13::bigint], + 'an identical removal replay returns its original outcome' +); +SELECT throws_ok( + $$SELECT public.change_workspace_access( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'remove', '53333333-3333-4333-8333-333333333333', 13, + 'baaaaaaa-0000-4000-8000-000000000022' + )$$, + 'P0001', 'operation ID conflict', + 'a removal operation ID cannot be reused with different arguments' +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '52222222-2222-4222-8222-222222222222', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspaces$$, + ARRAY[0::bigint], + 'a removed member immediately loses workspace reads' +); +SELECT throws_ok( + $$SELECT public.update_workspace_document( + current_setting('test.english_workspace_id')::uuid, + current_setting('test.english_document_id')::uuid, + 'Removed write', NULL, NULL, NULL, false, NULL, 13, + 'baaaaaaa-0000-4000-8000-000000000036' + )$$, + 'P0001', 'workspace unavailable', + 'a removed member immediately loses workspace writes' +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '51111111-1111-4111-8111-111111111111', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT throws_ok( + $$SELECT public.change_workspace_access( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'propose_transfer', '54444444-4444-4444-8444-444444444444', 13, + 'baaaaaaa-0000-4000-8000-000000000037' + )$$, + 'P0001', 'workspace unavailable', + 'ownership cannot be proposed to a nonmember' +); + +SELECT results_eq( + $$SELECT public.change_workspace_access( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'propose_transfer', '53333333-3333-4333-8333-333333333333', 13, + 'baaaaaaa-0000-4000-8000-000000000023' + )$$, + ARRAY[14::bigint], + 'the owner can nominate an existing member for ownership' +); +SELECT results_eq( + $$SELECT pending_owner_id FROM public.workspaces WHERE initial_language = 'en'$$, + ARRAY['53333333-3333-4333-8333-333333333333'::uuid], + 'the ownership nominee remains pending until acceptance' +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '55555555-5555-4555-8555-555555555555', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT throws_ok( + $$SELECT public.change_workspace_access( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'accept_transfer', '55555555-5555-4555-8555-555555555555', 14, + 'baaaaaaa-0000-4000-8000-000000000024' + )$$, + 'P0001', 'workspace unavailable', + 'only the nominated member can accept ownership' +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '53333333-3333-4333-8333-333333333333', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT results_eq( + $$SELECT public.change_workspace_access( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'accept_transfer', '53333333-3333-4333-8333-333333333333', 14, + 'baaaaaaa-0000-4000-8000-000000000025' + )$$, + ARRAY[15::bigint], + 'the nominated member accepts ownership atomically' +); +SELECT results_eq( + $$SELECT owner_id, pending_owner_id, revision, access_epoch + FROM public.workspaces WHERE initial_language = 'en'$$, + $$VALUES ('53333333-3333-4333-8333-333333333333'::uuid, NULL::uuid, 15::bigint, 2::bigint)$$, + 'accepted transfer changes one owner, clears pending state, and increments epochs' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspace_members + WHERE workspace_id = (SELECT id FROM public.workspaces WHERE initial_language = 'en') + AND user_id IN ( + '51111111-1111-4111-8111-111111111111', + '53333333-3333-4333-8333-333333333333' + )$$, + ARRAY[2::bigint], + 'accepted transfer keeps both the old and new owners as members' +); +SELECT results_eq( + $$SELECT public.change_workspace_access( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'accept_transfer', '53333333-3333-4333-8333-333333333333', 14, + 'baaaaaaa-0000-4000-8000-000000000025' + )$$, + ARRAY[15::bigint], + 'an identical transfer acceptance replay returns its original outcome' +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '55555555-5555-4555-8555-555555555555', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT throws_ok( + $$SELECT public.change_workspace_state( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'delete', 15, 'baaaaaaa-0000-4000-8000-000000000031' + )$$, + 'P0001', 'workspace unavailable', + 'a non-owner cannot delete a workspace' +); + +RESET ROLE; +SELECT lives_ok( + $$DELETE FROM auth.users WHERE id = '51111111-1111-4111-8111-111111111111'$$, + 'after transfer the former owner account can be deleted' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspaces WHERE initial_language = 'en'$$, + ARRAY[1::bigint], + 'deleting the former owner preserves the shared workspace' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspace_documents d + JOIN public.workspaces w ON w.id = d.workspace_id + WHERE w.initial_language = 'en'$$, + ARRAY[8::bigint], + 'deleting the former owner preserves shared documents' +); + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '53333333-3333-4333-8333-333333333333', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT results_eq( + $$SELECT public.change_workspace_state( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'delete', 15, 'baaaaaaa-0000-4000-8000-000000000026' + )$$, + ARRAY[16::bigint], + 'the current owner can soft-delete the workspace' +); +SELECT ok( + (SELECT status = 'deleted' AND deleted_at IS NOT NULL + FROM public.workspaces WHERE initial_language = 'en'), + 'soft deletion records deleted state and timestamp' +); +SELECT throws_ok( + $$SELECT public.update_workspace_document( + w.id, (SELECT id FROM public.workspace_documents WHERE workspace_id = w.id LIMIT 1), + 'Blocked', NULL, NULL, NULL, false, NULL, 16, + 'baaaaaaa-0000-4000-8000-000000000027' + ) FROM public.workspaces w WHERE initial_language = 'en'$$, + 'P0001', 'workspace unavailable', + 'deleted workspaces reject collaboration mutations' +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '55555555-5555-4555-8555-555555555555', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT throws_ok( + $$SELECT public.change_workspace_state( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'restore', 16, 'baaaaaaa-0000-4000-8000-000000000032' + )$$, + 'P0001', 'workspace unavailable', + 'a non-owner cannot restore a deleted workspace' +); + +RESET ROLE; +UPDATE public.workspaces +SET deleted_at = now() - interval '31 days' +WHERE initial_language = 'en'; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '53333333-3333-4333-8333-333333333333', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT throws_ok( + $$SELECT public.change_workspace_state( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'restore', 16, 'baaaaaaa-0000-4000-8000-000000000033' + )$$, + 'P0001', 'workspace unavailable', + 'a workspace cannot be restored after the recovery window' +); + +RESET ROLE; +UPDATE public.workspaces SET deleted_at = now() WHERE initial_language = 'en'; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '53333333-3333-4333-8333-333333333333', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT results_eq( + $$SELECT public.change_workspace_state( + (SELECT id FROM public.workspaces WHERE initial_language = 'en'), + 'restore', 16, 'baaaaaaa-0000-4000-8000-000000000028' + )$$, + ARRAY[17::bigint], + 'the current owner can restore within the recovery window' +); +SELECT results_eq( + $$SELECT status, deleted_at FROM public.workspaces WHERE initial_language = 'en'$$, + $$VALUES ('active'::text, NULL::timestamptz)$$, + 'workspace restore clears deleted state' +); +SELECT lives_ok( + $$SELECT public.clear_current_user_data()$$, + 'personal data clearing remains available to a workspace owner' +); +SELECT results_eq( + $$SELECT + (SELECT count(*) FROM public.workspaces WHERE initial_language = 'en')::bigint, + (SELECT count(*) FROM public.workspace_members WHERE workspace_id = (SELECT id FROM public.workspaces WHERE initial_language = 'en'))::bigint, + (SELECT count(*) FROM public.workspace_documents WHERE workspace_id = (SELECT id FROM public.workspaces WHERE initial_language = 'en'))::bigint, + (SELECT count(*) FROM public.workspace_audit_events WHERE workspace_id = (SELECT id FROM public.workspaces WHERE initial_language = 'en'))::bigint$$, + $$VALUES (1::bigint, 2::bigint, 8::bigint, 18::bigint)$$, + 'personal data clearing preserves shared workspace rows and audit history' +); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/supabase/tests/workspaces_rls_test.sql b/supabase/tests/workspaces_rls_test.sql new file mode 100644 index 000000000..37704003b --- /dev/null +++ b/supabase/tests/workspaces_rls_test.sql @@ -0,0 +1,284 @@ +BEGIN; +SELECT plan(36); + +INSERT INTO auth.users (id) +VALUES + ('11111111-1111-4111-8111-111111111111'), + ('22222222-2222-4222-8222-222222222222'), + ('33333333-3333-4333-8333-333333333333'), + ('44444444-4444-4444-8444-444444444444'); + +SELECT has_table('public', 'workspaces', 'workspaces exists'); +SELECT has_table('public', 'workspace_members', 'workspace_members exists'); +SELECT has_table('public', 'workspace_documents', 'workspace_documents exists'); +SELECT has_table('public', 'workspace_audit_events', 'workspace_audit_events exists'); + +SELECT results_eq( + $$SELECT relrowsecurity + FROM pg_class + WHERE oid IN ( + 'public.workspaces'::regclass, + 'public.workspace_members'::regclass, + 'public.workspace_documents'::regclass, + 'public.workspace_audit_events'::regclass + ) + ORDER BY relname$$, + ARRAY[true, true, true, true], + 'all workspace tables enable RLS' +); + +SELECT ok( + NOT has_table_privilege('anon', 'public.workspaces', 'SELECT') + AND NOT has_table_privilege('anon', 'public.workspace_members', 'SELECT') + AND NOT has_table_privilege('anon', 'public.workspace_documents', 'SELECT') + AND NOT has_table_privilege('anon', 'public.workspace_audit_events', 'SELECT'), + 'anonymous callers have no workspace table access' +); +SELECT ok( + has_table_privilege('authenticated', 'public.workspaces', 'SELECT') + AND has_table_privilege('authenticated', 'public.workspace_members', 'SELECT') + AND has_table_privilege('authenticated', 'public.workspace_documents', 'SELECT') + AND has_table_privilege('authenticated', 'public.workspace_audit_events', 'SELECT'), + 'authenticated callers can select workspace rows allowed by RLS' +); +SELECT ok( + NOT has_table_privilege('authenticated', 'public.workspaces', 'INSERT,UPDATE,DELETE') + AND NOT has_table_privilege('authenticated', 'public.workspace_members', 'INSERT,UPDATE,DELETE') + AND NOT has_table_privilege('authenticated', 'public.workspace_documents', 'INSERT,UPDATE,DELETE') + AND NOT has_table_privilege('authenticated', 'public.workspace_audit_events', 'INSERT,UPDATE,DELETE'), + 'authenticated callers cannot mutate workspace tables directly' +); + +SELECT ok( + has_function_privilege('authenticated', 'public.create_workspace(text,text,text,uuid)', 'EXECUTE') + AND has_function_privilege('authenticated', 'public.update_workspace_document(uuid,uuid,text,text,uuid[],uuid,boolean,text,bigint,uuid)', 'EXECUTE') + AND has_function_privilege('authenticated', 'public.change_workspace_access(uuid,text,uuid,bigint,uuid)', 'EXECUTE') + AND has_function_privilege('authenticated', 'public.change_workspace_state(uuid,text,bigint,uuid)', 'EXECUTE'), + 'authenticated callers can execute the four named workspace RPCs' +); +SELECT ok( + NOT has_function_privilege('anon', 'public.create_workspace(text,text,text,uuid)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.update_workspace_document(uuid,uuid,text,text,uuid[],uuid,boolean,text,bigint,uuid)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.change_workspace_access(uuid,text,uuid,bigint,uuid)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'public.change_workspace_state(uuid,text,bigint,uuid)', 'EXECUTE'), + 'anonymous callers cannot execute workspace RPCs' +); +SELECT ok( + NOT EXISTS ( + SELECT 1 + FROM pg_proc p, + LATERAL aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) acl + WHERE p.oid = ANY (ARRAY[ + 'public.create_workspace(text,text,text,uuid)'::regprocedure, + 'public.update_workspace_document(uuid,uuid,text,text,uuid[],uuid,boolean,text,bigint,uuid)'::regprocedure, + 'public.change_workspace_access(uuid,text,uuid,bigint,uuid)'::regprocedure, + 'public.change_workspace_state(uuid,text,bigint,uuid)'::regprocedure + ]) + AND acl.grantee = 0 + AND acl.privilege_type = 'EXECUTE' + ), + 'PUBLIC cannot execute workspace RPCs' +); +SELECT ok( + has_function_privilege('authenticated', 'private.is_current_workspace_member(uuid)', 'EXECUTE') + AND NOT has_function_privilege('anon', 'private.is_current_workspace_member(uuid)', 'EXECUTE') + AND NOT EXISTS ( + SELECT 1 + FROM pg_proc p, + LATERAL aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) acl + WHERE p.oid = 'private.is_current_workspace_member(uuid)'::regprocedure + AND acl.grantee = 0 + AND acl.privilege_type = 'EXECUTE' + ), + 'the private membership helper has the minimum policy-call grant' +); + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '11111111-1111-4111-8111-111111111111', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT lives_ok( + $$SELECT public.create_workspace( + 'First motion', 'First workspace', 'en', 'aaaaaaaa-0000-4000-8000-000000000001' + )$$, + 'the first owner can create a workspace through the RPC' +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '44444444-4444-4444-8444-444444444444', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT lives_ok( + $$SELECT public.create_workspace( + 'Second motion', 'Second workspace', 'en', 'aaaaaaaa-0000-4000-8000-000000000002' + )$$, + 'another owner can create an independent workspace' +); + +RESET ROLE; +INSERT INTO public.workspace_members (workspace_id, user_id) +SELECT id, '22222222-2222-4222-8222-222222222222' +FROM public.workspaces +WHERE owner_id = '11111111-1111-4111-8111-111111111111'; + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '11111111-1111-4111-8111-111111111111', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspaces$$, + ARRAY[1::bigint], + 'an owner reads only their workspace' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspace_members$$, + ARRAY[2::bigint], + 'an owner reads all members of their workspace' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspace_documents$$, + ARRAY[8::bigint], + 'an owner reads all documents of their workspace' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspace_audit_events$$, + ARRAY[1::bigint], + 'an owner reads their workspace audit events' +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '22222222-2222-4222-8222-222222222222', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT results_eq( + $$SELECT title FROM public.workspaces$$, + ARRAY['First workspace'::text], + 'a member reads their workspace' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspace_members$$, + ARRAY[2::bigint], + 'a member reads the membership roster for their workspace' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspace_documents$$, + ARRAY[8::bigint], + 'a member reads documents from their workspace' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspace_audit_events$$, + ARRAY[1::bigint], + 'a member reads audit events from their workspace' +); +SELECT throws_ok( + $$SELECT public.update_workspace_document( + (SELECT id FROM public.workspaces LIMIT 1), + 'ffffffff-ffff-4fff-8fff-ffffffffffff', + 'Forged', NULL, NULL, NULL, false, NULL, 0, + 'aaaaaaaa-0000-4000-8000-000000000003' + )$$, + 'P0001', + 'workspace unavailable', + 'a forged document ID returns the uniform unavailable error' +); +SELECT throws_ok( + $$SELECT public.update_workspace_document( + 'ffffffff-ffff-4fff-8fff-ffffffffffff', + 'ffffffff-ffff-4fff-8fff-ffffffffffff', + 'Forged', NULL, NULL, NULL, false, NULL, 0, + 'aaaaaaaa-0000-4000-8000-000000000004' + )$$, + 'P0001', + 'workspace unavailable', + 'a forged workspace ID returns the uniform unavailable error' +); +SELECT throws_ok( + $$INSERT INTO public.workspace_members (workspace_id, user_id) + SELECT id, '33333333-3333-4333-8333-333333333333' FROM public.workspaces LIMIT 1$$, + '42501', NULL, + 'members cannot add another member directly' +); +SELECT throws_ok( + $$UPDATE public.workspaces SET owner_id = '22222222-2222-4222-8222-222222222222'$$, + '42501', NULL, + 'members cannot escalate ownership directly' +); +SELECT throws_ok( + $$UPDATE public.workspace_documents SET assignee_id = '33333333-3333-4333-8333-333333333333'$$, + '42501', NULL, + 'members cannot forge document assignments directly' +); +SELECT throws_ok( + $$INSERT INTO public.workspace_audit_events ( + operation_id, request_fingerprint, workspace_id, actor_id, action + ) + SELECT 'aaaaaaaa-0000-4000-8000-000000000005', '\\x00', id, + '22222222-2222-4222-8222-222222222222', 'forged' + FROM public.workspaces LIMIT 1$$, + '42501', NULL, + 'members cannot forge audit events' +); +SELECT set_config( + 'test.workspace_id', + (SELECT id::text FROM public.workspaces LIMIT 1), + true +); + +RESET ROLE; +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.sub', '33333333-3333-4333-8333-333333333333', true); +SELECT set_config('request.jwt.claim.role', 'authenticated', true); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspaces$$, + ARRAY[0::bigint], + 'an outsider reads no workspaces' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspace_members$$, + ARRAY[0::bigint], + 'an outsider reads no workspace members' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspace_documents$$, + ARRAY[0::bigint], + 'an outsider reads no workspace documents' +); +SELECT results_eq( + $$SELECT count(*)::bigint FROM public.workspace_audit_events$$, + ARRAY[0::bigint], + 'an outsider reads no workspace audit events' +); +SELECT throws_ok( + $$SELECT public.change_workspace_state( + current_setting('test.workspace_id')::uuid, + 'archive', 0, 'aaaaaaaa-0000-4000-8000-000000000006' + )$$, + 'P0001', + 'workspace unavailable', + 'an outsider cannot infer or mutate a workspace by ID' +); + +RESET ROLE; +SET LOCAL ROLE anon; +SELECT set_config('request.jwt.claim.sub', '', true); +SELECT set_config('request.jwt.claim.role', 'anon', true); +SELECT throws_ok( + $$SELECT id FROM public.workspaces$$, + '42501', NULL, + 'anonymous callers cannot select workspaces' +); +SELECT throws_ok( + $$SELECT public.create_workspace( + 'Anonymous motion', NULL, 'en', 'aaaaaaaa-0000-4000-8000-000000000007' + )$$, + '42501', NULL, + 'anonymous callers cannot execute create_workspace' +); + +RESET ROLE; +SELECT throws_ok( + $$DELETE FROM auth.users WHERE id = '11111111-1111-4111-8111-111111111111'$$, + '23503', NULL, + 'the restrictive owner foreign key blocks raw owner deletion' +); + +SELECT * FROM finish(); +ROLLBACK;