Policies.utils.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. import { safeSql } from '@supabase/pg-meta'
  2. import { beforeEach, describe, expect, it, vi } from 'vitest'
  3. import {
  4. generateAiPoliciesForTable,
  5. generateProgrammaticPoliciesForTable,
  6. generateStartingPoliciesForTable,
  7. type GeneratedPolicy,
  8. } from './Policies.utils'
  9. import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query'
  10. // Mock generateSqlPolicy for AI tests
  11. const mockGenerateSqlPolicy = vi.fn()
  12. vi.mock('@/data/ai/sql-policy-mutation', () => ({
  13. generateSqlPolicy: (...args: unknown[]) => mockGenerateSqlPolicy(...args),
  14. }))
  15. // Helper to create a foreign key constraint
  16. const createForeignKey = (overrides: Partial<ForeignKeyConstraint> = {}): ForeignKeyConstraint => ({
  17. id: 1,
  18. constraint_name: 'fk_constraint',
  19. source_id: 100,
  20. source_schema: 'public',
  21. source_table: 'posts',
  22. source_columns: ['user_id'],
  23. target_id: 200,
  24. target_schema: 'auth',
  25. target_table: 'users',
  26. target_columns: ['id'],
  27. deletion_action: 'NO ACTION',
  28. update_action: 'NO ACTION',
  29. ...overrides,
  30. })
  31. describe('Policies.utils - Policy Generation', () => {
  32. beforeEach(() => {
  33. vi.clearAllMocks()
  34. })
  35. describe('generateProgrammaticPoliciesForTable', () => {
  36. it('should generate 4 CRUD policies for direct FK to auth.users', () => {
  37. const foreignKeyConstraints: ForeignKeyConstraint[] = [
  38. createForeignKey({
  39. source_schema: 'public',
  40. source_table: 'posts',
  41. source_columns: ['user_id'],
  42. target_schema: 'auth',
  43. target_table: 'users',
  44. target_columns: ['id'],
  45. }),
  46. ]
  47. const policies = generateProgrammaticPoliciesForTable({
  48. table: { name: 'posts', schema: 'public' },
  49. foreignKeyConstraints,
  50. })
  51. expect(policies).toHaveLength(4)
  52. const commands = policies.map((p) => p.command)
  53. expect(commands).toContain('SELECT')
  54. expect(commands).toContain('INSERT')
  55. expect(commands).toContain('UPDATE')
  56. expect(commands).toContain('DELETE')
  57. })
  58. it('should return empty array when no FK path to auth.users exists', () => {
  59. const foreignKeyConstraints: ForeignKeyConstraint[] = [
  60. createForeignKey({
  61. source_schema: 'public',
  62. source_table: 'posts',
  63. source_columns: ['category_id'],
  64. target_schema: 'public',
  65. target_table: 'categories',
  66. target_columns: ['id'],
  67. }),
  68. ]
  69. const policies = generateProgrammaticPoliciesForTable({
  70. table: { name: 'posts', schema: 'public' },
  71. foreignKeyConstraints,
  72. })
  73. expect(policies).toHaveLength(0)
  74. })
  75. it('should return empty array when foreignKeyConstraints is empty', () => {
  76. const policies = generateProgrammaticPoliciesForTable({
  77. table: { name: 'posts', schema: 'public' },
  78. foreignKeyConstraints: [],
  79. })
  80. expect(policies).toHaveLength(0)
  81. })
  82. it('should generate policies with EXISTS clause for indirect FK path (2 hops)', () => {
  83. // posts -> profiles -> auth.users
  84. const foreignKeyConstraints: ForeignKeyConstraint[] = [
  85. createForeignKey({
  86. id: 1,
  87. source_schema: 'public',
  88. source_table: 'posts',
  89. source_columns: ['profile_id'],
  90. target_schema: 'public',
  91. target_table: 'profiles',
  92. target_columns: ['id'],
  93. }),
  94. createForeignKey({
  95. id: 2,
  96. source_schema: 'public',
  97. source_table: 'profiles',
  98. source_columns: ['user_id'],
  99. target_schema: 'auth',
  100. target_table: 'users',
  101. target_columns: ['id'],
  102. }),
  103. ]
  104. const policies = generateProgrammaticPoliciesForTable({
  105. table: { name: 'posts', schema: 'public' },
  106. foreignKeyConstraints,
  107. })
  108. expect(policies).toHaveLength(4)
  109. // Check that the expression contains EXISTS for indirect path
  110. const selectPolicy = policies.find((p) => p.command === 'SELECT')
  111. expect(selectPolicy?.definition).toContain('exists')
  112. expect(selectPolicy?.sql).toContain('exists')
  113. })
  114. describe('policy structure validation', () => {
  115. const foreignKeyConstraints: ForeignKeyConstraint[] = [
  116. createForeignKey({
  117. source_schema: 'public',
  118. source_table: 'posts',
  119. source_columns: ['user_id'],
  120. target_schema: 'auth',
  121. target_table: 'users',
  122. target_columns: ['id'],
  123. }),
  124. ]
  125. it('should include all required fields in generated policies', () => {
  126. const policies = generateProgrammaticPoliciesForTable({
  127. table: { name: 'posts', schema: 'public' },
  128. foreignKeyConstraints,
  129. })
  130. for (const policy of policies) {
  131. expect(policy).toHaveProperty('name')
  132. expect(policy).toHaveProperty('sql')
  133. expect(policy).toHaveProperty('command')
  134. expect(policy).toHaveProperty('table', 'posts')
  135. expect(policy).toHaveProperty('schema', 'public')
  136. expect(policy).toHaveProperty('action', 'PERMISSIVE')
  137. expect(policy).toHaveProperty('roles')
  138. expect(policy.roles).toContain('authenticated')
  139. }
  140. })
  141. it('SELECT policy should have definition but no check', () => {
  142. const policies = generateProgrammaticPoliciesForTable({
  143. table: { name: 'posts', schema: 'public' },
  144. foreignKeyConstraints,
  145. })
  146. const selectPolicy = policies.find((p) => p.command === 'SELECT')
  147. expect(selectPolicy?.definition).toBeDefined()
  148. expect(selectPolicy?.check).toBeUndefined()
  149. })
  150. it('DELETE policy should have definition but no check', () => {
  151. const policies = generateProgrammaticPoliciesForTable({
  152. table: { name: 'posts', schema: 'public' },
  153. foreignKeyConstraints,
  154. })
  155. const deletePolicy = policies.find((p) => p.command === 'DELETE')
  156. expect(deletePolicy?.definition).toBeDefined()
  157. expect(deletePolicy?.check).toBeUndefined()
  158. })
  159. it('INSERT policy should have check but no definition', () => {
  160. const policies = generateProgrammaticPoliciesForTable({
  161. table: { name: 'posts', schema: 'public' },
  162. foreignKeyConstraints,
  163. })
  164. const insertPolicy = policies.find((p) => p.command === 'INSERT')
  165. expect(insertPolicy?.definition).toBeUndefined()
  166. expect(insertPolicy?.check).toBeDefined()
  167. })
  168. it('UPDATE policy should have both definition and check', () => {
  169. const policies = generateProgrammaticPoliciesForTable({
  170. table: { name: 'posts', schema: 'public' },
  171. foreignKeyConstraints,
  172. })
  173. const updatePolicy = policies.find((p) => p.command === 'UPDATE')
  174. expect(updatePolicy?.definition).toBeDefined()
  175. expect(updatePolicy?.check).toBeDefined()
  176. })
  177. it('should generate correct SQL syntax for direct FK', () => {
  178. const policies = generateProgrammaticPoliciesForTable({
  179. table: { name: 'posts', schema: 'public' },
  180. foreignKeyConstraints,
  181. })
  182. const selectPolicy = policies.find((p) => p.command === 'SELECT')
  183. expect(selectPolicy?.sql).toContain('CREATE POLICY')
  184. expect(selectPolicy?.sql).toContain('public.posts')
  185. expect(selectPolicy?.sql).toContain('AS PERMISSIVE FOR SELECT')
  186. expect(selectPolicy?.sql).toContain('TO authenticated')
  187. expect(selectPolicy?.sql).toContain('USING')
  188. expect(selectPolicy?.sql).toContain('auth.uid()')
  189. })
  190. })
  191. it('should handle non-public schema', () => {
  192. const foreignKeyConstraints: ForeignKeyConstraint[] = [
  193. createForeignKey({
  194. source_schema: 'private',
  195. source_table: 'documents',
  196. source_columns: ['owner_id'],
  197. target_schema: 'auth',
  198. target_table: 'users',
  199. target_columns: ['id'],
  200. }),
  201. ]
  202. const policies = generateProgrammaticPoliciesForTable({
  203. table: { name: 'documents', schema: 'private' },
  204. foreignKeyConstraints,
  205. })
  206. expect(policies).toHaveLength(4)
  207. expect(policies[0].schema).toBe('private')
  208. expect(policies[0].sql).toContain('private.documents')
  209. })
  210. })
  211. describe('generateAiPoliciesForTable', () => {
  212. const mockAiPolicies: GeneratedPolicy[] = [
  213. {
  214. name: 'ai_select_policy',
  215. sql: 'CREATE POLICY "ai_select_policy" ON public.posts FOR SELECT USING (true);',
  216. command: 'SELECT',
  217. table: 'posts',
  218. schema: 'public',
  219. definition: safeSql`true`,
  220. action: 'PERMISSIVE',
  221. roles: ['public'],
  222. },
  223. ]
  224. it('should return policies from AI when called with valid inputs', async () => {
  225. mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies)
  226. const policies = await generateAiPoliciesForTable({
  227. table: { name: 'posts', schema: 'public' },
  228. columns: [{ name: 'id' }, { name: 'title' }],
  229. projectRef: 'test-project',
  230. connectionString: 'postgresql://localhost:5432/test',
  231. })
  232. expect(mockGenerateSqlPolicy).toHaveBeenCalledWith({
  233. tableName: 'posts',
  234. schema: 'public',
  235. columns: ['id', 'title'],
  236. projectRef: 'test-project',
  237. connectionString: 'postgresql://localhost:5432/test',
  238. })
  239. expect(policies).toEqual(mockAiPolicies)
  240. })
  241. it('should return empty array when connectionString is null', async () => {
  242. const policies = await generateAiPoliciesForTable({
  243. table: { name: 'posts', schema: 'public' },
  244. columns: [{ name: 'id' }],
  245. projectRef: 'test-project',
  246. connectionString: null,
  247. })
  248. expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
  249. expect(policies).toEqual([])
  250. })
  251. it('should return empty array when connectionString is undefined', async () => {
  252. const policies = await generateAiPoliciesForTable({
  253. table: { name: 'posts', schema: 'public' },
  254. columns: [{ name: 'id' }],
  255. projectRef: 'test-project',
  256. connectionString: undefined,
  257. })
  258. expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
  259. expect(policies).toEqual([])
  260. })
  261. it('should handle API errors gracefully and return empty array', async () => {
  262. const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
  263. mockGenerateSqlPolicy.mockRejectedValue(new Error('API error'))
  264. const policies = await generateAiPoliciesForTable({
  265. table: { name: 'posts', schema: 'public' },
  266. columns: [{ name: 'id' }],
  267. projectRef: 'test-project',
  268. connectionString: 'postgresql://localhost:5432/test',
  269. })
  270. expect(policies).toEqual([])
  271. expect(consoleLogSpy).toHaveBeenCalledWith('AI policy generation failed:', expect.any(Error))
  272. consoleLogSpy.mockRestore()
  273. })
  274. it('should trim column names before sending to API', async () => {
  275. mockGenerateSqlPolicy.mockResolvedValue([])
  276. await generateAiPoliciesForTable({
  277. table: { name: 'posts', schema: 'public' },
  278. columns: [{ name: ' id ' }, { name: ' title ' }],
  279. projectRef: 'test-project',
  280. connectionString: 'postgresql://localhost:5432/test',
  281. })
  282. expect(mockGenerateSqlPolicy).toHaveBeenCalledWith(
  283. expect.objectContaining({
  284. columns: ['id', 'title'],
  285. })
  286. )
  287. })
  288. })
  289. describe('generateStartingPoliciesForTable', () => {
  290. const mockAiPolicies: GeneratedPolicy[] = [
  291. {
  292. name: 'ai_policy',
  293. sql: 'CREATE POLICY "ai_policy" ON public.posts FOR SELECT USING (true);',
  294. command: 'SELECT',
  295. table: 'posts',
  296. schema: 'public',
  297. definition: safeSql`true`,
  298. action: 'PERMISSIVE',
  299. roles: ['public'],
  300. },
  301. ]
  302. it('should use programmatic policies when FK path exists (does not call AI)', async () => {
  303. const foreignKeyConstraints: ForeignKeyConstraint[] = [
  304. createForeignKey({
  305. source_schema: 'public',
  306. source_table: 'posts',
  307. source_columns: ['user_id'],
  308. target_schema: 'auth',
  309. target_table: 'users',
  310. target_columns: ['id'],
  311. }),
  312. ]
  313. const policies = await generateStartingPoliciesForTable({
  314. table: { name: 'posts', schema: 'public' },
  315. foreignKeyConstraints,
  316. columns: [{ name: 'id' }],
  317. projectRef: 'test-project',
  318. connectionString: 'postgresql://localhost:5432/test',
  319. enableAi: true,
  320. })
  321. expect(policies).toHaveLength(4)
  322. expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
  323. })
  324. it('should fall back to AI when no FK path exists and enableAi is true', async () => {
  325. mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies)
  326. const policies = await generateStartingPoliciesForTable({
  327. table: { name: 'posts', schema: 'public' },
  328. foreignKeyConstraints: [],
  329. columns: [{ name: 'id' }],
  330. projectRef: 'test-project',
  331. connectionString: 'postgresql://localhost:5432/test',
  332. enableAi: true,
  333. })
  334. expect(mockGenerateSqlPolicy).toHaveBeenCalled()
  335. expect(policies).toEqual(mockAiPolicies)
  336. })
  337. it('should return empty array when no FK path exists and enableAi is false', async () => {
  338. const policies = await generateStartingPoliciesForTable({
  339. table: { name: 'posts', schema: 'public' },
  340. foreignKeyConstraints: [],
  341. columns: [{ name: 'id' }],
  342. projectRef: 'test-project',
  343. connectionString: 'postgresql://localhost:5432/test',
  344. enableAi: false,
  345. })
  346. expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
  347. expect(policies).toEqual([])
  348. })
  349. it('should return empty array when no FK path and AI returns empty', async () => {
  350. mockGenerateSqlPolicy.mockResolvedValue([])
  351. const policies = await generateStartingPoliciesForTable({
  352. table: { name: 'posts', schema: 'public' },
  353. foreignKeyConstraints: [],
  354. columns: [{ name: 'id' }],
  355. projectRef: 'test-project',
  356. connectionString: 'postgresql://localhost:5432/test',
  357. enableAi: true,
  358. })
  359. expect(policies).toEqual([])
  360. })
  361. it('should prioritize programmatic over AI even when both could generate policies', async () => {
  362. mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies)
  363. const foreignKeyConstraints: ForeignKeyConstraint[] = [
  364. createForeignKey({
  365. source_schema: 'public',
  366. source_table: 'posts',
  367. source_columns: ['user_id'],
  368. target_schema: 'auth',
  369. target_table: 'users',
  370. target_columns: ['id'],
  371. }),
  372. ]
  373. const policies = await generateStartingPoliciesForTable({
  374. table: { name: 'posts', schema: 'public' },
  375. foreignKeyConstraints,
  376. columns: [{ name: 'id' }],
  377. projectRef: 'test-project',
  378. connectionString: 'postgresql://localhost:5432/test',
  379. enableAi: true,
  380. })
  381. // Should return 4 programmatic policies, not 1 AI policy
  382. expect(policies).toHaveLength(4)
  383. expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
  384. })
  385. })
  386. })