fleet-python 0.2.65__py3-none-any.whl → 0.2.66a2__py3-none-any.whl

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.

Potentially problematic release.


This version of fleet-python might be problematic. Click here for more details.

@@ -0,0 +1,1098 @@
1
+ """
2
+ Test to verify expect_only works correctly with row additions and field-level specs.
3
+ """
4
+
5
+ import sqlite3
6
+ import tempfile
7
+ import os
8
+ import pytest
9
+ from fleet.verifiers.db import DatabaseSnapshot, IgnoreConfig
10
+
11
+
12
+ def test_field_level_specs_for_added_row():
13
+ """Test that field-level specs work for row additions"""
14
+
15
+ # Create two temporary databases
16
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
17
+ before_db = f.name
18
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
19
+ after_db = f.name
20
+
21
+ try:
22
+ # Setup before database
23
+ conn = sqlite3.connect(before_db)
24
+ conn.execute(
25
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
26
+ )
27
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
28
+ conn.commit()
29
+ conn.close()
30
+
31
+ # Setup after database - add a new row
32
+ conn = sqlite3.connect(after_db)
33
+ conn.execute(
34
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
35
+ )
36
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
37
+ conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
38
+ conn.commit()
39
+ conn.close()
40
+
41
+ # Create snapshots
42
+ before = DatabaseSnapshot(before_db)
43
+ after = DatabaseSnapshot(after_db)
44
+
45
+ # Field-level specs should work for added rows
46
+ before.diff(after).expect_only(
47
+ [
48
+ {"table": "users", "pk": 2, "field": "id", "after": 2},
49
+ {"table": "users", "pk": 2, "field": "name", "after": "Bob"},
50
+ {"table": "users", "pk": 2, "field": "status", "after": "inactive"},
51
+ ]
52
+ )
53
+
54
+ finally:
55
+ os.unlink(before_db)
56
+ os.unlink(after_db)
57
+
58
+
59
+ def test_field_level_specs_with_wrong_values():
60
+ """Test that wrong values are detected"""
61
+
62
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
63
+ before_db = f.name
64
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
65
+ after_db = f.name
66
+
67
+ try:
68
+ conn = sqlite3.connect(before_db)
69
+ conn.execute(
70
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
71
+ )
72
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
73
+ conn.commit()
74
+ conn.close()
75
+
76
+ conn = sqlite3.connect(after_db)
77
+ conn.execute(
78
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
79
+ )
80
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
81
+ conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
82
+ conn.commit()
83
+ conn.close()
84
+
85
+ before = DatabaseSnapshot(before_db)
86
+ after = DatabaseSnapshot(after_db)
87
+
88
+ # Should fail because status value is wrong
89
+ with pytest.raises(AssertionError, match="Unexpected database changes"):
90
+ before.diff(after).expect_only(
91
+ [
92
+ {"table": "users", "pk": 2, "field": "id", "after": 2},
93
+ {"table": "users", "pk": 2, "field": "name", "after": "Bob"},
94
+ {
95
+ "table": "users",
96
+ "pk": 2,
97
+ "field": "status",
98
+ "after": "WRONG_VALUE",
99
+ },
100
+ ]
101
+ )
102
+
103
+ finally:
104
+ os.unlink(before_db)
105
+ os.unlink(after_db)
106
+
107
+
108
+ def test_multiple_table_changes_with_mixed_specs():
109
+ """Test complex scenario with multiple tables and mixed field/row specs"""
110
+
111
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
112
+ before_db = f.name
113
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
114
+ after_db = f.name
115
+
116
+ try:
117
+ # Setup before database with multiple tables
118
+ conn = sqlite3.connect(before_db)
119
+ conn.execute(
120
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, role TEXT)"
121
+ )
122
+ conn.execute(
123
+ "CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, amount REAL, status TEXT)"
124
+ )
125
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'alice@test.com', 'admin')")
126
+ conn.execute("INSERT INTO users VALUES (2, 'Bob', 'bob@test.com', 'user')")
127
+ conn.execute("INSERT INTO orders VALUES (1, 1, 100.0, 'pending')")
128
+ conn.commit()
129
+ conn.close()
130
+
131
+ # Setup after database with complex changes
132
+ conn = sqlite3.connect(after_db)
133
+ conn.execute(
134
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, role TEXT)"
135
+ )
136
+ conn.execute(
137
+ "CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, amount REAL, status TEXT)"
138
+ )
139
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'alice@test.com', 'admin')")
140
+ conn.execute("INSERT INTO users VALUES (2, 'Bob', 'bob@test.com', 'user')")
141
+ conn.execute(
142
+ "INSERT INTO users VALUES (3, 'Charlie', 'charlie@test.com', 'user')"
143
+ )
144
+ conn.execute("INSERT INTO orders VALUES (1, 1, 100.0, 'completed')")
145
+ conn.execute("INSERT INTO orders VALUES (2, 2, 50.0, 'pending')")
146
+ conn.commit()
147
+ conn.close()
148
+
149
+ before = DatabaseSnapshot(before_db)
150
+ after = DatabaseSnapshot(after_db)
151
+
152
+ # Mixed specs: field-level for new user, whole-row for new order
153
+ before.diff(after).expect_only(
154
+ [
155
+ # Field-level specs for new user
156
+ {"table": "users", "pk": 3, "field": "id", "after": 3},
157
+ {"table": "users", "pk": 3, "field": "name", "after": "Charlie"},
158
+ {
159
+ "table": "users",
160
+ "pk": 3,
161
+ "field": "email",
162
+ "after": "charlie@test.com",
163
+ },
164
+ {"table": "users", "pk": 3, "field": "role", "after": "user"},
165
+ # Field-level spec for order status change
166
+ {"table": "orders", "pk": 1, "field": "status", "after": "completed"},
167
+ # Whole-row spec for new order
168
+ {"table": "orders", "pk": 2, "field": None, "after": "__added__"},
169
+ ]
170
+ )
171
+
172
+ finally:
173
+ os.unlink(before_db)
174
+ os.unlink(after_db)
175
+
176
+
177
+ def test_partial_field_specs_with_unexpected_changes():
178
+ """Test that partial field specs catch unexpected changes in unspecified fields"""
179
+
180
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
181
+ before_db = f.name
182
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
183
+ after_db = f.name
184
+
185
+ try:
186
+ conn = sqlite3.connect(before_db)
187
+ conn.execute(
188
+ "CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL, category TEXT, stock INTEGER)"
189
+ )
190
+ conn.execute(
191
+ "INSERT INTO products VALUES (1, 'Widget', 10.99, 'electronics', 100)"
192
+ )
193
+ conn.commit()
194
+ conn.close()
195
+
196
+ conn = sqlite3.connect(after_db)
197
+ conn.execute(
198
+ "CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL, category TEXT, stock INTEGER)"
199
+ )
200
+ conn.execute(
201
+ "INSERT INTO products VALUES (1, 'Widget', 12.99, 'electronics', 95)"
202
+ )
203
+ conn.commit()
204
+ conn.close()
205
+
206
+ before = DatabaseSnapshot(before_db)
207
+ after = DatabaseSnapshot(after_db)
208
+
209
+ # Only specify price change, but stock also changed - should fail
210
+ with pytest.raises(AssertionError, match="Unexpected database changes"):
211
+ before.diff(after).expect_only(
212
+ [
213
+ {"table": "products", "pk": 1, "field": "price", "after": 12.99},
214
+ ]
215
+ )
216
+
217
+ finally:
218
+ os.unlink(before_db)
219
+ os.unlink(after_db)
220
+
221
+
222
+ def test_numeric_type_conversion_in_specs():
223
+ """Test that numeric type conversions work correctly in field specs"""
224
+
225
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
226
+ before_db = f.name
227
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
228
+ after_db = f.name
229
+
230
+ try:
231
+ conn = sqlite3.connect(before_db)
232
+ conn.execute(
233
+ "CREATE TABLE metrics (id INTEGER PRIMARY KEY, value REAL, count INTEGER)"
234
+ )
235
+ conn.execute("INSERT INTO metrics VALUES (1, 3.14, 42)")
236
+ conn.commit()
237
+ conn.close()
238
+
239
+ conn = sqlite3.connect(after_db)
240
+ conn.execute(
241
+ "CREATE TABLE metrics (id INTEGER PRIMARY KEY, value REAL, count INTEGER)"
242
+ )
243
+ conn.execute("INSERT INTO metrics VALUES (1, 3.14, 42)")
244
+ conn.execute("INSERT INTO metrics VALUES (2, 2.71, 17)")
245
+ conn.commit()
246
+ conn.close()
247
+
248
+ before = DatabaseSnapshot(before_db)
249
+ after = DatabaseSnapshot(after_db)
250
+
251
+ # Test string vs integer comparison for primary key
252
+ before.diff(after).expect_only(
253
+ [
254
+ {"table": "metrics", "pk": "2", "field": "id", "after": 2},
255
+ {"table": "metrics", "pk": "2", "field": "value", "after": 2.71},
256
+ {"table": "metrics", "pk": "2", "field": "count", "after": 17},
257
+ ]
258
+ )
259
+
260
+ finally:
261
+ os.unlink(before_db)
262
+ os.unlink(after_db)
263
+
264
+
265
+ def test_deletion_with_field_level_specs():
266
+ """Test that field-level specs work for row deletions"""
267
+
268
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
269
+ before_db = f.name
270
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
271
+ after_db = f.name
272
+
273
+ try:
274
+ conn = sqlite3.connect(before_db)
275
+ conn.execute(
276
+ "CREATE TABLE inventory (id INTEGER PRIMARY KEY, item TEXT, quantity INTEGER, location TEXT)"
277
+ )
278
+ conn.execute("INSERT INTO inventory VALUES (1, 'Widget A', 10, 'Warehouse 1')")
279
+ conn.execute("INSERT INTO inventory VALUES (2, 'Widget B', 5, 'Warehouse 2')")
280
+ conn.execute("INSERT INTO inventory VALUES (3, 'Widget C', 15, 'Warehouse 1')")
281
+ conn.commit()
282
+ conn.close()
283
+
284
+ conn = sqlite3.connect(after_db)
285
+ conn.execute(
286
+ "CREATE TABLE inventory (id INTEGER PRIMARY KEY, item TEXT, quantity INTEGER, location TEXT)"
287
+ )
288
+ conn.execute("INSERT INTO inventory VALUES (1, 'Widget A', 10, 'Warehouse 1')")
289
+ conn.execute("INSERT INTO inventory VALUES (3, 'Widget C', 15, 'Warehouse 1')")
290
+ conn.commit()
291
+ conn.close()
292
+
293
+ before = DatabaseSnapshot(before_db)
294
+ after = DatabaseSnapshot(after_db)
295
+
296
+ # Field-level specs for deleted row
297
+ before.diff(after).expect_only(
298
+ [
299
+ {"table": "inventory", "pk": 2, "field": "id", "before": 2},
300
+ {"table": "inventory", "pk": 2, "field": "item", "before": "Widget B"},
301
+ {"table": "inventory", "pk": 2, "field": "quantity", "before": 5},
302
+ {
303
+ "table": "inventory",
304
+ "pk": 2,
305
+ "field": "location",
306
+ "before": "Warehouse 2",
307
+ },
308
+ ]
309
+ )
310
+
311
+ finally:
312
+ os.unlink(before_db)
313
+ os.unlink(after_db)
314
+
315
+
316
+ def test_mixed_data_types_and_null_values():
317
+ """Test field specs with mixed data types and null values"""
318
+
319
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
320
+ before_db = f.name
321
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
322
+ after_db = f.name
323
+
324
+ try:
325
+ conn = sqlite3.connect(before_db)
326
+ conn.execute(
327
+ "CREATE TABLE mixed_data (id INTEGER PRIMARY KEY, text_val TEXT, num_val REAL, bool_val INTEGER, null_val TEXT)"
328
+ )
329
+ conn.execute("INSERT INTO mixed_data VALUES (1, 'test', 42.5, 1, NULL)")
330
+ conn.commit()
331
+ conn.close()
332
+
333
+ conn = sqlite3.connect(after_db)
334
+ conn.execute(
335
+ "CREATE TABLE mixed_data (id INTEGER PRIMARY KEY, text_val TEXT, num_val REAL, bool_val INTEGER, null_val TEXT)"
336
+ )
337
+ conn.execute("INSERT INTO mixed_data VALUES (1, 'test', 42.5, 1, NULL)")
338
+ conn.execute("INSERT INTO mixed_data VALUES (2, NULL, 0.0, 0, 'not_null')")
339
+ conn.commit()
340
+ conn.close()
341
+
342
+ before = DatabaseSnapshot(before_db)
343
+ after = DatabaseSnapshot(after_db)
344
+
345
+ # Test various data types and null handling
346
+ before.diff(after).expect_only(
347
+ [
348
+ {"table": "mixed_data", "pk": 2, "field": "id", "after": 2},
349
+ {"table": "mixed_data", "pk": 2, "field": "text_val", "after": None},
350
+ {"table": "mixed_data", "pk": 2, "field": "num_val", "after": 0.0},
351
+ {"table": "mixed_data", "pk": 2, "field": "bool_val", "after": 0},
352
+ {
353
+ "table": "mixed_data",
354
+ "pk": 2,
355
+ "field": "null_val",
356
+ "after": "not_null",
357
+ },
358
+ ]
359
+ )
360
+
361
+ finally:
362
+ os.unlink(before_db)
363
+ os.unlink(after_db)
364
+
365
+
366
+ def test_whole_row_spec_backward_compat():
367
+ """Test that whole-row specs still work (backward compatibility)"""
368
+
369
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
370
+ before_db = f.name
371
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
372
+ after_db = f.name
373
+
374
+ try:
375
+ conn = sqlite3.connect(before_db)
376
+ conn.execute(
377
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
378
+ )
379
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
380
+ conn.commit()
381
+ conn.close()
382
+
383
+ conn = sqlite3.connect(after_db)
384
+ conn.execute(
385
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
386
+ )
387
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
388
+ conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
389
+ conn.commit()
390
+ conn.close()
391
+
392
+ before = DatabaseSnapshot(before_db)
393
+ after = DatabaseSnapshot(after_db)
394
+
395
+ # Whole-row spec should still work
396
+ before.diff(after).expect_only(
397
+ [{"table": "users", "pk": 2, "field": None, "after": "__added__"}]
398
+ )
399
+
400
+ finally:
401
+ os.unlink(before_db)
402
+ os.unlink(after_db)
403
+
404
+
405
+ def test_missing_field_specs():
406
+ """Test that missing field specs are detected"""
407
+
408
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
409
+ before_db = f.name
410
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
411
+ after_db = f.name
412
+
413
+ try:
414
+ conn = sqlite3.connect(before_db)
415
+ conn.execute(
416
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
417
+ )
418
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
419
+ conn.commit()
420
+ conn.close()
421
+
422
+ conn = sqlite3.connect(after_db)
423
+ conn.execute(
424
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
425
+ )
426
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
427
+ conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive')")
428
+ conn.commit()
429
+ conn.close()
430
+
431
+ before = DatabaseSnapshot(before_db)
432
+ after = DatabaseSnapshot(after_db)
433
+
434
+ # Should fail because status field spec is missing
435
+ with pytest.raises(AssertionError, match="Unexpected database changes"):
436
+ before.diff(after).expect_only(
437
+ [
438
+ {"table": "users", "pk": 2, "field": "id", "after": 2},
439
+ {"table": "users", "pk": 2, "field": "name", "after": "Bob"},
440
+ # Missing status field spec
441
+ ]
442
+ )
443
+
444
+ finally:
445
+ os.unlink(before_db)
446
+ os.unlink(after_db)
447
+
448
+
449
+ def test_modified_row_with_unauthorized_field_change():
450
+ """Test that unauthorized changes to existing rows are detected"""
451
+
452
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
453
+ before_db = f.name
454
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
455
+ after_db = f.name
456
+
457
+ try:
458
+ conn = sqlite3.connect(before_db)
459
+ conn.execute(
460
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
461
+ )
462
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active')")
463
+ conn.commit()
464
+ conn.close()
465
+
466
+ conn = sqlite3.connect(after_db)
467
+ conn.execute(
468
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT)"
469
+ )
470
+ conn.execute("INSERT INTO users VALUES (1, 'Alice Updated', 'suspended')")
471
+ conn.commit()
472
+ conn.close()
473
+
474
+ before = DatabaseSnapshot(before_db)
475
+ after = DatabaseSnapshot(after_db)
476
+
477
+ # Should fail because status change is not allowed
478
+ with pytest.raises(AssertionError, match="Unexpected database changes"):
479
+ before.diff(after).expect_only(
480
+ [
481
+ {
482
+ "table": "users",
483
+ "pk": 1,
484
+ "field": "name",
485
+ "after": "Alice Updated",
486
+ },
487
+ # Missing status field spec - status should not have changed
488
+ ]
489
+ )
490
+
491
+ finally:
492
+ os.unlink(before_db)
493
+ os.unlink(after_db)
494
+
495
+
496
+ def test_ignore_config_with_field_specs():
497
+ """Test that ignore_config works correctly with field-level specs"""
498
+
499
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
500
+ before_db = f.name
501
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
502
+ after_db = f.name
503
+
504
+ try:
505
+ conn = sqlite3.connect(before_db)
506
+ conn.execute(
507
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
508
+ )
509
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active', '2024-01-01')")
510
+ conn.commit()
511
+ conn.close()
512
+
513
+ conn = sqlite3.connect(after_db)
514
+ conn.execute(
515
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, status TEXT, updated_at TEXT)"
516
+ )
517
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'active', '2024-01-01')")
518
+ conn.execute("INSERT INTO users VALUES (2, 'Bob', 'inactive', '2024-01-02')")
519
+ conn.commit()
520
+ conn.close()
521
+
522
+ before = DatabaseSnapshot(before_db)
523
+ after = DatabaseSnapshot(after_db)
524
+
525
+ # Ignore updated_at field
526
+ ignore_config = IgnoreConfig(table_fields={"users": {"updated_at"}})
527
+
528
+ # Should work without specifying updated_at because it's ignored
529
+ before.diff(after, ignore_config).expect_only(
530
+ [
531
+ {"table": "users", "pk": 2, "field": "id", "after": 2},
532
+ {"table": "users", "pk": 2, "field": "name", "after": "Bob"},
533
+ {"table": "users", "pk": 2, "field": "status", "after": "inactive"},
534
+ ]
535
+ )
536
+
537
+ finally:
538
+ os.unlink(before_db)
539
+ os.unlink(after_db)
540
+
541
+
542
+ # ============================================================================
543
+ # Tests demonstrating OLD implementation's security issues
544
+ # These tests show cases that PASS with the old whole-row approach but
545
+ # represent security vulnerabilities that SHOULD have been caught.
546
+ # ============================================================================
547
+
548
+
549
+ def test_security_whole_row_spec_allows_malicious_values():
550
+ """
551
+ SECURITY ISSUE: Whole-row specs allow ANY field values, even malicious ones.
552
+
553
+ This test demonstrates the danger of using field=None (whole-row spec).
554
+ With the old implementation, this was the ONLY way to allow additions,
555
+ but it's too permissive and allows unauthorized data through.
556
+
557
+ This test PASSES (showing backward compatibility) but highlights why you
558
+ should migrate to field-level specs for better security.
559
+ """
560
+
561
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
562
+ before_db = f.name
563
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
564
+ after_db = f.name
565
+
566
+ try:
567
+ conn = sqlite3.connect(before_db)
568
+ conn.execute(
569
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT, active INTEGER)"
570
+ )
571
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'user', 1)")
572
+ conn.commit()
573
+ conn.close()
574
+
575
+ conn = sqlite3.connect(after_db)
576
+ conn.execute(
577
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT, active INTEGER)"
578
+ )
579
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'user', 1)")
580
+ # Malicious: user added with admin role!
581
+ conn.execute("INSERT INTO users VALUES (2, 'Hacker', 'admin', 1)")
582
+ conn.commit()
583
+ conn.close()
584
+
585
+ before = DatabaseSnapshot(before_db)
586
+ after = DatabaseSnapshot(after_db)
587
+
588
+ # This PASSES but is insecure - we're allowing a user with admin role!
589
+ # The old implementation would only support this approach
590
+ before.diff(after).expect_only(
591
+ [{"table": "users", "pk": 2, "field": None, "after": "__added__"}]
592
+ )
593
+
594
+ # What we SHOULD do (secure): specify exact values
595
+ # This would catch if role was 'admin' instead of 'user'
596
+
597
+ finally:
598
+ os.unlink(before_db)
599
+ os.unlink(after_db)
600
+
601
+
602
+ def test_security_field_level_specs_catch_malicious_role():
603
+ """
604
+ SECURITY: Field-level specs properly catch unauthorized values.
605
+
606
+ This demonstrates the NEW, secure way to validate additions.
607
+ If someone tries to add a user with 'admin' role when we expected 'user',
608
+ it will be caught.
609
+ """
610
+
611
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
612
+ before_db = f.name
613
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
614
+ after_db = f.name
615
+
616
+ try:
617
+ conn = sqlite3.connect(before_db)
618
+ conn.execute(
619
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT, active INTEGER)"
620
+ )
621
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'user', 1)")
622
+ conn.commit()
623
+ conn.close()
624
+
625
+ conn = sqlite3.connect(after_db)
626
+ conn.execute(
627
+ "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT, active INTEGER)"
628
+ )
629
+ conn.execute("INSERT INTO users VALUES (1, 'Alice', 'user', 1)")
630
+ # Attempted malicious addition with admin role
631
+ conn.execute("INSERT INTO users VALUES (2, 'Bob', 'admin', 1)")
632
+ conn.commit()
633
+ conn.close()
634
+
635
+ before = DatabaseSnapshot(before_db)
636
+ after = DatabaseSnapshot(after_db)
637
+
638
+ # This correctly FAILS because role is 'admin' not 'user'
639
+ with pytest.raises(AssertionError, match="Unexpected database changes"):
640
+ before.diff(after).expect_only(
641
+ [
642
+ {"table": "users", "pk": 2, "field": "id", "after": 2},
643
+ {"table": "users", "pk": 2, "field": "name", "after": "Bob"},
644
+ {
645
+ "table": "users",
646
+ "pk": 2,
647
+ "field": "role",
648
+ "after": "user",
649
+ }, # Expected 'user'
650
+ {"table": "users", "pk": 2, "field": "active", "after": 1},
651
+ ]
652
+ )
653
+
654
+ finally:
655
+ os.unlink(before_db)
656
+ os.unlink(after_db)
657
+
658
+
659
+ def test_security_sensitive_financial_data():
660
+ """
661
+ SECURITY: Whole-row spec could allow price manipulation in e-commerce.
662
+
663
+ Demonstrates a real-world security scenario where whole-row specs are dangerous.
664
+ """
665
+
666
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
667
+ before_db = f.name
668
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
669
+ after_db = f.name
670
+
671
+ try:
672
+ conn = sqlite3.connect(before_db)
673
+ conn.execute(
674
+ "CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, amount REAL, discount REAL)"
675
+ )
676
+ conn.execute("INSERT INTO orders VALUES (1, 100, 50.00, 0.0)")
677
+ conn.commit()
678
+ conn.close()
679
+
680
+ conn = sqlite3.connect(after_db)
681
+ conn.execute(
682
+ "CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, amount REAL, discount REAL)"
683
+ )
684
+ conn.execute("INSERT INTO orders VALUES (1, 100, 50.00, 0.0)")
685
+ # Malicious: order with 100% discount!
686
+ conn.execute("INSERT INTO orders VALUES (2, 200, 1000.00, 1000.00)")
687
+ conn.commit()
688
+ conn.close()
689
+
690
+ before = DatabaseSnapshot(before_db)
691
+ after = DatabaseSnapshot(after_db)
692
+
693
+ # OLD WAY (insecure): This PASSES even with suspicious 100% discount
694
+ before.diff(after).expect_only(
695
+ [{"table": "orders", "pk": 2, "field": None, "after": "__added__"}]
696
+ )
697
+
698
+ # NEW WAY (secure): Would catch the excessive discount
699
+ # If we specified expected values, this would fail:
700
+ with pytest.raises(AssertionError, match="Unexpected database changes"):
701
+ before.diff(after).expect_only(
702
+ [
703
+ {"table": "orders", "pk": 2, "field": "id", "after": 2},
704
+ {"table": "orders", "pk": 2, "field": "user_id", "after": 200},
705
+ {"table": "orders", "pk": 2, "field": "amount", "after": 1000.00},
706
+ {
707
+ "table": "orders",
708
+ "pk": 2,
709
+ "field": "discount",
710
+ "after": 0.0,
711
+ }, # Expected no discount
712
+ ]
713
+ )
714
+
715
+ finally:
716
+ os.unlink(before_db)
717
+ os.unlink(after_db)
718
+
719
+
720
+ def test_security_privilege_escalation_in_permissions():
721
+ """
722
+ SECURITY: Demonstrates privilege escalation vulnerability with whole-row specs.
723
+
724
+ In a permissions system, whole-row specs could allow unauthorized permission grants.
725
+ """
726
+
727
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
728
+ before_db = f.name
729
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
730
+ after_db = f.name
731
+
732
+ try:
733
+ conn = sqlite3.connect(before_db)
734
+ conn.execute(
735
+ "CREATE TABLE permissions (id INTEGER PRIMARY KEY, user_id INTEGER, resource TEXT, can_read INTEGER, can_write INTEGER, can_delete INTEGER)"
736
+ )
737
+ conn.execute("INSERT INTO permissions VALUES (1, 100, 'documents', 1, 0, 0)")
738
+ conn.commit()
739
+ conn.close()
740
+
741
+ conn = sqlite3.connect(after_db)
742
+ conn.execute(
743
+ "CREATE TABLE permissions (id INTEGER PRIMARY KEY, user_id INTEGER, resource TEXT, can_read INTEGER, can_write INTEGER, can_delete INTEGER)"
744
+ )
745
+ conn.execute("INSERT INTO permissions VALUES (1, 100, 'documents', 1, 0, 0)")
746
+ # Malicious: grant full permissions including delete!
747
+ conn.execute("INSERT INTO permissions VALUES (2, 200, 'admin_panel', 1, 1, 1)")
748
+ conn.commit()
749
+ conn.close()
750
+
751
+ before = DatabaseSnapshot(before_db)
752
+ after = DatabaseSnapshot(after_db)
753
+
754
+ # INSECURE: Whole-row spec allows the dangerous permission grant
755
+ before.diff(after).expect_only(
756
+ [{"table": "permissions", "pk": 2, "field": None, "after": "__added__"}]
757
+ )
758
+
759
+ # SECURE: Field-level specs would catch unauthorized delete permission
760
+ with pytest.raises(AssertionError, match="Unexpected database changes"):
761
+ before.diff(after).expect_only(
762
+ [
763
+ {"table": "permissions", "pk": 2, "field": "id", "after": 2},
764
+ {"table": "permissions", "pk": 2, "field": "user_id", "after": 200},
765
+ {
766
+ "table": "permissions",
767
+ "pk": 2,
768
+ "field": "resource",
769
+ "after": "admin_panel",
770
+ },
771
+ {"table": "permissions", "pk": 2, "field": "can_read", "after": 1},
772
+ {"table": "permissions", "pk": 2, "field": "can_write", "after": 1},
773
+ {
774
+ "table": "permissions",
775
+ "pk": 2,
776
+ "field": "can_delete",
777
+ "after": 0,
778
+ }, # Expected NO delete
779
+ ]
780
+ )
781
+
782
+ finally:
783
+ os.unlink(before_db)
784
+ os.unlink(after_db)
785
+
786
+
787
+ def test_security_data_injection_in_json_fields():
788
+ """
789
+ SECURITY: Whole-row specs could allow malicious data in JSON/text fields.
790
+ """
791
+
792
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
793
+ before_db = f.name
794
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
795
+ after_db = f.name
796
+
797
+ try:
798
+ conn = sqlite3.connect(before_db)
799
+ conn.execute(
800
+ "CREATE TABLE configs (id INTEGER PRIMARY KEY, name TEXT, settings TEXT)"
801
+ )
802
+ conn.execute(
803
+ "INSERT INTO configs VALUES (1, 'app_config', '{\"debug\": false}')"
804
+ )
805
+ conn.commit()
806
+ conn.close()
807
+
808
+ conn = sqlite3.connect(after_db)
809
+ conn.execute(
810
+ "CREATE TABLE configs (id INTEGER PRIMARY KEY, name TEXT, settings TEXT)"
811
+ )
812
+ conn.execute(
813
+ "INSERT INTO configs VALUES (1, 'app_config', '{\"debug\": false}')"
814
+ )
815
+ # Malicious: config with debug enabled and backdoor URL
816
+ conn.execute(
817
+ 'INSERT INTO configs VALUES (2, \'user_config\', \'{"debug": true, "backdoor": "https://evil.com"}\')'
818
+ )
819
+ conn.commit()
820
+ conn.close()
821
+
822
+ before = DatabaseSnapshot(before_db)
823
+ after = DatabaseSnapshot(after_db)
824
+
825
+ # INSECURE: Passes even with malicious settings
826
+ before.diff(after).expect_only(
827
+ [{"table": "configs", "pk": 2, "field": None, "after": "__added__"}]
828
+ )
829
+
830
+ # SECURE: Would catch the malicious settings
831
+ with pytest.raises(AssertionError, match="Unexpected database changes"):
832
+ before.diff(after).expect_only(
833
+ [
834
+ {"table": "configs", "pk": 2, "field": "id", "after": 2},
835
+ {
836
+ "table": "configs",
837
+ "pk": 2,
838
+ "field": "name",
839
+ "after": "user_config",
840
+ },
841
+ {
842
+ "table": "configs",
843
+ "pk": 2,
844
+ "field": "settings",
845
+ "after": '{"debug": false}',
846
+ },
847
+ ]
848
+ )
849
+
850
+ finally:
851
+ os.unlink(before_db)
852
+ os.unlink(after_db)
853
+
854
+
855
+ # ============================================================================
856
+ # Tests showing field-level specs being IGNORED (not validated)
857
+ # These demonstrate cases where you specify field values but they're not checked
858
+ # ============================================================================
859
+
860
+
861
+ def test_bug_field_specs_ignored_with_whole_row_spec():
862
+ """
863
+ This test SHOULD FAIL (on buggy code) because we specify wrong field values
864
+ that should be caught but aren't.
865
+ """
866
+
867
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
868
+ before_db = f.name
869
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
870
+ after_db = f.name
871
+
872
+ try:
873
+ conn = sqlite3.connect(before_db)
874
+ conn.execute(
875
+ "CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL, stock INTEGER)"
876
+ )
877
+ conn.execute("INSERT INTO products VALUES (1, 'Widget', 10.0, 100)")
878
+ conn.commit()
879
+ conn.close()
880
+
881
+ conn = sqlite3.connect(after_db)
882
+ conn.execute(
883
+ "CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price REAL, stock INTEGER)"
884
+ )
885
+ conn.execute("INSERT INTO products VALUES (1, 'Widget', 10.0, 100)")
886
+ # Add product with price=999.99 and stock=1
887
+ conn.execute("INSERT INTO products VALUES (2, 'Gadget', 999.99, 1)")
888
+ conn.commit()
889
+ conn.close()
890
+
891
+ before = DatabaseSnapshot(before_db)
892
+ after = DatabaseSnapshot(after_db)
893
+
894
+ # This SHOULD fail because we're specifying wrong values
895
+ # We say price=50.0 (actual: 999.99) and stock=500 (actual: 1)
896
+ # With the buggy implementation, this wrongly passes
897
+ # With the fix, this should raise AssertionError
898
+ with pytest.raises(AssertionError, match="Unexpected database changes"):
899
+ before.diff(after).expect_only(
900
+ [
901
+ {"table": "products", "pk": 2, "field": None, "after": "__added__"},
902
+ # These specify WRONG values - should be caught!
903
+ {
904
+ "table": "products",
905
+ "pk": 2,
906
+ "field": "price",
907
+ "after": 50.0,
908
+ }, # WRONG! Actually 999.99
909
+ {
910
+ "table": "products",
911
+ "pk": 2,
912
+ "field": "stock",
913
+ "after": 500,
914
+ }, # WRONG! Actually 1
915
+ ]
916
+ )
917
+
918
+ finally:
919
+ os.unlink(before_db)
920
+ os.unlink(after_db)
921
+
922
+
923
+ def test_bug_wrong_values_pass_with_whole_row_spec():
924
+ """
925
+ BUG: You can specify any values in field specs alongside a whole-row spec,
926
+ even completely wrong ones, and validation passes.
927
+
928
+ This test SHOULD FAIL to catch the dangerous security issue where role=admin
929
+ and balance=1000000 are allowed even though we specified role=user and balance=0.
930
+ """
931
+
932
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
933
+ before_db = f.name
934
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
935
+ after_db = f.name
936
+
937
+ try:
938
+ conn = sqlite3.connect(before_db)
939
+ conn.execute(
940
+ "CREATE TABLE accounts (id INTEGER PRIMARY KEY, username TEXT, role TEXT, balance REAL)"
941
+ )
942
+ conn.execute("INSERT INTO accounts VALUES (1, 'alice', 'user', 100.0)")
943
+ conn.commit()
944
+ conn.close()
945
+
946
+ conn = sqlite3.connect(after_db)
947
+ conn.execute(
948
+ "CREATE TABLE accounts (id INTEGER PRIMARY KEY, username TEXT, role TEXT, balance REAL)"
949
+ )
950
+ conn.execute("INSERT INTO accounts VALUES (1, 'alice', 'user', 100.0)")
951
+ # Actual: role=admin, balance=1000000.0
952
+ conn.execute("INSERT INTO accounts VALUES (2, 'bob', 'admin', 1000000.0)")
953
+ conn.commit()
954
+ conn.close()
955
+
956
+ before = DatabaseSnapshot(before_db)
957
+ after = DatabaseSnapshot(after_db)
958
+
959
+ # Should fail because field values don't match!
960
+ # We say role=user (actual: admin) and balance=0.0 (actual: 1000000.0)
961
+ with pytest.raises(AssertionError, match="Unexpected database changes"):
962
+ before.diff(after).expect_only(
963
+ [
964
+ {"table": "accounts", "pk": 2, "field": None, "after": "__added__"},
965
+ # These specifications are COMPLETELY WRONG - should be caught:
966
+ {
967
+ "table": "accounts",
968
+ "pk": 2,
969
+ "field": "role",
970
+ "after": "user",
971
+ }, # Actually "admin"!
972
+ {
973
+ "table": "accounts",
974
+ "pk": 2,
975
+ "field": "balance",
976
+ "after": 0.0,
977
+ }, # Actually 1000000.0!
978
+ ]
979
+ )
980
+
981
+ finally:
982
+ os.unlink(before_db)
983
+ os.unlink(after_db)
984
+
985
+
986
+ def test_bug_conflicting_specs_pass_silently():
987
+ """
988
+ BUG: You can have conflicting specs (field-level AND whole-row) and
989
+ the old implementation silently ignores the conflict, using only whole-row.
990
+
991
+ This test SHOULD FAIL because we specify is_public=0 but it's actually 1.
992
+ """
993
+
994
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
995
+ before_db = f.name
996
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
997
+ after_db = f.name
998
+
999
+ try:
1000
+ conn = sqlite3.connect(before_db)
1001
+ conn.execute(
1002
+ "CREATE TABLE settings (id INTEGER PRIMARY KEY, key TEXT, value TEXT, is_public INTEGER)"
1003
+ )
1004
+ conn.commit()
1005
+ conn.close()
1006
+
1007
+ conn = sqlite3.connect(after_db)
1008
+ conn.execute(
1009
+ "CREATE TABLE settings (id INTEGER PRIMARY KEY, key TEXT, value TEXT, is_public INTEGER)"
1010
+ )
1011
+ # Add a setting that should be private but isn't
1012
+ conn.execute(
1013
+ "INSERT INTO settings VALUES (1, 'api_key', 'secret123', 1)"
1014
+ ) # is_public=1 (BAD!)
1015
+ conn.commit()
1016
+ conn.close()
1017
+
1018
+ before = DatabaseSnapshot(before_db)
1019
+ after = DatabaseSnapshot(after_db)
1020
+
1021
+ # Should fail - we say is_public=0 but it's actually 1 (security issue!)
1022
+ with pytest.raises(AssertionError, match="Unexpected database changes"):
1023
+ before.diff(after).expect_only(
1024
+ [
1025
+ {"table": "settings", "pk": 1, "field": None, "after": "__added__"},
1026
+ {
1027
+ "table": "settings",
1028
+ "pk": 1,
1029
+ "field": "is_public",
1030
+ "after": 0,
1031
+ }, # Says private, but actually public!
1032
+ ]
1033
+ )
1034
+
1035
+ finally:
1036
+ os.unlink(before_db)
1037
+ os.unlink(after_db)
1038
+
1039
+
1040
+ def test_bug_field_specs_dont_work_for_deletions():
1041
+ """
1042
+ BUG: Field-level specs with 'before' values don't work for validating deletions.
1043
+ Only whole-row deletion specs (field=None) are checked.
1044
+
1045
+ This test SHOULD FAIL because we're deleting an admin session when we said
1046
+ we should only delete non-admin sessions (admin_session=0).
1047
+ """
1048
+
1049
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
1050
+ before_db = f.name
1051
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
1052
+ after_db = f.name
1053
+
1054
+ try:
1055
+ conn = sqlite3.connect(before_db)
1056
+ conn.execute(
1057
+ "CREATE TABLE sessions (id INTEGER PRIMARY KEY, user_id INTEGER, active INTEGER, admin_session INTEGER)"
1058
+ )
1059
+ conn.execute("INSERT INTO sessions VALUES (1, 100, 1, 0)")
1060
+ conn.execute("INSERT INTO sessions VALUES (2, 101, 1, 1)") # Admin session!
1061
+ conn.commit()
1062
+ conn.close()
1063
+
1064
+ conn = sqlite3.connect(after_db)
1065
+ conn.execute(
1066
+ "CREATE TABLE sessions (id INTEGER PRIMARY KEY, user_id INTEGER, active INTEGER, admin_session INTEGER)"
1067
+ )
1068
+ conn.execute("INSERT INTO sessions VALUES (1, 100, 1, 0)")
1069
+ # Session 2 (admin session) is deleted
1070
+ conn.commit()
1071
+ conn.close()
1072
+
1073
+ before = DatabaseSnapshot(before_db)
1074
+ after = DatabaseSnapshot(after_db)
1075
+
1076
+ # Should fail - we say admin_session=0 but it's actually 1!
1077
+ # We're deleting an admin session when we shouldn't be
1078
+ with pytest.raises(AssertionError, match="Unexpected database changes"):
1079
+ before.diff(after).expect_only(
1080
+ [
1081
+ {
1082
+ "table": "sessions",
1083
+ "pk": 2,
1084
+ "field": None,
1085
+ "after": "__removed__",
1086
+ },
1087
+ {
1088
+ "table": "sessions",
1089
+ "pk": 2,
1090
+ "field": "admin_session",
1091
+ "before": 0,
1092
+ }, # WRONG! Actually 1
1093
+ ]
1094
+ )
1095
+
1096
+ finally:
1097
+ os.unlink(before_db)
1098
+ os.unlink(after_db)