@getjack/jack 0.1.16 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Unit tests for sql-classifier.ts
3
+ *
4
+ * Tests SQL risk classification for security guardrails.
5
+ */
6
+
7
+ import { describe, expect, it } from "bun:test";
8
+
9
+ import {
10
+ type RiskLevel,
11
+ classifyStatement,
12
+ classifyStatements,
13
+ getRiskDescription,
14
+ splitStatements,
15
+ } from "./sql-classifier.ts";
16
+
17
+ describe("sql-classifier", () => {
18
+ describe("classifyStatement", () => {
19
+ describe("read operations", () => {
20
+ it("classifies SELECT as read", () => {
21
+ const result = classifyStatement("SELECT * FROM users");
22
+ expect(result.risk).toBe("read");
23
+ expect(result.operation).toBe("SELECT");
24
+ });
25
+
26
+ it("classifies SELECT with WHERE as read", () => {
27
+ const result = classifyStatement("SELECT id, name FROM users WHERE id = 1");
28
+ expect(result.risk).toBe("read");
29
+ expect(result.operation).toBe("SELECT");
30
+ });
31
+
32
+ it("classifies SELECT with JOIN as read", () => {
33
+ const result = classifyStatement(
34
+ "SELECT u.name, o.id FROM users u JOIN orders o ON u.id = o.user_id",
35
+ );
36
+ expect(result.risk).toBe("read");
37
+ expect(result.operation).toBe("SELECT");
38
+ });
39
+
40
+ it("classifies EXPLAIN as read", () => {
41
+ const result = classifyStatement("EXPLAIN SELECT * FROM users");
42
+ expect(result.risk).toBe("read");
43
+ expect(result.operation).toBe("EXPLAIN");
44
+ });
45
+
46
+ it("classifies PRAGMA (read-only) as read", () => {
47
+ const result = classifyStatement("PRAGMA table_info(users)");
48
+ expect(result.risk).toBe("read");
49
+ expect(result.operation).toBe("PRAGMA");
50
+ });
51
+
52
+ it("classifies PRAGMA without value as read", () => {
53
+ const result = classifyStatement("PRAGMA journal_mode");
54
+ expect(result.risk).toBe("read");
55
+ expect(result.operation).toBe("PRAGMA");
56
+ });
57
+
58
+ it("classifies WITH...SELECT (CTE) as read", () => {
59
+ const result = classifyStatement(`
60
+ WITH recent_users AS (
61
+ SELECT * FROM users WHERE created_at > date('now', '-7 days')
62
+ )
63
+ SELECT * FROM recent_users
64
+ `);
65
+ expect(result.risk).toBe("read");
66
+ expect(result.operation).toBe("SELECT");
67
+ });
68
+
69
+ it("classifies WITH...DELETE (CTE with DELETE) as destructive when no WHERE", () => {
70
+ // This was a security bypass - CTEs with DELETE were misclassified as read
71
+ const result = classifyStatement(`
72
+ WITH dummy AS (SELECT 1)
73
+ DELETE FROM users
74
+ `);
75
+ expect(result.risk).toBe("destructive");
76
+ expect(result.operation).toBe("DELETE");
77
+ });
78
+
79
+ it("classifies WITH...DELETE (CTE with DELETE WHERE) as write", () => {
80
+ const result = classifyStatement(`
81
+ WITH old_users AS (SELECT id FROM users WHERE created_at < '2020-01-01')
82
+ DELETE FROM users WHERE id IN (SELECT id FROM old_users)
83
+ `);
84
+ expect(result.risk).toBe("write");
85
+ expect(result.operation).toBe("DELETE");
86
+ });
87
+
88
+ it("classifies WITH...INSERT (CTE with INSERT) as write", () => {
89
+ const result = classifyStatement(`
90
+ WITH new_data AS (SELECT 'test' as name)
91
+ INSERT INTO users (name) SELECT name FROM new_data
92
+ `);
93
+ expect(result.risk).toBe("write");
94
+ expect(result.operation).toBe("INSERT");
95
+ });
96
+
97
+ it("classifies WITH...UPDATE (CTE with UPDATE) as write", () => {
98
+ const result = classifyStatement(`
99
+ WITH inactive AS (SELECT id FROM users WHERE last_login < '2020-01-01')
100
+ UPDATE users SET status = 'inactive' WHERE id IN (SELECT id FROM inactive)
101
+ `);
102
+ expect(result.risk).toBe("write");
103
+ expect(result.operation).toBe("UPDATE");
104
+ });
105
+ });
106
+
107
+ describe("write operations", () => {
108
+ it("classifies INSERT as write", () => {
109
+ const result = classifyStatement("INSERT INTO users (name) VALUES ('test')");
110
+ expect(result.risk).toBe("write");
111
+ expect(result.operation).toBe("INSERT");
112
+ });
113
+
114
+ it("classifies UPDATE with WHERE as write", () => {
115
+ const result = classifyStatement("UPDATE users SET name = 'new' WHERE id = 1");
116
+ expect(result.risk).toBe("write");
117
+ expect(result.operation).toBe("UPDATE");
118
+ });
119
+
120
+ it("classifies DELETE with WHERE as write", () => {
121
+ const result = classifyStatement("DELETE FROM users WHERE id = 1");
122
+ expect(result.risk).toBe("write");
123
+ expect(result.operation).toBe("DELETE");
124
+ });
125
+
126
+ it("classifies REPLACE as write", () => {
127
+ const result = classifyStatement("REPLACE INTO users (id, name) VALUES (1, 'test')");
128
+ expect(result.risk).toBe("write");
129
+ expect(result.operation).toBe("REPLACE");
130
+ });
131
+
132
+ it("classifies CREATE TABLE as write", () => {
133
+ const result = classifyStatement("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)");
134
+ expect(result.risk).toBe("write");
135
+ expect(result.operation).toBe("CREATE");
136
+ });
137
+
138
+ it("classifies CREATE INDEX as write", () => {
139
+ const result = classifyStatement("CREATE INDEX idx_users_name ON users(name)");
140
+ expect(result.risk).toBe("write");
141
+ expect(result.operation).toBe("CREATE");
142
+ });
143
+
144
+ it("classifies PRAGMA with assignment as write", () => {
145
+ const result = classifyStatement("PRAGMA journal_mode = WAL");
146
+ expect(result.risk).toBe("write");
147
+ expect(result.operation).toBe("PRAGMA");
148
+ });
149
+ });
150
+
151
+ describe("destructive operations", () => {
152
+ it("classifies DROP TABLE as destructive", () => {
153
+ const result = classifyStatement("DROP TABLE users");
154
+ expect(result.risk).toBe("destructive");
155
+ expect(result.operation).toBe("DROP");
156
+ });
157
+
158
+ it("classifies DROP TABLE IF EXISTS as destructive", () => {
159
+ const result = classifyStatement("DROP TABLE IF EXISTS users");
160
+ expect(result.risk).toBe("destructive");
161
+ expect(result.operation).toBe("DROP");
162
+ });
163
+
164
+ it("classifies DROP INDEX as destructive", () => {
165
+ const result = classifyStatement("DROP INDEX idx_users_name");
166
+ expect(result.risk).toBe("destructive");
167
+ expect(result.operation).toBe("DROP");
168
+ });
169
+
170
+ it("classifies TRUNCATE as destructive", () => {
171
+ const result = classifyStatement("TRUNCATE TABLE users");
172
+ expect(result.risk).toBe("destructive");
173
+ expect(result.operation).toBe("TRUNCATE");
174
+ });
175
+
176
+ it("classifies DELETE without WHERE as destructive", () => {
177
+ const result = classifyStatement("DELETE FROM users");
178
+ expect(result.risk).toBe("destructive");
179
+ expect(result.operation).toBe("DELETE");
180
+ });
181
+
182
+ it("classifies DELETE with only table name as destructive", () => {
183
+ const result = classifyStatement("DELETE FROM users;");
184
+ expect(result.risk).toBe("destructive");
185
+ expect(result.operation).toBe("DELETE");
186
+ });
187
+
188
+ it("classifies ALTER TABLE as destructive", () => {
189
+ const result = classifyStatement("ALTER TABLE users ADD COLUMN email TEXT");
190
+ expect(result.risk).toBe("destructive");
191
+ expect(result.operation).toBe("ALTER");
192
+ });
193
+
194
+ it("classifies ALTER TABLE RENAME as destructive", () => {
195
+ const result = classifyStatement("ALTER TABLE users RENAME TO customers");
196
+ expect(result.risk).toBe("destructive");
197
+ expect(result.operation).toBe("ALTER");
198
+ });
199
+
200
+ it("classifies ALTER TABLE DROP COLUMN as destructive", () => {
201
+ const result = classifyStatement("ALTER TABLE users DROP COLUMN email");
202
+ expect(result.risk).toBe("destructive");
203
+ expect(result.operation).toBe("ALTER");
204
+ });
205
+ });
206
+
207
+ describe("edge cases", () => {
208
+ it("handles lowercase SQL", () => {
209
+ const result = classifyStatement("select * from users");
210
+ expect(result.risk).toBe("read");
211
+ expect(result.operation).toBe("SELECT");
212
+ });
213
+
214
+ it("handles mixed case SQL", () => {
215
+ const result = classifyStatement("Select * From Users Where Id = 1");
216
+ expect(result.risk).toBe("read");
217
+ expect(result.operation).toBe("SELECT");
218
+ });
219
+
220
+ it("handles leading whitespace", () => {
221
+ const result = classifyStatement(" SELECT * FROM users");
222
+ expect(result.risk).toBe("read");
223
+ expect(result.operation).toBe("SELECT");
224
+ });
225
+
226
+ it("handles leading newlines", () => {
227
+ const result = classifyStatement("\n\n SELECT * FROM users");
228
+ expect(result.risk).toBe("read");
229
+ expect(result.operation).toBe("SELECT");
230
+ });
231
+
232
+ it("treats unknown operations as write for safety", () => {
233
+ const result = classifyStatement("VACUUM");
234
+ expect(result.risk).toBe("write");
235
+ expect(result.operation).toBe("VACUUM");
236
+ });
237
+
238
+ it("handles DELETE with complex WHERE as write", () => {
239
+ const result = classifyStatement(
240
+ "DELETE FROM users WHERE id IN (SELECT id FROM old_users)",
241
+ );
242
+ expect(result.risk).toBe("write");
243
+ expect(result.operation).toBe("DELETE");
244
+ });
245
+
246
+ it("handles DELETE with WHERE in lowercase", () => {
247
+ const result = classifyStatement("delete from users where id = 1");
248
+ expect(result.risk).toBe("write");
249
+ expect(result.operation).toBe("DELETE");
250
+ });
251
+
252
+ it("handles SQL with comments", () => {
253
+ const result = classifyStatement("-- Get all users\nSELECT * FROM users");
254
+ expect(result.risk).toBe("read");
255
+ expect(result.operation).toBe("SELECT");
256
+ });
257
+ });
258
+ });
259
+
260
+ describe("splitStatements", () => {
261
+ it("splits simple statements", () => {
262
+ const statements = splitStatements("SELECT 1; SELECT 2;");
263
+ expect(statements).toHaveLength(2);
264
+ expect(statements[0]).toBe("SELECT 1");
265
+ expect(statements[1]).toBe("SELECT 2");
266
+ });
267
+
268
+ it("handles statement without trailing semicolon", () => {
269
+ const statements = splitStatements("SELECT 1");
270
+ expect(statements).toHaveLength(1);
271
+ expect(statements[0]).toBe("SELECT 1");
272
+ });
273
+
274
+ it("handles mixed statements", () => {
275
+ const statements = splitStatements("SELECT 1; INSERT INTO t VALUES (1); SELECT 2");
276
+ expect(statements).toHaveLength(3);
277
+ });
278
+
279
+ it("handles semicolons inside strings", () => {
280
+ const statements = splitStatements("SELECT 'hello;world' FROM t; SELECT 2");
281
+ expect(statements).toHaveLength(2);
282
+ expect(statements[0]).toBe("SELECT 'hello;world' FROM t");
283
+ });
284
+
285
+ it("handles single quotes with escapes", () => {
286
+ const statements = splitStatements("SELECT 'it''s a test'; SELECT 2");
287
+ expect(statements).toHaveLength(2);
288
+ expect(statements[0]).toBe("SELECT 'it''s a test'");
289
+ });
290
+
291
+ it("handles double quotes", () => {
292
+ const statements = splitStatements('SELECT "col;name" FROM t; SELECT 2');
293
+ expect(statements).toHaveLength(2);
294
+ });
295
+
296
+ it("handles single-line comments", () => {
297
+ const statements = splitStatements("SELECT 1; -- comment; with semicolon\nSELECT 2");
298
+ expect(statements).toHaveLength(2);
299
+ });
300
+
301
+ it("handles multi-line comments", () => {
302
+ const statements = splitStatements("SELECT 1; /* comment; with; semicolons */ SELECT 2");
303
+ expect(statements).toHaveLength(2);
304
+ });
305
+
306
+ it("filters empty statements", () => {
307
+ const statements = splitStatements("SELECT 1;; ; SELECT 2;");
308
+ expect(statements).toHaveLength(2);
309
+ });
310
+
311
+ it("handles whitespace-only content", () => {
312
+ const statements = splitStatements(" \n\t ");
313
+ expect(statements).toHaveLength(0);
314
+ });
315
+ });
316
+
317
+ describe("classifyStatements", () => {
318
+ it("returns highest risk level", () => {
319
+ const { highestRisk } = classifyStatements("SELECT 1; INSERT INTO t VALUES (1)");
320
+ expect(highestRisk).toBe("write");
321
+ });
322
+
323
+ it("returns destructive as highest when present", () => {
324
+ const { highestRisk } = classifyStatements(
325
+ "SELECT 1; DROP TABLE t; INSERT INTO t VALUES (1)",
326
+ );
327
+ expect(highestRisk).toBe("destructive");
328
+ });
329
+
330
+ it("returns read when all statements are read", () => {
331
+ const { highestRisk } = classifyStatements("SELECT 1; SELECT 2; EXPLAIN SELECT 3");
332
+ expect(highestRisk).toBe("read");
333
+ });
334
+
335
+ it("returns all classified statements", () => {
336
+ const { statements } = classifyStatements("SELECT 1; INSERT INTO t VALUES (1)");
337
+ expect(statements).toHaveLength(2);
338
+ expect(statements[0]?.risk).toBe("read");
339
+ expect(statements[1]?.risk).toBe("write");
340
+ });
341
+
342
+ it("handles empty input", () => {
343
+ const { statements, highestRisk } = classifyStatements("");
344
+ expect(statements).toHaveLength(0);
345
+ expect(highestRisk).toBe("read");
346
+ });
347
+ });
348
+
349
+ describe("getRiskDescription", () => {
350
+ it("describes read risk", () => {
351
+ expect(getRiskDescription("read")).toBe("Read-only query");
352
+ });
353
+
354
+ it("describes write risk", () => {
355
+ expect(getRiskDescription("write")).toBe("Write operation (modifies data)");
356
+ });
357
+
358
+ it("describes destructive risk", () => {
359
+ expect(getRiskDescription("destructive")).toBe("Destructive operation (may cause data loss)");
360
+ });
361
+ });
362
+
363
+ describe("real-world SQL patterns", () => {
364
+ it("classifies migration-like CREATE TABLE", () => {
365
+ const result = classifyStatement(`
366
+ CREATE TABLE IF NOT EXISTS users (
367
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
368
+ name TEXT NOT NULL,
369
+ email TEXT UNIQUE,
370
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
371
+ )
372
+ `);
373
+ expect(result.risk).toBe("write");
374
+ expect(result.operation).toBe("CREATE");
375
+ });
376
+
377
+ it("classifies pagination query as read", () => {
378
+ const result = classifyStatement("SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 20");
379
+ expect(result.risk).toBe("read");
380
+ });
381
+
382
+ it("classifies aggregation query as read", () => {
383
+ const result = classifyStatement(
384
+ "SELECT COUNT(*), AVG(age) FROM users GROUP BY country HAVING COUNT(*) > 10",
385
+ );
386
+ expect(result.risk).toBe("read");
387
+ });
388
+
389
+ it("classifies INSERT with SELECT as write", () => {
390
+ const result = classifyStatement(
391
+ "INSERT INTO archive SELECT * FROM users WHERE created_at < '2023-01-01'",
392
+ );
393
+ expect(result.risk).toBe("write");
394
+ expect(result.operation).toBe("INSERT");
395
+ });
396
+
397
+ it("classifies UPDATE with subquery as write", () => {
398
+ const result = classifyStatement(
399
+ "UPDATE users SET status = 'inactive' WHERE id IN (SELECT user_id FROM inactive_list)",
400
+ );
401
+ expect(result.risk).toBe("write");
402
+ });
403
+ });
404
+ });