Spaces:
Running
Running
Sync ShopStack 2026-06-15: corrections panel, empty-state rewrite, market-source suppression
8294cde verified | """Tests for the encrypted backup service. | |
| The service exports a household's data to an encrypted JSON envelope and | |
| imports it back. Tests cover: | |
| - Round-trip: export → import → same row counts. | |
| - Wrong passphrase fails with a clear error. | |
| - Tampered ciphertext fails (AES-GCM auth tag). | |
| - Malformed JSON / wrong version fail with clear errors. | |
| - Per-table counts are reported accurately. | |
| - Empty / minimal backups work. | |
| - Passphrase length validation (export). | |
| - Cross-household restoration: restore into a different household. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import tempfile | |
| from datetime import date | |
| from pathlib import Path | |
| import pytest | |
| from shopstack.config import Settings | |
| from shopstack.persistence.database import Database | |
| from shopstack.schemas.models import InventoryLot, ShoppingListItem | |
| from shopstack.services.backup import ( | |
| BACKUP_VERSION, | |
| export_backup, | |
| import_backup, | |
| ) | |
| from tests.conftest import _remove_db_with_sidecars | |
| def fresh_db(): | |
| """Fresh temp DB with the ``hh1`` / ``hh2`` test households seeded. | |
| Backup/restore tests round-trip rows through encryption as ``hh1`` | |
| (and verify household isolation with ``hh2``). Phase 11 write paths | |
| verify household membership before persisting, so each test household | |
| must exist with itself as owner. | |
| """ | |
| fd, path = tempfile.mkstemp(suffix=".db") | |
| os.close(fd) | |
| s = Settings(_env_file=None, db_path=path, off_the_grid=True, local_auto_download=False) | |
| db = Database(path) | |
| for hid in ("hh1", "hh2"): | |
| db.add_household(hid, f"Test {hid}") | |
| db.add_household_member(hid, hid, role="owner") | |
| yield db, path | |
| _remove_db_with_sidecars(path) | |
| def _seed_minimal(db: Database, hh: str) -> dict[str, int]: | |
| """Seed a tiny but realistic dataset and return expected counts.""" | |
| db.active_household_id = hh | |
| # 2 inventory lots | |
| db.add_inventory_lot( | |
| InventoryLot( | |
| canonical_name="milk", | |
| display_name="Milk", | |
| quantity=1.0, | |
| unit="L", | |
| status="active", | |
| storage_location_id="home.kitchen.fridge.door_1", | |
| ), | |
| user_id=hh, | |
| ) | |
| db.add_inventory_lot( | |
| InventoryLot( | |
| canonical_name="onion", | |
| display_name="Onion", | |
| quantity=2.0, | |
| unit="kg", | |
| status="active", | |
| storage_location_id="home.pantry.shelf_1", | |
| ), | |
| user_id=hh, | |
| ) | |
| # 1 shopping list + 2 items | |
| sl = db.create_shopping_list(goal="Test", user_id=hh) | |
| db.add_list_item( | |
| sl.list_id, | |
| ShoppingListItem( | |
| canonical_name="tomato", display_name="Tomato", requested_quantity=2, unit="kg" | |
| ), | |
| ) | |
| db.add_list_item( | |
| sl.list_id, | |
| ShoppingListItem( | |
| canonical_name="rice", display_name="Rice", requested_quantity=5, unit="kg" | |
| ), | |
| ) | |
| return { | |
| "inventory_lots": 2, | |
| "shopping_lists": 1, | |
| "shopping_list_items": 2, | |
| } | |
| class TestExportBackup: | |
| def test_export_minimal_returns_success(self, fresh_db): | |
| db, _ = fresh_db | |
| _seed_minimal(db, "hh1") | |
| result = export_backup(db, "hh1", "test-passphrase") | |
| assert result.success is True | |
| assert result.operation == "export" | |
| assert result.error == "" | |
| assert result.envelope_json != "" | |
| def test_export_counts_match_seeded_data(self, fresh_db): | |
| db, _ = fresh_db | |
| _seed_minimal(db, "hh1") | |
| result = export_backup(db, "hh1", "test-passphrase") | |
| assert result.counts["inventory_lots"] == 2 | |
| assert result.counts["shopping_lists"] == 1 | |
| assert result.counts["shopping_list_items"] == 2 | |
| def test_envelope_is_valid_json_with_version(self, fresh_db): | |
| db, _ = fresh_db | |
| _seed_minimal(db, "hh1") | |
| result = export_backup(db, "hh1", "test-passphrase") | |
| envelope = json.loads(result.envelope_json) | |
| assert envelope["version"] == BACKUP_VERSION | |
| assert envelope["kdf"] == "pbkdf2-sha256" | |
| assert envelope["iterations"] == 200_000 | |
| assert envelope["household_id"] == "hh1" | |
| assert "salt_b64" in envelope | |
| assert "nonce_b64" in envelope | |
| assert "ciphertext_b64" in envelope | |
| def test_too_short_passphrase_rejected(self, fresh_db): | |
| db, _ = fresh_db | |
| result = export_backup(db, "hh1", "abc") # < 4 chars | |
| assert result.success is False | |
| assert "at least 4 characters" in result.error | |
| def test_empty_passphrase_rejected(self, fresh_db): | |
| db, _ = fresh_db | |
| result = export_backup(db, "hh1", "") | |
| assert result.success is False | |
| def test_export_empty_db_still_succeeds(self, fresh_db): | |
| """No data should still produce a valid (empty) envelope.""" | |
| db, _ = fresh_db | |
| db.active_household_id = "hh1" | |
| result = export_backup(db, "hh1", "test-passphrase") | |
| assert result.success is True | |
| assert result.counts["inventory_lots"] == 0 | |
| assert result.counts["shopping_lists"] == 0 | |
| def test_two_exports_have_different_salts_and_nonces(self, fresh_db): | |
| """Each export must be unique (random salt + nonce).""" | |
| db, _ = fresh_db | |
| _seed_minimal(db, "hh1") | |
| e1 = json.loads(export_backup(db, "hh1", "pass").envelope_json) | |
| e2 = json.loads(export_backup(db, "hh1", "pass").envelope_json) | |
| assert e1["salt_b64"] != e2["salt_b64"] | |
| assert e1["nonce_b64"] != e2["nonce_b64"] | |
| assert e1["ciphertext_b64"] != e2["ciphertext_b64"] | |
| class TestImportBackup: | |
| def test_round_trip_restores_all_rows(self, fresh_db): | |
| db, path = fresh_db | |
| _seed_minimal(db, "hh1") | |
| result = export_backup(db, "hh1", "pass-phrase") | |
| assert result.success | |
| # Wipe inventory/lists/prices/prefs | |
| for table in [ | |
| "inventory_lots", "shopping_list_items", "shopping_lists", | |
| "price_observations", "preference_signals", | |
| ]: | |
| try: | |
| db.conn.execute(f"DELETE FROM {table}") | |
| db.conn.commit() | |
| except Exception: | |
| pass | |
| # Restore | |
| imp = import_backup(db, result.envelope_json, "pass-phrase", "hh1") | |
| assert imp.success | |
| assert imp.counts["inventory_lots"] == 2 | |
| assert imp.counts["shopping_lists"] == 1 | |
| assert imp.counts["shopping_list_items"] == 2 | |
| def test_wrong_passphrase_fails(self, fresh_db): | |
| db, _ = fresh_db | |
| _seed_minimal(db, "hh1") | |
| result = export_backup(db, "hh1", "right-pass") | |
| imp = import_backup(db, result.envelope_json, "wrong-pass", "hh1") | |
| assert imp.success is False | |
| assert "Decryption failed" in imp.error or "tampered" in imp.error.lower() | |
| def test_tampered_ciphertext_fails(self, fresh_db): | |
| db, _ = fresh_db | |
| _seed_minimal(db, "hh1") | |
| result = export_backup(db, "hh1", "pass") | |
| envelope = json.loads(result.envelope_json) | |
| # Flip one character in the ciphertext | |
| ct = envelope["ciphertext_b64"] | |
| tampered = "A" + ct[1:] if ct[0] != "A" else "B" + ct[1:] | |
| envelope["ciphertext_b64"] = tampered | |
| imp = import_backup(db, json.dumps(envelope), "pass", "hh1") | |
| assert imp.success is False | |
| def test_malformed_envelope_json_fails(self, fresh_db): | |
| db, _ = fresh_db | |
| imp = import_backup(db, "not json at all", "pass", "hh1") | |
| assert imp.success is False | |
| assert "not JSON" in imp.error or "JSON" in imp.error | |
| def test_wrong_version_rejected(self, fresh_db): | |
| db, _ = fresh_db | |
| envelope = { | |
| "version": 99, | |
| "kdf": "pbkdf2-sha256", | |
| "iterations": 200000, | |
| "salt_b64": "AAAA", | |
| "nonce_b64": "AAAA", | |
| "ciphertext_b64": "AAAA", | |
| "created_at": "2026-06-12T00:00:00Z", | |
| "household_id": "hh1", | |
| } | |
| imp = import_backup(db, json.dumps(envelope), "pass", "hh1") | |
| assert imp.success is False | |
| assert "version" in imp.error.lower() | |
| def test_missing_envelope_fields_rejected(self, fresh_db): | |
| db, _ = fresh_db | |
| envelope = { | |
| "version": BACKUP_VERSION, | |
| "salt_b64": "AAAA", | |
| # missing nonce_b64, ciphertext_b64 | |
| } | |
| imp = import_backup(db, json.dumps(envelope), "pass", "hh1") | |
| assert imp.success is False | |
| assert "missing" in imp.error.lower() | |
| def test_empty_envelope_fails(self, fresh_db): | |
| db, _ = fresh_db | |
| imp = import_backup(db, "", "pass", "hh1") | |
| assert imp.success is False | |
| def test_empty_passphrase_fails(self, fresh_db): | |
| db, _ = fresh_db | |
| _seed_minimal(db, "hh1") | |
| result = export_backup(db, "hh1", "pass") | |
| imp = import_backup(db, result.envelope_json, "", "hh1") | |
| assert imp.success is False | |
| def test_restore_into_different_household(self, fresh_db): | |
| """Restore from hh1 envelope into hh2. Items are scoped to the | |
| restore household (hh2), not the source (hh1).""" | |
| db, _ = fresh_db | |
| _seed_minimal(db, "hh1") | |
| result = export_backup(db, "hh1", "pass") | |
| assert result.success | |
| # Now restore into hh2 | |
| imp = import_backup(db, result.envelope_json, "pass", "hh2") | |
| assert imp.success | |
| # Verify the items are visible in hh2 | |
| lots = db.get_inventory(user_id="hh2") | |
| assert len(lots) == 2 | |
| assert {l.canonical_name for l in lots} == {"milk", "onion"} | |
| def test_conflicts_are_reported_on_reimport(self, fresh_db): | |
| """Re-importing the same envelope over the same DB should report | |
| conflicts (not overwrite).""" | |
| db, _ = fresh_db | |
| _seed_minimal(db, "hh1") | |
| result = export_backup(db, "hh1", "pass") | |
| imp1 = import_backup(db, result.envelope_json, "pass", "hh1") | |
| assert imp1.success | |
| # Second import — every row should already exist | |
| imp2 = import_backup(db, result.envelope_json, "pass", "hh1") | |
| assert imp2.success | |
| # Conflicts should be in notes | |
| assert any("Skipped" in n and "conflicting" in n for n in imp2.notes) | |