diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aae930b3..051320d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -176,8 +176,13 @@ jobs: npm ci npm run build + - name: Install jq + run: sudo apt-get update && sudo apt-get install -y jq + - name: Run integration tests - run: npm run test:integration || npm run test:integration + run: | + export SUPABASE_SERVICE_ROLE_KEY="$(supabase status --output json | jq -r '.SERVICE_ROLE_KEY')" + npm run test:integration || npm run test:integration - name: Stop Supabase if: always() diff --git a/supabase/migrations/20250422000000_create_todos_table.sql b/supabase/migrations/20250422000000_create_todos_table.sql index a2dd7daa..ca7a76d8 100644 --- a/supabase/migrations/20250422000000_create_todos_table.sql +++ b/supabase/migrations/20250422000000_create_todos_table.sql @@ -3,15 +3,52 @@ CREATE TABLE IF NOT EXISTS public.todos ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), task TEXT NOT NULL, is_complete BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + user_id UUID REFERENCES auth.users(id) ); -- Set up Row Level Security (RLS) ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY; --- Create policies -CREATE POLICY "Allow anonymous access to todos" ON public.todos - FOR ALL +-- Allow anonymous users to read all todos (public data) +CREATE POLICY "Allow anonymous read access" ON public.todos + FOR SELECT + TO anon + USING (true); + +-- Allow anonymous users to insert todos (for backward compatibility with old tests) +CREATE POLICY "Allow anonymous insert access" ON public.todos + FOR INSERT TO anon - USING (true) WITH CHECK (true); + +-- Allow anonymous users to delete todos (for backward compatibility with old tests) +CREATE POLICY "Allow anonymous delete access" ON public.todos + FOR DELETE + TO anon + USING (true); + +-- Allow authenticated users to read their own todos +CREATE POLICY "Allow authenticated read own todos" ON public.todos + FOR SELECT + TO authenticated + USING (auth.uid() = user_id); + +-- Allow authenticated users to insert their own todos +CREATE POLICY "Allow authenticated insert own todos" ON public.todos + FOR INSERT + TO authenticated + WITH CHECK (auth.uid() = user_id); + +-- Allow authenticated users to update their own todos +CREATE POLICY "Allow authenticated update own todos" ON public.todos + FOR UPDATE + TO authenticated + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- Allow authenticated users to delete their own todos +CREATE POLICY "Allow authenticated delete own todos" ON public.todos + FOR DELETE + TO authenticated + USING (auth.uid() = user_id); diff --git a/supabase/migrations/20250424000000_storage_anon_policy.sql b/supabase/migrations/20250424000000_storage_anon_policy.sql new file mode 100644 index 00000000..dfea0b66 --- /dev/null +++ b/supabase/migrations/20250424000000_storage_anon_policy.sql @@ -0,0 +1,4 @@ +-- Create test bucket for storage tests +insert into storage.buckets (id, name, public) +values ('test-bucket', 'test-bucket', false) +on conflict (id) do nothing; diff --git a/test/deno/integration.test.ts b/test/deno/integration.test.ts index ca06e275..6d700bff 100644 --- a/test/deno/integration.test.ts +++ b/test/deno/integration.test.ts @@ -88,6 +88,64 @@ Deno.test( assertEquals(data.user!.email, email) }) + await t.step('Authentication - should sign in and out successfully', async () => { + const email = `deno-signout-${Date.now()}@example.com` + const password = 'password123' + + await supabase.auth.signUp({ email, password }) + const { data, error } = await supabase.auth.signInWithPassword({ email, password }) + + assertEquals(error, null) + assertExists(data.user) + assertEquals(data.user!.email, email) + + const { error: signOutError } = await supabase.auth.signOut() + + assertEquals(signOutError, null) + }) + + await t.step('Authentication - should get current user', async () => { + const email = `deno-getuser-${Date.now()}@example.com` + const password = 'password123' + + await supabase.auth.signUp({ email, password }) + await supabase.auth.signInWithPassword({ email, password }) + + const { data, error } = await supabase.auth.getUser() + + assertEquals(error, null) + assertExists(data.user) + assertEquals(data.user!.email, email) + }) + + await t.step('Authentication - should handle invalid credentials', async () => { + const email = `deno-invalid-${Date.now()}@example.com` + const password = 'password123' + + await supabase.auth.signUp({ email, password }) + + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password: 'wrongpassword', + }) + + assertExists(error) + assertEquals(data.user, null) + }) + + await t.step('Authentication - should handle non-existent user', async () => { + const email = `deno-nonexistent-${Date.now()}@example.com` + const password = 'password123' + + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password, + }) + + assertExists(error) + assertEquals(data.user, null) + }) + await t.step('Realtime - is able to connect and broadcast', async () => { const channelName = `channel-${crypto.randomUUID()}` let channel: RealtimeChannel diff --git a/test/integration.test.ts b/test/integration.test.ts index b3862416..227b3890 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -2,23 +2,23 @@ import { createClient, RealtimeChannel, SupabaseClient } from '../src/index' // These tests assume that a local Supabase server is already running // Start a local Supabase instance with 'supabase start' before running these tests -describe('Supabase Integration Tests', () => { - // Default local dev credentials from Supabase CLI - const SUPABASE_URL = 'http://127.0.0.1:54321' - const ANON_KEY = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' +// Default local dev credentials from Supabase CLI +const SUPABASE_URL = 'http://127.0.0.1:54321' +const ANON_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' - const supabase = createClient(SUPABASE_URL, ANON_KEY, { - realtime: { heartbeatIntervalMs: 500 }, - }) +const supabase = createClient(SUPABASE_URL, ANON_KEY, { + realtime: { heartbeatIntervalMs: 500 }, +}) +describe('Supabase Integration Tests', () => { test('should connect to Supabase instance', async () => { expect(supabase).toBeDefined() expect(supabase).toBeInstanceOf(SupabaseClient) }) describe('PostgREST', () => { - test('should query data from public schema', async () => { + test('should connect to PostgREST API', async () => { const { data, error } = await supabase.from('todos').select('*').limit(5) // The default schema includes a 'todos' table, but it might be empty @@ -57,11 +57,126 @@ describe('Supabase Integration Tests', () => { }) }) + describe('PostgreSQL RLS', () => { + let user1Email: string + let user2Email: string + let user1Id: string + let user2Id: string + let user1TodoId: string + let user2TodoId: string + + beforeAll(async () => { + // Create two test users + user1Email = `user1-${Date.now()}@example.com` + user2Email = `user2-${Date.now()}@example.com` + const password = 'password123' + + const { data: user1Data } = await supabase.auth.signUp({ + email: user1Email, + password, + }) + user1Id = user1Data.user!.id + + const { data: user2Data } = await supabase.auth.signUp({ + email: user2Email, + password, + }) + user2Id = user2Data.user!.id + + // Create todos for both users + await supabase.auth.signInWithPassword({ email: user1Email, password }) + const { data: user1Todo } = await supabase + .from('todos') + .insert({ task: 'User 1 Todo', is_complete: false, user_id: user1Id }) + .select() + .single() + user1TodoId = user1Todo!.id + + await supabase.auth.signInWithPassword({ email: user2Email, password }) + const { data: user2Todo } = await supabase + .from('todos') + .insert({ task: 'User 2 Todo', is_complete: false, user_id: user2Id }) + .select() + .single() + user2TodoId = user2Todo!.id + }) + + afterAll(async () => { + await supabase.auth.signOut() + }) + + test('should allow anonymous access via RLS policies', async () => { + await supabase.auth.signOut() + + const { data, error } = await supabase.from('todos').select('*').limit(5) + + expect(error).toBeNull() + expect(Array.isArray(data)).toBe(true) + }) + + test('should allow authenticated user to access their own data', async () => { + await supabase.auth.signInWithPassword({ email: user1Email, password: 'password123' }) + + const { data, error } = await supabase + .from('todos') + .select('*') + .eq('id', user1TodoId) + .single() + + expect(error).toBeNull() + expect(data).toBeDefined() + expect(data!.task).toBe('User 1 Todo') + }) + + test('should prevent access to other users data', async () => { + await supabase.auth.signInWithPassword({ email: user1Email, password: 'password123' }) + + const { data, error } = await supabase + .from('todos') + .select('*') + .eq('id', user2TodoId) + .single() + + expect(error).not.toBeNull() + expect(data).toBeNull() + }) + + test('should allow authenticated user to create their own data', async () => { + await supabase.auth.signInWithPassword({ email: user1Email, password: 'password123' }) + + const { data, error } = await supabase + .from('todos') + .insert({ task: 'New User 1 Todo', is_complete: false, user_id: user1Id }) + .select() + .single() + + expect(error).toBeNull() + expect(data).toBeDefined() + expect(data!.task).toBe('New User 1 Todo') + }) + + test('should allow authenticated user to update their own data', async () => { + await supabase.auth.signInWithPassword({ email: user1Email, password: 'password123' }) + + const { data, error } = await supabase + .from('todos') + .update({ task: 'Updated User 1 Todo' }) + .eq('id', user1TodoId) + .select() + .single() + + expect(error).toBeNull() + expect(data).toBeDefined() + expect(data!.task).toBe('Updated User 1 Todo') + }) + }) + describe('Authentication', () => { afterAll(async () => { // Clean up by signing out the user await supabase.auth.signOut() }) + test('should sign up a user', async () => { const email = `test-${Date.now()}@example.com` const password = 'password123' @@ -75,6 +190,64 @@ describe('Supabase Integration Tests', () => { expect(data.user).toBeDefined() expect(data.user!.email).toBe(email) }) + + test('should sign in and out successfully', async () => { + const email = `test-${Date.now()}@example.com` + const password = 'password123' + + await supabase.auth.signUp({ email, password }) + const { data, error } = await supabase.auth.signInWithPassword({ email, password }) + + expect(error).toBeNull() + expect(data.user).toBeDefined() + expect(data.user!.email).toBe(email) + + const { error: signOutError } = await supabase.auth.signOut() + + expect(signOutError).toBeNull() + }) + + test('should get current user', async () => { + const email = `test-${Date.now()}@example.com` + const password = 'password123' + + await supabase.auth.signUp({ email, password }) + await supabase.auth.signInWithPassword({ email, password }) + + const { data, error } = await supabase.auth.getUser() + + expect(error).toBeNull() + expect(data.user).toBeDefined() + expect(data.user!.email).toBe(email) + }) + + test('should handle invalid credentials', async () => { + const email = `test-${Date.now()}@example.com` + const password = 'password123' + + await supabase.auth.signUp({ email, password }) + + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password: 'wrongpassword', + }) + + expect(error).not.toBeNull() + expect(data.user).toBeNull() + }) + + test('should handle non-existent user', async () => { + const email = `nonexistent-${Date.now()}@example.com` + const password = 'password123' + + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password, + }) + + expect(error).not.toBeNull() + expect(data.user).toBeNull() + }) }) describe('Realtime', () => { @@ -133,3 +306,40 @@ describe('Supabase Integration Tests', () => { }, 10000) }) }) + +describe('Storage API', () => { + const bucket = 'test-bucket' + const filePath = 'test-file.txt' + const fileContent = new Blob(['Hello, Supabase Storage!'], { type: 'text/plain' }) + + // use service_role key for bypass RLS + const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || 'use-service-role-key' + const supabaseWithServiceRole = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { + realtime: { heartbeatIntervalMs: 500 }, + }) + + test('upload and list file in bucket', async () => { + // upload + const { data: uploadData, error: uploadError } = await supabaseWithServiceRole.storage + .from(bucket) + .upload(filePath, fileContent, { upsert: true }) + expect(uploadError).toBeNull() + expect(uploadData).toBeDefined() + + // list + const { data: listData, error: listError } = await supabaseWithServiceRole.storage + .from(bucket) + .list() + expect(listError).toBeNull() + expect(Array.isArray(listData)).toBe(true) + if (!listData) throw new Error('listData is null') + const fileNames = listData.map((f: any) => f.name) + expect(fileNames).toContain('test-file.txt') + + // delete file + const { error: deleteError } = await supabaseWithServiceRole.storage + .from(bucket) + .remove([filePath]) + expect(deleteError).toBeNull() + }) +}) diff --git a/test/integration/bun/integration.test.ts b/test/integration/bun/integration.test.ts index 608774b4..ab8b760b 100644 --- a/test/integration/bun/integration.test.ts +++ b/test/integration/bun/integration.test.ts @@ -1,16 +1,15 @@ import { test, expect } from 'bun:test' import { createClient } from '@supabase/supabase-js' -test('should subscribe to realtime channel', async () => { - const SUPABASE_URL = 'http://127.0.0.1:54321' - const ANON_KEY = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' +const SUPABASE_URL = 'http://127.0.0.1:54321' +const ANON_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' - const supabase = createClient(SUPABASE_URL, ANON_KEY, { - realtime: { heartbeatIntervalMs: 500 }, - }) +const supabase = createClient(SUPABASE_URL, ANON_KEY, { + realtime: { heartbeatIntervalMs: 500 }, +}) - // Setup authentication +test('should subscribe to realtime channel', async () => { await supabase.auth.signOut() const email = `bun-test-${Date.now()}@example.com` const password = 'password123' @@ -43,3 +42,70 @@ test('should subscribe to realtime channel', async () => { // Cleanup await supabase.removeAllChannels() }, 10000) + +test('should sign up a user', async () => { + await supabase.auth.signOut() + const email = `bun-auth-${Date.now()}@example.com` + const password = 'password123' + + const { data, error } = await supabase.auth.signUp({ + email, + password, + }) + + expect(error).toBeNull() + expect(data.user).toBeDefined() + expect(data.user!.email).toBe(email) +}) + +test('should sign in and out successfully', async () => { + await supabase.auth.signOut() + const email = `bun-signin-${Date.now()}@example.com` + const password = 'password123' + + await supabase.auth.signUp({ email, password }) + + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password, + }) + + expect(error).toBeNull() + expect(data.user).toBeDefined() + expect(data.user!.email).toBe(email) + + const { error: signOutError } = await supabase.auth.signOut() + + expect(signOutError).toBeNull() +}) + +test('should get current user', async () => { + await supabase.auth.signOut() + const email = `bun-getuser-${Date.now()}@example.com` + const password = 'password123' + + await supabase.auth.signUp({ email, password }) + await supabase.auth.signInWithPassword({ email, password }) + + const { data, error } = await supabase.auth.getUser() + + expect(error).toBeNull() + expect(data.user).toBeDefined() + expect(data.user!.email).toBe(email) +}) + +test('should handle invalid credentials', async () => { + await supabase.auth.signOut() + const email = `bun-invalid-${Date.now()}@example.com` + const password = 'password123' + + await supabase.auth.signUp({ email, password }) + + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password: 'wrongpassword', + }) + + expect(error).not.toBeNull() + expect(data.user).toBeNull() +}) diff --git a/test/integration/next/playwright.config.ts b/test/integration/next/playwright.config.ts index 75d53188..001172cf 100644 --- a/test/integration/next/playwright.config.ts +++ b/test/integration/next/playwright.config.ts @@ -4,13 +4,14 @@ export default defineConfig({ testDir: './tests', fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, + retries: 0, workers: process.env.CI ? 1 : undefined, + timeout: 20000, reporter: 'html', use: { baseURL: 'http://localhost:3000', - trace: 'on-first-retry', - video: 'on-first-retry', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', }, projects: [ { diff --git a/test/integration/node-browser/playwright.config.ts b/test/integration/node-browser/playwright.config.ts index b42dd367..33223a5e 100644 --- a/test/integration/node-browser/playwright.config.ts +++ b/test/integration/node-browser/playwright.config.ts @@ -4,12 +4,14 @@ export default defineConfig({ testDir: './', fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, + retries: 0, workers: process.env.CI ? 1 : undefined, + timeout: 20000, reporter: 'html', use: { baseURL: 'http://localhost:8004', - trace: 'on-first-retry', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', }, projects: [ {