wire.test.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import assert from 'node:assert/strict';
  2. import { test } from 'node:test';
  3. import { schemaSnapshotSchema, validateSchemaSnapshot } from './index.js';
  4. const okColumn = {
  5. sqlType: 'text',
  6. nullable: false,
  7. primaryKey: true,
  8. unique: false,
  9. };
  10. test('accepts a legitimate snapshot', () => {
  11. const ok = validateSchemaSnapshot({
  12. version: 1,
  13. tables: {
  14. notes: {
  15. columns: {
  16. id: okColumn,
  17. body: { sqlType: 'text', nullable: false, primaryKey: false, unique: false },
  18. createdAt: {
  19. sqlType: 'timestamptz',
  20. nullable: false,
  21. primaryKey: false,
  22. unique: false,
  23. default: 'now()',
  24. },
  25. },
  26. indexes: [],
  27. },
  28. posts: {
  29. columns: {
  30. id: okColumn,
  31. userId: {
  32. sqlType: 'text',
  33. nullable: false,
  34. primaryKey: false,
  35. unique: false,
  36. references: { table: 'notes', column: 'id', onDelete: 'cascade' },
  37. },
  38. },
  39. indexes: [{ columns: ['userId'], unique: false }],
  40. },
  41. },
  42. });
  43. assert.equal(ok.version, 1);
  44. assert.equal(Object.keys(ok.tables).length, 2);
  45. });
  46. test('rejects table name with quote-injection', () => {
  47. assert.throws(() =>
  48. validateSchemaSnapshot({
  49. version: 1,
  50. tables: {
  51. 'x" (id text); DROP SCHEMA "proj_other" CASCADE; CREATE TABLE "y': {
  52. columns: { id: okColumn },
  53. indexes: [],
  54. },
  55. },
  56. }),
  57. );
  58. });
  59. test('rejects table name with reserved _briven_ prefix', () => {
  60. assert.throws(() =>
  61. validateSchemaSnapshot({
  62. version: 1,
  63. tables: {
  64. _briven_meta: { columns: { id: okColumn }, indexes: [] },
  65. },
  66. }),
  67. );
  68. });
  69. test('rejects column name with quote-injection', () => {
  70. assert.throws(() =>
  71. validateSchemaSnapshot({
  72. version: 1,
  73. tables: {
  74. notes: {
  75. columns: { 'id" text); DROP TABLE "users': okColumn },
  76. indexes: [],
  77. },
  78. },
  79. }),
  80. );
  81. });
  82. test('rejects sqlType outside allowlist', () => {
  83. assert.throws(() =>
  84. validateSchemaSnapshot({
  85. version: 1,
  86. tables: {
  87. notes: {
  88. columns: {
  89. id: {
  90. sqlType: 'text PRIMARY KEY); DROP SCHEMA other CASCADE; --',
  91. nullable: false,
  92. primaryKey: true,
  93. unique: false,
  94. },
  95. },
  96. indexes: [],
  97. },
  98. },
  99. }),
  100. );
  101. });
  102. test('accepts sqlType with parametric forms (varchar, vector)', () => {
  103. const ok = validateSchemaSnapshot({
  104. version: 1,
  105. tables: {
  106. docs: {
  107. columns: {
  108. id: okColumn,
  109. title: { sqlType: 'varchar(255)', nullable: false, primaryKey: false, unique: false },
  110. embedding: { sqlType: 'vector(1536)', nullable: true, primaryKey: false, unique: false },
  111. },
  112. indexes: [],
  113. },
  114. },
  115. });
  116. assert.equal(ok.tables.docs?.columns.title?.sqlType, 'varchar(255)');
  117. });
  118. test('rejects default that breaks out of literal context', () => {
  119. assert.throws(() =>
  120. validateSchemaSnapshot({
  121. version: 1,
  122. tables: {
  123. notes: {
  124. columns: {
  125. id: okColumn,
  126. body: {
  127. sqlType: 'text',
  128. nullable: false,
  129. primaryKey: false,
  130. unique: false,
  131. default: "''); DROP TABLE users; --",
  132. },
  133. },
  134. indexes: [],
  135. },
  136. },
  137. }),
  138. );
  139. });
  140. test('accepts safe default forms', () => {
  141. for (const def of [
  142. 'now()',
  143. 'gen_random_uuid()',
  144. 'true',
  145. 'false',
  146. 'null',
  147. '0',
  148. '-1.5',
  149. "'hello'",
  150. 'current_timestamp',
  151. ]) {
  152. validateSchemaSnapshot({
  153. version: 1,
  154. tables: {
  155. t: {
  156. columns: {
  157. id: okColumn,
  158. v: {
  159. sqlType: 'text',
  160. nullable: true,
  161. primaryKey: false,
  162. unique: false,
  163. default: def,
  164. },
  165. },
  166. indexes: [],
  167. },
  168. },
  169. });
  170. }
  171. });
  172. test('rejects FK references with invalid identifier', () => {
  173. assert.throws(() =>
  174. validateSchemaSnapshot({
  175. version: 1,
  176. tables: {
  177. posts: {
  178. columns: {
  179. id: okColumn,
  180. userId: {
  181. sqlType: 'text',
  182. nullable: false,
  183. primaryKey: false,
  184. unique: false,
  185. references: { table: 'users"; DROP TABLE x; --', column: 'id' },
  186. },
  187. },
  188. indexes: [],
  189. },
  190. },
  191. }),
  192. );
  193. });
  194. test('rejects onDelete outside enum', () => {
  195. assert.throws(() =>
  196. validateSchemaSnapshot({
  197. version: 1,
  198. tables: {
  199. posts: {
  200. columns: {
  201. id: okColumn,
  202. userId: {
  203. sqlType: 'text',
  204. nullable: false,
  205. primaryKey: false,
  206. unique: false,
  207. references: {
  208. table: 'users',
  209. column: 'id',
  210. onDelete: 'CASCADE; DROP TABLE x; --' as 'cascade',
  211. },
  212. },
  213. },
  214. indexes: [],
  215. },
  216. },
  217. }),
  218. );
  219. });
  220. test('rejects index columns with invalid identifier', () => {
  221. assert.throws(() =>
  222. validateSchemaSnapshot({
  223. version: 1,
  224. tables: {
  225. notes: {
  226. columns: { id: okColumn },
  227. indexes: [{ columns: ['id"); DROP TABLE x; --'], unique: false }],
  228. },
  229. },
  230. }),
  231. );
  232. });
  233. test('rejects version other than 1', () => {
  234. assert.throws(() =>
  235. validateSchemaSnapshot({
  236. version: 2 as 1,
  237. tables: {},
  238. }),
  239. );
  240. });
  241. test('schemaSnapshotSchema is exported as a Zod schema', () => {
  242. assert.equal(typeof schemaSnapshotSchema.parse, 'function');
  243. });