pg-format.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import { describe, expect, expectTypeOf, test } from 'vitest'
  2. import { ident, keyword, literal, safeSql } from '../src/pg-format'
  3. describe('pg-format', () => {
  4. describe('ident', () => {
  5. describe('reserved keywords', () => {
  6. test('should quote "collation" reserved keyword', () => {
  7. expect(ident('collation')).toBe('"collation"')
  8. expect(ident('COLLATION')).toBe('"COLLATION"')
  9. expect(ident('Collation')).toBe('"Collation"')
  10. })
  11. test('should quote other reserved keywords', () => {
  12. expect(ident('select')).toBe('"select"')
  13. expect(ident('from')).toBe('"from"')
  14. expect(ident('where')).toBe('"where"')
  15. expect(ident('order')).toBe('"order"')
  16. expect(ident('group')).toBe('"group"')
  17. expect(ident('table')).toBe('"table"')
  18. expect(ident('column')).toBe('"column"')
  19. expect(ident('create')).toBe('"create"')
  20. expect(ident('insert')).toBe('"insert"')
  21. expect(ident('update')).toBe('"update"')
  22. expect(ident('delete')).toBe('"delete"')
  23. })
  24. test('should not quote non-reserved identifiers', () => {
  25. expect(ident('normal_column')).toBe('normal_column')
  26. expect(ident('my_table')).toBe('my_table')
  27. expect(ident('user_id')).toBe('user_id')
  28. expect(ident('_private')).toBe('_private')
  29. expect(ident('col1')).toBe('col1')
  30. })
  31. test('should quote identifiers with special characters', () => {
  32. expect(ident('column with spaces')).toBe('"column with spaces"')
  33. expect(ident('column-with-dashes')).toBe('"column-with-dashes"')
  34. expect(ident('column.with.dots')).toBe('"column.with.dots"')
  35. expect(ident('column$with$dollar')).toBe('column$with$dollar')
  36. })
  37. test('should handle double quotes in identifiers', () => {
  38. expect(ident('quoted"column')).toBe('"quoted""column"')
  39. expect(ident('"already quoted"')).toBe('"""already quoted"""')
  40. })
  41. test('should handle camelCase identifiers', () => {
  42. expect(ident('camelCaseColumn')).toBe('"camelCaseColumn"')
  43. expect(ident('PascalCase')).toBe('"PascalCase"')
  44. })
  45. test('should handle identifiers starting with numbers', () => {
  46. expect(ident('123column')).toBe('"123column"')
  47. expect(ident('1st_column')).toBe('"1st_column"')
  48. })
  49. })
  50. describe('edge cases', () => {
  51. test('should throw error for null or undefined', () => {
  52. expect(() => ident(null)).toThrow('SQL identifier cannot be null or undefined')
  53. expect(() => ident(undefined)).toThrow('SQL identifier cannot be null or undefined')
  54. })
  55. test('should handle boolean values', () => {
  56. expect(ident(true)).toBe('"t"')
  57. expect(ident(false)).toBe('"f"')
  58. })
  59. test('should handle arrays', () => {
  60. expect(ident(['col1', 'col2'])).toBe('col1,col2')
  61. expect(ident(['collation', 'select'])).toBe('"collation","select"')
  62. })
  63. test('should throw error for nested arrays', () => {
  64. expect(() => ident([['col1']])).toThrow(
  65. 'Nested array to grouped list conversion is not supported for SQL identifier'
  66. )
  67. })
  68. test('should throw error for objects', () => {
  69. expect(() => ident({ name: 'test' })).toThrow('SQL identifier cannot be an object')
  70. })
  71. })
  72. })
  73. describe('literal', () => {
  74. test('should handle null and undefined', () => {
  75. expect(literal(null)).toBe('NULL')
  76. expect(literal(undefined)).toBe('NULL')
  77. })
  78. test('should handle strings', () => {
  79. expect(literal('simple string')).toBe("'simple string'")
  80. expect(literal("string with 'quotes'")).toBe("'string with ''quotes'''")
  81. expect(literal('string with "double quotes"')).toBe('\'string with "double quotes"\'')
  82. })
  83. test('should handle numbers', () => {
  84. expect(literal(123)).toBe('123')
  85. expect(literal(0)).toBe('0')
  86. expect(literal(-42)).toBe('-42')
  87. expect(literal(3.14)).toBe('3.14')
  88. })
  89. test('should handle booleans', () => {
  90. expect(literal(true)).toBe("'t'")
  91. expect(literal(false)).toBe("'f'")
  92. })
  93. test('should handle special number values', () => {
  94. expect(literal(Number.POSITIVE_INFINITY)).toBe("'Infinity'")
  95. expect(literal(Number.NEGATIVE_INFINITY)).toBe("'-Infinity'")
  96. expect(literal(Number.NaN)).toBe("'NaN'")
  97. })
  98. test('should handle bigint', () => {
  99. expect(literal(BigInt(123))).toBe('123')
  100. expect(literal(BigInt('9007199254740991'))).toBe('9007199254740991')
  101. })
  102. test('should handle dates', () => {
  103. const date = new Date('2024-01-01T00:00:00Z')
  104. expect(literal(date)).toBe("'2024-01-01 00:00:00.000+00'")
  105. })
  106. test('should handle arrays', () => {
  107. expect(literal([1, 2, 3])).toBe('1,2,3')
  108. expect(literal(['a', 'b', 'c'])).toBe("'a','b','c'")
  109. })
  110. test('should handle objects as JSON', () => {
  111. expect(literal({ name: 'test' })).toBe('\'{"name":"test"}\'::jsonb')
  112. expect(literal({ id: 1, name: 'test' })).toBe('\'{"id":1,"name":"test"}\'::jsonb')
  113. })
  114. test('should handle strings with backslashes', () => {
  115. expect(literal('path\\to\\file')).toBe("E'path\\\\to\\\\file'")
  116. expect(literal('C:\\Users\\test')).toBe("E'C:\\\\Users\\\\test'")
  117. })
  118. })
  119. describe('keyword', () => {
  120. test('accepts single uppercase words', () => {
  121. expect(keyword('BEFORE')).toBe('BEFORE')
  122. expect(keyword('AFTER')).toBe('AFTER')
  123. expect(keyword('ROW')).toBe('ROW')
  124. })
  125. test('accepts allow-listed multi-word keywords', () => {
  126. expect(keyword('INSTEAD OF')).toBe('INSTEAD OF')
  127. expect(keyword('BY DEFAULT')).toBe('BY DEFAULT')
  128. })
  129. test('multi-word allow-list is case-insensitive', () => {
  130. expect(keyword('instead of')).toBe('instead of')
  131. expect(keyword('By Default')).toBe('By Default')
  132. })
  133. test('accepts words with underscores and digits', () => {
  134. expect(keyword('EACH_ROW')).toBe('EACH_ROW')
  135. expect(keyword('col2')).toBe('col2')
  136. })
  137. test('rejects empty string', () => {
  138. expect(() => keyword('')).toThrow('Not a valid keyword')
  139. })
  140. test('rejects strings starting with a digit', () => {
  141. expect(() => keyword('1BEFORE')).toThrow('Not a valid keyword')
  142. })
  143. test('rejects strings with semicolons', () => {
  144. expect(() => keyword('BEFORE;')).toThrow('Not a valid keyword')
  145. })
  146. test('rejects strings with dashes', () => {
  147. expect(() => keyword('BE-FORE')).toThrow('Not a valid keyword')
  148. })
  149. test('rejects strings with single quotes', () => {
  150. expect(() => keyword("BE'FORE")).toThrow('Not a valid keyword')
  151. })
  152. test('rejects strings with parentheses', () => {
  153. expect(() => keyword('fn()')).toThrow('Not a valid keyword')
  154. })
  155. test('rejects arbitrary multi-word phrases not on the allow-list', () => {
  156. expect(() => keyword('DROP TABLE')).toThrow('Not a valid keyword')
  157. expect(() => keyword('DELETE FROM users')).toThrow('Not a valid keyword')
  158. expect(() => keyword('Each Row')).toThrow('Not a valid keyword')
  159. })
  160. })
  161. describe('safeSql', () => {
  162. test('returns a plain string when there are no interpolations', () => {
  163. const result = safeSql`SELECT 1`
  164. expect(result).toBe('SELECT 1')
  165. })
  166. test('interpolates ident values', () => {
  167. const table = ident('my_table')
  168. const result = safeSql`SELECT * FROM ${table}`
  169. expect(result).toBe('SELECT * FROM my_table')
  170. })
  171. test('interpolates literal values', () => {
  172. const value = literal('hello')
  173. const result = safeSql`SELECT ${value}`
  174. expect(result).toBe("SELECT 'hello'")
  175. })
  176. test('interpolates multiple values', () => {
  177. const table = ident('users')
  178. const col = ident('email')
  179. const val = literal('test@example.com')
  180. const result = safeSql`SELECT ${col} FROM ${table} WHERE ${col} = ${val}`
  181. expect(result).toBe(`SELECT email FROM users WHERE email = 'test@example.com'`)
  182. })
  183. test('handles ident with special characters', () => {
  184. const table = ident('my "table"')
  185. const result = safeSql`SELECT * FROM ${table}`
  186. expect(result).toBe('SELECT * FROM "my ""table"""')
  187. })
  188. test('literal: escapes classic quote bypass', () => {
  189. const val = literal("' OR '1'='1")
  190. const result = safeSql`SELECT * FROM users WHERE password = ${val}`
  191. expect(result).toBe("SELECT * FROM users WHERE password = ''' OR ''1''=''1'")
  192. })
  193. test('literal: escapes UNION SELECT attack', () => {
  194. const val = literal("x' UNION SELECT username, password FROM admins --")
  195. const result = safeSql`SELECT name FROM products WHERE id = ${val}`
  196. expect(result).toBe(
  197. "SELECT name FROM products WHERE id = 'x'' UNION SELECT username, password FROM admins --'"
  198. )
  199. })
  200. test('literal: escapes stacked query injection', () => {
  201. const val = literal("1'; DROP TABLE users; --")
  202. const result = safeSql`SELECT * FROM users WHERE id = ${val}`
  203. expect(result).toBe("SELECT * FROM users WHERE id = '1''; DROP TABLE users; --'")
  204. })
  205. test('literal: escapes comment-based injection', () => {
  206. const val = literal("admin'--")
  207. const result = safeSql`SELECT * FROM users WHERE username = ${val}`
  208. expect(result).toBe("SELECT * FROM users WHERE username = 'admin''--'")
  209. })
  210. test('ident: escapes SQL keyword injection in table name', () => {
  211. const table = ident('users WHERE 1=1 --')
  212. const result = safeSql`SELECT * FROM ${table}`
  213. expect(result).toBe('SELECT * FROM "users WHERE 1=1 --"')
  214. })
  215. test('ident: escapes stacked query injection in column name', () => {
  216. const col = ident('id; DROP TABLE users')
  217. const result = safeSql`SELECT ${col} FROM users`
  218. expect(result).toBe('SELECT "id; DROP TABLE users" FROM users')
  219. })
  220. test('handles literal with null', () => {
  221. const val = literal(null)
  222. const result = safeSql`SELECT ${val}`
  223. expect(result).toBe('SELECT NULL')
  224. })
  225. test('handles literal with numbers', () => {
  226. const val = literal(42)
  227. const result = safeSql`SELECT ${val}`
  228. expect(result).toBe('SELECT 42')
  229. })
  230. test('can be nested', () => {
  231. const col = ident('id')
  232. const inner = safeSql`SELECT ${col} FROM ${ident('items')}`
  233. const outer = safeSql`WITH cte AS (${inner}) SELECT * FROM cte`
  234. expect(outer).toBe('WITH cte AS (SELECT id FROM items) SELECT * FROM cte')
  235. })
  236. })
  237. describe('safeSql type safety', () => {
  238. test('rejects a plain string interpolation', () => {
  239. const unsafeValue = 'malicious'
  240. // @ts-expect-error plain string is not a SafeSqlFragment
  241. safeSql`SELECT * FROM ${unsafeValue}`
  242. })
  243. test('rejects a number interpolation', () => {
  244. // @ts-expect-error number is not a SafeSqlFragment
  245. safeSql`SELECT * FROM users LIMIT ${10}`
  246. })
  247. test('rejects an object interpolation', () => {
  248. const obj = { table: 'users' }
  249. // @ts-expect-error object is not a SafeSqlFragment
  250. safeSql`SELECT * FROM ${obj}`
  251. })
  252. test('accepts SafeSqlFragment from ident', () => {
  253. const result = safeSql`SELECT * FROM ${ident('users')}`
  254. expectTypeOf(result).toBeString
  255. })
  256. test('accepts SafeSqlFragment from literal', () => {
  257. const result = safeSql`SELECT ${literal('value')}`
  258. expectTypeOf(result).toBeString
  259. })
  260. })
  261. })