shopstack / tests /test_backup_service.py
pranaysuyash's picture
Sync ShopStack 2026-06-15: corrections panel, empty-state rewrite, market-source suppression
8294cde verified
Raw
History Blame Contribute Delete
10.3 kB
"""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
@pytest.fixture()
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)