Files
supabase/apps/studio/components/interfaces/Auth/Policies/Policies.utils.test.ts
9f3af6e502 Generate policies experiment (#40881)
* policy generation

* add ai

* refactor

* table create performance

* policy list

* style

* simplify

* refactor

* flag

* tracking

* track

* ts

* fixes

* connection string

* re-add rls and realtime toggle

* restore old logic

* base path

* badge

* false rls

* copy

* add permissions button

* Refactor based on comments

* Fix TS

* More nudge

* Update tests

* Fix test

* Fixx

* Fix

* Address feedback

* Address issues

* Improve experiment telemetry for generate policies A/B test (#41172)

* Address code rabbit catch

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
Co-authored-by: Sean Oliver <882952+seanoliver@users.noreply.github.com>
2025-12-12 12:44:11 +08:00

452 lines
15 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
// Mock generateSqlPolicy for AI tests
const mockGenerateSqlPolicy = vi.fn()
vi.mock('data/ai/sql-policy-mutation', () => ({
generateSqlPolicy: (...args: unknown[]) => mockGenerateSqlPolicy(...args),
}))
// Import after mocks are set up
import {
generateAiPoliciesForTable,
generateProgrammaticPoliciesForTable,
generateStartingPoliciesForTable,
type GeneratedPolicy,
} from './Policies.utils'
// Helper to create a foreign key constraint
const createForeignKey = (overrides: Partial<ForeignKeyConstraint> = {}): ForeignKeyConstraint => ({
id: 1,
constraint_name: 'fk_constraint',
source_id: 100,
source_schema: 'public',
source_table: 'posts',
source_columns: ['user_id'],
target_id: 200,
target_schema: 'auth',
target_table: 'users',
target_columns: ['id'],
deletion_action: 'NO ACTION',
update_action: 'NO ACTION',
...overrides,
})
describe('Policies.utils - Policy Generation', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('generateProgrammaticPoliciesForTable', () => {
it('should generate 4 CRUD policies for direct FK to auth.users', () => {
const foreignKeyConstraints: ForeignKeyConstraint[] = [
createForeignKey({
source_schema: 'public',
source_table: 'posts',
source_columns: ['user_id'],
target_schema: 'auth',
target_table: 'users',
target_columns: ['id'],
}),
]
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
expect(policies).toHaveLength(4)
const commands = policies.map((p) => p.command)
expect(commands).toContain('SELECT')
expect(commands).toContain('INSERT')
expect(commands).toContain('UPDATE')
expect(commands).toContain('DELETE')
})
it('should return empty array when no FK path to auth.users exists', () => {
const foreignKeyConstraints: ForeignKeyConstraint[] = [
createForeignKey({
source_schema: 'public',
source_table: 'posts',
source_columns: ['category_id'],
target_schema: 'public',
target_table: 'categories',
target_columns: ['id'],
}),
]
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
expect(policies).toHaveLength(0)
})
it('should return empty array when foreignKeyConstraints is empty', () => {
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints: [],
})
expect(policies).toHaveLength(0)
})
it('should generate policies with EXISTS clause for indirect FK path (2 hops)', () => {
// posts -> profiles -> auth.users
const foreignKeyConstraints: ForeignKeyConstraint[] = [
createForeignKey({
id: 1,
source_schema: 'public',
source_table: 'posts',
source_columns: ['profile_id'],
target_schema: 'public',
target_table: 'profiles',
target_columns: ['id'],
}),
createForeignKey({
id: 2,
source_schema: 'public',
source_table: 'profiles',
source_columns: ['user_id'],
target_schema: 'auth',
target_table: 'users',
target_columns: ['id'],
}),
]
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
expect(policies).toHaveLength(4)
// Check that the expression contains EXISTS for indirect path
const selectPolicy = policies.find((p) => p.command === 'SELECT')
expect(selectPolicy?.definition).toContain('exists')
expect(selectPolicy?.sql).toContain('exists')
})
describe('policy structure validation', () => {
const foreignKeyConstraints: ForeignKeyConstraint[] = [
createForeignKey({
source_schema: 'public',
source_table: 'posts',
source_columns: ['user_id'],
target_schema: 'auth',
target_table: 'users',
target_columns: ['id'],
}),
]
it('should include all required fields in generated policies', () => {
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
for (const policy of policies) {
expect(policy).toHaveProperty('name')
expect(policy).toHaveProperty('sql')
expect(policy).toHaveProperty('command')
expect(policy).toHaveProperty('table', 'posts')
expect(policy).toHaveProperty('schema', 'public')
expect(policy).toHaveProperty('action', 'PERMISSIVE')
expect(policy).toHaveProperty('roles')
expect(policy.roles).toContain('public')
}
})
it('SELECT policy should have definition but no check', () => {
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
const selectPolicy = policies.find((p) => p.command === 'SELECT')
expect(selectPolicy?.definition).toBeDefined()
expect(selectPolicy?.check).toBeUndefined()
})
it('DELETE policy should have definition but no check', () => {
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
const deletePolicy = policies.find((p) => p.command === 'DELETE')
expect(deletePolicy?.definition).toBeDefined()
expect(deletePolicy?.check).toBeUndefined()
})
it('INSERT policy should have check but no definition', () => {
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
const insertPolicy = policies.find((p) => p.command === 'INSERT')
expect(insertPolicy?.definition).toBeUndefined()
expect(insertPolicy?.check).toBeDefined()
})
it('UPDATE policy should have both definition and check', () => {
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
const updatePolicy = policies.find((p) => p.command === 'UPDATE')
expect(updatePolicy?.definition).toBeDefined()
expect(updatePolicy?.check).toBeDefined()
})
it('should generate correct SQL syntax for direct FK', () => {
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
const selectPolicy = policies.find((p) => p.command === 'SELECT')
expect(selectPolicy?.sql).toContain('CREATE POLICY')
expect(selectPolicy?.sql).toContain('public.posts')
expect(selectPolicy?.sql).toContain('AS PERMISSIVE FOR SELECT')
expect(selectPolicy?.sql).toContain('TO public')
expect(selectPolicy?.sql).toContain('USING')
expect(selectPolicy?.sql).toContain('auth.uid()')
})
})
it('should handle non-public schema', () => {
const foreignKeyConstraints: ForeignKeyConstraint[] = [
createForeignKey({
source_schema: 'private',
source_table: 'documents',
source_columns: ['owner_id'],
target_schema: 'auth',
target_table: 'users',
target_columns: ['id'],
}),
]
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'documents', schema: 'private' },
foreignKeyConstraints,
})
expect(policies).toHaveLength(4)
expect(policies[0].schema).toBe('private')
expect(policies[0].sql).toContain('private.documents')
})
})
describe('generateAiPoliciesForTable', () => {
const mockAiPolicies: GeneratedPolicy[] = [
{
name: 'ai_select_policy',
sql: 'CREATE POLICY "ai_select_policy" ON public.posts FOR SELECT USING (true);',
command: 'SELECT',
table: 'posts',
schema: 'public',
definition: 'true',
action: 'PERMISSIVE',
roles: ['public'],
},
]
it('should return policies from AI when called with valid inputs', async () => {
mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies)
const policies = await generateAiPoliciesForTable({
table: { name: 'posts', schema: 'public' },
columns: [{ name: 'id' }, { name: 'title' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
})
expect(mockGenerateSqlPolicy).toHaveBeenCalledWith({
tableName: 'posts',
schema: 'public',
columns: ['id', 'title'],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
})
expect(policies).toEqual(mockAiPolicies)
})
it('should return empty array when connectionString is null', async () => {
const policies = await generateAiPoliciesForTable({
table: { name: 'posts', schema: 'public' },
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: null,
})
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
expect(policies).toEqual([])
})
it('should return empty array when connectionString is undefined', async () => {
const policies = await generateAiPoliciesForTable({
table: { name: 'posts', schema: 'public' },
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: undefined,
})
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
expect(policies).toEqual([])
})
it('should handle API errors gracefully and return empty array', async () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
mockGenerateSqlPolicy.mockRejectedValue(new Error('API error'))
const policies = await generateAiPoliciesForTable({
table: { name: 'posts', schema: 'public' },
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
})
expect(policies).toEqual([])
expect(consoleLogSpy).toHaveBeenCalledWith('AI policy generation failed:', expect.any(Error))
consoleLogSpy.mockRestore()
})
it('should trim column names before sending to API', async () => {
mockGenerateSqlPolicy.mockResolvedValue([])
await generateAiPoliciesForTable({
table: { name: 'posts', schema: 'public' },
columns: [{ name: ' id ' }, { name: ' title ' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
})
expect(mockGenerateSqlPolicy).toHaveBeenCalledWith(
expect.objectContaining({
columns: ['id', 'title'],
})
)
})
})
describe('generateStartingPoliciesForTable', () => {
const mockAiPolicies: GeneratedPolicy[] = [
{
name: 'ai_policy',
sql: 'CREATE POLICY "ai_policy" ON public.posts FOR SELECT USING (true);',
command: 'SELECT',
table: 'posts',
schema: 'public',
definition: 'true',
action: 'PERMISSIVE',
roles: ['public'],
},
]
it('should use programmatic policies when FK path exists (does not call AI)', async () => {
const foreignKeyConstraints: ForeignKeyConstraint[] = [
createForeignKey({
source_schema: 'public',
source_table: 'posts',
source_columns: ['user_id'],
target_schema: 'auth',
target_table: 'users',
target_columns: ['id'],
}),
]
const policies = await generateStartingPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
enableAi: true,
})
expect(policies).toHaveLength(4)
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
})
it('should fall back to AI when no FK path exists and enableAi is true', async () => {
mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies)
const policies = await generateStartingPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints: [],
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
enableAi: true,
})
expect(mockGenerateSqlPolicy).toHaveBeenCalled()
expect(policies).toEqual(mockAiPolicies)
})
it('should return empty array when no FK path exists and enableAi is false', async () => {
const policies = await generateStartingPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints: [],
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
enableAi: false,
})
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
expect(policies).toEqual([])
})
it('should return empty array when no FK path and AI returns empty', async () => {
mockGenerateSqlPolicy.mockResolvedValue([])
const policies = await generateStartingPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints: [],
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
enableAi: true,
})
expect(policies).toEqual([])
})
it('should prioritize programmatic over AI even when both could generate policies', async () => {
mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies)
const foreignKeyConstraints: ForeignKeyConstraint[] = [
createForeignKey({
source_schema: 'public',
source_table: 'posts',
source_columns: ['user_id'],
target_schema: 'auth',
target_table: 'users',
target_columns: ['id'],
}),
]
const policies = await generateStartingPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
enableAi: true,
})
// Should return 4 programmatic policies, not 1 AI policy
expect(policies).toHaveLength(4)
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
})
})
})