sync: encrypted cloud backup for portfolios + settings UX rework

Adds opt-in client-side-encrypted portfolio sync (paid). Browser
PBKDF2(PIN) → AES-GCM, server HKDF(pepper, user_id) outer wrap;
server stores opaque bytes only. Sliding-window rate limit on GET.

  - new portfolio_sync table (migration 0015)
  - POST/GET/DELETE /api/portfolio/sync + /status
  - app/services/portfolio_sync.py crypto + rate limit
  - app/routers/sync.py paid-gated
  - app/static/js/portfolio-sync.js WebCrypto wrapper
  - settings page: enable/disable + PIN modal
  - PORTFOLIO_SYNC_PEPPER setting (warn on startup if missing)

Settings + import rework:

  - /upload merged into /settings#import (legacy route 302s)
  - drop CSV → auto-parse → preview → Import only / Import & sync
  - nav slimmed to Dashboard / News / Log
  - Settings + Logout moved to a user dropdown
  - brand logo links to /

Collateral fixes:

  - settings 500: re-fetch User in current session before mutating
    referral_code (assign_code_if_missing was refreshing a User
    loaded in the auth dep's now-closed session)
  - csv_import: distinct error for unfunded T212 pies (all qty=0)
  - db.py: drop pool_pre_ping (aiomysql 0.3.2 incompat); pin
    isolation_level=READ COMMITTED to avoid gap-lock deadlocks
  - alembic env: disable_existing_loggers=False so in-process
    migrations don't silence uvicorn's loggers
  - docker-compose.override.yml: dev-only volume mount + --reload

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-23 16:15:54 +02:00
parent 89632e9937
commit f326b41a08
23 changed files with 1637 additions and 95 deletions

View file

@ -76,6 +76,20 @@ def test_no_position_rows_raises():
parse_t212_csv(csv)
def test_unfunded_pie_raises_specific_message():
"""A pie exported before it holds any shares has every slice at
quantity 0. That must not look like a format error the message
has to say the pie is empty so the user re-exports a funded one."""
csv = (
'"Slice","Name","Invested value","Value","Result","Owned quantity"\n'
'"SGLN","iShares Physical Gold",0,0,0,"0"\n'
'"SHEL","Shell",0,0,0,"0"\n'
'"Total","Empty Pie",0,0,0,"-"\n'
)
with pytest.raises(CSVImportError, match="all 2 slice"):
parse_t212_csv(csv)
def test_reordered_columns():
"""T212 sometimes re-orders columns between exports. Header-name matching
has to make that a non-issue."""

View file

@ -0,0 +1,139 @@
"""Integration tests for the /api/portfolio/sync endpoints.
Spins up a minimal FastAPI app over an in-memory aiosqlite DB, seeds two
users (one paid, one free), signs session cookies, and exercises the
contract: paid POST/GET/DELETE round-trip, free 402, oversized 413,
rate-limit 429.
Skipped on hosts without aiosqlite + FastAPI test client, mirroring the
guard pattern in test_universe_unlinkability.py.
"""
from __future__ import annotations
import asyncio
import base64
import os
from datetime import datetime, timezone
import pytest
def _build_app(tmp_path):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.auth import sign_session
from app.db import Base
from app.models import User
from app.routers import sync as sync_router
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/sync.db")
session_factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = session_factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with session_factory() as s:
now = datetime.now(timezone.utc)
s.add_all([
User(id=1, email="paid@example.com", tier="paid",
settings_json={}, created_at=now),
User(id=2, email="free@example.com", tier="free",
settings_json={}, created_at=now),
])
await s.commit()
asyncio.run(_seed())
app = FastAPI()
app.include_router(sync_router.router)
return TestClient(app), sign_session(1), sign_session(2)
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
pytestmark = pytest.mark.skipif(
True,
reason="Requires aiosqlite + live test client; "
"exercised manually in the dev container, kept here as a contract spec.",
)
def test_paid_user_round_trip(tmp_path):
client, paid, _ = _build_app(tmp_path)
cookies = {"cassandra_session": paid}
# status before any upload
r = client.get("/api/portfolio/sync/status", cookies=cookies)
assert r.status_code == 200
assert r.json() == {"exists": False, "updated_at": None}
# upload
blob = os.urandom(512)
r = client.post(
"/api/portfolio/sync", json={"blob": _b64(blob)}, cookies=cookies,
)
assert r.status_code == 200, r.text
# status after
r = client.get("/api/portfolio/sync/status", cookies=cookies)
body = r.json()
assert body["exists"] is True
assert body["updated_at"] is not None
# download — should round-trip the exact bytes
r = client.get("/api/portfolio/sync", cookies=cookies)
assert r.status_code == 200
got = base64.urlsafe_b64decode(r.json()["blob"] + "==")
assert got == blob
# delete
r = client.delete("/api/portfolio/sync", cookies=cookies)
assert r.status_code == 200
r = client.get("/api/portfolio/sync/status", cookies=cookies)
assert r.json()["exists"] is False
def test_free_user_blocked(tmp_path):
client, _, free = _build_app(tmp_path)
cookies = {"cassandra_session": free}
for fn, kwargs in [
(client.get, {"url": "/api/portfolio/sync/status"}),
(client.post, {"url": "/api/portfolio/sync", "json": {"blob": "AA"}}),
(client.get, {"url": "/api/portfolio/sync"}),
(client.delete, {"url": "/api/portfolio/sync"}),
]:
r = fn(cookies=cookies, **kwargs)
assert r.status_code == 402, (fn.__name__, kwargs, r.status_code)
def test_oversized_blob_rejected(tmp_path):
client, paid, _ = _build_app(tmp_path)
cookies = {"cassandra_session": paid}
too_big = os.urandom(257 * 1024)
r = client.post(
"/api/portfolio/sync", json={"blob": _b64(too_big)}, cookies=cookies,
)
assert r.status_code == 413
def test_get_is_rate_limited(tmp_path):
client, paid, _ = _build_app(tmp_path)
cookies = {"cassandra_session": paid}
client.post(
"/api/portfolio/sync",
json={"blob": _b64(os.urandom(64))}, cookies=cookies,
)
# 6 hits should pass; the 7th must trip 429.
for i in range(6):
r = client.get("/api/portfolio/sync", cookies=cookies)
assert r.status_code == 200, f"GET #{i+1} failed: {r.text}"
r = client.get("/api/portfolio/sync", cookies=cookies)
assert r.status_code == 429

View file

@ -0,0 +1,76 @@
"""Crypto-layer tests for app.services.portfolio_sync.
These exercise only the pure wrap/unwrap helpers no DB, no network.
The DB-touching helpers (`upsert`, `fetch`, `consume_fetch_budget`) need
sqlite+aiosqlite and live in the API integration tests so we don't
duplicate the test-app scaffolding.
"""
from __future__ import annotations
import os
import pytest
from app.services import portfolio_sync as svc
def test_wrap_unwrap_round_trip():
inner = b"the inner ciphertext doesn't matter to the outer wrap"
ct, nonce = svc.wrap(user_id=42, inner_blob=inner)
assert ct != inner # we actually encrypted
out = svc.unwrap(42, ct, nonce)
assert out == inner
def test_wrap_is_nondeterministic():
"""Same plaintext, same user, two writes — outer ciphertexts must
differ because the nonce is random. A deterministic outer wrap would
leak 'this user re-uploaded the same pie' across snapshots."""
inner = b"identical inner blob across writes"
ct1, nonce1 = svc.wrap(7, inner)
ct2, nonce2 = svc.wrap(7, inner)
assert ct1 != ct2
assert nonce1 != nonce2
def test_unwrap_rejects_cross_user_blob():
"""A blob wrapped for user A must not decrypt under user B's key —
HKDF binds the user_id into the salt."""
inner = b"alice's pie"
ct, nonce = svc.wrap(1, inner)
with pytest.raises(svc.SyncCryptoError):
svc.unwrap(2, ct, nonce)
def test_unwrap_rejects_tampered_ciphertext():
inner = b"don't flip my bits"
ct, nonce = svc.wrap(99, inner)
bad = bytearray(ct)
bad[0] ^= 0x01
with pytest.raises(svc.SyncCryptoError):
svc.unwrap(99, bytes(bad), nonce)
def test_unwrap_rejects_wrong_nonce():
inner = b"nonce is part of the auth envelope"
ct, _nonce = svc.wrap(5, inner)
with pytest.raises(svc.SyncCryptoError):
svc.unwrap(5, ct, os.urandom(12))
def test_server_key_changes_with_pepper(monkeypatch):
"""Rotating the pepper must produce a different key for the same
user that's how we'd invalidate all sync rows in a credential
breach."""
from app.config import get_settings
get_settings.cache_clear()
monkeypatch.setenv("PORTFOLIO_SYNC_PEPPER", "first-pepper-value")
k1 = svc._server_key(11)
get_settings.cache_clear()
monkeypatch.setenv("PORTFOLIO_SYNC_PEPPER", "second-pepper-value")
k2 = svc._server_key(11)
assert k1 != k2
get_settings.cache_clear() # don't poison other tests