"""End-to-end encryption on the server: what it keeps, what it refuses, and that turning encryption on or off never loses or corrupts anything, even when Wally stops half-way. The browser's side is played by tests/vault_crypto.py, a second implementation of js/vault/crypto.js. Run with: pytest tests """ import hashlib import json import shutil import sqlite3 import time from pathlib import Path import pytest from fastapi.testclient import TestClient pytest.importorskip("argon2") pytest.importorskip("cryptography") from api import database from api.routers import auth as auth_router from api.services import conversion, totp from tests import vault_crypto as vc from tests.test_api import TODAY, expense, rule, make_client # noqa: F401 (the fixture) # One password for the household PASS = "household-pass-1" def plain_household(client): alex = client.post("/people", json={"name": "Alex"}).json()["id"] ana = client.post("/people", json={"name": "Ana"}).json()["id"] client.headers["x-wally-person"] = str(alex) # Some of everything, with the awkward cases: shares, a note, tags, an import, a rule expense(client, name="Mercadona", amount="23.45", paid_by=alex, note="Café y pan", tags=["#weekly"]) expense(client, name="Farmacia Sant Jordi", category="Healthcare", amount="7.10", person_id=ana) expense(client, name="Dentist", category="Therapy", amount="80.00", shares={str(alex): 2000, str(ana): 6000}) rule(client, name="Netflix", amount="13.99", startDate="2026-08-27", paid_by=ana) client.post("/import/commit", json={"rows": [{"date": "2026-10-20", "name": "AMAZON MKTPLACE 99", "amount": "-15.20", "import_ref": "ref-1", "action": "add", "category": "Shopping"}]}) client.post("/categories", json={"name": "Kids", "color": 3, "icon": "dog"}) client.post("/tags", json={"name": "holiday"}) client.put("/tags/holiday", json={"icon": "palm"}) r = client.put("/signin", json={"enabled": True, "password": PASS}) assert r.status_code == 200, r.text client.headers.pop("x-wally-person", None) return alex, ana def db_digest() -> str: return hashlib.sha256(database.DB_PATH.read_bytes()).hexdigest() def export(client_db=None): from sqlmodel import Session from api.routers.vault import export_items with Session(database.engine) as db: items = export_items(db) for i in items: if i["k"] == "irule": i.pop("id") return sorted(json.dumps(i, sort_keys=True) for i in items) class Browser: """One device of one person: the household's keys, as the browser keeps them.""" def __init__(self, client: TestClient, person_id: int, password: str): self.c, self.person, self.password = client, person_id, password self.house = None self.vault_id = None def login(self, code: str | None = None): kdf = self.c.get("/vault/kdf").json() login, unlock_key = vc.from_password(self.password, kdf["salt"]) r = self.c.post("/login", json={"person_id": self.person, "password": login}) if r.status_code == 200 and r.json().get("code_needed"): r = self.c.post("/login/code", json={"code": code}) assert r.status_code == 200, r.text k = self.c.get("/vault/key").json() self.vault_id = k["vault_id"] self.house = vc.Household(vc.unlock(unlock_key, k["wrap"], k["vault_id"])) return self def proof(self) -> str: kdf = self.c.get("/vault/kdf").json() return vc.from_password(self.password, kdf["salt"])[0] if kdf["salt"] else self.password def all(self) -> dict: body = self.c.get("/vault/changes?since=0").json() return {r["id"]: (r["v"], self.house.open(r["id"], r["v"], r["ct"])) for r in body["records"]} def turn_on(client, person_id, password, fail_at=None): """Turning encryption on, as js/vault/session.js turnOn() does. Returns the secret and the recovery key.""" kdf = client.get("/vault/kdf").json() current = vc.from_password(password, kdf["salt"])[0] if kdf["salt"] else password salt = vc.new_salt() login, unlock_key = vc.from_password(password, salt) secret, vault_id = __import__("os").urandom(32), __import__("os").urandom(16).hex() recovery = vc.new_recovery_key() rlogin, runlock = vc.from_recovery_key(vc.parse_recovery_key(recovery)) r = client.post("/vault/convert/start", json={ "direction": "on", "current": current, "vault_id": vault_id, "salt": salt, "login_key": login, "wrap": vc.lock(unlock_key, secret, vault_id), "recovery_login": rlogin, "recovery_wrap": vc.lock(runlock, secret, vault_id), }) assert r.status_code == 200, r.text cid = r.json()["id"] if fail_at == "start": return cid house = vc.Household(secret) items = client.get(f"/vault/convert/export?id={cid}").json()["items"] sent = {} records = [] for item in items: rid = vc.record_id(item, house) sent[rid] = item records.append({"id": rid, "ct": house.seal(rid, 1, item)}) for i in range(0, len(records), 3): assert client.post("/vault/convert/records", json={"id": cid, "records": records[i:i + 3]}).status_code == 200 back = client.get(f"/vault/convert/readback?id={cid}").json()["records"] assert {r["id"]: house.open(r["id"], r["v"], r["ct"]) for r in back} == sent if fail_at == "check": return cid r = client.post("/vault/convert/finish", json={"id": cid, "count": len(sent)}) assert r.status_code == 200, r.text return {"secret": secret, "vault_id": vault_id, "recovery": recovery, "count": len(sent), "house": house} @pytest.fixture def encrypted(make_client): client = make_client() alex, ana = plain_household(client) assert client.post("/login", json={"person_id": alex, "password": PASS}).status_code == 200 before = export() result = turn_on(client, alex, PASS) return client, alex, ana, result, before # ---------- turning on ---------- def test_turning_on_leaves_nothing_in_the_clear(encrypted): client, alex, ana, result, before = encrypted status = client.get("/auth/status").json() assert status["vault"]["on"] and status["vault"]["kdf"] # Every plain table is empty, and no plain backup or old file is left conn = sqlite3.connect(database.DB_PATH) for table in ("transaction", "recurringtransaction", "category", "categorycolor", "categoryicon", "tag", "tagicon", "importrule"): assert conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0] == 0, table assert conn.execute("SELECT COUNT(*) FROM record").fetchone()[0] == result["count"] == len(before) conn.close() assert not conversion.prev_path().exists() and not conversion.next_path().exists() and not conversion.journal_path().exists() check = client.get("/vault/check").json() assert check["plain_rows"] == 0 and check["plain_backups"] == [] and check["leftover_files"] == [] # Like `strings wally.db`: no name, note, category or tag is anywhere in the file raw = database.DB_PATH.read_bytes() for word in ("Mercadona", "Farmacia", "Therapy", "Café", "AMAZON", "Netflix", "holiday", "weekly", "Kids", "Groceries", "13.99", "23.45"): assert word.encode() not in raw, word def test_turning_on_keeps_only_counts_of_the_upgrade_report(make_client): """A household upgraded from 1.x: its report names transactions and amounts. The files go, and the copy in the settings (which stay in the clear) keeps only counts, for the welcome to 2.0.""" from api import upgrade client = make_client() alex, ana = plain_household(client) report = { "from": "1.x", "to": "2.0.0", "at": "2026-11-03T10:00:00+00:00", "today": "2026-11-03", "backup": None, "kept": {"transactions": 5, "first": "2026-10-01", "last": "2026-10-20", "expense": "139.74", "income": "0.00", "rules": 1, "categories": 4, "tags": 2, "password": True, "settings": ["currency"]}, "removed": [{"date": "2026-12-01", "name": "Lloguer Pis Manresa", "amount": "469.83"}], "notes": [{"kind": "decimals", "count": 1, "examples": [{"date": "2026-10-02", "name": "Forn de pa", "amount": "1.005"}]}], "checked": ["transactions"], } upgrade.save(report, database.DATA_DIR) conn = sqlite3.connect(database.DB_PATH) conn.execute("INSERT OR REPLACE INTO appconfig (key, value) VALUES ('UPGRADE_REPORT', ?)", (json.dumps(report),)) conn.commit() conn.close() assert list(database.DATA_DIR.glob("upgrade-report-*")) # Long enough not to turn up by chance: three digits like "469" can, in ciphertext or in a timestamp words = ("Lloguer", "Forn de pa", "469.83", "139.74") before = b"".join(f.read_bytes() for f in database.DB_PATH.parent.glob(database.DB_PATH.name + "*")) assert all(w.encode() in before for w in words) # there to begin with, so their absence below means something assert client.post("/login", json={"person_id": alex, "password": PASS}).status_code == 200 turn_on(client, alex, PASS) assert not list(database.DATA_DIR.glob("upgrade-report-*")) raw = database.DB_PATH.read_bytes() for word in words: assert word.encode() not in raw, word # The welcome still says how many came across, and the download says what was taken out assert client.get("/auth/status").json()["upgrade"]["kept"]["transactions"] == 5 text = client.get("/upgrade/report").text assert "5 transactions" in text and "1 charge that 1.x added" in text and "taken out of this report when encryption was turned on" in text assert "spent" not in text def test_the_browsers_records_open_with_the_password_from_outside(encrypted): client, alex, ana, result, before = encrypted house, items = vc.open_database(database.DB_PATH, PASS) for i in items: if i["k"] == "irule": i.pop("id") assert sorted(json.dumps(i, sort_keys=True) for i in items) == before def test_plain_routes_and_api_keys_are_refused(encrypted): client, alex, *_ = encrypted for path in ("/transactions", "/ledger", "/recurring", "/categories", "/tags", "/tags/icons", "/rules", "/summary/monthly", f"/people/{alex}/usage"): assert client.get(path).status_code == 409, path assert client.post("/transactions", json={"name": "x", "category": "Food", "amount": "1", "date": TODAY}).status_code == 409 # A key the server would make can't open anything: the browser makes it, with the lock assert client.post("/api-keys", json={"name": "phone"}).status_code == 400 assert client.get("/config", headers={"x-api-key": "wly_whatever"}).status_code == 401 assert client.get("/transactions", headers={"x-api-key": "wly_whatever"}).status_code == 401 assert client.put("/signin", json={"enabled": False, "current": "x"}).status_code == 400 # ---------- API keys on an encrypted household ---------- def make_key(client, result, scope="write", name="script"): """As js/vault/session.js does: a key with a secret, and the household key locked under it.""" import os secret = os.urandom(32) raw = "wly_" + secret.hex() wrap = vc.lock(vc.hkdf(secret, "wally api key v1"), result["secret"], result["vault_id"]) r = client.post("/api-keys", json={"name": name, "scope": scope, "key": raw, "wrap": wrap}) assert r.status_code == 200, r.text return raw def plain_rows() -> int: conn = sqlite3.connect(database.DB_PATH) try: return sum(conn.execute(f'SELECT COUNT(*) FROM "{t}"').fetchone()[0] for t in conversion.PLAIN) finally: conn.close() def test_an_api_key_reads_an_encrypted_household(encrypted): client, alex, ana, result, before = encrypted key = {"x-api-key": make_key(client, result)} plain = TestClient(client.app) rows = plain.get("/ledger", headers=key).json()["rows"] assert len(rows) == 6 names = {t["name"] for t in plain.get("/transactions", headers=key).json()} assert {"Mercadona", "Farmacia Sant Jordi", "Dentist", "AMAZON MKTPLACE 99"} <= names assert "Kids" in plain.get("/categories", headers=key).json() assert plain.get("/summary/monthly", headers=key).status_code == 200 # Nothing it opened is left behind in the clear assert plain_rows() == 0 # Signed in in a browser, the same routes still answer from the browser only assert client.get("/transactions").status_code == 409 def test_an_api_key_changes_an_encrypted_household_as_a_browser_would(encrypted): client, alex, ana, result, before = encrypted b = Browser(client, alex, PASS) b.house, b.vault_id = result["house"], result["vault_id"] key = {"x-api-key": make_key(client, result)} plain = TestClient(client.app) r = plain.post("/transactions", json={"name": "Bakery", "category": "Groceries", "amount": "3.20", "date": TODAY}, headers=key) assert r.status_code == 200, r.text tx = r.json() recs = b.all() rid = tx["id"].replace("-", "") v, obj = recs[rid] assert v == 1 and obj["k"] == "tx" and obj["name"] == "Bakery" and obj["cents"] == 320 and obj["created_by"] == alex # A rename touches the category and every transaction in it, and nothing else seq = client.get("/vault/changes?since=0").json()["seq"] r = plain.put("/categories/Healthcare", json={"name": "Health"}, headers=key) assert r.status_code == 200, r.text changed = client.get(f"/vault/changes?since={seq}").json()["records"] opened = [b.house.open(c["id"], c["v"], c["ct"]) if c["ct"] else None for c in changed] assert {o["name"] for o in opened if o and o["k"] == "tx"} == {"Farmacia Sant Jordi"} assert any(o and o["k"] == "cat" and o["name"] == "Health" for o in opened) assert any(c["ct"] is None for c in changed) # the old name, deleted # Deleting is a deletion the browsers see assert plain.delete(f"/transactions/{tx['id']}", headers=key).status_code == 200 assert b.all()[rid][1] is None if rid in b.all() else True assert plain_rows() == 0 def test_charges_added_by_a_key_are_the_ones_a_browser_would_add(encrypted): client, alex, ana, result, before = encrypted key = {"x-api-key": make_key(client, result)} plain = TestClient(client.app) r = plain.post("/recurring", json={"name": "Gym", "category": "Health", "amount": "30", "frequency": "monthly", "startDate": "2026-08-01"}, headers=key) assert r.status_code == 200, r.text rule_id = r.json()["id"] b = Browser(client, alex, PASS) b.house = result["house"] made = sorted((o["date"], rid) for rid, (_, o) in b.all().items() if o and o["k"] == "tx" and o["recurringID"] == rule_id) assert made and all(rid == str(b.house.occurrence_id(rule_id, on)).replace("-", "") for on, rid in made) def test_a_read_key_only_reads_and_keys_end_when_deleted(encrypted): client, alex, ana, result, before = encrypted raw = make_key(client, result, scope="read") plain = TestClient(client.app) assert plain.get("/transactions", headers={"x-api-key": raw}).status_code == 200 r = plain.post("/transactions", json={"name": "x", "category": "Groceries", "amount": "1", "date": TODAY}, headers={"x-api-key": raw}) assert r.status_code == 403 # A key can't make or delete keys assert plain.post("/api-keys", json={"name": "more"}, headers={"x-api-key": raw}).status_code in (403, 400) kid = client.get("/api-keys").json()[0]["id"] assert plain.delete(f"/api-keys/{kid}", headers={"x-api-key": raw}).status_code == 403 assert client.delete(f"/api-keys/{kid}").status_code == 200 assert plain.get("/transactions", headers={"x-api-key": raw}).status_code == 401 def test_a_lock_that_opens_nothing_is_refused(encrypted): import os client, alex, ana, result, before = encrypted secret = os.urandom(32) raw = "wly_" + secret.hex() # Locked under the wrong key, and locking the wrong household key bad = vc.lock(os.urandom(32), result["secret"], result["vault_id"]) assert client.post("/api-keys", json={"name": "x", "key": raw, "wrap": bad}).status_code == 400 wrong = vc.lock(vc.hkdf(secret, "wally api key v1"), os.urandom(32), result["vault_id"]) assert client.post("/api-keys", json={"name": "x", "key": raw, "wrap": wrong}).status_code == 400 def test_keys_keep_working_when_encryption_is_turned_off(encrypted): client, alex, ana, result, before = encrypted raw = make_key(client, result) b = Browser(client, alex, PASS) b.house, b.vault_id = result["house"], result["vault_id"] turn_off(client, b) plain = TestClient(client.app) assert len(plain.get("/ledger", headers={"x-api-key": raw}).json()["rows"]) == 6 conn = sqlite3.connect(database.DB_PATH) assert conn.execute("SELECT COUNT(*) FROM apikeys WHERE wrap IS NOT NULL").fetchone()[0] == 0 conn.close() def test_engine_and_defaults_stay_off_after_restart(encrypted, make_client): client, alex, *_ = encrypted again = make_client() conn = sqlite3.connect(database.DB_PATH) assert conn.execute("SELECT COUNT(*) FROM category").fetchone()[0] == 0 assert conn.execute('SELECT COUNT(*) FROM "transaction"').fetchone()[0] == 0 conn.close() assert again.get("/auth/status").json()["vault"]["on"] # ---------- signing in ---------- def test_sign_in_sends_a_login_key_never_the_password(encrypted): client, alex, ana, result, before = encrypted client.post("/logout") # The password itself no longer works: the server has a hash of the login key only assert client.post("/login", json={"person_id": alex, "password": PASS}).status_code == 401 b = Browser(client, alex, PASS).login() assert len(b.all()) == result["count"] conn = sqlite3.connect(database.DB_PATH) stored = conn.execute("SELECT value FROM appconfig WHERE key='LOGIN_PASSWORD'").fetchone()[0] conn.close() assert PASS not in stored def test_everyone_opens_it_with_the_same_password(encrypted): client, alex, ana, result, before = encrypted other = TestClient(client.app) assert other.get("/vault/kdf").json()["state"] == "key" b = Browser(other, ana, PASS).login() assert len(b.all()) == result["count"] assert other.get("/auth/status").json()["me"]["id"] == ana # No codes for anyone: those routes are gone assert other.post("/vault/invites", json={}).status_code in (404, 405) assert other.post("/vault/redeem", json={}).status_code in (404, 405) def turn_on_two_step(client) -> str: setup = client.post("/two-step/setup").json() proof = vc.from_password(PASS, client.get("/vault/kdf").json()["salt"])[0] r = client.post("/two-step/confirm", json={"code": totp.code_at(setup["secret"], int(time.time() // 30)), "password": proof}) assert r.status_code == 200, r.text auth_router._last_counter["value"] = -1 return setup["secret"] def test_an_old_52_character_kit_still_opens_the_household(encrypted): """Kits printed before 2.0 shipped hold 52 characters; the key's bytes go through HKDF the same way.""" client, alex, ana, result, before = encrypted old = vc.new_recovery_key(52) assert len(old) == 64 rlogin, runlock = vc.from_recovery_key(vc.parse_recovery_key(old)) current = vc.from_password(PASS, client.get("/vault/kdf").json()["salt"])[0] r = client.post("/kit", json={"recovery_login": rlogin, "recovery_wrap": vc.lock(runlock, result["secret"], result["vault_id"]), "current": current}) assert r.status_code == 200, r.text other = TestClient(client.app) r = other.post("/kit/recover", json={"person_id": alex, "login": rlogin}) assert r.status_code == 200, r.text assert vc.unlock(runlock, r.json()["wrap"], r.json()["vault_id"]) == result["secret"] def test_the_emergency_kit_gets_in_without_two_step(encrypted): client, alex, ana, result, before = encrypted ana_c = TestClient(client.app) Browser(ana_c, ana, PASS).login() turn_on_two_step(client) other = TestClient(client.app) rlogin, runlock = vc.from_recovery_key(vc.parse_recovery_key(result["recovery"])) assert other.post("/kit/recover", json={"person_id": alex, "login": "0" * 64}).status_code == 401 assert other.post("/kit/password", json={"salt": vc.new_salt(), "new": "a" * 64, "wrap": vc.lock(b"k" * 32, b"s" * 32, "x")}).status_code == 401 # Password forgotten and phone lost: the recovery key alone is enough r = other.post("/kit/recover", json={"person_id": alex, "login": rlogin}) assert r.status_code == 200, r.text secret = vc.unlock(runlock, r.json()["wrap"], r.json()["vault_id"]) assert secret == result["secret"] salt = vc.new_salt() login, unlock_key = vc.from_password("brand-new-pass", salt) assert other.post("/kit/password", json={"salt": salt, "new": login, "wrap": vc.lock(unlock_key, secret, r.json()["vault_id"])}).status_code == 200 # A new password for the household: every other browser signs in again, with it assert client.get("/vault/key").status_code == 401 and ana_c.get("/vault/key").status_code == 401 assert other.get("/vault/key").status_code == 200 status = other.get("/auth/status").json() assert status["me"]["id"] == alex and status["two_step"] # The kit keeps working, and the new password opens the key assert other.post("/kit/recover", json={"person_id": alex, "login": rlogin}).status_code == 200 # A new kit replaces it, made in the browser, with the password kdf = other.get("/vault/kdf").json() rec2 = vc.new_recovery_key() rlogin2, runlock2 = vc.from_recovery_key(vc.parse_recovery_key(rec2)) body = {"recovery_login": rlogin2, "recovery_wrap": vc.lock(runlock2, secret, result["vault_id"])} assert other.post("/kit", json={**body, "current": "0" * 64}).status_code == 401 assert other.post("/kit", json={**body, "current": vc.from_password("brand-new-pass", kdf["salt"])[0]}).json() == {"made": other.get("/auth/status").json()["kit"]} assert other.post("/kit/recover", json={"person_id": alex, "login": rlogin}).status_code == 401 assert other.post("/kit/recover", json={"person_id": alex, "login": rlogin2}).status_code == 200 third = TestClient(client.app) kdf = third.get("/vault/kdf").json() assert third.post("/login", json={"person_id": ana, "password": vc.from_password("brand-new-pass", kdf["salt"])[0]}).json()["code_needed"] def test_nobody_can_turn_sign_in_off_from_the_server_while_encrypted(encrypted): client, alex, ana, result, before = encrypted from api import signin_off assert signin_off.main() == 1 assert client.get("/auth/status").json()["signin"] def test_changing_the_password_locks_the_key_again_and_signs_out_the_rest(encrypted): client, alex, ana, result, before = encrypted other = TestClient(client.app) Browser(other, ana, PASS).login() b = Browser(client, alex, PASS) salt = vc.new_salt() login, unlock_key = vc.from_password("changed-pass-1", salt) assert client.put("/password", json={"current": b.proof(), "new": login, "salt": salt}).status_code == 400 assert client.put("/password", json={"current": "0" * 64, "new": login, "salt": salt, "wrap": vc.lock(unlock_key, result["secret"], result["vault_id"])}).status_code == 401 r = client.put("/password", json={"current": b.proof(), "new": login, "salt": salt, "wrap": vc.lock(unlock_key, result["secret"], result["vault_id"])}) assert r.status_code == 200, r.text assert other.get("/vault/key").status_code == 401 Browser(other, ana, "changed-pass-1").login() # ---------- records ---------- def test_batches_are_all_or_nothing_and_conflicts_say_what_changed(encrypted): client, alex, ana, result, before = encrypted h = result["house"] rid_a, rid_b = "a" * 32, "b" * 32 r = client.post("/vault/batch", json={"ops": [{"id": rid_a, "base": 0, "ct": h.seal(rid_a, 1, {"k": "tag", "name": "one"})}]}) assert r.status_code == 200 and r.json()["versions"] == {rid_a: 1} seq = r.json()["seq"] # Another device saved rid_a meanwhile: base 0 is stale, and rid_b is not saved either r = client.post("/vault/batch", json={"ops": [{"id": rid_b, "base": 0, "ct": h.seal(rid_b, 1, {"k": "tag", "name": "two"})}, {"id": rid_a, "base": 0, "ct": h.seal(rid_a, 1, {"k": "tag", "name": "uno"})}]}) assert r.status_code == 409 conflict = r.json()["detail"]["conflicts"] assert [c["id"] for c in conflict] == [rid_a] and h.open(rid_a, conflict[0]["v"], conflict[0]["ct"])["name"] == "one" assert rid_b not in client.get(f"/vault/changes?since={seq - 1}").json()["records"] # Two devices adding the same recurring charge: the second is skipped, not an error r = client.post("/vault/batch", json={"ops": [{"id": rid_a, "base": 0, "ct": h.seal(rid_a, 1, {"k": "tag", "name": "x"}), "if_absent": True}]}) assert r.status_code == 200 and r.json()["skipped"] == [rid_a] # Deleting leaves a tombstone: changes since show it, a full download does not r = client.post("/vault/batch", json={"ops": [{"id": rid_a, "base": 1, "ct": None}]}) assert r.json()["versions"] == {rid_a: 2} since = client.get(f"/vault/changes?since={seq}").json()["records"] assert since == [{"id": rid_a, "v": 2, "seq": r.json()["seq"], "ct": None}] assert rid_a not in {x["id"] for x in client.get("/vault/changes?since=0").json()["records"]} # A record moved to another id or version doesn't open: the server can't swap them ct = h.seal(rid_b, 1, {"k": "tag", "name": "two"}) with pytest.raises(Exception): h.open(rid_a, 1, ct) with pytest.raises(Exception): h.open(rid_b, 2, ct) def test_bad_records_are_refused(encrypted): client, *_ = encrypted for op in ({"id": "nothex", "base": 0, "ct": vc.b64(b"x" * 100)}, {"id": "c" * 32, "base": 0, "ct": vc.b64(b"x" * 10)}, {"id": "c" * 32, "base": -1, "ct": None}, {"id": "c" * 32, "base": 0, "ct": "%%%"}): assert client.post("/vault/batch", json={"ops": [op]}).status_code == 400, op assert client.post("/vault/batch", json={"ops": [{"id": "c" * 32, "base": 0, "ct": None}] * 2}).status_code == 400 def test_old_tombstones_are_forgotten_and_old_devices_download_again(encrypted): client, alex, ana, result, before = encrypted from sqlmodel import Session from api.services.vault import purge_tombstones h = result["house"] rid = "d" * 32 client.post("/vault/batch", json={"ops": [{"id": rid, "base": 0, "ct": h.seal(rid, 1, {"k": "tag", "name": "t"})}]}) seq = client.post("/vault/batch", json={"ops": [{"id": rid, "base": 1, "ct": None}]}).json()["seq"] with Session(database.engine) as db: assert purge_tombstones(db, days=0) == 1 assert client.get("/vault/changes?since=1").status_code == 410 assert client.get(f"/vault/changes?since={seq}").status_code == 200 assert client.get("/vault/changes?since=0").status_code == 200 def test_removing_a_person_moves_their_records_in_the_same_save(encrypted): client, alex, ana, result, before = encrypted h = result["house"] rid = "e" * 32 r = client.request("DELETE", f"/people/{ana}", json={"move_to": alex, "ops": [{"id": rid, "base": 0, "ct": h.seal(rid, 1, {"k": "tag", "name": "moved"})}]}) assert r.status_code == 200 and r.json()["versions"] == {rid: 1} assert [p["id"] for p in client.get("/people").json()] == [alex] # A stale save keeps the person too client.post("/people", json={"name": "Sam"}) sam = [p for p in client.get("/people").json() if p["name"] == "Sam"][0]["id"] r = client.request("DELETE", f"/people/{sam}", json={"ops": [{"id": rid, "base": 0, "ct": h.seal(rid, 1, {"k": "tag", "name": "x"})}]}) assert r.status_code == 409 and sam in [p["id"] for p in client.get("/people").json()] # ---------- the pause ---------- def test_the_household_is_read_only_while_converting(make_client): client = make_client() alex, ana = plain_household(client) client.post("/login", json={"person_id": alex, "password": PASS}) ana_c = TestClient(client.app) ana_c.post("/login", json={"person_id": ana, "password": PASS}) turn_on(client, alex, PASS, fail_at="start") r = ana_c.post("/transactions", json={"name": "x", "category": "Food", "amount": "1", "date": TODAY}) assert r.status_code == 503 and r.json()["detail"]["paused"]["by"] == "Alex" assert ana_c.get("/transactions").status_code == 200 assert ana_c.get("/auth/status").json()["vault"]["paused"] == {"by": "Alex", "direction": "on"} # Nobody else can start one meanwhile assert ana_c.post("/vault/convert/start", json={"direction": "on", "current": PASS}).status_code in (403, 409, 400) def test_a_lost_browser_is_noticed_and_nothing_changes(make_client, monkeypatch): client = make_client() alex, ana = plain_household(client) client.post("/login", json={"person_id": alex, "password": PASS}) digest, before = db_digest(), export() cid = turn_on(client, alex, PASS, fail_at="check") assert conversion.next_path().exists() later = time.time() + conversion.PAUSE_SECONDS + 1 monkeypatch.setattr(conversion.time, "time", lambda: later) assert client.get("/auth/status").json()["vault"]["paused"] is None assert not conversion.next_path().exists() and not conversion.journal_path().exists() assert client.post("/vault/convert/finish", json={"id": cid, "count": 1}).status_code == 409 assert db_digest() == digest and export() == before def test_a_wrong_count_switches_nothing(make_client): client = make_client() alex, ana = plain_household(client) client.post("/login", json={"person_id": alex, "password": PASS}) digest = db_digest() cid = turn_on(client, alex, PASS, fail_at="check") assert client.post("/vault/convert/finish", json={"id": cid, "count": 3}).status_code == 409 assert db_digest() == digest and not conversion.next_path().exists() assert not client.get("/auth/status").json()["vault"]["on"] def test_not_enough_disk_is_said_up_front(make_client, monkeypatch): client = make_client() alex, ana = plain_household(client) client.post("/login", json={"person_id": alex, "password": PASS}) monkeypatch.setattr(conversion.shutil, "disk_usage", lambda p: shutil._ntuple_diskusage(10, 9, 1)) r = client.post("/vault/convert/start", json={"direction": "on", "current": PASS}) # The password is checked first; then the space assert r.status_code in (400, 507) digest = db_digest() with pytest.raises(Exception): conversion.start("on", alex, "Alex") assert db_digest() == digest and not conversion.journal_path().exists() # ---------- Wally stops half-way: on the next start, wally.db is the old file or the new one, never neither ---------- def _stopped(make_client, step): """Build a conversion to the point where the browser has checked everything, then stop at `step` as if the container died, and start Wally again.""" client = make_client() alex, ana = plain_household(client) client.post("/login", json={"person_id": alex, "password": PASS}) old_digest, before = db_digest(), export() turn_on(client, alex, PASS, fail_at="check") c = conversion.current() conversion._close_next() database.engine.dispose() new_digest = hashlib.sha256(conversion.next_path().read_bytes()).hexdigest() journal = {k: c[k] for k in ("id", "direction", "step", "by", "started")} db, nxt, prev = database.DB_PATH, conversion.next_path(), conversion.prev_path() if step == "build": pass elif step == "switch-before-rename": conversion.write_journal({**journal, "step": "switch"}) elif step == "switch-between-renames": conversion.write_journal({**journal, "step": "switch"}) db.rename(prev) elif step == "switch-after-renames": conversion.write_journal({**journal, "step": "switch"}) db.rename(prev) nxt.rename(db) elif step == "cleanup": conversion.write_journal({**journal, "step": "cleanup"}) db.rename(prev) nxt.rename(db) elif step == "torn-journal": conversion.journal_path().write_text('{"id": "x", "st') client.__exit__(None, None, None) database.STATE.pop("conversion", None) again = make_client() return again, old_digest, new_digest, before, alex @pytest.mark.parametrize("step", ["build", "torn-journal"]) def test_stopped_before_the_switch_keeps_the_old_file(make_client, step): client, old_digest, new_digest, before, alex = _stopped(make_client, step) assert db_digest() == old_digest assert not conversion.next_path().exists() and not conversion.journal_path().exists() assert not client.get("/auth/status").json()["vault"]["on"] assert export() == before # And it can be tried again client.post("/login", json={"person_id": alex, "password": PASS}) assert turn_on(client, alex, PASS)["count"] == len(before) @pytest.mark.parametrize("step", ["switch-before-rename", "switch-between-renames", "switch-after-renames", "cleanup"]) def test_stopped_during_the_switch_finishes_it(make_client, step): client, old_digest, new_digest, before, alex = _stopped(make_client, step) assert db_digest() == new_digest assert not conversion.next_path().exists() and not conversion.prev_path().exists() and not conversion.journal_path().exists() assert client.get("/auth/status").json()["vault"]["on"] house, items = vc.open_database(database.DB_PATH, PASS) for i in items: if i["k"] == "irule": i.pop("id") assert sorted(json.dumps(i, sort_keys=True) for i in items) == before # ---------- turning off: plain, encrypted, plain again, identical ---------- def turn_off(client, browser: Browser, fail_at=None): r = client.post("/vault/convert/start", json={"direction": "off", "current": browser.proof()}) assert r.status_code == 200, r.text cid = r.json()["id"] items = [obj for _, obj in browser.all().values()] r = client.post("/vault/convert/plain", json={"id": cid, "items": items}) assert r.status_code == 200, r.text back = client.get(f"/vault/convert/readback?id={cid}").json()["items"] def key(i): i = dict(i) if i["k"] == "irule": i.pop("id") return json.dumps(i, sort_keys=True) assert sorted(map(key, back)) == sorted(map(key, items)) if fail_at: return cid r = client.post("/vault/convert/finish", json={"id": cid, "count": len(items)}) assert r.status_code == 200, r.text def test_plain_encrypted_plain_is_identical(encrypted): client, alex, ana, result, before = encrypted b = Browser(client, alex, PASS) b.house, b.vault_id = result["house"], result["vault_id"] turn_off(client, b) assert export() == before status = client.get("/auth/status").json() assert not status["vault"]["on"] and status["signed_in"] # The encrypted file is kept as a backup; the key and the kit are gone; the password still works assert any(x["kind"] == "encrypted" for x in client.get("/backups").json()) conn = sqlite3.connect(database.DB_PATH) assert conn.execute("SELECT COUNT(*) FROM record").fetchone()[0] == 0 assert conn.execute("SELECT COUNT(*) FROM householdkey").fetchone()[0] == 0 conn.close() client.post("/logout") kdf = client.get("/vault/kdf").json() assert kdf["state"] == "plain" and kdf["salt"] login, _ = vc.from_password(PASS, kdf["salt"]) assert client.post("/login", json={"person_id": alex, "password": login}).status_code == 200 # The server works on the data again assert client.get("/transactions").status_code == 200 and len(client.get("/ledger").json()["rows"]) == 6 def test_turning_off_needs_the_password(encrypted): client, alex, *_ = encrypted assert client.post("/vault/convert/start", json={"direction": "off", "current": "0" * 64}).status_code == 401 def test_turning_off_tells_everyone_for_a_month(encrypted): client, alex, ana, result, before = encrypted b = Browser(client, alex, PASS) b.house, b.vault_id = result["house"], result["vault_id"] turn_off(client, b) off = client.get("/auth/status").json()["vault"]["turned_off"] assert off["by"] == alex # Turning it on again forgets the note client.post("/logout") Browser(client, alex, PASS) kdf = client.get("/vault/kdf").json() client.post("/login", json={"person_id": alex, "password": vc.from_password(PASS, kdf["salt"])[0]}) turn_on(client, alex, PASS) assert "turned_off" not in client.get("/auth/status").json()["vault"] # ---------- WALLY_ENCRYPTION=required ---------- def test_required_saves_nothing_in_the_clear_and_stays_on(make_client, monkeypatch): monkeypatch.setenv("WALLY_ENCRYPTION", "required") client = make_client() alex = client.post("/people", json={"name": "Alex"}).json()["id"] client.headers["x-wally-person"] = str(alex) assert client.get("/auth/status").json()["vault"]["required"] r = client.post("/transactions", json={"name": "x", "category": "Food", "amount": "1", "date": TODAY}) assert r.status_code == 409 and "encrypted" in r.json()["detail"] assert client.get("/transactions").status_code == 200 # Keys open the data while a call runs: only when the host allows it assert client.post("/api-keys", json={"name": "phone"}).status_code == 400 monkeypatch.setenv("WALLY_API_KEYS", "on") assert client.post("/api-keys", json={"name": "phone"}).status_code == 200 monkeypatch.delenv("WALLY_API_KEYS") assert client.put("/signin", json={"enabled": True, "password": PASS}).status_code == 200 client.headers.pop("x-wally-person") assert client.put("/signin", json={"enabled": False, "current": PASS}).status_code == 400 result = turn_on(client, alex, PASS) assert result["count"] == 10 # the default categories, now sealed b = Browser(client, alex, PASS) assert client.post("/vault/convert/start", json={"direction": "off", "current": b.proof()}).status_code == 403 assert client.put("/signin", json={"enabled": False, "current": b.proof()}).status_code == 400 def test_required_does_not_apply_to_the_demo(monkeypatch): from api.services.vault import required monkeypatch.setenv("WALLY_ENCRYPTION", "required") monkeypatch.setenv("DEMO", "true") assert not required() monkeypatch.setenv("DEMO", "false") assert required()