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:
parent
89632e9937
commit
f326b41a08
23 changed files with 1637 additions and 95 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
139
tests/test_portfolio_sync_api.py
Normal file
139
tests/test_portfolio_sync_api.py
Normal 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
|
||||
76
tests/test_portfolio_sync_service.py
Normal file
76
tests/test_portfolio_sync_service.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue