studio.test.ts98 lines · main
| 1 | import { describe, expect, test } from 'bun:test'; |
| 2 | |
| 3 | import { ValidationError } from '@briven/shared'; |
| 4 | |
| 5 | import { buildFilterClauses, FILTER_OPS } from './studio.js'; |
| 6 | |
| 7 | // NOTE (sprint plan S0.4): this file previously asserted MySQL output |
| 8 | // (backtick quoting + `?` placeholders) while the shipped code emits |
| 9 | // Postgres (`"col"` + `$n`). A test in the wrong dialect can never catch a |
| 10 | // real DoltGres break — it shows green regardless. Rewritten to match the |
| 11 | // actual Postgres/DoltGres SQL `buildFilterClauses` produces. |
| 12 | |
| 13 | const COLS = new Set(['id', 'name', 'created_at', 'score']); |
| 14 | |
| 15 | describe('buildFilterClauses — Postgres/DoltGres', () => { |
| 16 | test('eq renders parameterised equality with double-quote identifier + $n', () => { |
| 17 | const { clauses, params } = buildFilterClauses( |
| 18 | [{ column: 'name', op: 'eq', value: 'alice' }], |
| 19 | COLS, |
| 20 | 'users', |
| 21 | ); |
| 22 | expect(clauses).toEqual(['"name" = $1']); |
| 23 | expect(params).toEqual(['alice']); |
| 24 | }); |
| 25 | |
| 26 | test('contains renders case-insensitive substring match — no client pattern smuggling', () => { |
| 27 | const { clauses, params } = buildFilterClauses( |
| 28 | [{ column: 'name', op: 'contains', value: '%admin%' }], |
| 29 | COLS, |
| 30 | 'users', |
| 31 | ); |
| 32 | // The `%` are glued in SQL around the parameter, so a caller writing |
| 33 | // `%admin%` cannot smuggle pattern characters — the literal value is bound. |
| 34 | // DoltGres has no ILIKE, so we use lower()+LIKE for case-insensitive match. |
| 35 | expect(clauses).toEqual([`lower("name"::text) LIKE '%' || lower($1) || '%'`]); |
| 36 | expect(params).toEqual(['%admin%']); |
| 37 | }); |
| 38 | |
| 39 | test.each(FILTER_OPS.filter((op) => op !== 'eq' && op !== 'contains'))( |
| 40 | 'comparison op %s renders the right operator', |
| 41 | (op) => { |
| 42 | const { clauses, params } = buildFilterClauses( |
| 43 | [{ column: 'score', op, value: 42 }], |
| 44 | COLS, |
| 45 | 'leaderboard', |
| 46 | ); |
| 47 | const symbol = { gt: '>', lt: '<', gte: '>=', lte: '<=' }[op as 'gt' | 'lt' | 'gte' | 'lte']; |
| 48 | expect(clauses).toEqual([`"score" ${symbol} $1`]); |
| 49 | expect(params).toEqual([42]); |
| 50 | }, |
| 51 | ); |
| 52 | |
| 53 | test('multiple filters share a params array with incrementing $n placeholders', () => { |
| 54 | const { clauses, params } = buildFilterClauses( |
| 55 | [ |
| 56 | { column: 'name', op: 'eq', value: 'alice' }, |
| 57 | { column: 'score', op: 'gt', value: 100 }, |
| 58 | ], |
| 59 | COLS, |
| 60 | 'users', |
| 61 | ); |
| 62 | expect(clauses).toEqual(['"name" = $1', '"score" > $2']); |
| 63 | expect(params).toEqual(['alice', 100]); |
| 64 | }); |
| 65 | |
| 66 | test('unknown column → ValidationError (not 500)', () => { |
| 67 | expect(() => |
| 68 | buildFilterClauses([{ column: 'nonexistent', op: 'eq', value: 'x' }], COLS, 'users'), |
| 69 | ).toThrow(ValidationError); |
| 70 | }); |
| 71 | |
| 72 | test('invalid column name (SQL injection attempt) → ValidationError', () => { |
| 73 | expect(() => |
| 74 | buildFilterClauses( |
| 75 | [{ column: 'name; DROP TABLE users', op: 'eq', value: 'x' }], |
| 76 | COLS, |
| 77 | 'users', |
| 78 | ), |
| 79 | ).toThrow(ValidationError); |
| 80 | }); |
| 81 | |
| 82 | test('invalid operator → ValidationError', () => { |
| 83 | expect(() => |
| 84 | buildFilterClauses([{ column: 'name', op: 'like' as never, value: 'x' }], COLS, 'users'), |
| 85 | ).toThrow(ValidationError); |
| 86 | }); |
| 87 | |
| 88 | test('SQL injection value does NOT appear in the clause text', () => { |
| 89 | const { clauses, params } = buildFilterClauses( |
| 90 | [{ column: 'name', op: 'eq', value: "'; DROP TABLE users; --" }], |
| 91 | COLS, |
| 92 | 'users', |
| 93 | ); |
| 94 | expect(clauses[0]).toBe('"name" = $1'); |
| 95 | expect(clauses[0]).not.toContain('DROP'); |
| 96 | expect(params).toEqual(["'; DROP TABLE users; --"]); |
| 97 | }); |
| 98 | }); |