fleet-python 0.2.64__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.
- fleet/_async/models.py +2 -0
- fleet/_async/resources/sqlite.py +88 -2
- fleet/models.py +2 -0
- fleet/resources/sqlite.py +88 -2
- fleet/verifiers/db.py +158 -3
- {fleet_python-0.2.64.dist-info → fleet_python-0.2.66a2.dist-info}/METADATA +15 -1
- {fleet_python-0.2.64.dist-info → fleet_python-0.2.66a2.dist-info}/RECORD +11 -10
- tests/test_expect_only.py +1098 -0
- {fleet_python-0.2.64.dist-info → fleet_python-0.2.66a2.dist-info}/WHEEL +0 -0
- {fleet_python-0.2.64.dist-info → fleet_python-0.2.66a2.dist-info}/licenses/LICENSE +0 -0
- {fleet_python-0.2.64.dist-info → fleet_python-0.2.66a2.dist-info}/top_level.txt +0 -0
|
@@ -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)
|