Skip to content

Commit ee755d6

Browse files
committed
fix: Harden conflict resolution with input validation and edge case fixes
- Add epoch_ms bounds validation (rejects <=0 and >5min future timestamps) - Add History.create_added() call on upsert path to maintain audit trail - Add Meta: unknown=EXCLUDE to RemoveItems outer schema for backwards compat - Revert MIN_BACKEND_VERSION to 110 (less aggressive client gate) - Remove unused import and fix test assertions (400 not 422) - Add 8 timestamp validation tests and 1 bulk-remove unknown-fields test
1 parent d3e7d8a commit ee755d6

4 files changed

Lines changed: 174 additions & 25 deletions

File tree

backend/app/controller/shoppinglist/schemas.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
from marshmallow import fields, Schema, EXCLUDE
1+
from datetime import datetime, timezone
2+
3+
from marshmallow import fields, Schema, EXCLUDE, ValidationError
4+
5+
6+
def _validate_epoch_ms(value: int) -> None:
7+
"""Reject timestamps that are non-positive or more than 5 minutes in the future."""
8+
if value <= 0:
9+
raise ValidationError("Timestamp must be a positive integer.")
10+
max_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + 300_000 # +5 min
11+
if value > max_ms:
12+
raise ValidationError("Timestamp is too far in the future.")
213

314

415
class GetShoppingLists(Schema):
@@ -46,7 +57,7 @@ class Meta:
4657
unknown = EXCLUDE
4758

4859
description = fields.String(required=True)
49-
client_timestamp = fields.Integer()
60+
client_timestamp = fields.Integer(validate=_validate_epoch_ms)
5061

5162

5263
class RemoveItem(Schema):
@@ -56,17 +67,20 @@ class Meta:
5667
item_id = fields.Integer(
5768
required=True,
5869
)
59-
removed_at = fields.Integer()
70+
removed_at = fields.Integer(validate=_validate_epoch_ms)
6071

6172

6273
class RemoveItems(Schema):
74+
class Meta:
75+
unknown = EXCLUDE
76+
6377
class RecipeItem(Schema):
6478
class Meta:
6579
unknown = EXCLUDE
6680

6781
item_id = fields.Integer(
6882
required=True,
6983
)
70-
removed_at = fields.Integer()
84+
removed_at = fields.Integer(validate=_validate_epoch_ms)
7185

7286
items = fields.List(fields.Nested(RecipeItem))

backend/app/controller/shoppinglist/shoppinglist_controller.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ def updateItemDescription(args, id: int, item_id: int):
163163
return jsonify({"msg": "HISTORY_UPDATED", "reason": result.reason})
164164

165165
# Resolution.ACCEPT — proceed with the update (or create if not on list)
166+
created = con is None
166167
if con is None:
167168
con = ShoppinglistItems()
168169
con.shoppinglist = shoppinglist_obj
@@ -172,6 +173,10 @@ def updateItemDescription(args, id: int, item_id: int):
172173
con.description = args["description"] or ""
173174
resolver.set_updated_at(con, client_timestamp)
174175
con.save()
176+
177+
if created:
178+
History.create_added(shoppinglist_obj, item, con.description)
179+
175180
socketio.emit(
176181
"shoppinglist_item:add",
177182
{

backend/tests/api/test_api_shoppinglist_conflict.py

Lines changed: 150 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
Update: ACCEPT (legacy), ACCEPT (ts > updated_at), REJECT (ts <= updated_at),
77
NOOP (item gone, no history), UPDATE_HISTORY (item gone, history exists)
88
Add: Idempotent (already on list), always succeeds (not on list)
9+
Validation: zero, negative, and far-future timestamps are rejected by schema
910
"""
1011

11-
import time
1212
from datetime import datetime, timedelta, timezone
1313

1414
import pytest
@@ -28,6 +28,16 @@ def _now_ms() -> int:
2828
return _epoch_ms(datetime.now(timezone.utc))
2929

3030

31+
def _future_ms(seconds: int = 60) -> int:
32+
"""Return an epoch-ms timestamp ``seconds`` in the future (within validation window)."""
33+
return _epoch_ms(datetime.now(timezone.utc) + timedelta(seconds=seconds))
34+
35+
36+
def _past_ms(seconds: int = 3600) -> int:
37+
"""Return an epoch-ms timestamp ``seconds`` in the past."""
38+
return _epoch_ms(datetime.now(timezone.utc) - timedelta(seconds=seconds))
39+
40+
3141
def _add_item_to_list(client, shoppinglist_id, item_name="conflict_item"):
3242
"""Add an item by name and return (item_id, shoppinglist_id)."""
3343
resp = client.post(
@@ -99,8 +109,8 @@ def test_remove_fresh_timestamp_accepts(
99109
user_client_with_household, shoppinglist_id, "fresh_remove"
100110
)
101111

102-
# Use a timestamp well in the future so it's guaranteed > updated_at
103-
future_ts = _epoch_ms(datetime.now(timezone.utc) + timedelta(hours=1))
112+
# Use a timestamp slightly in the future (within the 5-min validation window)
113+
future_ts = _future_ms(60)
104114
resp = user_client_with_household.delete(
105115
f"/api/shoppinglist/{shoppinglist_id}/item",
106116
json={"item_id": item_id, "removed_at": future_ts},
@@ -119,7 +129,7 @@ def test_remove_stale_timestamp_rejects(
119129
)
120130

121131
# First, update the item's description with a fresh timestamp to bump updated_at
122-
fresh_ts = _epoch_ms(datetime.now(timezone.utc) + timedelta(hours=1))
132+
fresh_ts = _future_ms(120)
123133
resp = user_client_with_household.put(
124134
f"/api/shoppinglist/{shoppinglist_id}/item/{item_id}",
125135
json={
@@ -130,7 +140,7 @@ def test_remove_stale_timestamp_rejects(
130140
assert resp.status_code == 200
131141

132142
# Now try to remove with a stale timestamp (before the update)
133-
stale_ts = _epoch_ms(datetime.now(timezone.utc) - timedelta(hours=1))
143+
stale_ts = _past_ms(3600)
134144
resp = user_client_with_household.delete(
135145
f"/api/shoppinglist/{shoppinglist_id}/item",
136146
json={"item_id": item_id, "removed_at": stale_ts},
@@ -172,7 +182,7 @@ def test_update_fresh_timestamp_accepts(
172182
item_id = _add_item_to_list(
173183
user_client_with_household, shoppinglist_id, "fresh_update"
174184
)
175-
future_ts = _epoch_ms(datetime.now(timezone.utc) + timedelta(hours=1))
185+
future_ts = _future_ms(60)
176186
resp = user_client_with_household.put(
177187
f"/api/shoppinglist/{shoppinglist_id}/item/{item_id}",
178188
json={"description": "fresh update", "client_timestamp": future_ts},
@@ -190,15 +200,15 @@ def test_update_stale_timestamp_rejects(
190200
)
191201

192202
# First update with a fresh timestamp to bump updated_at
193-
fresh_ts = _epoch_ms(datetime.now(timezone.utc) + timedelta(hours=1))
203+
fresh_ts = _future_ms(120)
194204
resp = user_client_with_household.put(
195205
f"/api/shoppinglist/{shoppinglist_id}/item/{item_id}",
196206
json={"description": "first update", "client_timestamp": fresh_ts},
197207
)
198208
assert resp.status_code == 200
199209

200210
# Now try to update with a stale timestamp
201-
stale_ts = _epoch_ms(datetime.now(timezone.utc) - timedelta(hours=1))
211+
stale_ts = _past_ms(3600)
202212
resp = user_client_with_household.put(
203213
f"/api/shoppinglist/{shoppinglist_id}/item/{item_id}",
204214
json={"description": "stale update", "client_timestamp": stale_ts},
@@ -255,7 +265,7 @@ def test_update_item_not_on_list_with_timestamp_updates_history(
255265
assert resp.status_code == 200
256266

257267
# Update with a timestamp in the future (> History DROPPED created_at)
258-
future_ts = _epoch_ms(datetime.now(timezone.utc) + timedelta(hours=1))
268+
future_ts = _future_ms(60)
259269
resp = user_client_with_household.put(
260270
f"/api/shoppinglist/{shoppinglist_id}/item/{item_id}",
261271
json={"description": "updated in history", "client_timestamp": future_ts},
@@ -279,16 +289,15 @@ def test_update_item_not_on_list_stale_timestamp_is_noop(
279289
user_client_with_household, shoppinglist_id, "noop_history_item"
280290
)
281291

282-
# Remove the item with a far-future timestamp so History's created_at is in the future
283-
far_future = _epoch_ms(datetime.now(timezone.utc) + timedelta(days=10))
292+
# Remove the item (legacy — no removed_at). History created_at ~ server now.
284293
resp = user_client_with_household.delete(
285294
f"/api/shoppinglist/{shoppinglist_id}/item",
286-
json={"item_id": item_id, "removed_at": far_future},
295+
json={"item_id": item_id},
287296
)
288297
assert resp.status_code == 200
289298

290-
# Try to update with a timestamp that's still in the past relative to the removal
291-
past_ts = _epoch_ms(datetime.now(timezone.utc) - timedelta(hours=1))
299+
# Try to update with a timestamp in the past (before the removal)
300+
past_ts = _past_ms(3600)
292301
resp = user_client_with_household.put(
293302
f"/api/shoppinglist/{shoppinglist_id}/item/{item_id}",
294303
json={"description": "should be ignored", "client_timestamp": past_ts},
@@ -376,6 +385,22 @@ def test_update_with_unknown_fields_excluded(
376385
)
377386
assert resp.status_code == 200
378387

388+
def test_bulk_remove_with_unknown_fields_excluded(
389+
self, user_client_with_household, shoppinglist_id
390+
):
391+
"""RemoveItems (bulk) schema should silently drop unknown fields."""
392+
item_id = _add_item_to_list(
393+
user_client_with_household, shoppinglist_id, "bulk_exclude"
394+
)
395+
resp = user_client_with_household.delete(
396+
f"/api/shoppinglist/{shoppinglist_id}/items",
397+
json={
398+
"items": [{"item_id": item_id, "unknown_field": 42}],
399+
"top_level_unknown": True,
400+
},
401+
)
402+
assert resp.status_code == 200
403+
379404

380405
# ===========================================================================
381406
# Ordering correctness
@@ -400,16 +425,16 @@ def test_updated_at_set_to_client_timestamp_not_server_time(
400425
user_client_with_household, shoppinglist_id, "ordering_item"
401426
)
402427

403-
# Update with a timestamp far in the future
404-
far_future = _epoch_ms(datetime.now(timezone.utc) + timedelta(days=5))
428+
# Update with a timestamp at the edge of the validation window
429+
far_future = _future_ms(240)
405430
resp = user_client_with_household.put(
406431
f"/api/shoppinglist/{shoppinglist_id}/item/{item_id}",
407432
json={"description": "future update", "client_timestamp": far_future},
408433
)
409434
assert resp.status_code == 200
410435

411436
# Try to update with a timestamp that's AFTER now but BEFORE far_future
412-
slightly_less = _epoch_ms(datetime.now(timezone.utc) + timedelta(days=3))
437+
slightly_less = _future_ms(60)
413438
resp = user_client_with_household.put(
414439
f"/api/shoppinglist/{shoppinglist_id}/item/{item_id}",
415440
json={
@@ -437,16 +462,16 @@ def test_remove_respects_client_timestamp_ordering(
437462
user_client_with_household, shoppinglist_id, "remove_order_item"
438463
)
439464

440-
# Update with far-future timestamp
441-
far_future = _epoch_ms(datetime.now(timezone.utc) + timedelta(days=5))
465+
# Update with future timestamp
466+
far_future = _future_ms(240)
442467
resp = user_client_with_household.put(
443468
f"/api/shoppinglist/{shoppinglist_id}/item/{item_id}",
444469
json={"description": "pinned", "client_timestamp": far_future},
445470
)
446471
assert resp.status_code == 200
447472

448473
# Remove with a timestamp between now and far_future
449-
mid_ts = _epoch_ms(datetime.now(timezone.utc) + timedelta(days=2))
474+
mid_ts = _future_ms(60)
450475
resp = user_client_with_household.delete(
451476
f"/api/shoppinglist/{shoppinglist_id}/item",
452477
json={"item_id": item_id, "removed_at": mid_ts},
@@ -456,3 +481,108 @@ def test_remove_respects_client_timestamp_ordering(
456481
# Item should still be on the list
457482
items = _get_items(user_client_with_household, shoppinglist_id)
458483
assert any(i["id"] == item_id for i in items)
484+
485+
486+
# ===========================================================================
487+
# Timestamp validation (schema-level)
488+
# ===========================================================================
489+
490+
491+
class TestTimestampValidation:
492+
"""Tests that the schema rejects invalid timestamp values."""
493+
494+
def test_update_rejects_zero_timestamp(
495+
self, user_client_with_household, shoppinglist_id
496+
):
497+
"""client_timestamp=0 should be rejected by schema validation."""
498+
item_id = _add_item_to_list(
499+
user_client_with_household, shoppinglist_id, "val_zero"
500+
)
501+
resp = user_client_with_household.put(
502+
f"/api/shoppinglist/{shoppinglist_id}/item/{item_id}",
503+
json={"description": "test", "client_timestamp": 0},
504+
)
505+
assert resp.status_code == 400
506+
507+
def test_update_rejects_negative_timestamp(
508+
self, user_client_with_household, shoppinglist_id
509+
):
510+
"""Negative client_timestamp should be rejected by schema validation."""
511+
item_id = _add_item_to_list(
512+
user_client_with_household, shoppinglist_id, "val_negative"
513+
)
514+
resp = user_client_with_household.put(
515+
f"/api/shoppinglist/{shoppinglist_id}/item/{item_id}",
516+
json={"description": "test", "client_timestamp": -1000},
517+
)
518+
assert resp.status_code == 400
519+
520+
def test_update_rejects_far_future_timestamp(
521+
self, user_client_with_household, shoppinglist_id
522+
):
523+
"""client_timestamp far in the future (>5 min) should be rejected."""
524+
item_id = _add_item_to_list(
525+
user_client_with_household, shoppinglist_id, "val_future"
526+
)
527+
far_future = _epoch_ms(datetime.now(timezone.utc) + timedelta(hours=1))
528+
resp = user_client_with_household.put(
529+
f"/api/shoppinglist/{shoppinglist_id}/item/{item_id}",
530+
json={"description": "test", "client_timestamp": far_future},
531+
)
532+
assert resp.status_code == 400
533+
534+
def test_remove_rejects_zero_timestamp(
535+
self, user_client_with_household, shoppinglist_id
536+
):
537+
"""removed_at=0 should be rejected by schema validation."""
538+
item_id = _add_item_to_list(
539+
user_client_with_household, shoppinglist_id, "rm_val_zero"
540+
)
541+
resp = user_client_with_household.delete(
542+
f"/api/shoppinglist/{shoppinglist_id}/item",
543+
json={"item_id": item_id, "removed_at": 0},
544+
)
545+
assert resp.status_code == 400
546+
547+
def test_remove_rejects_negative_timestamp(
548+
self, user_client_with_household, shoppinglist_id
549+
):
550+
"""Negative removed_at should be rejected by schema validation."""
551+
item_id = _add_item_to_list(
552+
user_client_with_household, shoppinglist_id, "rm_val_neg"
553+
)
554+
resp = user_client_with_household.delete(
555+
f"/api/shoppinglist/{shoppinglist_id}/item",
556+
json={"item_id": item_id, "removed_at": -999},
557+
)
558+
assert resp.status_code == 400
559+
560+
def test_remove_rejects_far_future_timestamp(
561+
self, user_client_with_household, shoppinglist_id
562+
):
563+
"""removed_at far in the future (>5 min) should be rejected."""
564+
item_id = _add_item_to_list(
565+
user_client_with_household, shoppinglist_id, "rm_val_future"
566+
)
567+
far_future = _epoch_ms(datetime.now(timezone.utc) + timedelta(hours=1))
568+
resp = user_client_with_household.delete(
569+
f"/api/shoppinglist/{shoppinglist_id}/item",
570+
json={"item_id": item_id, "removed_at": far_future},
571+
)
572+
assert resp.status_code == 400
573+
574+
def test_valid_timestamp_within_window_accepted(
575+
self, user_client_with_household, shoppinglist_id
576+
):
577+
"""A timestamp within the 5-minute window should be accepted."""
578+
item_id = _add_item_to_list(
579+
user_client_with_household, shoppinglist_id, "val_ok"
580+
)
581+
valid_ts = _future_ms(60)
582+
resp = user_client_with_household.put(
583+
f"/api/shoppinglist/{shoppinglist_id}/item/{item_id}",
584+
json={"description": "valid", "client_timestamp": valid_ts},
585+
)
586+
assert resp.status_code == 200
587+
data = resp.get_json()
588+
assert data.get("description") == "valid"

kitchenowl/lib/config.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import 'package:package_info_plus/package_info_plus.dart';
33

44
abstract class Config {
55
// ignore: constant_identifier_names
6-
static const int MIN_BACKEND_VERSION = 121;
6+
static const int MIN_BACKEND_VERSION = 110;
77
static const String defaultServer = "https://app.kitchenowl.org";
88
static Future<PackageInfo?>? _packageInfo; // Gets loaded by SettingsCubit
99
static PackageInfo? _packageInfoSync;

0 commit comments

Comments
 (0)